Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/cluster-multinode-gate.md
Original file line numberDiff line numberDiff line change
@@ -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).
23 changes: 20 additions & 3 deletions packages/cli/src/commands/serve.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: {
Expand Down
7 changes: 7 additions & 0 deletions packages/services/service-cluster/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -66,3 +66,10 @@ export type {
CounterIncrOptions,
ClusterCallContext,
} from '@objectstack/spec/contracts';

export {
registerMultiNodeGate,
checkMultiNodeAllowed,
__resetMultiNodeGate,
type MultiNodeGate,
} from './multi-node-gate.js';
37 changes: 37 additions & 0 deletions packages/services/service-cluster/src/multi-node-gate.test.ts
Original file line numberDiff line numberDiff line change
@@ -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 });
});
});
46 changes: 46 additions & 0 deletions packages/services/service-cluster/src/multi-node-gate.ts
Original file line numberDiff line numberDiff line change
@@ -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;
}