borgmcp-server 0.1.4 → 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 (43) hide show
  1. package/README.md +7 -6
  2. package/THIRD_PARTY_NOTICES.md +1 -0
  3. package/dist/coordination-api.d.ts +2 -1
  4. package/dist/coordination-api.js +269 -85
  5. package/dist/coordination-api.js.map +1 -1
  6. package/dist/credentials.d.ts +4 -6
  7. package/dist/credentials.js +43 -19
  8. package/dist/credentials.js.map +1 -1
  9. package/dist/debug-log.d.ts +2 -3
  10. package/dist/debug-log.js +2 -3
  11. package/dist/debug-log.js.map +1 -1
  12. package/dist/enrollment.d.ts +3 -11
  13. package/dist/enrollment.js +21 -60
  14. package/dist/enrollment.js.map +1 -1
  15. package/dist/https-server.d.ts +2 -20
  16. package/dist/https-server.js +33 -51
  17. package/dist/https-server.js.map +1 -1
  18. package/dist/message-taxonomy.d.ts +38 -0
  19. package/dist/message-taxonomy.js +218 -0
  20. package/dist/message-taxonomy.js.map +1 -0
  21. package/dist/migrations.js +28 -0
  22. package/dist/migrations.js.map +1 -1
  23. package/dist/service.d.ts +4 -1
  24. package/dist/service.js +25 -10
  25. package/dist/service.js.map +1 -1
  26. package/dist/store.d.ts +42 -8
  27. package/dist/store.js +380 -100
  28. package/dist/store.js.map +1 -1
  29. package/npm-shrinkwrap.json +6 -7
  30. package/package.json +2 -2
  31. package/src/coordination-api.ts +278 -82
  32. package/src/credentials.ts +44 -26
  33. package/src/debug-log.ts +5 -5
  34. package/src/enrollment.ts +32 -78
  35. package/src/https-server.ts +44 -82
  36. package/src/message-taxonomy.ts +284 -0
  37. package/src/migrations.ts +28 -0
  38. package/src/service.ts +29 -9
  39. package/src/store.ts +432 -121
  40. package/dist/protocol-draft.d.ts +0 -2
  41. package/dist/protocol-draft.js +0 -31
  42. package/dist/protocol-draft.js.map +0 -1
  43. package/src/protocol-draft.ts +0 -32
package/README.md CHANGED
@@ -6,22 +6,23 @@ HTTPS.
6
6
 
7
7
  ## Release status
8
8
 
9
- The current public preview remains `borgmcp-server@0.1.1`, published on npm.
9
+ The current public preview is `borgmcp-server@0.1.4`, published on npm.
10
10
  Versions `0.1.2` and `0.1.3` were not published; their immutable tags are
11
11
  internal failed-release evidence and are not installation or dogfood targets.
12
- The current source is the unpublished `0.1.4` release candidate. It retains the
12
+ Version `0.1.4` retains the
13
13
  reviewed owner-enrollment, idempotent multi-cube creation, and stable prior-seat
14
14
  reattachment baseline, and adds managed role creation and updates, fail-closed
15
15
  setup reinitialization, opt-in redacted debug logging, live-safe invitation
16
16
  minting, and atomic cube-scoped invitations with enforced observer posture.
17
- The candidate consumes the audited exact `borgmcp-shared@0.3.0` registry
18
- release.
17
+ The published `0.1.4` package consumes the audited exact
18
+ `borgmcp-shared@0.3.0` registry release. Current source consumes the audited
19
+ exact `borgmcp-shared@0.4.0` release for the clean-slate protocol generation.
19
20
 
20
21
  Setup prepares local identity and storage and prints one-time recovery and
21
22
  owner-enrollment secrets; it creates no cube. Version `0.1.1` completed the
22
23
  documented exact-source, tagged-artifact, and protected-publication gates.
23
- Version `0.1.4` has no tag or publication authorization until its exact merged
24
- source and tagged artifact complete the gates in `docs/releasing.md`.
24
+ Version `0.1.4` completed a fresh exact-source, tagged-artifact, tokenless OIDC
25
+ publication, provenance, signature, and attestation gate chain.
25
26
 
26
27
  ## Requirements
27
28
 
@@ -21,6 +21,7 @@ refer to each installed package for its complete license text.
21
21
  | `@peculiar/utils` | 2.0.3 | MIT |
22
22
  | `@peculiar/x509` | 1.14.3 | MIT |
23
23
  | `asn1js` | 3.0.10 | BSD-3-Clause |
24
+ | `borgmcp-shared` | 0.4.0 | Apache-2.0 |
24
25
  | `bytestreamjs` | 2.0.1 | BSD-3-Clause |
25
26
  | `pkijs` | 3.4.0 | BSD-3-Clause |
26
27
  | `pvtsutils` | 1.3.6 | MIT |
@@ -8,6 +8,7 @@ export interface CoordinationRequest {
8
8
  readonly principal: Principal;
9
9
  readonly body?: unknown;
10
10
  readonly cursor?: string;
11
+ readonly since?: string;
11
12
  readonly signal: AbortSignal;
12
13
  }
13
14
  export interface CoordinationResponse {
@@ -17,7 +18,7 @@ export interface CoordinationResponse {
17
18
  }
18
19
  export declare class CoordinationApi {
19
20
  #private;
20
- constructor(runtime: StoreRuntime, authority: CredentialAuthority, debugLogger?: DebugLogger);
21
+ constructor(runtime: StoreRuntime, authority: CredentialAuthority, debugLogger?: DebugLogger, streamHeartbeatMs?: number);
21
22
  armReplayTransition(): {
22
23
  readonly reached: Promise<void>;
23
24
  readonly release: () => void;
@@ -1,16 +1,20 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { ATTACH_PATH, ErrorCode, PROTOCOL_VERSION, ProtocolContractError, createProtocolEnvelope, decodeAttachRequestEnvelope, decodeProtocolEnvelope, } from "borgmcp-shared/protocol";
2
3
  import { assertServerDerivedPrincipal } from "./principal.js";
3
4
  import { disabledDebugLogger } from "./debug-log.js";
4
- import { CursorExpiredError, AttachConflictError, AccessDeniedError, CreateCubeConflictError, DefaultRoleRequiredError, RoleConflictError, RoleSectionConflictError, ScopedStoreError, StorageCapacityError, } from "./store.js";
5
+ import { patchMessageTaxonomy, resolveMessageRouting, validateMessageTaxonomy, } from "./message-taxonomy.js";
6
+ import { CursorExpiredError, AttachSessionRejectedError, AccessDeniedError, CreateCubeConflictError, DefaultRoleRequiredError, RoleConflictError, RoleSectionConflictError, ScopedStoreError, StorageCapacityError, } from "./store.js";
5
7
  export class CoordinationApi {
6
8
  #runtime;
7
9
  #authority;
8
10
  #debugLogger;
11
+ #streamHeartbeatMs;
9
12
  #replayBarrier;
10
- constructor(runtime, authority, debugLogger = disabledDebugLogger) {
13
+ constructor(runtime, authority, debugLogger = disabledDebugLogger, streamHeartbeatMs = 5_000) {
11
14
  this.#runtime = runtime;
12
15
  this.#authority = authority;
13
16
  this.#debugLogger = debugLogger;
17
+ this.#streamHeartbeatMs = streamHeartbeatMs;
14
18
  }
15
19
  armReplayTransition() {
16
20
  let markReached;
@@ -28,36 +32,36 @@ export class CoordinationApi {
28
32
  async handle(request) {
29
33
  assertServerDerivedPrincipal(request.principal);
30
34
  const authentication = request.principal;
31
- if (request.path === "/api/client/attach" && request.method === "POST") {
35
+ if (request.path === ATTACH_PATH && request.method === "POST") {
32
36
  const requestId = safeRequestId(request.body);
33
37
  try {
34
- const envelope = decodeEnvelope(request.body);
35
- exactKeys(envelope.payload, ["cube_id", "role_id", "retry_key"], ["prior_drone_id"]);
36
- const priorDroneId = envelope.payload["prior_drone_id"] === undefined
37
- ? undefined
38
- : requiredUuid(envelope.payload, "prior_drone_id");
38
+ const envelope = decodeAttachRequestEnvelope(request.body);
39
+ const priorDroneId = envelope.payload.prior_drone_id;
39
40
  const attachment = this.#authority.attachSeat(this.#runtime.forPrincipal(authentication), {
40
- cubeId: requiredUuid(envelope.payload, "cube_id"),
41
- roleId: requiredUuid(envelope.payload, "role_id"),
42
- retryKey: requiredUuid(envelope.payload, "retry_key"),
41
+ cubeId: envelope.payload.cube_id,
42
+ roleId: envelope.payload.role_id,
43
+ sessionCredential: envelope.payload.session_credential,
43
44
  ...(priorDroneId === undefined ? {} : { priorDroneId }),
44
45
  });
45
- return success(201, envelope.requestId, {
46
+ return success(attachment.result === "created" ? 201 : 200, envelope.request_id, {
47
+ result: attachment.result,
46
48
  cube: attachment.cube,
47
49
  role: attachment.role,
48
50
  drone: attachment.drone,
49
51
  session: {
50
- token: attachment.credential,
52
+ id: attachment.sessionId,
51
53
  expires_at: attachment.expiresAt,
52
- generation: attachment.generation,
53
- posture: attachment.posture,
54
54
  },
55
- reattached: attachment.reattached,
56
55
  });
57
56
  }
58
57
  catch (error) {
59
- if (error instanceof AttachConflictError) {
60
- return failure(409, "INVALID_INPUT", "The attach request conflicts.", requestId);
58
+ if (error instanceof ProtocolContractError) {
59
+ return error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION
60
+ ? failure(426, error.code, "Unsupported protocol version.", requestId)
61
+ : failure(400, "INVALID_INPUT", "Invalid protocol request.", requestId);
62
+ }
63
+ if (error instanceof AttachSessionRejectedError) {
64
+ return failure(401, ErrorCode.SESSION_REJECTED, error.message, requestId);
61
65
  }
62
66
  if (error instanceof ScopedStoreError) {
63
67
  return failure(404, "NOT_FOUND", error.message, requestId);
@@ -77,6 +81,7 @@ export class CoordinationApi {
77
81
  owner_id: cube.ownerId,
78
82
  name: cube.name,
79
83
  cube_directive: cube.directive,
84
+ message_taxonomy: cube.messageTaxonomy,
80
85
  created_at: cube.createdAt,
81
86
  updated_at: cube.updatedAt,
82
87
  }));
@@ -112,6 +117,11 @@ export class CoordinationApi {
112
117
  if (error instanceof StorageCapacityError) {
113
118
  return failure(507, "CAPACITY_EXCEEDED", error.message, requestId);
114
119
  }
120
+ if (error instanceof ProtocolContractError) {
121
+ return error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION
122
+ ? failure(426, error.code, "Unsupported protocol version.", requestId)
123
+ : failure(400, "INVALID_INPUT", "Invalid protocol request.", requestId);
124
+ }
115
125
  if (error instanceof InputError || error instanceof TypeError || error instanceof RangeError) {
116
126
  return failure(400, "INVALID_INPUT", "Invalid protocol request.", requestId);
117
127
  }
@@ -120,7 +130,7 @@ export class CoordinationApi {
120
130
  }
121
131
  const roleMatch = /^\/api\/cubes\/([0-9a-f-]{36})\/roles\/([0-9a-f-]{36})(\/section-patch)?$/u
122
132
  .exec(request.path);
123
- const match = /^\/api\/cubes\/([0-9a-f-]{36})(?:\/(roles|drones|logs|acks|decisions|stream))?$/u
133
+ const match = /^\/api\/cubes\/([0-9a-f-]{36})(?:\/(roles|drones|logs|acks|decisions|stream|taxonomy-patch))?$/u
124
134
  .exec(request.path);
125
135
  if (match === null && roleMatch === null) {
126
136
  return failure(404, "NOT_FOUND", "The requested resource was not found.");
@@ -144,11 +154,50 @@ export class CoordinationApi {
144
154
  owner_id: cube.ownerId,
145
155
  name: cube.name,
146
156
  cube_directive: cube.directive,
157
+ message_taxonomy: cube.messageTaxonomy,
147
158
  created_at: cube.createdAt,
148
159
  updated_at: cube.updatedAt,
149
160
  },
150
161
  });
151
162
  }
163
+ if (resource === undefined && request.method === "PATCH") {
164
+ const envelope = decodeEnvelope(request.body);
165
+ exactKeys(envelope.payload, [], ["cube_directive", "message_taxonomy"]);
166
+ if (Object.keys(envelope.payload).length === 0)
167
+ throw new InputError();
168
+ const directive = optionalText(envelope.payload, "cube_directive", 100_000);
169
+ const messageTaxonomy = optionalMessageTaxonomy(envelope.payload["message_taxonomy"]);
170
+ const cube = store.updateCube(cubeId, {
171
+ ...(directive === undefined ? {} : { directive }),
172
+ ...(messageTaxonomy === undefined ? {} : { messageTaxonomy }),
173
+ });
174
+ return success(200, envelope.requestId, { cube: cubePayload(cube) });
175
+ }
176
+ if (resource === "taxonomy-patch" && request.method === "POST") {
177
+ const envelope = decodeEnvelope(request.body);
178
+ const action = envelope.payload["action"];
179
+ const cube = store.getCube(cubeId);
180
+ if (cube === null)
181
+ throw new ScopedStoreError();
182
+ if (action === "remove") {
183
+ exactKeys(envelope.payload, ["action", "class"]);
184
+ const messageTaxonomy = patchMessageTaxonomy(cube.messageTaxonomy, {
185
+ action,
186
+ className: requiredString(envelope.payload, "class", 64),
187
+ });
188
+ return success(200, envelope.requestId, {
189
+ cube: cubePayload(store.updateCube(cubeId, { messageTaxonomy })),
190
+ });
191
+ }
192
+ if (action !== "add" && action !== "replace")
193
+ throw new InputError();
194
+ exactKeys(envelope.payload, ["action", "class_def"]);
195
+ const classDef = validateMessageTaxonomy([envelope.payload["class_def"]])[0];
196
+ const messageTaxonomy = patchMessageTaxonomy(cube.messageTaxonomy, { action, classDef });
197
+ return success(200, envelope.requestId, {
198
+ cube: cubePayload(store.updateCube(cubeId, { messageTaxonomy })),
199
+ });
200
+ }
152
201
  if (resource === "roles" && request.method === "GET") {
153
202
  return success(200, "roles-read", { roles: store.listRoles(cubeId) });
154
203
  }
@@ -162,6 +211,7 @@ export class CoordinationApi {
162
211
  "is_human_seat",
163
212
  "can_broadcast",
164
213
  "receives_all_direct",
214
+ "role_class",
165
215
  ]);
166
216
  const role = store.createRole(cubeId, {
167
217
  name: requiredRoleName(envelope.payload, "name"),
@@ -172,6 +222,9 @@ export class CoordinationApi {
172
222
  isHumanSeat: optionalBoolean(envelope.payload["is_human_seat"]),
173
223
  canBroadcast: optionalBoolean(envelope.payload["can_broadcast"]),
174
224
  receivesAllDirect: optionalBoolean(envelope.payload["receives_all_direct"]),
225
+ ...(envelope.payload["role_class"] === undefined
226
+ ? {}
227
+ : { roleClass: optionalRoleClass(envelope.payload["role_class"]) }),
175
228
  });
176
229
  return success(201, envelope.requestId, { role });
177
230
  }
@@ -186,6 +239,7 @@ export class CoordinationApi {
186
239
  "is_human_seat",
187
240
  "can_broadcast",
188
241
  "receives_all_direct",
242
+ "role_class",
189
243
  ]);
190
244
  if (Object.keys(envelope.payload).length === 0)
191
245
  throw new InputError();
@@ -199,6 +253,7 @@ export class CoordinationApi {
199
253
  const isHumanSeat = optionalBooleanValue(envelope.payload["is_human_seat"]);
200
254
  const canBroadcast = optionalBooleanValue(envelope.payload["can_broadcast"]);
201
255
  const receivesAllDirect = optionalBooleanValue(envelope.payload["receives_all_direct"]);
256
+ const roleClass = optionalRoleClass(envelope.payload["role_class"]);
202
257
  const role = store.updateRole(cubeId, roleId, {
203
258
  ...(name === undefined ? {} : { name }),
204
259
  ...(shortDescription === undefined ? {} : { shortDescription }),
@@ -208,6 +263,7 @@ export class CoordinationApi {
208
263
  ...(isHumanSeat === undefined ? {} : { isHumanSeat }),
209
264
  ...(canBroadcast === undefined ? {} : { canBroadcast }),
210
265
  ...(receivesAllDirect === undefined ? {} : { receivesAllDirect }),
266
+ ...(roleClass === undefined ? {} : { roleClass }),
211
267
  });
212
268
  return success(200, envelope.requestId, { role });
213
269
  }
@@ -245,22 +301,38 @@ export class CoordinationApi {
245
301
  });
246
302
  }
247
303
  if (resource === "drones" && request.method === "GET") {
248
- return success(200, "drones-read", { drones: store.listDrones(cubeId) });
304
+ if (request.since === undefined) {
305
+ return success(200, "drones-read", { drones: store.listDrones(cubeId) });
306
+ }
307
+ const since = decodeSince(request.since);
308
+ return success(200, "drones-read", store.listDronesSince(cubeId, since));
249
309
  }
250
310
  if (resource === "logs" && request.method === "POST") {
251
311
  const envelope = decodeEnvelope(request.body);
252
- exactKeys(envelope.payload, ["message"], ["visibility", "recipientDroneIds"]);
312
+ exactKeys(envelope.payload, ["message"], [
313
+ "visibility", "recipientDroneIds", "class", "to",
314
+ ]);
253
315
  const message = requiredString(envelope.payload, "message", 10_240);
254
316
  const visibility = optionalVisibility(envelope.payload["visibility"]);
255
317
  const recipientDroneIds = optionalUuidArray(envelope.payload["recipientDroneIds"]);
256
- if (((visibility ?? "broadcast") === "broadcast" && recipientDroneIds !== undefined) ||
257
- (visibility === "direct" && (recipientDroneIds?.length ?? 0) === 0)) {
258
- throw new InputError();
259
- }
260
- const entry = store.appendLog(cubeId, {
318
+ const className = optionalString(envelope.payload, "class", 64);
319
+ const to = optionalStringArray(envelope.payload["to"]);
320
+ const cube = store.getCube(cubeId);
321
+ if (cube === null)
322
+ throw new ScopedStoreError();
323
+ const resolved = resolveMessageRouting({
261
324
  message,
262
325
  ...(visibility === undefined ? {} : { visibility }),
263
326
  ...(recipientDroneIds === undefined ? {} : { recipientDroneIds }),
327
+ ...(className === undefined ? {} : { className }),
328
+ ...(to === undefined ? {} : { to }),
329
+ }, cube.messageTaxonomy, store.listRoles(cubeId), store.listDrones(cubeId));
330
+ const entry = store.appendLog(cubeId, {
331
+ message,
332
+ visibility: resolved.visibility,
333
+ ...(resolved.visibility === "direct"
334
+ ? { recipientDroneIds: resolved.recipientDroneIds }
335
+ : {}),
264
336
  });
265
337
  this.#debugLogger.emit({
266
338
  event: "activity_append",
@@ -334,6 +406,9 @@ export class CoordinationApi {
334
406
  if (error instanceof CursorExpiredError) {
335
407
  return failure(410, "CURSOR_EXPIRED", error.message);
336
408
  }
409
+ if (error instanceof AccessDeniedError) {
410
+ return failure(403, ErrorCode.ACCESS_DENIED, error.message);
411
+ }
337
412
  if (error instanceof ScopedStoreError) {
338
413
  return failure(404, "NOT_FOUND", error.message);
339
414
  }
@@ -349,6 +424,11 @@ export class CoordinationApi {
349
424
  if (error instanceof StorageCapacityError) {
350
425
  return failure(507, "CAPACITY_EXCEEDED", error.message, safeRequestId(request.body));
351
426
  }
427
+ if (error instanceof ProtocolContractError) {
428
+ return error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION
429
+ ? failure(426, error.code, "Unsupported protocol version.", safeRequestId(request.body))
430
+ : failure(400, "INVALID_INPUT", "Invalid protocol request.", safeRequestId(request.body));
431
+ }
352
432
  if (error instanceof InputError || error instanceof TypeError || error instanceof RangeError) {
353
433
  return failure(400, "INVALID_INPUT", "Invalid protocol request.", safeRequestId(request.body));
354
434
  }
@@ -363,7 +443,10 @@ export class CoordinationApi {
363
443
  let unsubscribe = () => undefined;
364
444
  let subscribed = false;
365
445
  let deliveryCount = 0;
446
+ let heartbeat;
366
447
  const queue = new AsyncStringQueue(() => {
448
+ if (heartbeat !== undefined)
449
+ clearInterval(heartbeat);
367
450
  unsubscribe();
368
451
  session.release();
369
452
  if (subscribed) {
@@ -376,7 +459,9 @@ export class CoordinationApi {
376
459
  });
377
460
  }
378
461
  }, () => { deliveryCount += 1; });
379
- const pending = [];
462
+ let replayDirty = false;
463
+ const pendingNotifications = [];
464
+ let notificationIndex = 0;
380
465
  let live = false;
381
466
  try {
382
467
  unsubscribe = store.subscribeActivity(cubeId, (entry) => {
@@ -386,23 +471,15 @@ export class CoordinationApi {
386
471
  }
387
472
  if (live)
388
473
  queue.push(encodeLogEvent(entry));
389
- else if (pending.length >= 200)
390
- queue.close();
474
+ else if ("kind" in entry)
475
+ pendingNotifications.push(entry);
391
476
  else
392
- pending.push(entry);
477
+ replayDirty = true;
393
478
  });
479
+ subscribed = true;
394
480
  signal.addEventListener("abort", () => queue.close(), { once: true });
395
- const replayPage = store.readLog(cubeId, cursor, 200);
396
- const replay = replayPage.entries;
397
- this.#debugLogger.emit({
398
- event: "cursor_replay",
399
- mode: "sse",
400
- cubeId,
401
- cursorId: cursor?.id ?? null,
402
- returnedCount: replay.length,
403
- behindBy: replayPage.behind_by,
404
- truncated: replayPage.has_more,
405
- });
481
+ const firstPage = store.readLog(cubeId, cursor, 200);
482
+ this.#logReplayPage(cubeId, cursor, firstPage);
406
483
  const barrier = this.#replayBarrier;
407
484
  this.#replayBarrier = undefined;
408
485
  if (barrier !== undefined) {
@@ -410,29 +487,63 @@ export class CoordinationApi {
410
487
  barrier.markReached();
411
488
  await barrier.release;
412
489
  }
413
- const seen = new Set(replay.map(cursorKey));
414
- for (const entry of replay)
415
- queue.push(encodeLogEvent(entry));
416
- for (const entry of pending) {
417
- if (!seen.has(cursorKey(entry)) && afterCursor(entry, cursor)) {
418
- seen.add(cursorKey(entry));
419
- queue.push(encodeLogEvent(entry));
490
+ void (async () => {
491
+ let page = firstPage;
492
+ let replayCursor = cursor;
493
+ let replayCount = 0;
494
+ try {
495
+ for (;;) {
496
+ for (const entry of page.entries) {
497
+ if (!await queue.write(encodeLogEvent(entry)))
498
+ return;
499
+ replayCursor = { id: entry.id, created_at: entry.created_at };
500
+ replayCount += 1;
501
+ }
502
+ if (page.has_more || replayDirty) {
503
+ replayDirty = false;
504
+ page = store.readLog(cubeId, replayCursor, 200);
505
+ this.#logReplayPage(cubeId, replayCursor, page);
506
+ continue;
507
+ }
508
+ while (notificationIndex < pendingNotifications.length) {
509
+ if (!await queue.write(encodeLogEvent(pendingNotifications[notificationIndex])))
510
+ return;
511
+ notificationIndex += 1;
512
+ }
513
+ // Reserve room for the bookmark before making live callbacks visible.
514
+ // Any append while waiting marks replay dirty and is fetched first.
515
+ if (!await queue.waitForSpace())
516
+ return;
517
+ if (replayDirty || notificationIndex < pendingNotifications.length) {
518
+ replayDirty = false;
519
+ page = store.readLog(cubeId, replayCursor, 200);
520
+ this.#logReplayPage(cubeId, replayCursor, page);
521
+ continue;
522
+ }
523
+ queue.push(`event: bookmark\ndata: ${JSON.stringify({
524
+ as_of: new Date().toISOString(),
525
+ replay_complete: true,
526
+ })}\n\n`);
527
+ live = true;
528
+ heartbeat = setInterval(() => {
529
+ queue.tryPush(encodeHeartbeat());
530
+ }, this.#streamHeartbeatMs);
531
+ heartbeat.unref();
532
+ this.#debugLogger.emit({
533
+ event: "sse_subscribe",
534
+ connectionId,
535
+ cubeId,
536
+ principal,
537
+ replayCount,
538
+ truncated: false,
539
+ });
540
+ return;
541
+ }
420
542
  }
421
- }
422
- queue.push(`event: bookmark\ndata: ${JSON.stringify({
423
- as_of: new Date().toISOString(),
424
- replay_complete: true,
425
- })}\n\n`);
426
- live = true;
427
- subscribed = true;
428
- this.#debugLogger.emit({
429
- event: "sse_subscribe",
430
- connectionId,
431
- cubeId,
432
- principal,
433
- replayCount: replay.length,
434
- truncated: replayPage.has_more,
435
- });
543
+ catch {
544
+ queue.close();
545
+ }
546
+ })();
436
547
  return { status: 200, stream: queue };
437
548
  }
438
549
  catch (error) {
@@ -444,12 +555,24 @@ export class CoordinationApi {
444
555
  throw error;
445
556
  }
446
557
  }
558
+ #logReplayPage(cubeId, cursor, page) {
559
+ this.#debugLogger.emit({
560
+ event: "cursor_replay",
561
+ mode: "sse",
562
+ cubeId,
563
+ cursorId: cursor?.id ?? null,
564
+ returnedCount: page.entries.length,
565
+ behindBy: page.behind_by,
566
+ truncated: page.has_more,
567
+ });
568
+ }
447
569
  }
448
570
  class InputError extends Error {
449
571
  }
450
572
  class AsyncStringQueue {
451
573
  #values = [];
452
574
  #waiters = [];
575
+ #spaceWaiters = [];
453
576
  #cleanup;
454
577
  #onDelivered;
455
578
  #closed = false;
@@ -464,13 +587,30 @@ class AsyncStringQueue {
464
587
  this.close();
465
588
  return;
466
589
  }
467
- const waiter = this.#waiters.shift();
468
- if (waiter === undefined)
469
- this.#values.push(value);
470
- else {
471
- this.#onDelivered();
472
- waiter({ value, done: false });
590
+ this.#enqueue(value);
591
+ }
592
+ tryPush(value) {
593
+ if (this.#closed || this.#values.length >= 200)
594
+ return false;
595
+ this.#enqueue(value);
596
+ return true;
597
+ }
598
+ async write(value) {
599
+ while (!this.#closed && this.#values.length >= 200) {
600
+ if (!await this.waitForSpace())
601
+ return false;
473
602
  }
603
+ if (this.#closed)
604
+ return false;
605
+ this.#enqueue(value);
606
+ return true;
607
+ }
608
+ waitForSpace() {
609
+ if (this.#closed)
610
+ return Promise.resolve(false);
611
+ if (this.#values.length < 200)
612
+ return Promise.resolve(true);
613
+ return new Promise((resolve) => this.#spaceWaiters.push(resolve));
474
614
  }
475
615
  close() {
476
616
  if (this.#closed)
@@ -479,12 +619,15 @@ class AsyncStringQueue {
479
619
  this.#cleanup();
480
620
  for (const waiter of this.#waiters.splice(0))
481
621
  waiter({ value: undefined, done: true });
622
+ for (const waiter of this.#spaceWaiters.splice(0))
623
+ waiter(false);
482
624
  }
483
625
  [Symbol.asyncIterator]() {
484
626
  return {
485
627
  next: async () => {
486
628
  const value = this.#values.shift();
487
629
  if (value !== undefined) {
630
+ this.#spaceWaiters.shift()?.(true);
488
631
  this.#onDelivered();
489
632
  return { value, done: false };
490
633
  }
@@ -498,17 +641,19 @@ class AsyncStringQueue {
498
641
  },
499
642
  };
500
643
  }
644
+ #enqueue(value) {
645
+ const waiter = this.#waiters.shift();
646
+ if (waiter === undefined)
647
+ this.#values.push(value);
648
+ else {
649
+ this.#onDelivered();
650
+ waiter({ value, done: false });
651
+ }
652
+ }
501
653
  }
502
654
  function decodeEnvelope(value) {
503
- const record = object(value);
504
- exactKeys(record, ["protocol_version", "request_id", "payload"]);
505
- if (record["protocol_version"] !== "1")
506
- throw new InputError();
507
- const requestId = record["request_id"];
508
- if (typeof requestId !== "string" || !/^[A-Za-z0-9._-]{8,128}$/u.test(requestId)) {
509
- throw new InputError();
510
- }
511
- return { requestId, payload: object(record["payload"]) };
655
+ const envelope = decodeProtocolEnvelope(value, object);
656
+ return { requestId: envelope.request_id, payload: envelope.payload };
512
657
  }
513
658
  function object(value) {
514
659
  if (typeof value !== "object" || value === null || Array.isArray(value))
@@ -568,6 +713,28 @@ function optionalBooleanValue(value) {
568
713
  throw new InputError();
569
714
  return value;
570
715
  }
716
+ function optionalRoleClass(value) {
717
+ if (value === undefined)
718
+ return undefined;
719
+ if (value !== "queen" && value !== "worker")
720
+ throw new InputError();
721
+ return value;
722
+ }
723
+ function optionalMessageTaxonomy(value) {
724
+ return value === undefined ? undefined : validateMessageTaxonomy(value);
725
+ }
726
+ function optionalStringArray(value) {
727
+ if (value === undefined)
728
+ return undefined;
729
+ if (!Array.isArray(value) || value.length < 1 || value.length > 100 ||
730
+ value.some((entry) => typeof entry !== "string" || entry.length < 1 || entry.length > 120)) {
731
+ throw new InputError();
732
+ }
733
+ const entries = value;
734
+ if (new Set(entries).size !== entries.length)
735
+ throw new InputError();
736
+ return entries;
737
+ }
571
738
  function requiredSectionHeading(record, key) {
572
739
  const value = requiredString(record, key, 60);
573
740
  const heading = value.trim();
@@ -632,6 +799,13 @@ function decodeOpaqueCursor(value) {
632
799
  throw new InputError();
633
800
  }
634
801
  }
802
+ function decodeSince(value) {
803
+ if (uuidPattern.test(value))
804
+ return value.toLowerCase();
805
+ if (timestampPattern.test(value) && new Date(value).toISOString() === value)
806
+ return value;
807
+ throw new InputError();
808
+ }
635
809
  function safeRequestId(value) {
636
810
  if (typeof value !== "object" || value === null || Array.isArray(value))
637
811
  return undefined;
@@ -641,30 +815,40 @@ function safeRequestId(value) {
641
815
  : undefined;
642
816
  }
643
817
  function success(status, requestId, payload) {
644
- return { status, body: { protocol_version: "1", request_id: requestId, payload } };
818
+ return { status, body: createProtocolEnvelope(requestId, payload) };
645
819
  }
646
820
  function failure(status, code, message, requestId) {
647
821
  return {
648
822
  status,
649
823
  body: {
650
- protocol_version: "1",
824
+ protocol_version: PROTOCOL_VERSION,
651
825
  ...(requestId === undefined ? {} : { request_id: requestId }),
652
826
  error: { code, message },
653
827
  },
654
828
  };
655
829
  }
656
830
  function encodeLogEvent(entry) {
831
+ if ("kind" in entry) {
832
+ return `event: log\nid: ${entry.id}\ndata: ${JSON.stringify({ entry })}\n\n`;
833
+ }
657
834
  return `event: log\nid: ${entry.id}\ndata: ${JSON.stringify({
658
835
  cursor: { id: entry.id, created_at: entry.created_at },
659
836
  entry,
660
837
  })}\n\n`;
661
838
  }
662
- function cursorKey(entry) {
663
- return `${entry.created_at}\0${entry.id}`;
839
+ function encodeHeartbeat() {
840
+ return `event: heartbeat\ndata: ${JSON.stringify({ ts: new Date().toISOString() })}\n\n`;
664
841
  }
665
- function afterCursor(entry, cursor) {
666
- return cursor === null || entry.created_at > cursor.created_at ||
667
- (entry.created_at === cursor.created_at && entry.id > cursor.id);
842
+ function cubePayload(cube) {
843
+ return {
844
+ id: cube.id,
845
+ owner_id: cube.ownerId,
846
+ name: cube.name,
847
+ cube_directive: cube.directive,
848
+ message_taxonomy: cube.messageTaxonomy,
849
+ created_at: cube.createdAt,
850
+ updated_at: cube.updatedAt,
851
+ };
668
852
  }
669
853
  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;
670
854
  const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;