diff --git a/.changeset/cluster-multinode-gate.md b/.changeset/cluster-multinode-gate.md new file mode 100644 index 0000000000..31c290b3ee --- /dev/null +++ b/.changeset/cluster-multinode-gate.md @@ -0,0 +1,17 @@ +--- +"@objectstack/service-cluster": minor +"@objectstack/cli": minor +--- + +feat(cluster): multi-node authorization gate (open mechanism) + +`@objectstack/service-cluster` now exports `registerMultiNodeGate` / +`checkMultiNodeAllowed`: a distribution (e.g. the Enterprise Edition) can +register a gate that authorizes whether the runtime may enable a multi-node +(remote-driver) topology. The open framework ships no gate — multi-node is +always allowed. + +`os serve` consults the gate before activating a remote cluster driver; on +denial it **downgrades to single-node (in-memory) rather than failing** — +multi-node is an add-on, never bricks the runtime. The framework holds zero +license logic; this is the open seam an EE license plugs into (cloud ADR-0022). diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 626c9857f5..6350fc090d 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -539,9 +539,26 @@ export default class Serve extends Command { let clusterConfig: { driver: string; url?: string } | undefined; const __clusterDriver = process.env.OS_CLUSTER_DRIVER?.trim(); if (__clusterDriver && __clusterDriver !== 'memory') { - try { await import(`@objectstack/service-cluster-${__clusterDriver}`); } - catch { /* may already be registered by the loaded config */ } - clusterConfig = { driver: __clusterDriver, url: process.env.OS_REDIS_URL }; + // Multi-node authorization gate (open mechanism): a distribution (e.g. + // an EE license) may deny multi-node. On denial, downgrade to + // single-node rather than fail — multi-node is an add-on, never brick. + // Dynamic, non-literal specifier so the CLI does not statically depend + // on the cluster package (mirrors the remote-driver import below). + const __clusterPkg: string = '@objectstack/service-cluster'; + const { checkMultiNodeAllowed } = (await import(__clusterPkg)) as { + checkMultiNodeAllowed: () => { allowed: boolean; reason?: string }; + }; + const __gate = checkMultiNodeAllowed(); + if (!__gate.allowed) { + console.warn( + `[cluster] multi-node not authorized (${__gate.reason ?? 'denied'}) — ` + + `downgrading to single-node (in-memory cluster). Remove OS_CLUSTER_DRIVER to silence.`, + ); + } else { + try { await import(`@objectstack/service-cluster-${__clusterDriver}`); } + catch { /* may already be registered by the loaded config */ } + clusterConfig = { driver: __clusterDriver, url: process.env.OS_REDIS_URL }; + } } const runtime = new Runtime({ kernel: { diff --git a/packages/services/service-cluster/src/index.ts b/packages/services/service-cluster/src/index.ts index 52a2df92bf..11fd22f486 100644 --- a/packages/services/service-cluster/src/index.ts +++ b/packages/services/service-cluster/src/index.ts @@ -66,3 +66,10 @@ export type { CounterIncrOptions, ClusterCallContext, } from '@objectstack/spec/contracts'; + +export { + registerMultiNodeGate, + checkMultiNodeAllowed, + __resetMultiNodeGate, + type MultiNodeGate, +} from './multi-node-gate.js'; diff --git a/packages/services/service-cluster/src/multi-node-gate.test.ts b/packages/services/service-cluster/src/multi-node-gate.test.ts new file mode 100644 index 0000000000..7a9f7f1d71 --- /dev/null +++ b/packages/services/service-cluster/src/multi-node-gate.test.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +import { describe, it, expect, afterEach } from 'vitest'; +import { + registerMultiNodeGate, + checkMultiNodeAllowed, + __resetMultiNodeGate, +} from './multi-node-gate.js'; + +afterEach(() => __resetMultiNodeGate()); + +describe('multi-node gate', () => { + it('allows when no gate is registered (open framework)', () => { + expect(checkMultiNodeAllowed()).toEqual({ allowed: true }); + }); + + it('honors a denying gate with reason', () => { + registerMultiNodeGate({ allowMultiNode: () => ({ allowed: false, reason: 'unlicensed' }) }); + expect(checkMultiNodeAllowed()).toEqual({ allowed: false, reason: 'unlicensed' }); + }); + + it('honors an allowing gate', () => { + registerMultiNodeGate({ allowMultiNode: () => ({ allowed: true }) }); + expect(checkMultiNodeAllowed().allowed).toBe(true); + }); + + it('last registration wins', () => { + registerMultiNodeGate({ allowMultiNode: () => ({ allowed: false }) }); + registerMultiNodeGate({ allowMultiNode: () => ({ allowed: true }) }); + expect(checkMultiNodeAllowed().allowed).toBe(true); + }); + + it('reset restores open default', () => { + registerMultiNodeGate({ allowMultiNode: () => ({ allowed: false }) }); + __resetMultiNodeGate(); + expect(checkMultiNodeAllowed()).toEqual({ allowed: true }); + }); +}); diff --git a/packages/services/service-cluster/src/multi-node-gate.ts b/packages/services/service-cluster/src/multi-node-gate.ts new file mode 100644 index 0000000000..57e333e0a8 --- /dev/null +++ b/packages/services/service-cluster/src/multi-node-gate.ts @@ -0,0 +1,46 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Multi-node authorization gate (open mechanism). + * + * The open framework ships **no gate** — multi-node is always allowed. A + * distribution (e.g. the Enterprise Edition) registers a gate to authorize + * whether the runtime may enable a multi-node (remote-driver) topology — for + * example, an EE license check. The framework deliberately knows nothing about + * *why* a gate allows or denies; it only consults the registered decision. + * + * When a gate denies, the caller (e.g. `os serve`) **downgrades to single-node** + * rather than failing — multi-node is an add-on, not a precondition for the + * runtime to serve. This is distinct from the split-brain guard, which throws + * on an outright misconfiguration (memory driver declared multi-node). + */ +export interface MultiNodeGate { + /** + * Called before the runtime enables a remote-driver (multi-node) topology. + * Return `allowed: false` to force single-node; `reason` is surfaced in logs. + */ + allowMultiNode(): { allowed: boolean; reason?: string }; +} + +let registered: MultiNodeGate | undefined; + +/** + * Register the multi-node authorization gate. Last registration wins. A + * distribution calls this at boot (before the cluster topology is resolved). + */ +export function registerMultiNodeGate(gate: MultiNodeGate): void { + registered = gate; +} + +/** + * Resolve the multi-node decision. With no gate registered (open framework), + * multi-node is allowed. + */ +export function checkMultiNodeAllowed(): { allowed: boolean; reason?: string } { + return registered ? registered.allowMultiNode() : { allowed: true }; +} + +/** Clear the registered gate. For tests. */ +export function __resetMultiNodeGate(): void { + registered = undefined; +}