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.
- package/README.md +7 -6
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/dist/coordination-api.d.ts +2 -1
- package/dist/coordination-api.js +269 -85
- package/dist/coordination-api.js.map +1 -1
- package/dist/credentials.d.ts +4 -6
- package/dist/credentials.js +43 -19
- package/dist/credentials.js.map +1 -1
- package/dist/debug-log.d.ts +2 -3
- package/dist/debug-log.js +2 -3
- package/dist/debug-log.js.map +1 -1
- package/dist/enrollment.d.ts +3 -11
- package/dist/enrollment.js +21 -60
- package/dist/enrollment.js.map +1 -1
- package/dist/https-server.d.ts +2 -20
- package/dist/https-server.js +33 -51
- package/dist/https-server.js.map +1 -1
- package/dist/message-taxonomy.d.ts +38 -0
- package/dist/message-taxonomy.js +218 -0
- package/dist/message-taxonomy.js.map +1 -0
- package/dist/migrations.js +28 -0
- package/dist/migrations.js.map +1 -1
- package/dist/service.d.ts +4 -1
- package/dist/service.js +25 -10
- package/dist/service.js.map +1 -1
- package/dist/store.d.ts +42 -8
- package/dist/store.js +380 -100
- package/dist/store.js.map +1 -1
- package/npm-shrinkwrap.json +6 -7
- package/package.json +2 -2
- package/src/coordination-api.ts +278 -82
- package/src/credentials.ts +44 -26
- package/src/debug-log.ts +5 -5
- package/src/enrollment.ts +32 -78
- package/src/https-server.ts +44 -82
- package/src/message-taxonomy.ts +284 -0
- package/src/migrations.ts +28 -0
- package/src/service.ts +29 -9
- package/src/store.ts +432 -121
- package/dist/protocol-draft.d.ts +0 -2
- package/dist/protocol-draft.js +0 -31
- package/dist/protocol-draft.js.map +0 -1
- 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
|
|
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
|
-
|
|
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
|
|
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`
|
|
24
|
-
|
|
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
|
|
package/THIRD_PARTY_NOTICES.md
CHANGED
|
@@ -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;
|
package/dist/coordination-api.js
CHANGED
|
@@ -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 {
|
|
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 ===
|
|
35
|
+
if (request.path === ATTACH_PATH && request.method === "POST") {
|
|
32
36
|
const requestId = safeRequestId(request.body);
|
|
33
37
|
try {
|
|
34
|
-
const envelope =
|
|
35
|
-
|
|
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:
|
|
41
|
-
roleId:
|
|
42
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
60
|
-
return
|
|
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
|
-
|
|
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"], [
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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 (
|
|
390
|
-
|
|
474
|
+
else if ("kind" in entry)
|
|
475
|
+
pendingNotifications.push(entry);
|
|
391
476
|
else
|
|
392
|
-
|
|
477
|
+
replayDirty = true;
|
|
393
478
|
});
|
|
479
|
+
subscribed = true;
|
|
394
480
|
signal.addEventListener("abort", () => queue.close(), { once: true });
|
|
395
|
-
const
|
|
396
|
-
|
|
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
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
|
504
|
-
|
|
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:
|
|
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:
|
|
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
|
|
663
|
-
return
|
|
839
|
+
function encodeHeartbeat() {
|
|
840
|
+
return `event: heartbeat\ndata: ${JSON.stringify({ ts: new Date().toISOString() })}\n\n`;
|
|
664
841
|
}
|
|
665
|
-
function
|
|
666
|
-
return
|
|
667
|
-
|
|
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;
|