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.
- package/README.md +70 -22
- package/THIRD_PARTY_NOTICES.md +1 -0
- package/dist/cli.js +62 -11
- package/dist/cli.js.map +1 -1
- package/dist/coordination-api.d.ts +3 -1
- package/dist/coordination-api.js +476 -78
- package/dist/coordination-api.js.map +1 -1
- package/dist/credentials.d.ts +13 -7
- package/dist/credentials.js +77 -15
- package/dist/credentials.js.map +1 -1
- package/dist/debug-log.d.ts +76 -0
- package/dist/debug-log.js +124 -0
- package/dist/debug-log.js.map +1 -0
- 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 +5 -20
- package/dist/https-server.js +108 -50
- package/dist/https-server.js.map +1 -1
- package/dist/index.d.ts +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.d.ts +4 -0
- package/dist/migrations.js +99 -0
- package/dist/migrations.js.map +1 -1
- package/dist/operator-error.d.ts +2 -1
- package/dist/operator-error.js +23 -5
- package/dist/operator-error.js.map +1 -1
- package/dist/role-section.d.ts +14 -0
- package/dist/role-section.js +87 -0
- package/dist/role-section.js.map +1 -0
- package/dist/service.d.ts +14 -4
- package/dist/service.js +243 -25
- package/dist/service.js.map +1 -1
- package/dist/start-options.d.ts +5 -1
- package/dist/start-options.js +17 -3
- package/dist/start-options.js.map +1 -1
- package/dist/store.d.ts +106 -8
- package/dist/store.js +772 -120
- package/dist/store.js.map +1 -1
- package/npm-shrinkwrap.json +6 -7
- package/package.json +2 -2
- package/src/cli.ts +61 -11
- package/src/coordination-api.ts +490 -72
- package/src/credentials.ts +103 -19
- package/src/debug-log.ts +165 -0
- package/src/enrollment.ts +32 -78
- package/src/https-server.ts +113 -78
- package/src/index.ts +1 -1
- package/src/message-taxonomy.ts +284 -0
- package/src/migrations.ts +102 -0
- package/src/operator-error.ts +40 -6
- package/src/role-section.ts +108 -0
- package/src/service.ts +268 -27
- package/src/start-options.ts +21 -4
- package/src/store.ts +887 -142
- 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/dist/coordination-api.js
CHANGED
|
@@ -1,12 +1,20 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { ATTACH_PATH, ErrorCode, PROTOCOL_VERSION, ProtocolContractError, createProtocolEnvelope, decodeAttachRequestEnvelope, decodeProtocolEnvelope, } from "borgmcp-shared/protocol";
|
|
1
3
|
import { assertServerDerivedPrincipal } from "./principal.js";
|
|
2
|
-
import {
|
|
4
|
+
import { disabledDebugLogger } from "./debug-log.js";
|
|
5
|
+
import { patchMessageTaxonomy, resolveMessageRouting, validateMessageTaxonomy, } from "./message-taxonomy.js";
|
|
6
|
+
import { CursorExpiredError, AttachSessionRejectedError, AccessDeniedError, CreateCubeConflictError, DefaultRoleRequiredError, RoleConflictError, RoleSectionConflictError, ScopedStoreError, StorageCapacityError, } from "./store.js";
|
|
3
7
|
export class CoordinationApi {
|
|
4
8
|
#runtime;
|
|
5
9
|
#authority;
|
|
10
|
+
#debugLogger;
|
|
11
|
+
#streamHeartbeatMs;
|
|
6
12
|
#replayBarrier;
|
|
7
|
-
constructor(runtime, authority) {
|
|
13
|
+
constructor(runtime, authority, debugLogger = disabledDebugLogger, streamHeartbeatMs = 5_000) {
|
|
8
14
|
this.#runtime = runtime;
|
|
9
15
|
this.#authority = authority;
|
|
16
|
+
this.#debugLogger = debugLogger;
|
|
17
|
+
this.#streamHeartbeatMs = streamHeartbeatMs;
|
|
10
18
|
}
|
|
11
19
|
armReplayTransition() {
|
|
12
20
|
let markReached;
|
|
@@ -24,35 +32,36 @@ export class CoordinationApi {
|
|
|
24
32
|
async handle(request) {
|
|
25
33
|
assertServerDerivedPrincipal(request.principal);
|
|
26
34
|
const authentication = request.principal;
|
|
27
|
-
if (request.path ===
|
|
35
|
+
if (request.path === ATTACH_PATH && request.method === "POST") {
|
|
28
36
|
const requestId = safeRequestId(request.body);
|
|
29
37
|
try {
|
|
30
|
-
const envelope =
|
|
31
|
-
|
|
32
|
-
const priorDroneId = envelope.payload["prior_drone_id"] === undefined
|
|
33
|
-
? undefined
|
|
34
|
-
: requiredUuid(envelope.payload, "prior_drone_id");
|
|
38
|
+
const envelope = decodeAttachRequestEnvelope(request.body);
|
|
39
|
+
const priorDroneId = envelope.payload.prior_drone_id;
|
|
35
40
|
const attachment = this.#authority.attachSeat(this.#runtime.forPrincipal(authentication), {
|
|
36
|
-
cubeId:
|
|
37
|
-
roleId:
|
|
38
|
-
|
|
41
|
+
cubeId: envelope.payload.cube_id,
|
|
42
|
+
roleId: envelope.payload.role_id,
|
|
43
|
+
sessionCredential: envelope.payload.session_credential,
|
|
39
44
|
...(priorDroneId === undefined ? {} : { priorDroneId }),
|
|
40
45
|
});
|
|
41
|
-
return success(201, envelope.
|
|
46
|
+
return success(attachment.result === "created" ? 201 : 200, envelope.request_id, {
|
|
47
|
+
result: attachment.result,
|
|
42
48
|
cube: attachment.cube,
|
|
43
49
|
role: attachment.role,
|
|
44
50
|
drone: attachment.drone,
|
|
45
51
|
session: {
|
|
46
|
-
|
|
52
|
+
id: attachment.sessionId,
|
|
47
53
|
expires_at: attachment.expiresAt,
|
|
48
|
-
generation: attachment.generation,
|
|
49
54
|
},
|
|
50
|
-
reattached: attachment.reattached,
|
|
51
55
|
});
|
|
52
56
|
}
|
|
53
57
|
catch (error) {
|
|
54
|
-
if (error instanceof
|
|
55
|
-
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);
|
|
56
65
|
}
|
|
57
66
|
if (error instanceof ScopedStoreError) {
|
|
58
67
|
return failure(404, "NOT_FOUND", error.message, requestId);
|
|
@@ -72,6 +81,7 @@ export class CoordinationApi {
|
|
|
72
81
|
owner_id: cube.ownerId,
|
|
73
82
|
name: cube.name,
|
|
74
83
|
cube_directive: cube.directive,
|
|
84
|
+
message_taxonomy: cube.messageTaxonomy,
|
|
75
85
|
created_at: cube.createdAt,
|
|
76
86
|
updated_at: cube.updatedAt,
|
|
77
87
|
}));
|
|
@@ -107,21 +117,31 @@ export class CoordinationApi {
|
|
|
107
117
|
if (error instanceof StorageCapacityError) {
|
|
108
118
|
return failure(507, "CAPACITY_EXCEEDED", error.message, requestId);
|
|
109
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
|
+
}
|
|
110
125
|
if (error instanceof InputError || error instanceof TypeError || error instanceof RangeError) {
|
|
111
126
|
return failure(400, "INVALID_INPUT", "Invalid protocol request.", requestId);
|
|
112
127
|
}
|
|
113
128
|
throw error;
|
|
114
129
|
}
|
|
115
130
|
}
|
|
116
|
-
const
|
|
131
|
+
const roleMatch = /^\/api\/cubes\/([0-9a-f-]{36})\/roles\/([0-9a-f-]{36})(\/section-patch)?$/u
|
|
117
132
|
.exec(request.path);
|
|
118
|
-
|
|
133
|
+
const match = /^\/api\/cubes\/([0-9a-f-]{36})(?:\/(roles|drones|logs|acks|decisions|stream|taxonomy-patch))?$/u
|
|
134
|
+
.exec(request.path);
|
|
135
|
+
if (match === null && roleMatch === null) {
|
|
119
136
|
return failure(404, "NOT_FOUND", "The requested resource was not found.");
|
|
120
|
-
|
|
121
|
-
|
|
137
|
+
}
|
|
138
|
+
const cubeId = (roleMatch?.[1] ?? match?.[1]);
|
|
139
|
+
const roleId = roleMatch?.[2];
|
|
140
|
+
if (!uuidPattern.test(cubeId) || (roleId !== undefined && !uuidPattern.test(roleId))) {
|
|
122
141
|
return failure(404, "NOT_FOUND", "The requested resource was not found.");
|
|
123
142
|
}
|
|
124
|
-
const resource = match[2];
|
|
143
|
+
const resource = roleMatch === null ? match?.[2] : "role";
|
|
144
|
+
const sectionPatch = roleMatch?.[3] !== undefined;
|
|
125
145
|
const store = this.#runtime.forPrincipal(authentication);
|
|
126
146
|
try {
|
|
127
147
|
if (resource === undefined && request.method === "GET") {
|
|
@@ -134,31 +154,194 @@ export class CoordinationApi {
|
|
|
134
154
|
owner_id: cube.ownerId,
|
|
135
155
|
name: cube.name,
|
|
136
156
|
cube_directive: cube.directive,
|
|
157
|
+
message_taxonomy: cube.messageTaxonomy,
|
|
137
158
|
created_at: cube.createdAt,
|
|
138
159
|
updated_at: cube.updatedAt,
|
|
139
160
|
},
|
|
140
161
|
});
|
|
141
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
|
+
}
|
|
142
201
|
if (resource === "roles" && request.method === "GET") {
|
|
143
202
|
return success(200, "roles-read", { roles: store.listRoles(cubeId) });
|
|
144
203
|
}
|
|
204
|
+
if (resource === "roles" && request.method === "POST") {
|
|
205
|
+
const envelope = decodeEnvelope(request.body);
|
|
206
|
+
exactKeys(envelope.payload, ["name"], [
|
|
207
|
+
"short_description",
|
|
208
|
+
"detailed_description",
|
|
209
|
+
"is_default",
|
|
210
|
+
"is_mandatory",
|
|
211
|
+
"is_human_seat",
|
|
212
|
+
"can_broadcast",
|
|
213
|
+
"receives_all_direct",
|
|
214
|
+
"role_class",
|
|
215
|
+
]);
|
|
216
|
+
const role = store.createRole(cubeId, {
|
|
217
|
+
name: requiredRoleName(envelope.payload, "name"),
|
|
218
|
+
shortDescription: optionalText(envelope.payload, "short_description", 1_024) ?? "",
|
|
219
|
+
detailedDescription: optionalText(envelope.payload, "detailed_description", 51_200) ?? "",
|
|
220
|
+
isDefault: optionalBoolean(envelope.payload["is_default"]),
|
|
221
|
+
isMandatory: optionalBoolean(envelope.payload["is_mandatory"]),
|
|
222
|
+
isHumanSeat: optionalBoolean(envelope.payload["is_human_seat"]),
|
|
223
|
+
canBroadcast: optionalBoolean(envelope.payload["can_broadcast"]),
|
|
224
|
+
receivesAllDirect: optionalBoolean(envelope.payload["receives_all_direct"]),
|
|
225
|
+
...(envelope.payload["role_class"] === undefined
|
|
226
|
+
? {}
|
|
227
|
+
: { roleClass: optionalRoleClass(envelope.payload["role_class"]) }),
|
|
228
|
+
});
|
|
229
|
+
return success(201, envelope.requestId, { role });
|
|
230
|
+
}
|
|
231
|
+
if (resource === "role" && !sectionPatch && request.method === "PATCH") {
|
|
232
|
+
const envelope = decodeEnvelope(request.body);
|
|
233
|
+
exactKeys(envelope.payload, [], [
|
|
234
|
+
"name",
|
|
235
|
+
"short_description",
|
|
236
|
+
"detailed_description",
|
|
237
|
+
"is_default",
|
|
238
|
+
"is_mandatory",
|
|
239
|
+
"is_human_seat",
|
|
240
|
+
"can_broadcast",
|
|
241
|
+
"receives_all_direct",
|
|
242
|
+
"role_class",
|
|
243
|
+
]);
|
|
244
|
+
if (Object.keys(envelope.payload).length === 0)
|
|
245
|
+
throw new InputError();
|
|
246
|
+
const name = envelope.payload["name"] === undefined
|
|
247
|
+
? undefined
|
|
248
|
+
: requiredRoleName(envelope.payload, "name");
|
|
249
|
+
const shortDescription = optionalText(envelope.payload, "short_description", 1_024);
|
|
250
|
+
const detailedDescription = optionalText(envelope.payload, "detailed_description", 51_200);
|
|
251
|
+
const isDefault = optionalBooleanValue(envelope.payload["is_default"]);
|
|
252
|
+
const isMandatory = optionalBooleanValue(envelope.payload["is_mandatory"]);
|
|
253
|
+
const isHumanSeat = optionalBooleanValue(envelope.payload["is_human_seat"]);
|
|
254
|
+
const canBroadcast = optionalBooleanValue(envelope.payload["can_broadcast"]);
|
|
255
|
+
const receivesAllDirect = optionalBooleanValue(envelope.payload["receives_all_direct"]);
|
|
256
|
+
const roleClass = optionalRoleClass(envelope.payload["role_class"]);
|
|
257
|
+
const role = store.updateRole(cubeId, roleId, {
|
|
258
|
+
...(name === undefined ? {} : { name }),
|
|
259
|
+
...(shortDescription === undefined ? {} : { shortDescription }),
|
|
260
|
+
...(detailedDescription === undefined ? {} : { detailedDescription }),
|
|
261
|
+
...(isDefault === undefined ? {} : { isDefault }),
|
|
262
|
+
...(isMandatory === undefined ? {} : { isMandatory }),
|
|
263
|
+
...(isHumanSeat === undefined ? {} : { isHumanSeat }),
|
|
264
|
+
...(canBroadcast === undefined ? {} : { canBroadcast }),
|
|
265
|
+
...(receivesAllDirect === undefined ? {} : { receivesAllDirect }),
|
|
266
|
+
...(roleClass === undefined ? {} : { roleClass }),
|
|
267
|
+
});
|
|
268
|
+
return success(200, envelope.requestId, { role });
|
|
269
|
+
}
|
|
270
|
+
if (resource === "role" && sectionPatch && request.method === "POST") {
|
|
271
|
+
const envelope = decodeEnvelope(request.body);
|
|
272
|
+
const action = envelope.payload["action"];
|
|
273
|
+
if (action === "delete") {
|
|
274
|
+
exactKeys(envelope.payload, ["action", "heading"]);
|
|
275
|
+
const role = store.patchRoleSection(cubeId, roleId, {
|
|
276
|
+
action,
|
|
277
|
+
heading: requiredSectionHeading(envelope.payload, "heading"),
|
|
278
|
+
});
|
|
279
|
+
return success(200, envelope.requestId, { role });
|
|
280
|
+
}
|
|
281
|
+
if (action !== "replace" && action !== "insert")
|
|
282
|
+
throw new InputError();
|
|
283
|
+
exactKeys(envelope.payload, ["action", "heading", "body"], action === "insert" ? ["after"] : []);
|
|
284
|
+
const body = optionalText(envelope.payload, "body", 51_200);
|
|
285
|
+
if (body === undefined)
|
|
286
|
+
throw new InputError();
|
|
287
|
+
const heading = requiredSectionHeading(envelope.payload, "heading");
|
|
288
|
+
if (action === "replace") {
|
|
289
|
+
return success(200, envelope.requestId, {
|
|
290
|
+
role: store.patchRoleSection(cubeId, roleId, { action, heading, body }),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
const after = optionalNullableSectionHeading(envelope.payload["after"]);
|
|
294
|
+
return success(200, envelope.requestId, {
|
|
295
|
+
role: store.patchRoleSection(cubeId, roleId, {
|
|
296
|
+
action,
|
|
297
|
+
heading,
|
|
298
|
+
body,
|
|
299
|
+
...(after === undefined ? {} : { after }),
|
|
300
|
+
}),
|
|
301
|
+
});
|
|
302
|
+
}
|
|
145
303
|
if (resource === "drones" && request.method === "GET") {
|
|
146
|
-
|
|
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));
|
|
147
309
|
}
|
|
148
310
|
if (resource === "logs" && request.method === "POST") {
|
|
149
311
|
const envelope = decodeEnvelope(request.body);
|
|
150
|
-
exactKeys(envelope.payload, ["message"], [
|
|
312
|
+
exactKeys(envelope.payload, ["message"], [
|
|
313
|
+
"visibility", "recipientDroneIds", "class", "to",
|
|
314
|
+
]);
|
|
151
315
|
const message = requiredString(envelope.payload, "message", 10_240);
|
|
152
316
|
const visibility = optionalVisibility(envelope.payload["visibility"]);
|
|
153
317
|
const recipientDroneIds = optionalUuidArray(envelope.payload["recipientDroneIds"]);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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({
|
|
159
324
|
message,
|
|
160
325
|
...(visibility === undefined ? {} : { visibility }),
|
|
161
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
|
+
: {}),
|
|
336
|
+
});
|
|
337
|
+
this.#debugLogger.emit({
|
|
338
|
+
event: "activity_append",
|
|
339
|
+
cubeId,
|
|
340
|
+
entryId: entry.id,
|
|
341
|
+
principal: authentication,
|
|
342
|
+
droneId: entry.drone_id,
|
|
343
|
+
visibility: entry.visibility,
|
|
344
|
+
recipientDroneIds: entry.recipient_drone_ids,
|
|
162
345
|
});
|
|
163
346
|
return success(201, envelope.requestId, { entry });
|
|
164
347
|
}
|
|
@@ -167,7 +350,17 @@ export class CoordinationApi {
|
|
|
167
350
|
exactKeys(envelope.payload, ["cursor"], ["limit"]);
|
|
168
351
|
const cursor = decodeCursor(envelope.payload["cursor"]);
|
|
169
352
|
const limit = optionalLimit(envelope.payload["limit"]);
|
|
170
|
-
|
|
353
|
+
const page = store.readLog(cubeId, cursor, limit);
|
|
354
|
+
this.#debugLogger.emit({
|
|
355
|
+
event: "cursor_replay",
|
|
356
|
+
mode: "page",
|
|
357
|
+
cubeId,
|
|
358
|
+
cursorId: cursor?.id ?? null,
|
|
359
|
+
returnedCount: page.entries.length,
|
|
360
|
+
behindBy: page.behind_by,
|
|
361
|
+
truncated: page.has_more,
|
|
362
|
+
});
|
|
363
|
+
return success(200, envelope.requestId, page);
|
|
171
364
|
}
|
|
172
365
|
if (resource === "acks" && request.method === "POST") {
|
|
173
366
|
const envelope = decodeEnvelope(request.body);
|
|
@@ -177,6 +370,7 @@ export class CoordinationApi {
|
|
|
177
370
|
throw new InputError();
|
|
178
371
|
exactKeys(envelope.payload, ["entry_id", "kind"]);
|
|
179
372
|
store.acknowledge(cubeId, entryId, kind);
|
|
373
|
+
this.#debugLogger.emit({ event: "ack_write", cubeId, entryId, kind, principal: authentication });
|
|
180
374
|
return { status: 204 };
|
|
181
375
|
}
|
|
182
376
|
if (resource === "decisions" && request.method === "POST") {
|
|
@@ -185,13 +379,18 @@ export class CoordinationApi {
|
|
|
185
379
|
const topic = requiredString(envelope.payload, "topic", 120);
|
|
186
380
|
const decision = requiredString(envelope.payload, "decision", 100_000);
|
|
187
381
|
const rationale = optionalString(envelope.payload, "rationale", 100_000);
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
...(rationale === undefined ? {} : { rationale }),
|
|
193
|
-
}),
|
|
382
|
+
const decisionRecord = store.recordDecision(cubeId, {
|
|
383
|
+
topic,
|
|
384
|
+
decision,
|
|
385
|
+
...(rationale === undefined ? {} : { rationale }),
|
|
194
386
|
});
|
|
387
|
+
this.#debugLogger.emit({
|
|
388
|
+
event: "decision_write",
|
|
389
|
+
cubeId,
|
|
390
|
+
decisionId: decisionRecord.id,
|
|
391
|
+
principal: authentication,
|
|
392
|
+
});
|
|
393
|
+
return success(201, envelope.requestId, { decision: decisionRecord });
|
|
195
394
|
}
|
|
196
395
|
if (resource === "decisions" && request.method === "PUT") {
|
|
197
396
|
const envelope = decodeEnvelope(request.body);
|
|
@@ -207,12 +406,29 @@ export class CoordinationApi {
|
|
|
207
406
|
if (error instanceof CursorExpiredError) {
|
|
208
407
|
return failure(410, "CURSOR_EXPIRED", error.message);
|
|
209
408
|
}
|
|
409
|
+
if (error instanceof AccessDeniedError) {
|
|
410
|
+
return failure(403, ErrorCode.ACCESS_DENIED, error.message);
|
|
411
|
+
}
|
|
210
412
|
if (error instanceof ScopedStoreError) {
|
|
211
413
|
return failure(404, "NOT_FOUND", error.message);
|
|
212
414
|
}
|
|
415
|
+
if (error instanceof RoleConflictError) {
|
|
416
|
+
return failure(409, error.code, error.message, safeRequestId(request.body));
|
|
417
|
+
}
|
|
418
|
+
if (error instanceof DefaultRoleRequiredError) {
|
|
419
|
+
return failure(409, error.code, error.message, safeRequestId(request.body));
|
|
420
|
+
}
|
|
421
|
+
if (error instanceof RoleSectionConflictError) {
|
|
422
|
+
return failure(409, error.code, error.message, safeRequestId(request.body));
|
|
423
|
+
}
|
|
213
424
|
if (error instanceof StorageCapacityError) {
|
|
214
425
|
return failure(507, "CAPACITY_EXCEEDED", error.message, safeRequestId(request.body));
|
|
215
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
|
+
}
|
|
216
432
|
if (error instanceof InputError || error instanceof TypeError || error instanceof RangeError) {
|
|
217
433
|
return failure(400, "INVALID_INPUT", "Invalid protocol request.", safeRequestId(request.body));
|
|
218
434
|
}
|
|
@@ -221,14 +437,31 @@ export class CoordinationApi {
|
|
|
221
437
|
}
|
|
222
438
|
async #openStream(principal, cubeId, cursor, requestSignal) {
|
|
223
439
|
const store = this.#runtime.forPrincipal(principal);
|
|
440
|
+
const connectionId = randomUUID();
|
|
224
441
|
const session = this.#authority.registerLiveSession(principal);
|
|
225
442
|
const signal = AbortSignal.any([requestSignal, session.signal]);
|
|
226
443
|
let unsubscribe = () => undefined;
|
|
444
|
+
let subscribed = false;
|
|
445
|
+
let deliveryCount = 0;
|
|
446
|
+
let heartbeat;
|
|
227
447
|
const queue = new AsyncStringQueue(() => {
|
|
448
|
+
if (heartbeat !== undefined)
|
|
449
|
+
clearInterval(heartbeat);
|
|
228
450
|
unsubscribe();
|
|
229
451
|
session.release();
|
|
230
|
-
|
|
231
|
-
|
|
452
|
+
if (subscribed) {
|
|
453
|
+
this.#debugLogger.emit({
|
|
454
|
+
event: "sse_unsubscribe",
|
|
455
|
+
connectionId,
|
|
456
|
+
cubeId,
|
|
457
|
+
principal,
|
|
458
|
+
deliveryCount,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}, () => { deliveryCount += 1; });
|
|
462
|
+
let replayDirty = false;
|
|
463
|
+
const pendingNotifications = [];
|
|
464
|
+
let notificationIndex = 0;
|
|
232
465
|
let live = false;
|
|
233
466
|
try {
|
|
234
467
|
unsubscribe = store.subscribeActivity(cubeId, (entry) => {
|
|
@@ -238,13 +471,15 @@ export class CoordinationApi {
|
|
|
238
471
|
}
|
|
239
472
|
if (live)
|
|
240
473
|
queue.push(encodeLogEvent(entry));
|
|
241
|
-
else if (
|
|
242
|
-
|
|
474
|
+
else if ("kind" in entry)
|
|
475
|
+
pendingNotifications.push(entry);
|
|
243
476
|
else
|
|
244
|
-
|
|
477
|
+
replayDirty = true;
|
|
245
478
|
});
|
|
479
|
+
subscribed = true;
|
|
246
480
|
signal.addEventListener("abort", () => queue.close(), { once: true });
|
|
247
|
-
const
|
|
481
|
+
const firstPage = store.readLog(cubeId, cursor, 200);
|
|
482
|
+
this.#logReplayPage(cubeId, cursor, firstPage);
|
|
248
483
|
const barrier = this.#replayBarrier;
|
|
249
484
|
this.#replayBarrier = undefined;
|
|
250
485
|
if (barrier !== undefined) {
|
|
@@ -252,20 +487,63 @@ export class CoordinationApi {
|
|
|
252
487
|
barrier.markReached();
|
|
253
488
|
await barrier.release;
|
|
254
489
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
+
}
|
|
262
542
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
})}\n\n`);
|
|
268
|
-
live = true;
|
|
543
|
+
catch {
|
|
544
|
+
queue.close();
|
|
545
|
+
}
|
|
546
|
+
})();
|
|
269
547
|
return { status: 200, stream: queue };
|
|
270
548
|
}
|
|
271
549
|
catch (error) {
|
|
@@ -277,16 +555,30 @@ export class CoordinationApi {
|
|
|
277
555
|
throw error;
|
|
278
556
|
}
|
|
279
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
|
+
}
|
|
280
569
|
}
|
|
281
570
|
class InputError extends Error {
|
|
282
571
|
}
|
|
283
572
|
class AsyncStringQueue {
|
|
284
573
|
#values = [];
|
|
285
574
|
#waiters = [];
|
|
575
|
+
#spaceWaiters = [];
|
|
286
576
|
#cleanup;
|
|
577
|
+
#onDelivered;
|
|
287
578
|
#closed = false;
|
|
288
|
-
constructor(cleanup) {
|
|
579
|
+
constructor(cleanup, onDelivered = () => undefined) {
|
|
289
580
|
this.#cleanup = cleanup;
|
|
581
|
+
this.#onDelivered = onDelivered;
|
|
290
582
|
}
|
|
291
583
|
push(value) {
|
|
292
584
|
if (this.#closed)
|
|
@@ -295,11 +587,30 @@ class AsyncStringQueue {
|
|
|
295
587
|
this.close();
|
|
296
588
|
return;
|
|
297
589
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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;
|
|
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));
|
|
303
614
|
}
|
|
304
615
|
close() {
|
|
305
616
|
if (this.#closed)
|
|
@@ -308,13 +619,18 @@ class AsyncStringQueue {
|
|
|
308
619
|
this.#cleanup();
|
|
309
620
|
for (const waiter of this.#waiters.splice(0))
|
|
310
621
|
waiter({ value: undefined, done: true });
|
|
622
|
+
for (const waiter of this.#spaceWaiters.splice(0))
|
|
623
|
+
waiter(false);
|
|
311
624
|
}
|
|
312
625
|
[Symbol.asyncIterator]() {
|
|
313
626
|
return {
|
|
314
627
|
next: async () => {
|
|
315
628
|
const value = this.#values.shift();
|
|
316
|
-
if (value !== undefined)
|
|
629
|
+
if (value !== undefined) {
|
|
630
|
+
this.#spaceWaiters.shift()?.(true);
|
|
631
|
+
this.#onDelivered();
|
|
317
632
|
return { value, done: false };
|
|
633
|
+
}
|
|
318
634
|
if (this.#closed)
|
|
319
635
|
return { value: undefined, done: true };
|
|
320
636
|
return new Promise((resolve) => this.#waiters.push(resolve));
|
|
@@ -325,17 +641,19 @@ class AsyncStringQueue {
|
|
|
325
641
|
},
|
|
326
642
|
};
|
|
327
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
|
+
}
|
|
328
653
|
}
|
|
329
654
|
function decodeEnvelope(value) {
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
if (record["protocol_version"] !== "1")
|
|
333
|
-
throw new InputError();
|
|
334
|
-
const requestId = record["request_id"];
|
|
335
|
-
if (typeof requestId !== "string" || !/^[A-Za-z0-9._-]{8,128}$/u.test(requestId)) {
|
|
336
|
-
throw new InputError();
|
|
337
|
-
}
|
|
338
|
-
return { requestId, payload: object(record["payload"]) };
|
|
655
|
+
const envelope = decodeProtocolEnvelope(value, object);
|
|
656
|
+
return { requestId: envelope.request_id, payload: envelope.payload };
|
|
339
657
|
}
|
|
340
658
|
function object(value) {
|
|
341
659
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
@@ -361,12 +679,75 @@ function requiredPresentationName(record, key) {
|
|
|
361
679
|
throw new InputError();
|
|
362
680
|
return value;
|
|
363
681
|
}
|
|
682
|
+
function requiredRoleName(record, key) {
|
|
683
|
+
const value = record[key];
|
|
684
|
+
if (typeof value !== "string" || value.length < 1 || value.length > 64)
|
|
685
|
+
throw new InputError();
|
|
686
|
+
return value;
|
|
687
|
+
}
|
|
364
688
|
function optionalString(record, key, maxBytes) {
|
|
365
689
|
const value = record[key];
|
|
366
690
|
if (value === undefined)
|
|
367
691
|
return undefined;
|
|
368
692
|
return requiredString(record, key, maxBytes);
|
|
369
693
|
}
|
|
694
|
+
function optionalText(record, key, maxCharacters) {
|
|
695
|
+
const value = record[key];
|
|
696
|
+
if (value === undefined)
|
|
697
|
+
return undefined;
|
|
698
|
+
if (typeof value !== "string" || value.length > maxCharacters)
|
|
699
|
+
throw new InputError();
|
|
700
|
+
return value;
|
|
701
|
+
}
|
|
702
|
+
function optionalBoolean(value) {
|
|
703
|
+
if (value === undefined)
|
|
704
|
+
return false;
|
|
705
|
+
if (typeof value !== "boolean")
|
|
706
|
+
throw new InputError();
|
|
707
|
+
return value;
|
|
708
|
+
}
|
|
709
|
+
function optionalBooleanValue(value) {
|
|
710
|
+
if (value === undefined)
|
|
711
|
+
return undefined;
|
|
712
|
+
if (typeof value !== "boolean")
|
|
713
|
+
throw new InputError();
|
|
714
|
+
return value;
|
|
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
|
+
}
|
|
738
|
+
function requiredSectionHeading(record, key) {
|
|
739
|
+
const value = requiredString(record, key, 60);
|
|
740
|
+
const heading = value.trim();
|
|
741
|
+
if (heading.length === 0 || /^\s/u.test(value) || /[:\n\r]/u.test(heading) ||
|
|
742
|
+
/^[*\-#>`]/u.test(heading))
|
|
743
|
+
throw new InputError();
|
|
744
|
+
return heading;
|
|
745
|
+
}
|
|
746
|
+
function optionalNullableSectionHeading(value) {
|
|
747
|
+
if (value === undefined || value === null)
|
|
748
|
+
return value;
|
|
749
|
+
return requiredSectionHeading({ value }, "value");
|
|
750
|
+
}
|
|
370
751
|
function requiredUuid(record, key) {
|
|
371
752
|
const value = record[key];
|
|
372
753
|
if (typeof value !== "string" || !uuidPattern.test(value))
|
|
@@ -418,6 +799,13 @@ function decodeOpaqueCursor(value) {
|
|
|
418
799
|
throw new InputError();
|
|
419
800
|
}
|
|
420
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
|
+
}
|
|
421
809
|
function safeRequestId(value) {
|
|
422
810
|
if (typeof value !== "object" || value === null || Array.isArray(value))
|
|
423
811
|
return undefined;
|
|
@@ -427,30 +815,40 @@ function safeRequestId(value) {
|
|
|
427
815
|
: undefined;
|
|
428
816
|
}
|
|
429
817
|
function success(status, requestId, payload) {
|
|
430
|
-
return { status, body:
|
|
818
|
+
return { status, body: createProtocolEnvelope(requestId, payload) };
|
|
431
819
|
}
|
|
432
820
|
function failure(status, code, message, requestId) {
|
|
433
821
|
return {
|
|
434
822
|
status,
|
|
435
823
|
body: {
|
|
436
|
-
protocol_version:
|
|
824
|
+
protocol_version: PROTOCOL_VERSION,
|
|
437
825
|
...(requestId === undefined ? {} : { request_id: requestId }),
|
|
438
826
|
error: { code, message },
|
|
439
827
|
},
|
|
440
828
|
};
|
|
441
829
|
}
|
|
442
830
|
function encodeLogEvent(entry) {
|
|
831
|
+
if ("kind" in entry) {
|
|
832
|
+
return `event: log\nid: ${entry.id}\ndata: ${JSON.stringify({ entry })}\n\n`;
|
|
833
|
+
}
|
|
443
834
|
return `event: log\nid: ${entry.id}\ndata: ${JSON.stringify({
|
|
444
835
|
cursor: { id: entry.id, created_at: entry.created_at },
|
|
445
836
|
entry,
|
|
446
837
|
})}\n\n`;
|
|
447
838
|
}
|
|
448
|
-
function
|
|
449
|
-
return
|
|
839
|
+
function encodeHeartbeat() {
|
|
840
|
+
return `event: heartbeat\ndata: ${JSON.stringify({ ts: new Date().toISOString() })}\n\n`;
|
|
450
841
|
}
|
|
451
|
-
function
|
|
452
|
-
return
|
|
453
|
-
|
|
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
|
+
};
|
|
454
852
|
}
|
|
455
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;
|
|
456
854
|
const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/u;
|