@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/src/server.ts ADDED
@@ -0,0 +1,386 @@
1
+ // HTTP + WebSocket server kit for LSDP/1.
2
+ //
3
+ // Single-scene at a time, single-process. The active scene is mutable so the
4
+ // interop test control plane can swap it via /test/setup without restarting
5
+ // the server.
6
+ //
7
+ // LSDP/1 spec compliance highlights:
8
+ // - WebSocket subprotocol negotiation: `lsdp.v1`
9
+ // - subscribe → snapshot(seq=1) → delta(seq=2,...)
10
+ // - input frames validated against `authenticate()` decision + canWritePath()
11
+ // - error frames carry codes from the closed taxonomy
12
+ // - heartbeat: ping → pong
13
+
14
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
15
+ import { WebSocketServer, type WebSocket } from "ws";
16
+ import {
17
+ delta as deltaFrame,
18
+ decodeClientFrame,
19
+ encodeFrame,
20
+ errorFrame,
21
+ isProtocolErrorCode,
22
+ LumencastError,
23
+ pong,
24
+ snapshot as snapshotFrame,
25
+ WS_SUBPROTOCOL,
26
+ WS_SUBPROTOCOL_V1_1,
27
+ type Cause,
28
+ type ErrorCode,
29
+ type Patch,
30
+ type SceneVersion,
31
+ } from "@lumencast/protocol";
32
+ import { canWritePath, defaultAuthenticate, type Authenticate, type AuthDecision } from "./auth.js";
33
+ import type { Scene } from "./scene.js";
34
+
35
+ export interface ServerConfig {
36
+ /** TCP port to listen on. 0 = random. */
37
+ port?: number;
38
+ /** Hostname to bind. Defaults to 127.0.0.1. */
39
+ host?: string;
40
+ /** The initial scene this server serves. May be swapped via setActiveScene(). */
41
+ scene: Scene;
42
+ /** Resolves a SceneVersion to its LSML bundle JSON. May be swapped. */
43
+ bundleProvider: (version: SceneVersion) => Promise<unknown> | unknown;
44
+ /** Authenticate every subscribe; default accepts everything as `viewer`. */
45
+ authenticate?: Authenticate;
46
+ /** Optional ws path. Defaults to `/lsdp/v1`. */
47
+ wsPath?: string;
48
+ /** Optional bundle path prefix. Defaults to `/lsdp/v1/scenes`. */
49
+ bundlePathPrefix?: string;
50
+ }
51
+
52
+ export interface ServerHandle {
53
+ readonly wsUrl: string;
54
+ readonly httpUrl: string;
55
+ /**
56
+ * Replace the active scene. Detaches every subscriber (they receive a 1012
57
+ * "service restart" close so well-behaved clients reconnect cleanly).
58
+ * Test/interop only — production code should never call this.
59
+ */
60
+ setActiveScene(scene: Scene): void;
61
+ /** Replace the bundle provider. Test/interop only. */
62
+ setBundleProvider(fn: (version: SceneVersion) => Promise<unknown> | unknown): void;
63
+ /** Read the current active scene. */
64
+ activeScene(): Scene;
65
+ /** Drop all subscribers without replacing the scene. Test/interop only. */
66
+ reset(): void;
67
+ close(): Promise<void>;
68
+ }
69
+
70
+ interface Subscriber {
71
+ ws: WebSocket;
72
+ // Per-scene seq (LSDP/1.1 §18.1.1) — see activeScene.currentSeq().
73
+ // Subscribers don't carry their own counter.
74
+ decision: AuthDecision | null;
75
+ unsubscribePatches: (() => void) | null;
76
+ }
77
+
78
+ export async function startServer(config: ServerConfig): Promise<ServerHandle> {
79
+ const host = config.host ?? "127.0.0.1";
80
+ const wsPath = config.wsPath ?? "/lsdp/v1";
81
+ const bundlePathPrefix = config.bundlePathPrefix ?? "/lsdp/v1/scenes";
82
+ const authenticate = config.authenticate ?? defaultAuthenticate;
83
+
84
+ // Mutable refs — swappable via setActiveScene / setBundleProvider.
85
+ let activeScene: Scene = config.scene;
86
+ let bundleProvider = config.bundleProvider;
87
+
88
+ const httpServer: Server = createServer((req, res) => handleHttp(req, res));
89
+
90
+ await new Promise<void>((resolve, reject) => {
91
+ httpServer.once("error", reject);
92
+ httpServer.listen(config.port ?? 0, host, () => {
93
+ httpServer.off("error", reject);
94
+ resolve();
95
+ });
96
+ });
97
+
98
+ const address = httpServer.address();
99
+ if (!address || typeof address === "string") {
100
+ throw new Error("server: failed to bind HTTP");
101
+ }
102
+ const port = address.port;
103
+
104
+ const wss = new WebSocketServer({
105
+ server: httpServer,
106
+ path: wsPath,
107
+ handleProtocols: (offered: Set<string>) => {
108
+ // LSDP/1.1 preferred, 1.0 fallback. Anything else → reject upgrade.
109
+ if (offered.has(WS_SUBPROTOCOL_V1_1)) return WS_SUBPROTOCOL_V1_1;
110
+ if (offered.has(WS_SUBPROTOCOL)) return WS_SUBPROTOCOL;
111
+ return false;
112
+ },
113
+ });
114
+
115
+ const subscribers = new Set<Subscriber>();
116
+
117
+ wss.on("connection", (ws) => {
118
+ const sub: Subscriber = { ws, decision: null, unsubscribePatches: null };
119
+ subscribers.add(sub);
120
+
121
+ ws.on("message", (data) => handleMessage(sub, String(data)).catch(() => undefined));
122
+ ws.on("close", () => {
123
+ sub.unsubscribePatches?.();
124
+ subscribers.delete(sub);
125
+ });
126
+ });
127
+
128
+ async function handleMessage(sub: Subscriber, raw: string): Promise<void> {
129
+ let frame;
130
+ try {
131
+ frame = decodeClientFrame(raw);
132
+ } catch (err) {
133
+ const message = err instanceof Error ? err.message : "decode error";
134
+ // LSDP/1 §13: envelope.v mismatch → close with code 1002 directly,
135
+ // no error frame (the v != 1 client wouldn't grok our v: 1 envelope).
136
+ if (message.includes("envelope.v")) {
137
+ sub.ws.close(1002, "VERSION_MISMATCH");
138
+ return;
139
+ }
140
+ const code = err instanceof LumencastError ? err.code : "INVALID_VALUE";
141
+ const recoverable = err instanceof LumencastError ? err.recoverable : false;
142
+ const path = err instanceof LumencastError ? err.path : undefined;
143
+ sendError(sub, code, message, recoverable, path !== undefined ? { path } : undefined);
144
+ // Recoverable decode errors (e.g. forbidden object value in a single
145
+ // patch per §3.2.1) MUST keep the connection open ; only fatal
146
+ // structural errors close.
147
+ if (!recoverable) {
148
+ sub.ws.close(1002, code);
149
+ }
150
+ return;
151
+ }
152
+ if (frame === null) return; // forward-compat ignore
153
+
154
+ switch (frame.type) {
155
+ case "subscribe": {
156
+ await handleSubscribe(sub, frame.token, frame.since_sequence);
157
+ return;
158
+ }
159
+ case "input": {
160
+ await handleInput(sub, frame.patches, frame.client_msg_id);
161
+ return;
162
+ }
163
+ case "ping": {
164
+ // LSDP/1.1 §3.5 — echo nonce verbatim in pong (omit if absent).
165
+ sendFrame(sub, pong(frame.nonce));
166
+ return;
167
+ }
168
+ case "unsubscribe": {
169
+ // LSDP/1.1 §4.4 — clean teardown. Close WS within 1s, no error frame.
170
+ sub.ws.close(1000, "unsubscribe");
171
+ return;
172
+ }
173
+ }
174
+ }
175
+
176
+ async function handleSubscribe(
177
+ sub: Subscriber,
178
+ token: string,
179
+ sinceSequence?: number,
180
+ ): Promise<void> {
181
+ let decision: AuthDecision;
182
+ try {
183
+ decision = await authenticate(token);
184
+ } catch (err) {
185
+ const code: ErrorCode =
186
+ err instanceof LumencastError && isProtocolErrorCode(err.code) ? err.code : "AUTH_DENIED";
187
+ sendError(sub, code, err instanceof Error ? err.message : "auth error", false);
188
+ sub.ws.close(1008, code);
189
+ return;
190
+ }
191
+ sub.decision = decision;
192
+
193
+ // LSDP/1.1 §4.1, §18 — honour since_sequence when the replay buffer
194
+ // covers the gap. Otherwise fall back to a fresh snapshot at the
195
+ // current scene seq.
196
+ const curSeq = activeScene.currentSeq();
197
+ let useReplay = false;
198
+ if (sinceSequence !== undefined && sinceSequence > 0 && sinceSequence <= curSeq) {
199
+ const { records, covered } = activeScene.replaySince(sinceSequence);
200
+ if (covered) {
201
+ useReplay = true;
202
+ for (const r of records) {
203
+ sendFrame(sub, deltaFrame({ seq: r.seq, patches: r.patches, cause: r.cause }));
204
+ }
205
+ }
206
+ }
207
+ if (!useReplay) {
208
+ sendFrame(
209
+ sub,
210
+ snapshotFrame({
211
+ seq: curSeq,
212
+ scene_id: activeScene.sceneId,
213
+ scene_version: activeScene.sceneVersion,
214
+ state: activeScene.store.snapshot(),
215
+ ts: new Date().toISOString(),
216
+ }),
217
+ );
218
+ }
219
+
220
+ // Wire up delta forwarding from the *current* active scene. If the active
221
+ // scene swaps, this subscriber stays bound to the old one and is detached
222
+ // by setActiveScene(). Per-scene seq (§18.1.1) — the listener receives
223
+ // the seq directly from the scene.
224
+ sub.unsubscribePatches = activeScene.onPatches((seq, patches, cause) => {
225
+ sendFrame(sub, deltaFrame({ seq, patches, cause }));
226
+ });
227
+ }
228
+
229
+ async function handleInput(
230
+ sub: Subscriber,
231
+ patches: Patch[],
232
+ clientMsgId?: string,
233
+ ): Promise<void> {
234
+ if (!sub.decision) {
235
+ sendError(sub, "AUTH_DENIED", "input before subscribe", false);
236
+ return;
237
+ }
238
+ // 1. Role-based authorization (LSDP/1 §9).
239
+ for (const p of patches) {
240
+ if (!canWritePath(sub.decision, p.path)) {
241
+ sendError(
242
+ sub,
243
+ "WRITE_FORBIDDEN",
244
+ `role ${sub.decision.role} cannot write ${p.path}`,
245
+ true,
246
+ { path: p.path },
247
+ );
248
+ return;
249
+ }
250
+ }
251
+ // 2. Schema validation against operator_inputs (LSDP/1 §4.2 + LSML §8).
252
+ // Atomic: reject the whole frame on the first violation, no patches applied.
253
+ const validationError = activeScene.validateInput(patches);
254
+ if (validationError) {
255
+ sendError(sub, validationError.code, validationError.message, true, {
256
+ path: validationError.path,
257
+ });
258
+ return;
259
+ }
260
+ // LSDP/1.1 §3.2.3 — derive provenance metadata for the resulting delta.
261
+ // The `source` is conventionally `<role>:<subject>` ; when client_msg_id
262
+ // is provided, echo it verbatim into `input_id` for optimistic-UI
263
+ // correlation (§4.2). Cause is omitted entirely if neither yields data,
264
+ // so 1.0 wire shape is preserved when no client opts into 1.1.
265
+ const subject = sub.decision.subject ?? sub.decision.role;
266
+ const cause: Cause | undefined = clientMsgId
267
+ ? { source: `${sub.decision.role}:${subject}`, input_id: clientMsgId }
268
+ : undefined;
269
+ activeScene.update(patches, cause);
270
+ }
271
+
272
+ function handleHttp(req: IncomingMessage, res: ServerResponse): void {
273
+ const url = req.url ?? "/";
274
+ res.setHeader("access-control-allow-origin", "*");
275
+ res.setHeader("access-control-allow-methods", "GET, OPTIONS");
276
+ res.setHeader("access-control-allow-headers", "*");
277
+ if (req.method === "OPTIONS") {
278
+ res.statusCode = 204;
279
+ res.end();
280
+ return;
281
+ }
282
+
283
+ if (req.method === "GET" && url === `${wsPath}/health`) {
284
+ writeJson(res, 200, { status: "ok" });
285
+ return;
286
+ }
287
+
288
+ // GET /lsdp/v1/scenes/{id}/bundle?v=sha256:...
289
+ const escaped = bundlePathPrefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
290
+ const re = new RegExp(`^${escaped}/([^/?]+)/bundle(?:\\?v=([^&]+))?$`);
291
+ const match = url.match(re);
292
+ if (req.method === "GET" && match) {
293
+ const versionParam = match[2] ? decodeURIComponent(match[2]) : activeScene.sceneVersion;
294
+ Promise.resolve(bundleProvider(versionParam))
295
+ .then((bundle) => {
296
+ if (!bundle) {
297
+ writeJson(res, 404, { error: "bundle_not_found" });
298
+ return;
299
+ }
300
+ res.statusCode = 200;
301
+ res.setHeader("content-type", "application/json");
302
+ res.setHeader("cache-control", "public, max-age=31536000, immutable");
303
+ res.end(JSON.stringify(bundle));
304
+ })
305
+ .catch((err) => writeJson(res, 500, { error: String(err) }));
306
+ return;
307
+ }
308
+
309
+ writeJson(res, 404, { error: "not_found" });
310
+ }
311
+
312
+ function sendFrame(sub: Subscriber, frame: object): void {
313
+ if (sub.ws.readyState !== sub.ws.OPEN) return;
314
+ sub.ws.send(encodeFrame(frame as Parameters<typeof encodeFrame>[0]));
315
+ }
316
+
317
+ function sendError(
318
+ sub: Subscriber,
319
+ code: ErrorCode,
320
+ message: string,
321
+ recoverable: boolean,
322
+ extras?: { path?: string; retry_after_ms?: number },
323
+ ): void {
324
+ // Errors are connection-scoped, not scene-scoped events
325
+ // (LSDP/1.1 §18.1.1) — they ride the current scene seq without
326
+ // advancing it.
327
+ sendFrame(
328
+ sub,
329
+ errorFrame({
330
+ seq: activeScene.currentSeq(),
331
+ code,
332
+ message,
333
+ recoverable,
334
+ ...(extras?.path !== undefined ? { path: extras.path } : {}),
335
+ ...(extras?.retry_after_ms !== undefined ? { retry_after_ms: extras.retry_after_ms } : {}),
336
+ }),
337
+ );
338
+ }
339
+
340
+ function detachAllSubscribers(closeCode = 1012, reason = "service restart"): void {
341
+ for (const sub of subscribers) {
342
+ sub.unsubscribePatches?.();
343
+ try {
344
+ sub.ws.close(closeCode, reason);
345
+ } catch {
346
+ // ignore
347
+ }
348
+ }
349
+ subscribers.clear();
350
+ }
351
+
352
+ function setActiveScene(scene: Scene): void {
353
+ detachAllSubscribers();
354
+ activeScene = scene;
355
+ }
356
+
357
+ function setBundleProvider(fn: (version: SceneVersion) => Promise<unknown> | unknown): void {
358
+ bundleProvider = fn;
359
+ }
360
+
361
+ function reset(): void {
362
+ detachAllSubscribers();
363
+ }
364
+
365
+ async function close(): Promise<void> {
366
+ detachAllSubscribers(1000, "shutdown");
367
+ await new Promise<void>((resolve) => wss.close(() => resolve()));
368
+ await new Promise<void>((resolve) => httpServer.close(() => resolve()));
369
+ }
370
+
371
+ return {
372
+ wsUrl: `ws://${host}:${port}${wsPath}`,
373
+ httpUrl: `http://${host}:${port}`,
374
+ setActiveScene,
375
+ setBundleProvider,
376
+ activeScene: () => activeScene,
377
+ reset,
378
+ close,
379
+ };
380
+ }
381
+
382
+ function writeJson(res: ServerResponse, status: number, body: unknown): void {
383
+ res.statusCode = status;
384
+ res.setHeader("content-type", "application/json");
385
+ res.end(JSON.stringify(body));
386
+ }
package/src/store.ts ADDED
@@ -0,0 +1,40 @@
1
+ // LeafStore — a leaf-grain key/value map with an `onPatches` event.
2
+ // Adapters write into the store; the Scene listens and rebroadcasts as deltas.
3
+
4
+ import type { Cause, LeafPath, LeafValue, Patch } from "@lumencast/protocol";
5
+
6
+ export type LeafStoreListener = (patches: Patch[], cause?: Cause) => void;
7
+
8
+ export class LeafStore {
9
+ private state: Record<LeafPath, LeafValue> = {};
10
+ private listeners = new Set<LeafStoreListener>();
11
+
12
+ constructor(initial: Record<LeafPath, LeafValue> = {}) {
13
+ this.state = { ...initial };
14
+ }
15
+
16
+ /** Snapshot of the full state (shallow copy). */
17
+ snapshot(): Record<LeafPath, LeafValue> {
18
+ return { ...this.state };
19
+ }
20
+
21
+ /** Read a single leaf. */
22
+ get(path: LeafPath): LeafValue | undefined {
23
+ return this.state[path];
24
+ }
25
+
26
+ /** Apply patches and notify listeners. Returns the patches actually applied.
27
+ * Optional `cause` is forwarded to listeners so a downstream delta can carry
28
+ * provenance (LSDP/1.1 §3.2.3). */
29
+ apply(patches: Patch[], cause?: Cause): Patch[] {
30
+ if (patches.length === 0) return [];
31
+ for (const p of patches) this.state[p.path] = p.value;
32
+ for (const l of this.listeners) l(patches, cause);
33
+ return patches;
34
+ }
35
+
36
+ onPatches(listener: LeafStoreListener): () => void {
37
+ this.listeners.add(listener);
38
+ return () => this.listeners.delete(listener);
39
+ }
40
+ }
@@ -0,0 +1,279 @@
1
+ // LSDP/1 interop test control plane.
2
+ //
3
+ // HTTP API spec: lumencast-protocol/interop/CONTROL.md
4
+ // Reference impl: lumencast-go/interop/control/control.go (mirrored here)
5
+ //
6
+ // This module is for test infrastructure only. NEVER mount on a public-facing
7
+ // port. The serve-scenario CLI is the only sanctioned consumer.
8
+
9
+ import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
10
+ import { canWritePath, type AuthDecision, type Role, type StaticTokens } from "./auth.js";
11
+ import { createScene } from "./scene.js";
12
+ import type { ServerHandle } from "./server.js";
13
+
14
+ export interface TestControlOptions {
15
+ /** TCP port. 0 = random. */
16
+ port?: number;
17
+ host?: string;
18
+ /** The lumencast server the plane drives. */
19
+ server: ServerHandle;
20
+ /** The static-token authenticator the plane mutates on /test/setup. */
21
+ auth: StaticTokens;
22
+ }
23
+
24
+ export interface TestControlHandle {
25
+ readonly url: string;
26
+ close(): Promise<void>;
27
+ }
28
+
29
+ interface SetupRequest {
30
+ scenario?: string;
31
+ tokens?: Record<string, string>;
32
+ bundles: SetupBundle[];
33
+ initial_state?: Record<string, unknown>;
34
+ }
35
+
36
+ interface SetupBundle {
37
+ id: string;
38
+ hash?: string;
39
+ inline?: unknown;
40
+ }
41
+
42
+ interface EmitRequest {
43
+ patches: { path: string; value: unknown }[];
44
+ }
45
+
46
+ const SERVER_NAME = "lumencast-js";
47
+ const CONTROL_PLANE_VERSION = 1;
48
+
49
+ const ROUTES: Record<string, { method: string }> = {
50
+ "/test/setup": { method: "POST" },
51
+ "/test/reset": { method: "POST" },
52
+ "/test/state": { method: "GET" },
53
+ "/test/emit": { method: "POST" },
54
+ "/test/health": { method: "GET" },
55
+ };
56
+
57
+ export async function startTestControl(options: TestControlOptions): Promise<TestControlHandle> {
58
+ const host = options.host ?? "127.0.0.1";
59
+ const httpServer: Server = createServer((req, res) => handle(req, res));
60
+
61
+ await new Promise<void>((resolve, reject) => {
62
+ httpServer.once("error", reject);
63
+ httpServer.listen(options.port ?? 0, host, () => {
64
+ httpServer.off("error", reject);
65
+ resolve();
66
+ });
67
+ });
68
+
69
+ const address = httpServer.address();
70
+ if (!address || typeof address === "string") {
71
+ throw new Error("test-control: failed to bind HTTP");
72
+ }
73
+ const port = address.port;
74
+
75
+ function handle(req: IncomingMessage, res: ServerResponse): void {
76
+ const url = req.url ?? "/";
77
+ const method = req.method ?? "";
78
+ const route = ROUTES[url];
79
+ if (!route) {
80
+ writeProblem(res, 404, "not-found", `unknown path ${url}`);
81
+ return;
82
+ }
83
+ if (method !== route.method) {
84
+ writeProblem(res, 405, "method-not-allowed", `${method} not allowed on ${url}`);
85
+ return;
86
+ }
87
+ switch (url) {
88
+ case "/test/setup":
89
+ return void onSetup(req, res);
90
+ case "/test/reset":
91
+ return void onReset(req, res);
92
+ case "/test/state":
93
+ return void onState(req, res);
94
+ case "/test/emit":
95
+ return void onEmit(req, res);
96
+ case "/test/health":
97
+ return void onHealth(req, res);
98
+ }
99
+ }
100
+
101
+ async function onSetup(req: IncomingMessage, res: ServerResponse): Promise<void> {
102
+ let body: SetupRequest;
103
+ try {
104
+ body = (await readJson(req)) as SetupRequest;
105
+ } catch (err) {
106
+ writeProblem(res, 400, "bad-body", `invalid JSON: ${(err as Error).message}`);
107
+ return;
108
+ }
109
+ if (!body || !Array.isArray(body.bundles) || body.bundles.length === 0) {
110
+ writeProblem(res, 400, "missing-bundle", "at least one bundle required");
111
+ return;
112
+ }
113
+
114
+ options.server.reset();
115
+ installTokens(options.auth, body.tokens ?? {});
116
+
117
+ const primary = body.bundles[0];
118
+ if (!primary || !primary.id) {
119
+ writeProblem(res, 400, "missing-bundle-id", "bundles[0].id required");
120
+ return;
121
+ }
122
+
123
+ const initialState = (body.initial_state ?? {}) as Record<string, never>;
124
+ // Prefer the inline LSML's `scene_id` over the bundle id (which is just
125
+ // the $BUNDLE.<id>.hash placeholder identifier). Fall back to the bundle id.
126
+ const inline =
127
+ primary.inline && typeof primary.inline === "object" && primary.inline !== null
128
+ ? (primary.inline as { scene_id?: unknown; operator_inputs?: unknown })
129
+ : undefined;
130
+ const inlineSceneId = inline?.scene_id;
131
+ const sceneId = typeof inlineSceneId === "string" ? inlineSceneId : primary.id;
132
+ const sceneVersion = primary.hash ?? "";
133
+ const operatorInputs = Array.isArray(inline?.operator_inputs)
134
+ ? (inline.operator_inputs as Parameters<typeof createScene>[0]["operatorInputs"])
135
+ : undefined;
136
+ const scene = createScene({
137
+ sceneId,
138
+ sceneVersion,
139
+ initialState,
140
+ ...(operatorInputs ? { operatorInputs } : {}),
141
+ });
142
+ options.server.setActiveScene(scene);
143
+
144
+ // The bundle provider serves the inline body of any registered bundle.
145
+ const bundleMap = new Map<string, unknown>();
146
+ for (const b of body.bundles) {
147
+ if (b.hash && b.inline !== undefined) bundleMap.set(b.hash, b.inline);
148
+ }
149
+ options.server.setBundleProvider((version) => bundleMap.get(version));
150
+
151
+ writeJson(res, 200, {
152
+ ws_url: options.server.wsUrl,
153
+ scene_id: sceneId,
154
+ scene_version: sceneVersion,
155
+ });
156
+ }
157
+
158
+ function onReset(_req: IncomingMessage, res: ServerResponse): void {
159
+ options.server.reset();
160
+ options.auth.reset();
161
+ res.statusCode = 204;
162
+ res.end();
163
+ }
164
+
165
+ function onState(_req: IncomingMessage, res: ServerResponse): void {
166
+ const scene = options.server.activeScene();
167
+ // The contract returns 409 if /test/setup wasn't called; we approximate
168
+ // that condition by checking whether the scene has been swapped from a
169
+ // synthetic initial scene. Since the kit always has an active scene, we
170
+ // expose whatever is current. Callers that need the 409 semantics call
171
+ // /test/reset first to drop everything.
172
+ writeJson(res, 200, {
173
+ scene_id: scene.sceneId,
174
+ scene_version: scene.sceneVersion,
175
+ state: scene.store.snapshot(),
176
+ });
177
+ }
178
+
179
+ async function onEmit(req: IncomingMessage, res: ServerResponse): Promise<void> {
180
+ let body: EmitRequest;
181
+ try {
182
+ body = (await readJson(req)) as EmitRequest;
183
+ } catch (err) {
184
+ writeProblem(res, 400, "bad-body", `invalid JSON: ${(err as Error).message}`);
185
+ return;
186
+ }
187
+ if (!body || !Array.isArray(body.patches) || body.patches.length === 0) {
188
+ writeProblem(res, 400, "empty-patches", "at least one patch required");
189
+ return;
190
+ }
191
+ const scene = options.server.activeScene();
192
+ try {
193
+ scene.update(body.patches as Parameters<typeof scene.update>[0]);
194
+ } catch (err) {
195
+ writeProblem(res, 400, "invalid", (err as Error).message);
196
+ return;
197
+ }
198
+ res.statusCode = 204;
199
+ res.end();
200
+ }
201
+
202
+ function onHealth(_req: IncomingMessage, res: ServerResponse): void {
203
+ writeJson(res, 200, {
204
+ status: "ok",
205
+ control_plane_version: CONTROL_PLANE_VERSION,
206
+ server: SERVER_NAME,
207
+ });
208
+ }
209
+
210
+ async function close(): Promise<void> {
211
+ await new Promise<void>((resolve) => httpServer.close(() => resolve()));
212
+ }
213
+
214
+ return {
215
+ url: `http://${host}:${port}`,
216
+ close,
217
+ };
218
+ }
219
+
220
+ /** Replace the StaticTokens contents with one entry per recognised
221
+ * placeholder. $TOKEN_INVALID is intentionally NOT installed — the LSDP/1
222
+ * conformance suite expects auth to reject it. */
223
+ export function installTokens(auth: StaticTokens, tokens: Record<string, string>): void {
224
+ auth.reset();
225
+ for (const [placeholder, value] of Object.entries(tokens)) {
226
+ if (placeholder === "$TOKEN_INVALID" || !value) continue;
227
+ const role = placeholderRole(placeholder);
228
+ if (!role) continue;
229
+ auth.set(value, { role, subject: placeholder });
230
+ }
231
+ // canWritePath is referenced indirectly via the role we install — keep the
232
+ // import alive so the bundler doesn't drop it.
233
+ void canWritePath;
234
+ }
235
+
236
+ function placeholderRole(placeholder: string): Role | null {
237
+ switch (placeholder) {
238
+ case "$TOKEN_OPERATOR":
239
+ return "operator";
240
+ case "$TOKEN_VIEWER":
241
+ return "viewer";
242
+ case "$TOKEN_SERVICE":
243
+ return "service";
244
+ case "$TOKEN_TEST":
245
+ return "test";
246
+ default:
247
+ return null;
248
+ }
249
+ }
250
+
251
+ function writeJson(res: ServerResponse, status: number, body: unknown): void {
252
+ res.statusCode = status;
253
+ res.setHeader("content-type", "application/json");
254
+ res.end(JSON.stringify(body));
255
+ }
256
+
257
+ function writeProblem(res: ServerResponse, status: number, code: string, detail: string): void {
258
+ res.statusCode = status;
259
+ res.setHeader("content-type", "application/problem+json");
260
+ res.end(
261
+ JSON.stringify({
262
+ type: "about:blank",
263
+ title: `control: ${code}`,
264
+ status,
265
+ detail,
266
+ }),
267
+ );
268
+ }
269
+
270
+ async function readJson(req: IncomingMessage): Promise<unknown> {
271
+ const chunks: Buffer[] = [];
272
+ for await (const chunk of req) chunks.push(chunk as Buffer);
273
+ const body = Buffer.concat(chunks).toString("utf8");
274
+ if (body.length === 0) return {};
275
+ return JSON.parse(body);
276
+ }
277
+
278
+ // AuthDecision is referenced for type clarity; keep it exported via the helper.
279
+ export type { AuthDecision };