borgmcp-server 0.1.1 → 0.1.5

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 (61) hide show
  1. package/README.md +70 -22
  2. package/THIRD_PARTY_NOTICES.md +1 -0
  3. package/dist/cli.js +62 -11
  4. package/dist/cli.js.map +1 -1
  5. package/dist/coordination-api.d.ts +3 -1
  6. package/dist/coordination-api.js +476 -78
  7. package/dist/coordination-api.js.map +1 -1
  8. package/dist/credentials.d.ts +13 -7
  9. package/dist/credentials.js +77 -15
  10. package/dist/credentials.js.map +1 -1
  11. package/dist/debug-log.d.ts +76 -0
  12. package/dist/debug-log.js +124 -0
  13. package/dist/debug-log.js.map +1 -0
  14. package/dist/enrollment.d.ts +3 -11
  15. package/dist/enrollment.js +21 -60
  16. package/dist/enrollment.js.map +1 -1
  17. package/dist/https-server.d.ts +5 -20
  18. package/dist/https-server.js +108 -50
  19. package/dist/https-server.js.map +1 -1
  20. package/dist/index.d.ts +1 -1
  21. package/dist/message-taxonomy.d.ts +38 -0
  22. package/dist/message-taxonomy.js +218 -0
  23. package/dist/message-taxonomy.js.map +1 -0
  24. package/dist/migrations.d.ts +4 -0
  25. package/dist/migrations.js +99 -0
  26. package/dist/migrations.js.map +1 -1
  27. package/dist/operator-error.d.ts +2 -1
  28. package/dist/operator-error.js +23 -5
  29. package/dist/operator-error.js.map +1 -1
  30. package/dist/role-section.d.ts +14 -0
  31. package/dist/role-section.js +87 -0
  32. package/dist/role-section.js.map +1 -0
  33. package/dist/service.d.ts +14 -4
  34. package/dist/service.js +243 -25
  35. package/dist/service.js.map +1 -1
  36. package/dist/start-options.d.ts +5 -1
  37. package/dist/start-options.js +17 -3
  38. package/dist/start-options.js.map +1 -1
  39. package/dist/store.d.ts +106 -8
  40. package/dist/store.js +772 -120
  41. package/dist/store.js.map +1 -1
  42. package/npm-shrinkwrap.json +6 -7
  43. package/package.json +2 -2
  44. package/src/cli.ts +61 -11
  45. package/src/coordination-api.ts +490 -72
  46. package/src/credentials.ts +103 -19
  47. package/src/debug-log.ts +165 -0
  48. package/src/enrollment.ts +32 -78
  49. package/src/https-server.ts +113 -78
  50. package/src/index.ts +1 -1
  51. package/src/message-taxonomy.ts +284 -0
  52. package/src/migrations.ts +102 -0
  53. package/src/operator-error.ts +40 -6
  54. package/src/role-section.ts +108 -0
  55. package/src/service.ts +268 -27
  56. package/src/start-options.ts +21 -4
  57. package/src/store.ts +887 -142
  58. package/dist/protocol-draft.d.ts +0 -2
  59. package/dist/protocol-draft.js +0 -31
  60. package/dist/protocol-draft.js.map +0 -1
  61. package/src/protocol-draft.ts +0 -32
@@ -1,14 +1,35 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ ATTACH_PATH,
4
+ ErrorCode,
5
+ PROTOCOL_VERSION,
6
+ ProtocolContractError,
7
+ createProtocolEnvelope,
8
+ decodeAttachRequestEnvelope,
9
+ decodeProtocolEnvelope,
10
+ } from "borgmcp-shared/protocol";
1
11
  import type { Principal } from "./principal.js";
2
12
  import { assertServerDerivedPrincipal } from "./principal.js";
13
+ import { disabledDebugLogger, type DebugLogger } from "./debug-log.js";
14
+ import {
15
+ patchMessageTaxonomy,
16
+ resolveMessageRouting,
17
+ validateMessageTaxonomy,
18
+ } from "./message-taxonomy.js";
3
19
  import type { CredentialAuthority } from "./credentials.js";
4
20
  import {
5
21
  CursorExpiredError,
6
- AttachConflictError,
22
+ AttachSessionRejectedError,
7
23
  AccessDeniedError,
8
24
  CreateCubeConflictError,
25
+ DefaultRoleRequiredError,
26
+ RoleConflictError,
27
+ RoleSectionConflictError,
9
28
  ScopedStoreError,
10
29
  StorageCapacityError,
11
- type EnrichedActivityRecord,
30
+ type ActivityPage,
31
+ type ActivityStreamRecord,
32
+ type CubeRecord,
12
33
  type LogCursor,
13
34
  type StoreRuntime,
14
35
  } from "./store.js";
@@ -19,6 +40,7 @@ export interface CoordinationRequest {
19
40
  readonly principal: Principal;
20
41
  readonly body?: unknown;
21
42
  readonly cursor?: string;
43
+ readonly since?: string;
22
44
  readonly signal: AbortSignal;
23
45
  }
24
46
 
@@ -42,11 +64,20 @@ interface ReplayBarrier {
42
64
  export class CoordinationApi {
43
65
  readonly #runtime: StoreRuntime;
44
66
  readonly #authority: CredentialAuthority;
67
+ readonly #debugLogger: DebugLogger;
68
+ readonly #streamHeartbeatMs: number;
45
69
  #replayBarrier: ReplayBarrier | undefined;
46
70
 
47
- constructor(runtime: StoreRuntime, authority: CredentialAuthority) {
71
+ constructor(
72
+ runtime: StoreRuntime,
73
+ authority: CredentialAuthority,
74
+ debugLogger: DebugLogger = disabledDebugLogger,
75
+ streamHeartbeatMs = 5_000,
76
+ ) {
48
77
  this.#runtime = runtime;
49
78
  this.#authority = authority;
79
+ this.#debugLogger = debugLogger;
80
+ this.#streamHeartbeatMs = streamHeartbeatMs;
50
81
  }
51
82
 
52
83
  armReplayTransition(): { readonly reached: Promise<void>; readonly release: () => void } {
@@ -67,37 +98,38 @@ export class CoordinationApi {
67
98
  assertServerDerivedPrincipal(request.principal);
68
99
  const authentication = request.principal;
69
100
 
70
- if (request.path === "/api/client/attach" && request.method === "POST") {
101
+ if (request.path === ATTACH_PATH && request.method === "POST") {
71
102
  const requestId = safeRequestId(request.body);
72
103
  try {
73
- const envelope = decodeEnvelope(request.body);
74
- exactKeys(envelope.payload, ["cube_id", "role_id", "retry_key"], ["prior_drone_id"]);
75
- const priorDroneId = envelope.payload["prior_drone_id"] === undefined
76
- ? undefined
77
- : requiredUuid(envelope.payload, "prior_drone_id");
104
+ const envelope = decodeAttachRequestEnvelope(request.body);
105
+ const priorDroneId = envelope.payload.prior_drone_id;
78
106
  const attachment = this.#authority.attachSeat(
79
107
  this.#runtime.forPrincipal(authentication),
80
108
  {
81
- cubeId: requiredUuid(envelope.payload, "cube_id"),
82
- roleId: requiredUuid(envelope.payload, "role_id"),
83
- retryKey: requiredUuid(envelope.payload, "retry_key"),
109
+ cubeId: envelope.payload.cube_id,
110
+ roleId: envelope.payload.role_id,
111
+ sessionCredential: envelope.payload.session_credential,
84
112
  ...(priorDroneId === undefined ? {} : { priorDroneId }),
85
113
  },
86
114
  );
87
- return success(201, envelope.requestId, {
115
+ return success(attachment.result === "created" ? 201 : 200, envelope.request_id, {
116
+ result: attachment.result,
88
117
  cube: attachment.cube,
89
118
  role: attachment.role,
90
119
  drone: attachment.drone,
91
120
  session: {
92
- token: attachment.credential,
121
+ id: attachment.sessionId,
93
122
  expires_at: attachment.expiresAt,
94
- generation: attachment.generation,
95
123
  },
96
- reattached: attachment.reattached,
97
124
  });
98
125
  } catch (error) {
99
- if (error instanceof AttachConflictError) {
100
- return failure(409, "INVALID_INPUT", "The attach request conflicts.", requestId);
126
+ if (error instanceof ProtocolContractError) {
127
+ return error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION
128
+ ? failure(426, error.code, "Unsupported protocol version.", requestId)
129
+ : failure(400, "INVALID_INPUT", "Invalid protocol request.", requestId);
130
+ }
131
+ if (error instanceof AttachSessionRejectedError) {
132
+ return failure(401, ErrorCode.SESSION_REJECTED, error.message, requestId);
101
133
  }
102
134
  if (error instanceof ScopedStoreError) {
103
135
  return failure(404, "NOT_FOUND", error.message, requestId);
@@ -118,6 +150,7 @@ export class CoordinationApi {
118
150
  owner_id: cube.ownerId,
119
151
  name: cube.name,
120
152
  cube_directive: cube.directive,
153
+ message_taxonomy: cube.messageTaxonomy,
121
154
  created_at: cube.createdAt,
122
155
  updated_at: cube.updatedAt,
123
156
  }));
@@ -152,6 +185,11 @@ export class CoordinationApi {
152
185
  if (error instanceof StorageCapacityError) {
153
186
  return failure(507, "CAPACITY_EXCEEDED", error.message, requestId);
154
187
  }
188
+ if (error instanceof ProtocolContractError) {
189
+ return error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION
190
+ ? failure(426, error.code, "Unsupported protocol version.", requestId)
191
+ : failure(400, "INVALID_INPUT", "Invalid protocol request.", requestId);
192
+ }
155
193
  if (error instanceof InputError || error instanceof TypeError || error instanceof RangeError) {
156
194
  return failure(400, "INVALID_INPUT", "Invalid protocol request.", requestId);
157
195
  }
@@ -159,14 +197,20 @@ export class CoordinationApi {
159
197
  }
160
198
  }
161
199
 
162
- const match = /^\/api\/cubes\/([0-9a-f-]{36})(?:\/(roles|drones|logs|acks|decisions|stream))?$/u
200
+ const roleMatch = /^\/api\/cubes\/([0-9a-f-]{36})\/roles\/([0-9a-f-]{36})(\/section-patch)?$/u
201
+ .exec(request.path);
202
+ const match = /^\/api\/cubes\/([0-9a-f-]{36})(?:\/(roles|drones|logs|acks|decisions|stream|taxonomy-patch))?$/u
163
203
  .exec(request.path);
164
- if (match === null) return failure(404, "NOT_FOUND", "The requested resource was not found.");
165
- const cubeId = match[1]!;
166
- if (!uuidPattern.test(cubeId)) {
204
+ if (match === null && roleMatch === null) {
167
205
  return failure(404, "NOT_FOUND", "The requested resource was not found.");
168
206
  }
169
- const resource = match[2];
207
+ const cubeId = (roleMatch?.[1] ?? match?.[1])!;
208
+ const roleId = roleMatch?.[2];
209
+ if (!uuidPattern.test(cubeId) || (roleId !== undefined && !uuidPattern.test(roleId))) {
210
+ return failure(404, "NOT_FOUND", "The requested resource was not found.");
211
+ }
212
+ const resource = roleMatch === null ? match?.[2] : "role";
213
+ const sectionPatch = roleMatch?.[3] !== undefined;
170
214
  const store = this.#runtime.forPrincipal(authentication);
171
215
 
172
216
  try {
@@ -179,31 +223,187 @@ export class CoordinationApi {
179
223
  owner_id: cube.ownerId,
180
224
  name: cube.name,
181
225
  cube_directive: cube.directive,
226
+ message_taxonomy: cube.messageTaxonomy,
182
227
  created_at: cube.createdAt,
183
228
  updated_at: cube.updatedAt,
184
229
  },
185
230
  });
186
231
  }
232
+ if (resource === undefined && request.method === "PATCH") {
233
+ const envelope = decodeEnvelope(request.body);
234
+ exactKeys(envelope.payload, [], ["cube_directive", "message_taxonomy"]);
235
+ if (Object.keys(envelope.payload).length === 0) throw new InputError();
236
+ const directive = optionalText(envelope.payload, "cube_directive", 100_000);
237
+ const messageTaxonomy = optionalMessageTaxonomy(envelope.payload["message_taxonomy"]);
238
+ const cube = store.updateCube(cubeId, {
239
+ ...(directive === undefined ? {} : { directive }),
240
+ ...(messageTaxonomy === undefined ? {} : { messageTaxonomy }),
241
+ });
242
+ return success(200, envelope.requestId, { cube: cubePayload(cube) });
243
+ }
244
+ if (resource === "taxonomy-patch" && request.method === "POST") {
245
+ const envelope = decodeEnvelope(request.body);
246
+ const action = envelope.payload["action"];
247
+ const cube = store.getCube(cubeId);
248
+ if (cube === null) throw new ScopedStoreError();
249
+ if (action === "remove") {
250
+ exactKeys(envelope.payload, ["action", "class"]);
251
+ const messageTaxonomy = patchMessageTaxonomy(cube.messageTaxonomy, {
252
+ action,
253
+ className: requiredString(envelope.payload, "class", 64),
254
+ });
255
+ return success(200, envelope.requestId, {
256
+ cube: cubePayload(store.updateCube(cubeId, { messageTaxonomy })),
257
+ });
258
+ }
259
+ if (action !== "add" && action !== "replace") throw new InputError();
260
+ exactKeys(envelope.payload, ["action", "class_def"]);
261
+ const classDef = validateMessageTaxonomy([envelope.payload["class_def"]])![0]!;
262
+ const messageTaxonomy = patchMessageTaxonomy(cube.messageTaxonomy, { action, classDef });
263
+ return success(200, envelope.requestId, {
264
+ cube: cubePayload(store.updateCube(cubeId, { messageTaxonomy })),
265
+ });
266
+ }
187
267
  if (resource === "roles" && request.method === "GET") {
188
268
  return success(200, "roles-read", { roles: store.listRoles(cubeId) });
189
269
  }
270
+ if (resource === "roles" && request.method === "POST") {
271
+ const envelope = decodeEnvelope(request.body);
272
+ exactKeys(envelope.payload, ["name"], [
273
+ "short_description",
274
+ "detailed_description",
275
+ "is_default",
276
+ "is_mandatory",
277
+ "is_human_seat",
278
+ "can_broadcast",
279
+ "receives_all_direct",
280
+ "role_class",
281
+ ]);
282
+ const role = store.createRole(cubeId, {
283
+ name: requiredRoleName(envelope.payload, "name"),
284
+ shortDescription: optionalText(envelope.payload, "short_description", 1_024) ?? "",
285
+ detailedDescription: optionalText(envelope.payload, "detailed_description", 51_200) ?? "",
286
+ isDefault: optionalBoolean(envelope.payload["is_default"]),
287
+ isMandatory: optionalBoolean(envelope.payload["is_mandatory"]),
288
+ isHumanSeat: optionalBoolean(envelope.payload["is_human_seat"]),
289
+ canBroadcast: optionalBoolean(envelope.payload["can_broadcast"]),
290
+ receivesAllDirect: optionalBoolean(envelope.payload["receives_all_direct"]),
291
+ ...(envelope.payload["role_class"] === undefined
292
+ ? {}
293
+ : { roleClass: optionalRoleClass(envelope.payload["role_class"])! }),
294
+ });
295
+ return success(201, envelope.requestId, { role });
296
+ }
297
+ if (resource === "role" && !sectionPatch && request.method === "PATCH") {
298
+ const envelope = decodeEnvelope(request.body);
299
+ exactKeys(envelope.payload, [], [
300
+ "name",
301
+ "short_description",
302
+ "detailed_description",
303
+ "is_default",
304
+ "is_mandatory",
305
+ "is_human_seat",
306
+ "can_broadcast",
307
+ "receives_all_direct",
308
+ "role_class",
309
+ ]);
310
+ if (Object.keys(envelope.payload).length === 0) throw new InputError();
311
+ const name = envelope.payload["name"] === undefined
312
+ ? undefined
313
+ : requiredRoleName(envelope.payload, "name");
314
+ const shortDescription = optionalText(envelope.payload, "short_description", 1_024);
315
+ const detailedDescription = optionalText(envelope.payload, "detailed_description", 51_200);
316
+ const isDefault = optionalBooleanValue(envelope.payload["is_default"]);
317
+ const isMandatory = optionalBooleanValue(envelope.payload["is_mandatory"]);
318
+ const isHumanSeat = optionalBooleanValue(envelope.payload["is_human_seat"]);
319
+ const canBroadcast = optionalBooleanValue(envelope.payload["can_broadcast"]);
320
+ const receivesAllDirect = optionalBooleanValue(envelope.payload["receives_all_direct"]);
321
+ const roleClass = optionalRoleClass(envelope.payload["role_class"]);
322
+ const role = store.updateRole(cubeId, roleId!, {
323
+ ...(name === undefined ? {} : { name }),
324
+ ...(shortDescription === undefined ? {} : { shortDescription }),
325
+ ...(detailedDescription === undefined ? {} : { detailedDescription }),
326
+ ...(isDefault === undefined ? {} : { isDefault }),
327
+ ...(isMandatory === undefined ? {} : { isMandatory }),
328
+ ...(isHumanSeat === undefined ? {} : { isHumanSeat }),
329
+ ...(canBroadcast === undefined ? {} : { canBroadcast }),
330
+ ...(receivesAllDirect === undefined ? {} : { receivesAllDirect }),
331
+ ...(roleClass === undefined ? {} : { roleClass }),
332
+ });
333
+ return success(200, envelope.requestId, { role });
334
+ }
335
+ if (resource === "role" && sectionPatch && request.method === "POST") {
336
+ const envelope = decodeEnvelope(request.body);
337
+ const action = envelope.payload["action"];
338
+ if (action === "delete") {
339
+ exactKeys(envelope.payload, ["action", "heading"]);
340
+ const role = store.patchRoleSection(cubeId, roleId!, {
341
+ action,
342
+ heading: requiredSectionHeading(envelope.payload, "heading"),
343
+ });
344
+ return success(200, envelope.requestId, { role });
345
+ }
346
+ if (action !== "replace" && action !== "insert") throw new InputError();
347
+ exactKeys(envelope.payload, ["action", "heading", "body"], action === "insert" ? ["after"] : []);
348
+ const body = optionalText(envelope.payload, "body", 51_200);
349
+ if (body === undefined) throw new InputError();
350
+ const heading = requiredSectionHeading(envelope.payload, "heading");
351
+ if (action === "replace") {
352
+ return success(200, envelope.requestId, {
353
+ role: store.patchRoleSection(cubeId, roleId!, { action, heading, body }),
354
+ });
355
+ }
356
+ const after = optionalNullableSectionHeading(envelope.payload["after"]);
357
+ return success(200, envelope.requestId, {
358
+ role: store.patchRoleSection(cubeId, roleId!, {
359
+ action,
360
+ heading,
361
+ body,
362
+ ...(after === undefined ? {} : { after }),
363
+ }),
364
+ });
365
+ }
190
366
  if (resource === "drones" && request.method === "GET") {
191
- return success(200, "drones-read", { drones: store.listDrones(cubeId) });
367
+ if (request.since === undefined) {
368
+ return success(200, "drones-read", { drones: store.listDrones(cubeId) });
369
+ }
370
+ const since = decodeSince(request.since);
371
+ return success(200, "drones-read", store.listDronesSince(cubeId, since));
192
372
  }
193
373
  if (resource === "logs" && request.method === "POST") {
194
374
  const envelope = decodeEnvelope(request.body);
195
- exactKeys(envelope.payload, ["message"], ["visibility", "recipientDroneIds"]);
375
+ exactKeys(envelope.payload, ["message"], [
376
+ "visibility", "recipientDroneIds", "class", "to",
377
+ ]);
196
378
  const message = requiredString(envelope.payload, "message", 10_240);
197
379
  const visibility = optionalVisibility(envelope.payload["visibility"]);
198
380
  const recipientDroneIds = optionalUuidArray(envelope.payload["recipientDroneIds"]);
199
- if (((visibility ?? "broadcast") === "broadcast" && recipientDroneIds !== undefined) ||
200
- (visibility === "direct" && (recipientDroneIds?.length ?? 0) === 0)) {
201
- throw new InputError();
202
- }
203
- const entry = store.appendLog(cubeId, {
381
+ const className = optionalString(envelope.payload, "class", 64);
382
+ const to = optionalStringArray(envelope.payload["to"]);
383
+ const cube = store.getCube(cubeId);
384
+ if (cube === null) throw new ScopedStoreError();
385
+ const resolved = resolveMessageRouting({
204
386
  message,
205
387
  ...(visibility === undefined ? {} : { visibility }),
206
388
  ...(recipientDroneIds === undefined ? {} : { recipientDroneIds }),
389
+ ...(className === undefined ? {} : { className }),
390
+ ...(to === undefined ? {} : { to }),
391
+ }, cube.messageTaxonomy, store.listRoles(cubeId), store.listDrones(cubeId));
392
+ const entry = store.appendLog(cubeId, {
393
+ message,
394
+ visibility: resolved.visibility,
395
+ ...(resolved.visibility === "direct"
396
+ ? { recipientDroneIds: resolved.recipientDroneIds }
397
+ : {}),
398
+ });
399
+ this.#debugLogger.emit({
400
+ event: "activity_append",
401
+ cubeId,
402
+ entryId: entry.id,
403
+ principal: authentication,
404
+ droneId: entry.drone_id,
405
+ visibility: entry.visibility,
406
+ recipientDroneIds: entry.recipient_drone_ids,
207
407
  });
208
408
  return success(201, envelope.requestId, { entry });
209
409
  }
@@ -212,7 +412,17 @@ export class CoordinationApi {
212
412
  exactKeys(envelope.payload, ["cursor"], ["limit"]);
213
413
  const cursor = decodeCursor(envelope.payload["cursor"]);
214
414
  const limit = optionalLimit(envelope.payload["limit"]);
215
- return success(200, envelope.requestId, store.readLog(cubeId, cursor, limit));
415
+ const page = store.readLog(cubeId, cursor, limit);
416
+ this.#debugLogger.emit({
417
+ event: "cursor_replay",
418
+ mode: "page",
419
+ cubeId,
420
+ cursorId: cursor?.id ?? null,
421
+ returnedCount: page.entries.length,
422
+ behindBy: page.behind_by,
423
+ truncated: page.has_more,
424
+ });
425
+ return success(200, envelope.requestId, page);
216
426
  }
217
427
  if (resource === "acks" && request.method === "POST") {
218
428
  const envelope = decodeEnvelope(request.body);
@@ -221,6 +431,7 @@ export class CoordinationApi {
221
431
  if (kind !== "ack" && kind !== "claim") throw new InputError();
222
432
  exactKeys(envelope.payload, ["entry_id", "kind"]);
223
433
  store.acknowledge(cubeId, entryId, kind);
434
+ this.#debugLogger.emit({ event: "ack_write", cubeId, entryId, kind, principal: authentication });
224
435
  return { status: 204 };
225
436
  }
226
437
  if (resource === "decisions" && request.method === "POST") {
@@ -229,13 +440,18 @@ export class CoordinationApi {
229
440
  const topic = requiredString(envelope.payload, "topic", 120);
230
441
  const decision = requiredString(envelope.payload, "decision", 100_000);
231
442
  const rationale = optionalString(envelope.payload, "rationale", 100_000);
232
- return success(201, envelope.requestId, {
233
- decision: store.recordDecision(cubeId, {
443
+ const decisionRecord = store.recordDecision(cubeId, {
234
444
  topic,
235
445
  decision,
236
446
  ...(rationale === undefined ? {} : { rationale }),
237
- }),
447
+ });
448
+ this.#debugLogger.emit({
449
+ event: "decision_write",
450
+ cubeId,
451
+ decisionId: decisionRecord.id,
452
+ principal: authentication,
238
453
  });
454
+ return success(201, envelope.requestId, { decision: decisionRecord });
239
455
  }
240
456
  if (resource === "decisions" && request.method === "PUT") {
241
457
  const envelope = decodeEnvelope(request.body);
@@ -250,12 +466,29 @@ export class CoordinationApi {
250
466
  if (error instanceof CursorExpiredError) {
251
467
  return failure(410, "CURSOR_EXPIRED", error.message);
252
468
  }
469
+ if (error instanceof AccessDeniedError) {
470
+ return failure(403, ErrorCode.ACCESS_DENIED, error.message);
471
+ }
253
472
  if (error instanceof ScopedStoreError) {
254
473
  return failure(404, "NOT_FOUND", error.message);
255
474
  }
475
+ if (error instanceof RoleConflictError) {
476
+ return failure(409, error.code, error.message, safeRequestId(request.body));
477
+ }
478
+ if (error instanceof DefaultRoleRequiredError) {
479
+ return failure(409, error.code, error.message, safeRequestId(request.body));
480
+ }
481
+ if (error instanceof RoleSectionConflictError) {
482
+ return failure(409, error.code, error.message, safeRequestId(request.body));
483
+ }
256
484
  if (error instanceof StorageCapacityError) {
257
485
  return failure(507, "CAPACITY_EXCEEDED", error.message, safeRequestId(request.body));
258
486
  }
487
+ if (error instanceof ProtocolContractError) {
488
+ return error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION
489
+ ? failure(426, error.code, "Unsupported protocol version.", safeRequestId(request.body))
490
+ : failure(400, "INVALID_INPUT", "Invalid protocol request.", safeRequestId(request.body));
491
+ }
259
492
  if (error instanceof InputError || error instanceof TypeError || error instanceof RangeError) {
260
493
  return failure(400, "INVALID_INPUT", "Invalid protocol request.", safeRequestId(request.body));
261
494
  }
@@ -270,14 +503,30 @@ export class CoordinationApi {
270
503
  requestSignal: AbortSignal,
271
504
  ): Promise<CoordinationResponse> {
272
505
  const store = this.#runtime.forPrincipal(principal);
506
+ const connectionId = randomUUID();
273
507
  const session = this.#authority.registerLiveSession(principal);
274
508
  const signal = AbortSignal.any([requestSignal, session.signal]);
275
509
  let unsubscribe = (): void => undefined;
510
+ let subscribed = false;
511
+ let deliveryCount = 0;
512
+ let heartbeat: NodeJS.Timeout | undefined;
276
513
  const queue = new AsyncStringQueue(() => {
514
+ if (heartbeat !== undefined) clearInterval(heartbeat);
277
515
  unsubscribe();
278
516
  session.release();
279
- });
280
- const pending: EnrichedActivityRecord[] = [];
517
+ if (subscribed) {
518
+ this.#debugLogger.emit({
519
+ event: "sse_unsubscribe",
520
+ connectionId,
521
+ cubeId,
522
+ principal,
523
+ deliveryCount,
524
+ });
525
+ }
526
+ }, () => { deliveryCount += 1; });
527
+ let replayDirty = false;
528
+ const pendingNotifications: ActivityStreamRecord[] = [];
529
+ let notificationIndex = 0;
281
530
  let live = false;
282
531
  try {
283
532
  unsubscribe = store.subscribeActivity(cubeId, (entry) => {
@@ -286,11 +535,13 @@ export class CoordinationApi {
286
535
  return;
287
536
  }
288
537
  if (live) queue.push(encodeLogEvent(entry));
289
- else if (pending.length >= 200) queue.close();
290
- else pending.push(entry);
538
+ else if ("kind" in entry) pendingNotifications.push(entry);
539
+ else replayDirty = true;
291
540
  });
541
+ subscribed = true;
292
542
  signal.addEventListener("abort", () => queue.close(), { once: true });
293
- const replay = store.readLog(cubeId, cursor, 200).entries;
543
+ const firstPage = store.readLog(cubeId, cursor, 200);
544
+ this.#logReplayPage(cubeId, cursor, firstPage);
294
545
  const barrier = this.#replayBarrier;
295
546
  this.#replayBarrier = undefined;
296
547
  if (barrier !== undefined) {
@@ -298,19 +549,61 @@ export class CoordinationApi {
298
549
  barrier.markReached();
299
550
  await barrier.release;
300
551
  }
301
- const seen = new Set(replay.map(cursorKey));
302
- for (const entry of replay) queue.push(encodeLogEvent(entry));
303
- for (const entry of pending) {
304
- if (!seen.has(cursorKey(entry)) && afterCursor(entry, cursor)) {
305
- seen.add(cursorKey(entry));
306
- queue.push(encodeLogEvent(entry));
552
+ void (async () => {
553
+ let page = firstPage;
554
+ let replayCursor = cursor;
555
+ let replayCount = 0;
556
+ try {
557
+ for (;;) {
558
+ for (const entry of page.entries) {
559
+ if (!await queue.write(encodeLogEvent(entry))) return;
560
+ replayCursor = { id: entry.id, created_at: entry.created_at };
561
+ replayCount += 1;
562
+ }
563
+ if (page.has_more || replayDirty) {
564
+ replayDirty = false;
565
+ page = store.readLog(cubeId, replayCursor, 200);
566
+ this.#logReplayPage(cubeId, replayCursor, page);
567
+ continue;
568
+ }
569
+
570
+ while (notificationIndex < pendingNotifications.length) {
571
+ if (!await queue.write(encodeLogEvent(pendingNotifications[notificationIndex]!))) return;
572
+ notificationIndex += 1;
573
+ }
574
+
575
+ // Reserve room for the bookmark before making live callbacks visible.
576
+ // Any append while waiting marks replay dirty and is fetched first.
577
+ if (!await queue.waitForSpace()) return;
578
+ if (replayDirty || notificationIndex < pendingNotifications.length) {
579
+ replayDirty = false;
580
+ page = store.readLog(cubeId, replayCursor, 200);
581
+ this.#logReplayPage(cubeId, replayCursor, page);
582
+ continue;
583
+ }
584
+ queue.push(`event: bookmark\ndata: ${JSON.stringify({
585
+ as_of: new Date().toISOString(),
586
+ replay_complete: true,
587
+ })}\n\n`);
588
+ live = true;
589
+ heartbeat = setInterval(() => {
590
+ queue.tryPush(encodeHeartbeat());
591
+ }, this.#streamHeartbeatMs);
592
+ heartbeat.unref();
593
+ this.#debugLogger.emit({
594
+ event: "sse_subscribe",
595
+ connectionId,
596
+ cubeId,
597
+ principal,
598
+ replayCount,
599
+ truncated: false,
600
+ });
601
+ return;
602
+ }
603
+ } catch {
604
+ queue.close();
307
605
  }
308
- }
309
- queue.push(`event: bookmark\ndata: ${JSON.stringify({
310
- as_of: new Date().toISOString(),
311
- replay_complete: true,
312
- })}\n\n`);
313
- live = true;
606
+ })();
314
607
  return { status: 200, stream: queue };
315
608
  } catch (error) {
316
609
  queue.close();
@@ -319,6 +612,22 @@ export class CoordinationApi {
319
612
  throw error;
320
613
  }
321
614
  }
615
+
616
+ #logReplayPage(
617
+ cubeId: string,
618
+ cursor: LogCursor | null,
619
+ page: ActivityPage,
620
+ ): void {
621
+ this.#debugLogger.emit({
622
+ event: "cursor_replay",
623
+ mode: "sse",
624
+ cubeId,
625
+ cursorId: cursor?.id ?? null,
626
+ returnedCount: page.entries.length,
627
+ behindBy: page.behind_by,
628
+ truncated: page.has_more,
629
+ });
630
+ }
322
631
  }
323
632
 
324
633
  class InputError extends Error {}
@@ -326,11 +635,14 @@ class InputError extends Error {}
326
635
  class AsyncStringQueue implements AsyncIterable<string> {
327
636
  readonly #values: string[] = [];
328
637
  readonly #waiters: Array<(result: IteratorResult<string>) => void> = [];
638
+ readonly #spaceWaiters: Array<(available: boolean) => void> = [];
329
639
  readonly #cleanup: () => void;
640
+ readonly #onDelivered: () => void;
330
641
  #closed = false;
331
642
 
332
- constructor(cleanup: () => void) {
643
+ constructor(cleanup: () => void, onDelivered: () => void = () => undefined) {
333
644
  this.#cleanup = cleanup;
645
+ this.#onDelivered = onDelivered;
334
646
  }
335
647
 
336
648
  push(value: string): void {
@@ -339,9 +651,28 @@ class AsyncStringQueue implements AsyncIterable<string> {
339
651
  this.close();
340
652
  return;
341
653
  }
342
- const waiter = this.#waiters.shift();
343
- if (waiter === undefined) this.#values.push(value);
344
- else waiter({ value, done: false });
654
+ this.#enqueue(value);
655
+ }
656
+
657
+ tryPush(value: string): boolean {
658
+ if (this.#closed || this.#values.length >= 200) return false;
659
+ this.#enqueue(value);
660
+ return true;
661
+ }
662
+
663
+ async write(value: string): Promise<boolean> {
664
+ while (!this.#closed && this.#values.length >= 200) {
665
+ if (!await this.waitForSpace()) return false;
666
+ }
667
+ if (this.#closed) return false;
668
+ this.#enqueue(value);
669
+ return true;
670
+ }
671
+
672
+ waitForSpace(): Promise<boolean> {
673
+ if (this.#closed) return Promise.resolve(false);
674
+ if (this.#values.length < 200) return Promise.resolve(true);
675
+ return new Promise((resolve) => this.#spaceWaiters.push(resolve));
345
676
  }
346
677
 
347
678
  close(): void {
@@ -349,13 +680,18 @@ class AsyncStringQueue implements AsyncIterable<string> {
349
680
  this.#closed = true;
350
681
  this.#cleanup();
351
682
  for (const waiter of this.#waiters.splice(0)) waiter({ value: undefined, done: true });
683
+ for (const waiter of this.#spaceWaiters.splice(0)) waiter(false);
352
684
  }
353
685
 
354
686
  [Symbol.asyncIterator](): AsyncIterator<string> {
355
687
  return {
356
688
  next: async () => {
357
689
  const value = this.#values.shift();
358
- if (value !== undefined) return { value, done: false };
690
+ if (value !== undefined) {
691
+ this.#spaceWaiters.shift()?.(true);
692
+ this.#onDelivered();
693
+ return { value, done: false };
694
+ }
359
695
  if (this.#closed) return { value: undefined, done: true };
360
696
  return new Promise<IteratorResult<string>>((resolve) => this.#waiters.push(resolve));
361
697
  },
@@ -365,17 +701,20 @@ class AsyncStringQueue implements AsyncIterable<string> {
365
701
  },
366
702
  };
367
703
  }
704
+
705
+ #enqueue(value: string): void {
706
+ const waiter = this.#waiters.shift();
707
+ if (waiter === undefined) this.#values.push(value);
708
+ else {
709
+ this.#onDelivered();
710
+ waiter({ value, done: false });
711
+ }
712
+ }
368
713
  }
369
714
 
370
715
  function decodeEnvelope(value: unknown): RequestEnvelope {
371
- const record = object(value);
372
- exactKeys(record, ["protocol_version", "request_id", "payload"]);
373
- if (record["protocol_version"] !== "1") throw new InputError();
374
- const requestId = record["request_id"];
375
- if (typeof requestId !== "string" || !/^[A-Za-z0-9._-]{8,128}$/u.test(requestId)) {
376
- throw new InputError();
377
- }
378
- return { requestId, payload: object(record["payload"]) };
716
+ const envelope = decodeProtocolEnvelope(value, object);
717
+ return { requestId: envelope.request_id, payload: envelope.payload };
379
718
  }
380
719
 
381
720
  function object(value: unknown): Record<string, unknown> {
@@ -403,6 +742,12 @@ function requiredPresentationName(record: Record<string, unknown>, key: string):
403
742
  return value;
404
743
  }
405
744
 
745
+ function requiredRoleName(record: Record<string, unknown>, key: string): string {
746
+ const value = record[key];
747
+ if (typeof value !== "string" || value.length < 1 || value.length > 64) throw new InputError();
748
+ return value;
749
+ }
750
+
406
751
  function optionalString(
407
752
  record: Record<string, unknown>,
408
753
  key: string,
@@ -413,6 +758,63 @@ function optionalString(
413
758
  return requiredString(record, key, maxBytes);
414
759
  }
415
760
 
761
+ function optionalText(
762
+ record: Record<string, unknown>,
763
+ key: string,
764
+ maxCharacters: number,
765
+ ): string | undefined {
766
+ const value = record[key];
767
+ if (value === undefined) return undefined;
768
+ if (typeof value !== "string" || value.length > maxCharacters) throw new InputError();
769
+ return value;
770
+ }
771
+
772
+ function optionalBoolean(value: unknown): boolean {
773
+ if (value === undefined) return false;
774
+ if (typeof value !== "boolean") throw new InputError();
775
+ return value;
776
+ }
777
+
778
+ function optionalBooleanValue(value: unknown): boolean | undefined {
779
+ if (value === undefined) return undefined;
780
+ if (typeof value !== "boolean") throw new InputError();
781
+ return value;
782
+ }
783
+
784
+ function optionalRoleClass(value: unknown): "queen" | "worker" | undefined {
785
+ if (value === undefined) return undefined;
786
+ if (value !== "queen" && value !== "worker") throw new InputError();
787
+ return value;
788
+ }
789
+
790
+ function optionalMessageTaxonomy(value: unknown) {
791
+ return value === undefined ? undefined : validateMessageTaxonomy(value);
792
+ }
793
+
794
+ function optionalStringArray(value: unknown): string[] | undefined {
795
+ if (value === undefined) return undefined;
796
+ if (!Array.isArray(value) || value.length < 1 || value.length > 100 ||
797
+ value.some((entry) => typeof entry !== "string" || entry.length < 1 || entry.length > 120)) {
798
+ throw new InputError();
799
+ }
800
+ const entries = value as string[];
801
+ if (new Set(entries).size !== entries.length) throw new InputError();
802
+ return entries;
803
+ }
804
+
805
+ function requiredSectionHeading(record: Record<string, unknown>, key: string): string {
806
+ const value = requiredString(record, key, 60);
807
+ const heading = value.trim();
808
+ if (heading.length === 0 || /^\s/u.test(value) || /[:\n\r]/u.test(heading) ||
809
+ /^[*\-#>`]/u.test(heading)) throw new InputError();
810
+ return heading;
811
+ }
812
+
813
+ function optionalNullableSectionHeading(value: unknown): string | null | undefined {
814
+ if (value === undefined || value === null) return value;
815
+ return requiredSectionHeading({ value }, "value");
816
+ }
817
+
416
818
  function requiredUuid(record: Record<string, unknown>, key: string): string {
417
819
  const value = record[key];
418
820
  if (typeof value !== "string" || !uuidPattern.test(value)) throw new InputError();
@@ -460,6 +862,12 @@ function decodeOpaqueCursor(value: string | undefined): LogCursor | null {
460
862
  }
461
863
  }
462
864
 
865
+ function decodeSince(value: string): string {
866
+ if (uuidPattern.test(value)) return value.toLowerCase();
867
+ if (timestampPattern.test(value) && new Date(value).toISOString() === value) return value;
868
+ throw new InputError();
869
+ }
870
+
463
871
  function safeRequestId(value: unknown): string | undefined {
464
872
  if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
465
873
  const requestId = (value as Record<string, unknown>)["request_id"];
@@ -469,7 +877,7 @@ function safeRequestId(value: unknown): string | undefined {
469
877
  }
470
878
 
471
879
  function success(status: number, requestId: string, payload: unknown): CoordinationResponse {
472
- return { status, body: { protocol_version: "1", request_id: requestId, payload } };
880
+ return { status, body: createProtocolEnvelope(requestId, payload) };
473
881
  }
474
882
 
475
883
  function failure(
@@ -481,27 +889,37 @@ function failure(
481
889
  return {
482
890
  status,
483
891
  body: {
484
- protocol_version: "1",
892
+ protocol_version: PROTOCOL_VERSION,
485
893
  ...(requestId === undefined ? {} : { request_id: requestId }),
486
894
  error: { code, message },
487
895
  },
488
896
  };
489
897
  }
490
898
 
491
- function encodeLogEvent(entry: EnrichedActivityRecord): string {
899
+ function encodeLogEvent(entry: ActivityStreamRecord): string {
900
+ if ("kind" in entry) {
901
+ return `event: log\nid: ${entry.id}\ndata: ${JSON.stringify({ entry })}\n\n`;
902
+ }
492
903
  return `event: log\nid: ${entry.id}\ndata: ${JSON.stringify({
493
904
  cursor: { id: entry.id, created_at: entry.created_at },
494
905
  entry,
495
906
  })}\n\n`;
496
907
  }
497
908
 
498
- function cursorKey(entry: EnrichedActivityRecord): string {
499
- return `${entry.created_at}\0${entry.id}`;
909
+ function encodeHeartbeat(): string {
910
+ return `event: heartbeat\ndata: ${JSON.stringify({ ts: new Date().toISOString() })}\n\n`;
500
911
  }
501
912
 
502
- function afterCursor(entry: EnrichedActivityRecord, cursor: LogCursor | null): boolean {
503
- return cursor === null || entry.created_at > cursor.created_at ||
504
- (entry.created_at === cursor.created_at && entry.id > cursor.id);
913
+ function cubePayload(cube: CubeRecord) {
914
+ return {
915
+ id: cube.id,
916
+ owner_id: cube.ownerId,
917
+ name: cube.name,
918
+ cube_directive: cube.directive,
919
+ message_taxonomy: cube.messageTaxonomy,
920
+ created_at: cube.createdAt,
921
+ updated_at: cube.updatedAt,
922
+ };
505
923
  }
506
924
 
507
925
  const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;