Skip to content

Commit 2b355d5

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(cluster): multi-node authorization gate (open seam for EE license) (#2230)
@objectstack/service-cluster exports registerMultiNodeGate / checkMultiNodeAllowed — a generic seam a distribution (e.g. EE) registers a gate into to authorize multi-node (remote-driver) topology. The open framework ships no gate: multi-node is always allowed. Zero license logic here; this is the open mechanism an EE license plugs into (cloud ADR-0022). 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 CLI keeps using a dynamic, non-literal import specifier so it does not statically depend on the cluster package (mirrors the remote-driver import). - multi-node-gate.ts: registry + checkMultiNodeAllowed + reset (5 tests). - service-cluster build (full DTS) + 45 tests green; cli build green. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 211425e commit 2b355d5

5 files changed

Lines changed: 127 additions & 3 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/service-cluster": minor
3+
"@objectstack/cli": minor
4+
---
5+
6+
feat(cluster): multi-node authorization gate (open mechanism)
7+
8+
`@objectstack/service-cluster` now exports `registerMultiNodeGate` /
9+
`checkMultiNodeAllowed`: a distribution (e.g. the Enterprise Edition) can
10+
register a gate that authorizes whether the runtime may enable a multi-node
11+
(remote-driver) topology. The open framework ships no gate — multi-node is
12+
always allowed.
13+
14+
`os serve` consults the gate before activating a remote cluster driver; on
15+
denial it **downgrades to single-node (in-memory) rather than failing**
16+
multi-node is an add-on, never bricks the runtime. The framework holds zero
17+
license logic; this is the open seam an EE license plugs into (cloud ADR-0022).

packages/cli/src/commands/serve.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -539,9 +539,26 @@ export default class Serve extends Command {
539539
let clusterConfig: { driver: string; url?: string } | undefined;
540540
const __clusterDriver = process.env.OS_CLUSTER_DRIVER?.trim();
541541
if (__clusterDriver && __clusterDriver !== 'memory') {
542-
try { await import(`@objectstack/service-cluster-${__clusterDriver}`); }
543-
catch { /* may already be registered by the loaded config */ }
544-
clusterConfig = { driver: __clusterDriver, url: process.env.OS_REDIS_URL };
542+
// Multi-node authorization gate (open mechanism): a distribution (e.g.
543+
// an EE license) may deny multi-node. On denial, downgrade to
544+
// single-node rather than fail — multi-node is an add-on, never brick.
545+
// Dynamic, non-literal specifier so the CLI does not statically depend
546+
// on the cluster package (mirrors the remote-driver import below).
547+
const __clusterPkg: string = '@objectstack/service-cluster';
548+
const { checkMultiNodeAllowed } = (await import(__clusterPkg)) as {
549+
checkMultiNodeAllowed: () => { allowed: boolean; reason?: string };
550+
};
551+
const __gate = checkMultiNodeAllowed();
552+
if (!__gate.allowed) {
553+
console.warn(
554+
`[cluster] multi-node not authorized (${__gate.reason ?? 'denied'}) — ` +
555+
`downgrading to single-node (in-memory cluster). Remove OS_CLUSTER_DRIVER to silence.`,
556+
);
557+
} else {
558+
try { await import(`@objectstack/service-cluster-${__clusterDriver}`); }
559+
catch { /* may already be registered by the loaded config */ }
560+
clusterConfig = { driver: __clusterDriver, url: process.env.OS_REDIS_URL };
561+
}
545562
}
546563
const runtime = new Runtime({
547564
kernel: {

packages/services/service-cluster/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,3 +66,10 @@ export type {
6666
CounterIncrOptions,
6767
ClusterCallContext,
6868
} from '@objectstack/spec/contracts';
69+
70+
export {
71+
registerMultiNodeGate,
72+
checkMultiNodeAllowed,
73+
__resetMultiNodeGate,
74+
type MultiNodeGate,
75+
} from './multi-node-gate.js';
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
import { describe, it, expect, afterEach } from 'vitest';
3+
import {
4+
registerMultiNodeGate,
5+
checkMultiNodeAllowed,
6+
__resetMultiNodeGate,
7+
} from './multi-node-gate.js';
8+
9+
afterEach(() => __resetMultiNodeGate());
10+
11+
describe('multi-node gate', () => {
12+
it('allows when no gate is registered (open framework)', () => {
13+
expect(checkMultiNodeAllowed()).toEqual({ allowed: true });
14+
});
15+
16+
it('honors a denying gate with reason', () => {
17+
registerMultiNodeGate({ allowMultiNode: () => ({ allowed: false, reason: 'unlicensed' }) });
18+
expect(checkMultiNodeAllowed()).toEqual({ allowed: false, reason: 'unlicensed' });
19+
});
20+
21+
it('honors an allowing gate', () => {
22+
registerMultiNodeGate({ allowMultiNode: () => ({ allowed: true }) });
23+
expect(checkMultiNodeAllowed().allowed).toBe(true);
24+
});
25+
26+
it('last registration wins', () => {
27+
registerMultiNodeGate({ allowMultiNode: () => ({ allowed: false }) });
28+
registerMultiNodeGate({ allowMultiNode: () => ({ allowed: true }) });
29+
expect(checkMultiNodeAllowed().allowed).toBe(true);
30+
});
31+
32+
it('reset restores open default', () => {
33+
registerMultiNodeGate({ allowMultiNode: () => ({ allowed: false }) });
34+
__resetMultiNodeGate();
35+
expect(checkMultiNodeAllowed()).toEqual({ allowed: true });
36+
});
37+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Multi-node authorization gate (open mechanism).
5+
*
6+
* The open framework ships **no gate** — multi-node is always allowed. A
7+
* distribution (e.g. the Enterprise Edition) registers a gate to authorize
8+
* whether the runtime may enable a multi-node (remote-driver) topology — for
9+
* example, an EE license check. The framework deliberately knows nothing about
10+
* *why* a gate allows or denies; it only consults the registered decision.
11+
*
12+
* When a gate denies, the caller (e.g. `os serve`) **downgrades to single-node**
13+
* rather than failing — multi-node is an add-on, not a precondition for the
14+
* runtime to serve. This is distinct from the split-brain guard, which throws
15+
* on an outright misconfiguration (memory driver declared multi-node).
16+
*/
17+
export interface MultiNodeGate {
18+
/**
19+
* Called before the runtime enables a remote-driver (multi-node) topology.
20+
* Return `allowed: false` to force single-node; `reason` is surfaced in logs.
21+
*/
22+
allowMultiNode(): { allowed: boolean; reason?: string };
23+
}
24+
25+
let registered: MultiNodeGate | undefined;
26+
27+
/**
28+
* Register the multi-node authorization gate. Last registration wins. A
29+
* distribution calls this at boot (before the cluster topology is resolved).
30+
*/
31+
export function registerMultiNodeGate(gate: MultiNodeGate): void {
32+
registered = gate;
33+
}
34+
35+
/**
36+
* Resolve the multi-node decision. With no gate registered (open framework),
37+
* multi-node is allowed.
38+
*/
39+
export function checkMultiNodeAllowed(): { allowed: boolean; reason?: string } {
40+
return registered ? registered.allowMultiNode() : { allowed: true };
41+
}
42+
43+
/** Clear the registered gate. For tests. */
44+
export function __resetMultiNodeGate(): void {
45+
registered = undefined;
46+
}

0 commit comments

Comments
 (0)