@lumencast/server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +69 -0
  3. package/dist/.tsbuildinfo +1 -0
  4. package/dist/adapters/http-poll.d.ts +16 -0
  5. package/dist/adapters/http-poll.d.ts.map +1 -0
  6. package/dist/adapters/http-poll.js +46 -0
  7. package/dist/adapters/http-poll.js.map +1 -0
  8. package/dist/auth.d.ts +33 -0
  9. package/dist/auth.d.ts.map +1 -0
  10. package/dist/auth.js +65 -0
  11. package/dist/auth.js.map +1 -0
  12. package/dist/cli.d.ts +3 -0
  13. package/dist/cli.d.ts.map +1 -0
  14. package/dist/cli.js +154 -0
  15. package/dist/cli.js.map +1 -0
  16. package/dist/index.d.ts +7 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +8 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/replay-buffer.d.ts +32 -0
  21. package/dist/replay-buffer.d.ts.map +1 -0
  22. package/dist/replay-buffer.js +60 -0
  23. package/dist/replay-buffer.js.map +1 -0
  24. package/dist/scene.d.ts +67 -0
  25. package/dist/scene.d.ts.map +1 -0
  26. package/dist/scene.js +109 -0
  27. package/dist/scene.js.map +1 -0
  28. package/dist/server.d.ts +38 -0
  29. package/dist/server.d.ts.map +1 -0
  30. package/dist/server.js +279 -0
  31. package/dist/server.js.map +1 -0
  32. package/dist/store.d.ts +17 -0
  33. package/dist/store.d.ts.map +1 -0
  34. package/dist/store.js +34 -0
  35. package/dist/store.js.map +1 -0
  36. package/dist/test-control.d.ts +22 -0
  37. package/dist/test-control.d.ts.map +1 -0
  38. package/dist/test-control.js +225 -0
  39. package/dist/test-control.js.map +1 -0
  40. package/package.json +50 -0
  41. package/src/adapters/http-poll.ts +59 -0
  42. package/src/auth.ts +81 -0
  43. package/src/cli.ts +166 -0
  44. package/src/index.ts +20 -0
  45. package/src/replay-buffer.ts +72 -0
  46. package/src/scene.ts +168 -0
  47. package/src/server.ts +386 -0
  48. package/src/store.ts +40 -0
  49. package/src/test-control.ts +279 -0
package/package.json ADDED
@@ -0,0 +1,50 @@
1
+ {
2
+ "name": "@lumencast/server",
3
+ "version": "0.1.0",
4
+ "description": "Node server kit for LSDP/1 — HTTP+WS, scene composition, leaf store, server-side adapters, token-agnostic auth hooks.",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "module": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ },
15
+ "./cli": "./dist/cli.js"
16
+ },
17
+ "bin": {
18
+ "lumencast-js": "./dist/cli.js"
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src"
23
+ ],
24
+ "homepage": "https://github.com/Lumencast/lumencast-js/tree/main/packages/server",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/Lumencast/lumencast-js.git",
28
+ "directory": "packages/server"
29
+ },
30
+ "engines": {
31
+ "node": ">=22"
32
+ },
33
+ "publishConfig": {
34
+ "access": "public",
35
+ "registry": "https://registry.npmjs.org/"
36
+ },
37
+ "dependencies": {
38
+ "ws": "^8.18.0",
39
+ "@lumencast/protocol": "0.1.0"
40
+ },
41
+ "devDependencies": {
42
+ "@types/ws": "^8.5.13",
43
+ "vitest": "^4.1.5"
44
+ },
45
+ "scripts": {
46
+ "build": "tsc -b",
47
+ "typecheck": "tsc -b --pretty",
48
+ "test": "vitest run"
49
+ }
50
+ }
@@ -0,0 +1,59 @@
1
+ // http_poll adapter — fetches a URL on an interval and writes the result
2
+ // as leaf-grain patches into a Scene.
3
+
4
+ import type { LeafPath, LeafValue, Patch } from "@lumencast/protocol";
5
+ import type { Scene } from "../scene.js";
6
+
7
+ export interface HttpPollOptions {
8
+ url: string;
9
+ intervalMs: number;
10
+ /** Path prefix the adapter writes under — same semantic as `external_adapters[].writes_to` in LSML. */
11
+ writesTo: LeafPath;
12
+ /** Extract a flat record of leaf paths from the response. */
13
+ extract: (response: unknown) => Record<LeafPath, LeafValue>;
14
+ /** Optional headers (e.g. Authorization). */
15
+ headers?: Record<string, string>;
16
+ /** Optional error reporter — defaults to silent. */
17
+ onError?: (err: unknown) => void;
18
+ }
19
+
20
+ export function startHttpPoll(scene: Scene, options: HttpPollOptions): () => void {
21
+ let stopped = false;
22
+ let timer: ReturnType<typeof setTimeout> | null = null;
23
+
24
+ async function tick(): Promise<void> {
25
+ if (stopped) return;
26
+ try {
27
+ const res = await fetch(options.url, { headers: options.headers ?? {} });
28
+ if (res.ok) {
29
+ const body = (await res.json()) as unknown;
30
+ const extracted = options.extract(body);
31
+ const patches: Patch[] = Object.entries(extracted).map(([path, value]) => ({
32
+ path: pathJoin(options.writesTo, path),
33
+ value,
34
+ }));
35
+ if (patches.length > 0) scene.update(patches);
36
+ } else {
37
+ options.onError?.(new Error(`http_poll non-OK status: ${res.status}`));
38
+ }
39
+ } catch (err) {
40
+ options.onError?.(err);
41
+ }
42
+ if (!stopped) {
43
+ timer = setTimeout(() => void tick(), options.intervalMs);
44
+ }
45
+ }
46
+
47
+ void tick();
48
+
49
+ return () => {
50
+ stopped = true;
51
+ if (timer) clearTimeout(timer);
52
+ };
53
+ }
54
+
55
+ function pathJoin(prefix: LeafPath, sub: LeafPath): LeafPath {
56
+ if (prefix.length === 0) return sub;
57
+ if (sub.length === 0) return prefix;
58
+ return `${prefix}.${sub}`;
59
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,81 @@
1
+ // Auth model — token-agnostic. The server kit only consumes the result of
2
+ // the user-supplied `authenticate` hook. LSDP/1 §8: the protocol does not
3
+ // validate tokens, it transmits them.
4
+
5
+ import type { LeafPath } from "@lumencast/protocol";
6
+
7
+ export type Role = "viewer" | "operator" | "service" | "test";
8
+
9
+ export interface AuthDecision {
10
+ role: Role;
11
+ /** For role=service: restrict input writes to this path prefix. Optional. */
12
+ paths?: LeafPath[];
13
+ /** Diagnostic — surfaced in logs / metrics, not in the wire frames. */
14
+ subject?: string;
15
+ }
16
+
17
+ export type Authenticate = (token: string) => Promise<AuthDecision> | AuthDecision;
18
+
19
+ /**
20
+ * Default authenticate — accepts everything as `viewer`.
21
+ * Useful for local development; never deploy this to production.
22
+ */
23
+ export const defaultAuthenticate: Authenticate = () => ({ role: "viewer" });
24
+
25
+ /**
26
+ * Mutable token → AuthDecision map for test setups (interop control plane).
27
+ * NEVER use in production — tokens are kept in process memory and any caller
28
+ * with reset()/setMany() can rewrite the auth store.
29
+ */
30
+ export class StaticTokens {
31
+ private readonly map = new Map<string, AuthDecision>();
32
+
33
+ /** Authenticate hook bound to this instance. Pass to `startServer()`. */
34
+ authenticate: Authenticate = (token: string) => {
35
+ const d = this.map.get(token);
36
+ if (!d) {
37
+ throw new (class extends Error {
38
+ readonly code = "AUTH_DENIED" as const;
39
+ readonly recoverable = false;
40
+ })(`unknown token`);
41
+ }
42
+ return d;
43
+ };
44
+
45
+ set(token: string, decision: AuthDecision): void {
46
+ this.map.set(token, decision);
47
+ }
48
+
49
+ setMany(entries: Iterable<[string, AuthDecision]>): void {
50
+ for (const [t, d] of entries) this.map.set(t, d);
51
+ }
52
+
53
+ delete(token: string): boolean {
54
+ return this.map.delete(token);
55
+ }
56
+
57
+ reset(): void {
58
+ this.map.clear();
59
+ }
60
+
61
+ size(): number {
62
+ return this.map.size;
63
+ }
64
+ }
65
+
66
+ /** Returns true if the role is permitted to write to the given path. */
67
+ export function canWritePath(decision: AuthDecision, path: LeafPath): boolean {
68
+ // Test sessions have their own namespace.
69
+ if (decision.role === "test") return path.startsWith("__test.");
70
+ // Viewers can never write.
71
+ if (decision.role === "viewer") return false;
72
+ // Operators can write any __inputs.* path.
73
+ if (decision.role === "operator") return path.startsWith("__inputs.");
74
+ // Services can write __inputs.* paths, optionally restricted by `paths`.
75
+ if (decision.role === "service") {
76
+ if (!path.startsWith("__inputs.")) return false;
77
+ if (!decision.paths || decision.paths.length === 0) return true;
78
+ return decision.paths.some((p) => path.startsWith(p));
79
+ }
80
+ return false;
81
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env node
2
+ // lumencast-js server CLI.
3
+ //
4
+ // Subcommands:
5
+ // serve-scenario --ws-port N --test-control-port M [--host H]
6
+ // Boots the LSDP/1 server with the interop test control plane on a
7
+ // separate port. Prints exactly one JSON discovery line on stdout :
8
+ // {"control_url":"http://...","ws_url":"ws://.../lsdp/v1"}
9
+ // before any other output, then stays silent on stdout (logs go to stderr).
10
+
11
+ import { parseArgs } from "node:util";
12
+ import { createScene } from "./scene.js";
13
+ import { startServer, type ServerHandle } from "./server.js";
14
+ import { StaticTokens } from "./auth.js";
15
+ import { startTestControl, type TestControlHandle } from "./test-control.js";
16
+
17
+ async function main(argv: string[]): Promise<number> {
18
+ const sub = argv[0];
19
+ if (!sub || sub === "--help" || sub === "-h") {
20
+ printUsage();
21
+ return sub ? 0 : 2;
22
+ }
23
+ if (sub === "serve-scenario") return cmdServeScenario(argv.slice(1));
24
+ process.stderr.write(`lumencast-js: unknown subcommand "${sub}"\n`);
25
+ printUsage();
26
+ return 2;
27
+ }
28
+
29
+ function printUsage(): void {
30
+ process.stderr.write(
31
+ [
32
+ "usage: lumencast-js <subcommand> [flags]",
33
+ "",
34
+ "subcommands:",
35
+ " serve-scenario --ws-port N --test-control-port M [--host H]",
36
+ " Boot LSDP/1 server + interop test control plane (separate ports).",
37
+ "",
38
+ ].join("\n"),
39
+ );
40
+ }
41
+
42
+ async function cmdServeScenario(args: string[]): Promise<number> {
43
+ let parsed;
44
+ try {
45
+ parsed = parseArgs({
46
+ args,
47
+ options: {
48
+ "ws-port": { type: "string" },
49
+ "test-control-port": { type: "string" },
50
+ host: { type: "string", default: "127.0.0.1" },
51
+ help: { type: "boolean", short: "h" },
52
+ },
53
+ strict: true,
54
+ allowPositionals: false,
55
+ });
56
+ } catch (err) {
57
+ process.stderr.write(`serve-scenario: ${(err as Error).message}\n`);
58
+ return 2;
59
+ }
60
+
61
+ if (parsed.values["help"]) {
62
+ process.stderr.write("serve-scenario --ws-port N --test-control-port M [--host H]\n");
63
+ return 0;
64
+ }
65
+
66
+ const wsPort = parseIntFlag(parsed.values["ws-port"], "--ws-port");
67
+ const controlPort = parseIntFlag(parsed.values["test-control-port"], "--test-control-port");
68
+ if (wsPort === null || controlPort === null) return 2;
69
+ const host = (parsed.values["host"] as string | undefined) ?? "127.0.0.1";
70
+
71
+ // Bootstrap a synthetic scene + empty bundle provider. Both are immediately
72
+ // overridden by the first POST /test/setup.
73
+ const initialScene = createScene({
74
+ sceneId: "__interop_initial__",
75
+ sceneVersion: "sha256:" + "0".repeat(64),
76
+ initialState: {},
77
+ });
78
+ const auth = new StaticTokens();
79
+
80
+ let server: ServerHandle;
81
+ let control: TestControlHandle;
82
+ try {
83
+ server = await startServer({
84
+ port: wsPort,
85
+ host,
86
+ scene: initialScene,
87
+ bundleProvider: () => undefined,
88
+ authenticate: auth.authenticate,
89
+ });
90
+ } catch (err) {
91
+ process.stderr.write(`serve-scenario: bind ws: ${(err as Error).message}\n`);
92
+ return 1;
93
+ }
94
+ try {
95
+ control = await startTestControl({
96
+ port: controlPort,
97
+ host,
98
+ server,
99
+ auth,
100
+ });
101
+ } catch (err) {
102
+ await server.close();
103
+ process.stderr.write(`serve-scenario: bind control: ${(err as Error).message}\n`);
104
+ return 1;
105
+ }
106
+
107
+ // Discovery line — written before any other stdout output. The matrix
108
+ // driver greps for "control_url" to know we're ready.
109
+ process.stdout.write(
110
+ JSON.stringify({
111
+ control_url: control.url,
112
+ ws_url: server.wsUrl,
113
+ }) + "\n",
114
+ );
115
+
116
+ let shutdown: (() => Promise<void>) | null = async () => {
117
+ shutdown = null;
118
+ process.stderr.write("[lumencast-js] shutting down\n");
119
+ try {
120
+ await control.close();
121
+ } catch {
122
+ // ignore
123
+ }
124
+ try {
125
+ await server.close();
126
+ } catch {
127
+ // ignore
128
+ }
129
+ };
130
+
131
+ process.on("SIGINT", () => void shutdown?.());
132
+ process.on("SIGTERM", () => void shutdown?.());
133
+
134
+ await new Promise<void>((resolve) => {
135
+ const tick = (): void => {
136
+ if (shutdown === null) resolve();
137
+ else setTimeout(tick, 100);
138
+ };
139
+ tick();
140
+ });
141
+
142
+ return 0;
143
+ }
144
+
145
+ function parseIntFlag(raw: unknown, name: string): number | null {
146
+ if (typeof raw !== "string") {
147
+ process.stderr.write(`${name} required\n`);
148
+ return null;
149
+ }
150
+ const n = Number.parseInt(raw, 10);
151
+ if (!Number.isFinite(n) || n < 0 || n > 65535) {
152
+ process.stderr.write(`${name}: invalid port\n`);
153
+ return null;
154
+ }
155
+ return n;
156
+ }
157
+
158
+ main(process.argv.slice(2)).then(
159
+ (code) => {
160
+ process.exitCode = code;
161
+ },
162
+ (err) => {
163
+ process.stderr.write(`lumencast-js: ${(err as Error).stack ?? String(err)}\n`);
164
+ process.exitCode = 1;
165
+ },
166
+ );
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ // Public surface of @lumencast/server.
2
+
3
+ export { startServer, type ServerConfig, type ServerHandle } from "./server.js";
4
+ export { createScene, type Scene, type SceneInit } from "./scene.js";
5
+ export { LeafStore, type LeafStoreListener } from "./store.js";
6
+ export {
7
+ defaultAuthenticate,
8
+ canWritePath,
9
+ StaticTokens,
10
+ type Authenticate,
11
+ type AuthDecision,
12
+ type Role,
13
+ } from "./auth.js";
14
+ export { startHttpPoll, type HttpPollOptions } from "./adapters/http-poll.js";
15
+ export {
16
+ startTestControl,
17
+ installTokens,
18
+ type TestControlOptions,
19
+ type TestControlHandle,
20
+ } from "./test-control.js";
@@ -0,0 +1,72 @@
1
+ // Per-scene replay buffer (LSDP/1.1 §18.1).
2
+ // Bounded ring of recent (seq, patches, cause) emissions so a 1.1
3
+ // client reconnecting with `since_sequence` can resume without a fresh
4
+ // snapshot.
5
+
6
+ import type { Cause, Patch } from "@lumencast/protocol";
7
+
8
+ export interface ReplayRecord {
9
+ readonly seq: number;
10
+ readonly patches: Patch[];
11
+ readonly cause?: Cause;
12
+ }
13
+
14
+ /** Default capacity (LSDP/1.1 §18.1 SHOULD ≥ 256). */
15
+ export const DEFAULT_REPLAY_BUFFER_SIZE = 256;
16
+
17
+ export class ReplayBuffer {
18
+ private readonly cap: number;
19
+ private readonly records: (ReplayRecord | undefined)[];
20
+ private head = 0;
21
+ private size = 0;
22
+
23
+ constructor(capacity = DEFAULT_REPLAY_BUFFER_SIZE) {
24
+ this.cap = capacity > 0 ? capacity : DEFAULT_REPLAY_BUFFER_SIZE;
25
+ this.records = new Array<ReplayRecord | undefined>(this.cap);
26
+ }
27
+
28
+ /** Record one emission. Caller is responsible for monotonic seq. */
29
+ push(record: ReplayRecord): void {
30
+ this.records[this.head] = record;
31
+ this.head = (this.head + 1) % this.cap;
32
+ if (this.size < this.cap) this.size++;
33
+ }
34
+
35
+ /**
36
+ * Return every record with seq > sinceSeq, in monotonic order.
37
+ *
38
+ * The boolean is `false` when sinceSeq is older than the buffer's
39
+ * earliest retained entry — the caller MUST then fall back to a
40
+ * fresh snapshot per LSDP/1.1 §18.1.
41
+ */
42
+ since(sinceSeq: number): { records: ReplayRecord[]; covered: boolean } {
43
+ if (this.size === 0) {
44
+ // Empty buffer — caller decides whether sinceSeq matches the
45
+ // current scene seq (caught up) or warrants a snapshot.
46
+ return { records: [], covered: true };
47
+ }
48
+ const tail = (this.head - this.size + this.cap) % this.cap;
49
+ const earliest = this.records[tail]!.seq;
50
+ if (sinceSeq + 1 < earliest) {
51
+ return { records: [], covered: false };
52
+ }
53
+ const out: ReplayRecord[] = [];
54
+ for (let i = 0; i < this.size; i++) {
55
+ const idx = (tail + i) % this.cap;
56
+ const r = this.records[idx]!;
57
+ if (r.seq > sinceSeq) out.push(r);
58
+ }
59
+ return { records: out, covered: true };
60
+ }
61
+
62
+ /** Clear the buffer. Used on scene_changed. */
63
+ reset(): void {
64
+ this.head = 0;
65
+ this.size = 0;
66
+ this.records.fill(undefined);
67
+ }
68
+
69
+ get length(): number {
70
+ return this.size;
71
+ }
72
+ }
package/src/scene.ts ADDED
@@ -0,0 +1,168 @@
1
+ // Scene — the server's authoritative view of one Lumencast scene.
2
+ // Wraps a LeafStore + identity (sceneId, sceneVersion) and an optional
3
+ // operator_inputs schema used by the server to validate `input` frames.
4
+
5
+ import type { Cause, LeafPath, LeafValue, Patch, SceneId, SceneVersion } from "@lumencast/protocol";
6
+ import { LeafStore } from "./store.js";
7
+ import { DEFAULT_REPLAY_BUFFER_SIZE, ReplayBuffer, type ReplayRecord } from "./replay-buffer.js";
8
+
9
+ export interface OperatorInputDecl {
10
+ path: LeafPath;
11
+ /** "string" | "number" | "boolean" | "enum" | ... — see LSML 1.0 §8. */
12
+ type: string;
13
+ /** Type-specific constraints — currently maxLength, minLength, min, max, pattern. */
14
+ constraints?: {
15
+ maxLength?: number;
16
+ minLength?: number;
17
+ min?: number;
18
+ max?: number;
19
+ pattern?: string;
20
+ };
21
+ values?: unknown[];
22
+ writable_by?: string[];
23
+ [extra: string]: unknown;
24
+ }
25
+
26
+ export interface SceneInit {
27
+ sceneId: SceneId;
28
+ sceneVersion: SceneVersion;
29
+ initialState?: Record<LeafPath, LeafValue>;
30
+ /** Declared operator inputs. When set, `validateInput` enforces the schema. */
31
+ operatorInputs?: OperatorInputDecl[];
32
+ }
33
+
34
+ export type ValidationError =
35
+ | { code: "UNKNOWN_PATH"; path: LeafPath; message: string }
36
+ | { code: "INVALID_VALUE"; path: LeafPath; message: string };
37
+
38
+ export interface Scene {
39
+ readonly sceneId: SceneId;
40
+ readonly sceneVersion: SceneVersion;
41
+ readonly store: LeafStore;
42
+ readonly operatorInputs: OperatorInputDecl[];
43
+ /** Update one or more leaves. Atomic per call. Optional `cause` propagates
44
+ * to subscribers as the resulting Delta.cause (LSDP/1.1 §3.2.3). */
45
+ update(patches: Patch[] | Record<LeafPath, LeafValue>, cause?: Cause): void;
46
+ /** Subscribe to all patches emitted by this scene. The listener receives
47
+ * the per-scene `seq` (LSDP/1.1 §18.1.1) — the same value across all
48
+ * concurrent listeners on a given delta. */
49
+ onPatches(listener: (seq: number, patches: Patch[], cause?: Cause) => void): () => void;
50
+ /** Current per-scene seq counter (LSDP/1.1 §18.1.1). 1 on a fresh
51
+ * scene ; increments on every emit. Late-joining subscribers ship
52
+ * snapshot at this value. */
53
+ currentSeq(): number;
54
+ /** Replay records strictly after `sinceSeq` for §18.1 resume.
55
+ * Returns `covered: false` when sinceSeq is older than the buffer's
56
+ * earliest entry — the caller MUST fall back to a fresh snapshot. */
57
+ replaySince(sinceSeq: number): { records: ReplayRecord[]; covered: boolean };
58
+ /**
59
+ * Validate input patches against the declared operator_inputs schema.
60
+ * Returns the first error or null. The reserved `__test.*` namespace is
61
+ * always permitted (test sessions own that namespace per LSDP/1 §10).
62
+ */
63
+ validateInput(patches: Patch[]): ValidationError | null;
64
+ }
65
+
66
+ export function createScene(init: SceneInit): Scene {
67
+ const store = new LeafStore(init.initialState ?? {});
68
+ const operatorInputs = init.operatorInputs ?? [];
69
+ const inputsByPath = new Map<LeafPath, OperatorInputDecl>();
70
+ for (const oi of operatorInputs) inputsByPath.set(oi.path, oi);
71
+
72
+ // LSDP/1.1 §18.1.1 — per-scene monotonic seq. Pre-seeded to 1 so the
73
+ // very first subscriber's snapshot ships at seq=1 (matches every
74
+ // existing 1.0 conformance scenario). Subsequent emits increment.
75
+ let seq = 1;
76
+ const replay = new ReplayBuffer(DEFAULT_REPLAY_BUFFER_SIZE);
77
+ type SceneListener = (seq: number, patches: Patch[], cause?: Cause) => void;
78
+ const sceneListeners = new Set<SceneListener>();
79
+
80
+ // Bridge LeafStore.onPatches → scene listeners. Single store listener
81
+ // increments `seq`, records to the buffer, fans out to scene listeners.
82
+ store.onPatches((patches, cause) => {
83
+ seq += 1;
84
+ replay.push({ seq, patches: patches.slice(), cause });
85
+ for (const l of sceneListeners) l(seq, patches, cause);
86
+ });
87
+
88
+ function update(input: Patch[] | Record<LeafPath, LeafValue>, cause?: Cause): void {
89
+ const patches: Patch[] = Array.isArray(input)
90
+ ? input
91
+ : Object.entries(input).map(([path, value]) => ({ path, value }));
92
+ store.apply(patches, cause);
93
+ }
94
+
95
+ function validateInput(patches: Patch[]): ValidationError | null {
96
+ // No declared schema → server accepts any path (legacy / dev-mode behavior).
97
+ if (operatorInputs.length === 0) return null;
98
+
99
+ for (const p of patches) {
100
+ if (p.path.startsWith("__test.")) continue; // test namespace bypass
101
+ const decl = inputsByPath.get(p.path);
102
+ if (!decl) {
103
+ return {
104
+ code: "UNKNOWN_PATH",
105
+ path: p.path,
106
+ message: `path ${p.path} not declared in operator_inputs`,
107
+ };
108
+ }
109
+ const err = checkConstraint(decl, p.value);
110
+ if (err) return { code: "INVALID_VALUE", path: p.path, message: err };
111
+ }
112
+ return null;
113
+ }
114
+
115
+ return {
116
+ sceneId: init.sceneId,
117
+ sceneVersion: init.sceneVersion,
118
+ store,
119
+ operatorInputs,
120
+ update,
121
+ onPatches: (listener) => {
122
+ sceneListeners.add(listener);
123
+ return () => {
124
+ sceneListeners.delete(listener);
125
+ };
126
+ },
127
+ currentSeq: () => seq,
128
+ replaySince: (sinceSeq) => replay.since(sinceSeq),
129
+ validateInput,
130
+ };
131
+ }
132
+
133
+ function checkConstraint(decl: OperatorInputDecl, value: unknown): string | null {
134
+ switch (decl.type) {
135
+ case "string":
136
+ case "text":
137
+ if (typeof value !== "string") return `expected string, got ${typeof value}`;
138
+ if (decl.constraints?.maxLength !== undefined && value.length > decl.constraints.maxLength) {
139
+ return `exceeds maxLength ${decl.constraints.maxLength}`;
140
+ }
141
+ if (decl.constraints?.minLength !== undefined && value.length < decl.constraints.minLength) {
142
+ return `below minLength ${decl.constraints.minLength}`;
143
+ }
144
+ if (decl.constraints?.pattern && !new RegExp(decl.constraints.pattern).test(value)) {
145
+ return `does not match pattern`;
146
+ }
147
+ return null;
148
+ case "number":
149
+ if (typeof value !== "number") return `expected number, got ${typeof value}`;
150
+ if (decl.constraints?.min !== undefined && value < decl.constraints.min) {
151
+ return `below min ${decl.constraints.min}`;
152
+ }
153
+ if (decl.constraints?.max !== undefined && value > decl.constraints.max) {
154
+ return `above max ${decl.constraints.max}`;
155
+ }
156
+ return null;
157
+ case "boolean":
158
+ if (typeof value !== "boolean") return `expected boolean, got ${typeof value}`;
159
+ return null;
160
+ case "enum":
161
+ if (decl.values && !decl.values.includes(value)) {
162
+ return `not in enum`;
163
+ }
164
+ return null;
165
+ default:
166
+ return null;
167
+ }
168
+ }