borgmcp-server 0.1.4 → 0.1.7

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 +32 -11
  2. package/THIRD_PARTY_NOTICES.md +1 -0
  3. package/dist/coordination-api.d.ts +2 -1
  4. package/dist/coordination-api.js +298 -89
  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 +66 -8
  27. package/dist/store.js +525 -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 +312 -86
  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 +593 -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
@@ -41,9 +41,7 @@ export interface CubeInvitationResult extends InvitationCubeScope {
41
41
  readonly invitation: string;
42
42
  }
43
43
 
44
- export interface SeatAttachResponse extends SeatAttachRecord {
45
- readonly credential: string;
46
- }
44
+ export type SeatAttachResponse = SeatAttachRecord;
47
45
 
48
46
  export class CredentialDigester {
49
47
  readonly #key: Buffer;
@@ -88,8 +86,9 @@ export class CredentialDigester {
88
86
  export class LiveCredentialRegistry {
89
87
  readonly #sessions = new Map<string, Set<AbortController>>();
90
88
  readonly #keys = new Map<AbortController, readonly string[]>();
89
+ readonly #timers = new Map<AbortController, NodeJS.Timeout>();
91
90
 
92
- register(identities: string | readonly string[]): {
91
+ register(identities: string | readonly string[], expiresInMs?: number): {
93
92
  readonly signal: AbortSignal;
94
93
  readonly release: () => void;
95
94
  } {
@@ -101,6 +100,15 @@ export class LiveCredentialRegistry {
101
100
  sessions.add(controller);
102
101
  this.#sessions.set(key, sessions);
103
102
  }
103
+ if (expiresInMs !== undefined) {
104
+ if (expiresInMs <= 0) {
105
+ this.#expire(controller);
106
+ } else {
107
+ const timer = setTimeout(() => this.#expire(controller), expiresInMs);
108
+ timer.unref();
109
+ this.#timers.set(controller, timer);
110
+ }
111
+ }
104
112
  return {
105
113
  signal: controller.signal,
106
114
  release: () => this.#release(controller),
@@ -121,6 +129,11 @@ export class LiveCredentialRegistry {
121
129
  }
122
130
 
123
131
  #release(controller: AbortController): void {
132
+ const timer = this.#timers.get(controller);
133
+ if (timer !== undefined) {
134
+ clearTimeout(timer);
135
+ this.#timers.delete(controller);
136
+ }
124
137
  const keys = this.#keys.get(controller);
125
138
  if (keys === undefined) return;
126
139
  this.#keys.delete(controller);
@@ -130,6 +143,11 @@ export class LiveCredentialRegistry {
130
143
  if (sessions?.size === 0) this.#sessions.delete(key);
131
144
  }
132
145
  }
146
+
147
+ #expire(controller: AbortController): void {
148
+ this.#release(controller);
149
+ controller.abort();
150
+ }
133
151
  }
134
152
 
135
153
  export class CredentialAuthority {
@@ -269,7 +287,7 @@ export class CredentialAuthority {
269
287
 
270
288
  authenticateStatus(
271
289
  authorization: string | undefined,
272
- ): Principal | "missing" | "invalid" | "revoked" {
290
+ ): Principal | "missing" | "invalid" | "revoked" | "evicted" {
273
291
  if (authorization === undefined) return "missing";
274
292
  const secret = bearerSecret(authorization);
275
293
  const clientDigest = safeDigest(this.#digester, secret, "client");
@@ -285,6 +303,7 @@ export class CredentialAuthority {
285
303
  return clientPrincipal(client!.clientId!);
286
304
  }
287
305
  if (droneValid) {
306
+ if (drone!.evictedAt !== null) return "evicted";
288
307
  if (drone!.revokedAt != null || drone!.expiresAt <= this.#clock().toISOString()) {
289
308
  return "revoked";
290
309
  }
@@ -303,32 +322,30 @@ export class CredentialAuthority {
303
322
  request: {
304
323
  readonly cubeId: string;
305
324
  readonly roleId: string;
306
- readonly retryKey: string;
325
+ readonly sessionCredential: string;
307
326
  readonly priorDroneId?: string;
308
327
  },
309
328
  ): SeatAttachResponse {
310
- const credential = generateSecret();
311
329
  const record = store.attachSeat({
312
- ...request,
330
+ cubeId: request.cubeId,
331
+ roleId: request.roleId,
332
+ ...(request.priorDroneId === undefined ? {} : { priorDroneId: request.priorDroneId }),
313
333
  droneId: randomUUID(),
314
334
  sessionId: randomUUID(),
315
335
  credentialId: randomUUID(),
316
- credentialDigest: this.#digester.digest(credential, "drone-session"),
336
+ credentialDigest: this.#digester.digest(request.sessionCredential, "drone-session"),
317
337
  expiresAt: new Date(this.#clock().getTime() + 86_400_000).toISOString(),
318
338
  });
319
- for (const sessionId of record.revokedSessionIds) {
320
- this.#registry.invalidate(sessionId);
321
- this.#debugLogger.emit({ event: "credential", action: "session_revoked", sessionId });
339
+ if (record.result === "created") {
340
+ this.#debugLogger.emit({
341
+ event: "credential",
342
+ action: "session_created",
343
+ sessionId: record.sessionId,
344
+ cubeId: record.cube.id,
345
+ droneId: record.drone.id,
346
+ });
322
347
  }
323
- this.#debugLogger.emit({
324
- event: "credential",
325
- action: "session_created",
326
- sessionId: record.sessionId,
327
- cubeId: record.cube.id,
328
- droneId: record.drone.id,
329
- generation: record.generation,
330
- });
331
- return { ...record, credential };
348
+ return record;
332
349
  }
333
350
 
334
351
  rotateClient(clientId: string): string {
@@ -354,11 +371,12 @@ export class CredentialAuthority {
354
371
 
355
372
  registerLiveSession(principal: string | Principal) {
356
373
  if (typeof principal === "string") return this.#registry.register(principal);
357
- return this.#registry.register(
358
- principal.kind === "drone-session"
359
- ? [principal.id, principal.clientId]
360
- : principal.id,
361
- );
374
+ if (principal.kind !== "drone-session") return this.#registry.register(principal.id);
375
+ const expiresAt = this.#store.findActiveDroneSessionExpiry(principal.id);
376
+ const expiresInMs = expiresAt === null
377
+ ? 0
378
+ : new Date(expiresAt).getTime() - this.#clock().getTime();
379
+ return this.#registry.register([principal.id, principal.clientId], expiresInMs);
362
380
  }
363
381
  }
364
382
 
package/src/debug-log.ts CHANGED
@@ -10,6 +10,7 @@ export type DebugRoute =
10
10
  | "cube_roles"
11
11
  | "cube_role"
12
12
  | "cube_role_section_patch"
13
+ | "cube_taxonomy_patch"
13
14
  | "cube_drones"
14
15
  | "cube_logs"
15
16
  | "cube_acks"
@@ -20,14 +21,14 @@ export type DebugRoute =
20
21
  export type DebugEvent =
21
22
  | { readonly event: "startup"; readonly bindMode: "loopback" | "lan"; readonly port: number; readonly dataDirectory: "configured" | "tls_only" }
22
23
  | { readonly event: "lifecycle"; readonly action: "listening" | "stopped" }
23
- | { readonly event: "request"; readonly route: DebugRoute; readonly method: string; readonly authentication: "not_required" | "missing" | "invalid" | "revoked" | "accepted"; readonly authorization: "not_checked" | "accepted" | "denied_or_not_found"; readonly principal?: Principal; readonly status: number; readonly durationMs: number }
24
+ | { readonly event: "request"; readonly route: DebugRoute; readonly method: string; readonly authentication: "not_required" | "missing" | "invalid" | "revoked" | "evicted" | "accepted"; readonly authorization: "not_checked" | "accepted" | "denied_or_not_found"; readonly principal?: Principal; readonly status: number; readonly durationMs: number }
24
25
  | { readonly event: "activity_append"; readonly cubeId: string; readonly entryId: string; readonly principal: Principal; readonly droneId: string | null; readonly visibility: "broadcast" | "direct"; readonly recipientDroneIds: readonly string[] }
25
26
  | { readonly event: "cursor_replay"; readonly mode: "page" | "sse"; readonly cubeId: string; readonly cursorId: string | null; readonly returnedCount: number; readonly behindBy: number; readonly truncated: boolean }
26
27
  | { readonly event: "ack_write"; readonly cubeId: string; readonly entryId: string; readonly kind: "ack" | "claim"; readonly principal: Principal }
27
28
  | { readonly event: "decision_write"; readonly cubeId: string; readonly decisionId: string; readonly principal: Principal }
28
29
  | { readonly event: "sse_subscribe"; readonly connectionId: string; readonly cubeId: string; readonly principal: Principal; readonly replayCount: number; readonly truncated: boolean }
29
30
  | { readonly event: "sse_unsubscribe"; readonly connectionId: string; readonly cubeId: string; readonly principal: Principal; readonly deliveryCount: number }
30
- | { readonly event: "credential"; readonly action: "invitation_created" | "enrollment_accepted" | "enrollment_rejected" | "session_created" | "session_revoked" | "client_rotated" | "client_revoked"; readonly purpose?: "owner" | "client"; readonly clientId?: string; readonly cubeId?: string; readonly droneId?: string; readonly sessionId?: string; readonly generation?: number }
31
+ | { readonly event: "credential"; readonly action: "invitation_created" | "enrollment_accepted" | "enrollment_rejected" | "session_created" | "session_revoked" | "client_rotated" | "client_revoked"; readonly purpose?: "owner" | "client"; readonly clientId?: string; readonly cubeId?: string; readonly droneId?: string; readonly sessionId?: string }
31
32
  | { readonly event: "transport_rejection"; readonly reason: "tls_client_error" | "http_parser_error" };
32
33
 
33
34
  export interface DebugLogger {
@@ -113,7 +114,6 @@ function projectEvent(event: DebugEvent): Record<string, unknown> | null {
113
114
  ...optionalUuid("cube_id", value["cubeId"]),
114
115
  ...optionalUuid("drone_id", value["droneId"]),
115
116
  ...optionalUuid("session_id", value["sessionId"]),
116
- ...(Number.isSafeInteger(value["generation"]) ? { generation: value["generation"] } : {}),
117
117
  };
118
118
  case "transport_rejection":
119
119
  return { event: "transport_rejection", reason: enumValue(value["reason"], ["tls_client_error", "http_parser_error"], "tls_client_error") };
@@ -158,8 +158,8 @@ function enumValue<const T extends string>(value: unknown, allowed: readonly T[]
158
158
  }
159
159
 
160
160
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
161
- const DEBUG_ROUTES: readonly DebugRoute[] = ["health", "protocol", "enrollment_exchange", "client_attach", "cubes", "cube", "cube_roles", "cube_role", "cube_role_section_patch", "cube_drones", "cube_logs", "cube_acks", "cube_decisions", "cube_stream", "unknown"];
161
+ const DEBUG_ROUTES: readonly DebugRoute[] = ["health", "protocol", "enrollment_exchange", "client_attach", "cubes", "cube", "cube_roles", "cube_role", "cube_role_section_patch", "cube_taxonomy_patch", "cube_drones", "cube_logs", "cube_acks", "cube_decisions", "cube_stream", "unknown"];
162
162
  const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OTHER"] as const;
163
- const AUTH_RESULTS = ["not_required", "missing", "invalid", "revoked", "accepted"] as const;
163
+ const AUTH_RESULTS = ["not_required", "missing", "invalid", "revoked", "evicted", "accepted"] as const;
164
164
  const AUTHZ_RESULTS = ["not_checked", "accepted", "denied_or_not_found"] as const;
165
165
  const CREDENTIAL_ACTIONS = ["invitation_created", "enrollment_accepted", "enrollment_rejected", "session_created", "session_revoked", "client_rotated", "client_revoked"] as const;
package/src/enrollment.ts CHANGED
@@ -1,26 +1,37 @@
1
+ import {
2
+ ErrorCode,
3
+ PROTOCOL_VERSION,
4
+ ProtocolContractError,
5
+ createProtocolEnvelope,
6
+ decodeEnrollmentExchangeRequestEnvelope,
7
+ type EnrollmentExchangeRequest,
8
+ type ProtocolEnvelope,
9
+ } from "borgmcp-shared/protocol";
1
10
  import type { CredentialAuthority } from "./credentials.js";
2
11
  import { StorageCapacityError } from "./store.js";
3
12
 
4
- interface EnrollmentEnvelope {
5
- readonly protocol_version: "1";
6
- readonly request_id: string;
7
- readonly payload: {
8
- readonly invitation: string;
9
- readonly retry_key: string;
10
- readonly client_credential: string;
11
- readonly client_name?: string;
12
- };
13
- }
13
+ type EnrollmentEnvelope = ProtocolEnvelope<EnrollmentExchangeRequest>;
14
14
 
15
15
  export function createEnrollmentExchange(authority: CredentialAuthority) {
16
16
  return async (body: unknown): Promise<{
17
- readonly status: 201 | 400 | 401 | 507;
17
+ readonly status: 201 | 400 | 401 | 426 | 507;
18
18
  readonly body?: unknown;
19
19
  }> => {
20
20
  let envelope: EnrollmentEnvelope;
21
21
  try {
22
22
  envelope = decodeEnrollmentEnvelope(body);
23
- } catch {
23
+ } catch (error) {
24
+ if (error instanceof ProtocolContractError &&
25
+ error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION) {
26
+ return {
27
+ status: 426,
28
+ body: errorEnvelope(
29
+ ErrorCode.UNSUPPORTED_PROTOCOL_VERSION,
30
+ "Unsupported protocol version.",
31
+ safeRequestId(body),
32
+ ),
33
+ };
34
+ }
24
35
  return {
25
36
  status: 400,
26
37
  body: errorEnvelope(
@@ -59,26 +70,22 @@ export function createEnrollmentExchange(authority: CredentialAuthority) {
59
70
  }
60
71
  return {
61
72
  status: 201,
62
- body: {
63
- protocol_version: "1",
64
- request_id: envelope.request_id,
65
- payload: {
73
+ body: createProtocolEnvelope(envelope.request_id, {
66
74
  purpose: response.purpose,
67
75
  client_id: response.clientId,
68
76
  server_capabilities: response.serverCapabilities,
69
- },
70
- },
77
+ }),
71
78
  };
72
79
  };
73
80
  }
74
81
 
75
82
  function errorEnvelope(
76
- code: "INVALID_INPUT" | "AUTH_INVALID" | "CAPACITY_EXCEEDED",
83
+ code: "INVALID_INPUT" | "AUTH_INVALID" | "CAPACITY_EXCEEDED" | "UNSUPPORTED_PROTOCOL_VERSION",
77
84
  message: string,
78
85
  requestId?: string,
79
86
  ) {
80
87
  return {
81
- protocol_version: "1" as const,
88
+ protocol_version: PROTOCOL_VERSION,
82
89
  ...(requestId === undefined ? {} : { request_id: requestId }),
83
90
  error: { code, message },
84
91
  };
@@ -93,64 +100,11 @@ function safeRequestId(value: unknown): string | undefined {
93
100
  }
94
101
 
95
102
  export function decodeEnrollmentEnvelope(value: unknown): EnrollmentEnvelope {
96
- const envelope = exactRecord(value, ["protocol_version", "request_id", "payload"]);
97
- if (envelope["protocol_version"] !== "1") throw new Error("Invalid enrollment request.");
98
- const requestId = envelope["request_id"];
99
- if (typeof requestId !== "string" || !/^[A-Za-z0-9._-]{8,128}$/u.test(requestId)) {
100
- throw new Error("Invalid enrollment request.");
101
- }
102
- const payload = exactRecord(
103
- envelope["payload"],
104
- ["invitation", "retry_key", "client_credential"],
105
- ["client_name"],
106
- );
107
- const invitation = payload["invitation"];
108
- if (typeof invitation !== "string" || !/^[A-Za-z0-9_-]{43,1024}$/u.test(invitation)) {
109
- throw new Error("Invalid enrollment request.");
110
- }
111
- const retryKey = payload["retry_key"];
112
- if (typeof retryKey !== "string" ||
113
- !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(retryKey)) {
114
- throw new Error("Invalid enrollment request.");
115
- }
116
- const clientCredential = payload["client_credential"];
117
- if (typeof clientCredential !== "string" ||
118
- !/^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/u.test(clientCredential)) {
119
- throw new Error("Invalid enrollment request.");
120
- }
121
- const clientName = payload["client_name"];
122
- if (clientName !== undefined &&
123
- (typeof clientName !== "string" || Buffer.byteLength(clientName) > 120 ||
124
- !/^[A-Za-z0-9][A-Za-z0-9 ._-]*$/u.test(clientName))) {
125
- throw new Error("Invalid enrollment request.");
126
- }
127
- return {
128
- protocol_version: "1",
129
- request_id: requestId,
130
- payload: clientName === undefined
131
- ? { invitation, retry_key: retryKey.toLowerCase(), client_credential: clientCredential }
132
- : {
133
- invitation,
134
- retry_key: retryKey.toLowerCase(),
135
- client_credential: clientCredential,
136
- client_name: clientName,
137
- },
138
- };
139
- }
140
-
141
- function exactRecord(
142
- value: unknown,
143
- required: readonly string[],
144
- optional: readonly string[] = [],
145
- ): Record<string, unknown> {
146
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
147
- throw new Error("Invalid enrollment request.");
148
- }
149
- const record = value as Record<string, unknown>;
150
- const allowed = new Set([...required, ...optional]);
151
- if (Object.keys(record).some((key) => !allowed.has(key)) ||
152
- required.some((key) => !Object.hasOwn(record, key))) {
103
+ try {
104
+ return decodeEnrollmentExchangeRequestEnvelope(value);
105
+ } catch (error) {
106
+ if (error instanceof ProtocolContractError &&
107
+ error.code === ErrorCode.UNSUPPORTED_PROTOCOL_VERSION) throw error;
153
108
  throw new Error("Invalid enrollment request.");
154
109
  }
155
- return record;
156
110
  }
@@ -2,27 +2,22 @@ import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { createServer, type Server as HttpsServer } from "node:https";
3
3
  import type { AddressInfo, Socket } from "node:net";
4
4
  import { createHash, X509Certificate } from "node:crypto";
5
+ import {
6
+ ATTACH_PATH,
7
+ CUBES_PATH,
8
+ ENROLLMENT_EXCHANGE_PATH,
9
+ ErrorCode,
10
+ HEALTH_PATH,
11
+ PROTOCOL_INFO_PATH,
12
+ PROTOCOL_VERSION,
13
+ createProtocolTagPreflight,
14
+ } from "borgmcp-shared/protocol";
5
15
 
6
16
  import { resolveBindOptions, type BindOptionsInput } from "./network-policy.js";
7
17
  import type { CoordinationRequest, CoordinationResponse } from "./coordination-api.js";
8
18
  import type { Principal } from "./principal.js";
9
19
  import { disabledDebugLogger, type DebugLogger, type DebugRoute } from "./debug-log.js";
10
20
 
11
- export interface ProtocolInfoDocument {
12
- readonly protocol_version: string;
13
- readonly package: {
14
- readonly name: "borgmcp-shared";
15
- readonly version: string;
16
- };
17
- readonly capabilities: readonly string[];
18
- readonly limits: {
19
- readonly max_request_bytes: number;
20
- readonly max_log_message_bytes: number;
21
- readonly max_read_page_size: number;
22
- readonly max_replay_page_size: number;
23
- };
24
- }
25
-
26
21
  export interface ServiceLimits {
27
22
  readonly maxConnections: number;
28
23
  readonly maxConnectionsPerAddress: number;
@@ -62,18 +57,13 @@ export const DEFAULT_SERVICE_LIMITS: ServiceLimits = {
62
57
  };
63
58
 
64
59
  export interface RequestHandlerContext {
65
- readonly protocolInfo: ProtocolInfoDocument;
66
- readonly authorizeProtocol: (
67
- authorization: string | undefined,
68
- signal: AbortSignal,
69
- ) => Promise<boolean | "missing" | "invalid" | "revoked">;
70
60
  readonly exchangeEnrollment?: (
71
61
  body: unknown,
72
- ) => Promise<{ readonly status: 201 | 400 | 401 | 507; readonly body?: unknown }>;
62
+ ) => Promise<{ readonly status: 201 | 400 | 401 | 426 | 507; readonly body?: unknown }>;
73
63
  readonly authorizeCoordination?: (
74
64
  authorization: string | undefined,
75
65
  signal: AbortSignal,
76
- ) => Promise<Principal | "missing" | "invalid" | "revoked">;
66
+ ) => Promise<Principal | "missing" | "invalid" | "revoked" | "evicted">;
77
67
  readonly handleCoordination?: (request: CoordinationRequest) => Promise<CoordinationResponse>;
78
68
  readonly debugLogger: DebugLogger;
79
69
  }
@@ -85,8 +75,6 @@ export interface HttpsServerOptions {
85
75
  readonly cert: string | Buffer;
86
76
  readonly ca?: string | Buffer;
87
77
  };
88
- readonly protocolInfo: ProtocolInfoDocument;
89
- readonly authorizeProtocol: RequestHandlerContext["authorizeProtocol"];
90
78
  readonly exchangeEnrollment?: RequestHandlerContext["exchangeEnrollment"];
91
79
  readonly authorizeCoordination?: RequestHandlerContext["authorizeCoordination"];
92
80
  readonly handleCoordination?: RequestHandlerContext["handleCoordination"];
@@ -168,8 +156,6 @@ export function createRequestHandlerContext(
168
156
  options: HttpsServerOptions,
169
157
  ): RequestHandlerContext {
170
158
  return Object.freeze({
171
- protocolInfo: options.protocolInfo,
172
- authorizeProtocol: options.authorizeProtocol,
173
159
  ...(options.exchangeEnrollment === undefined
174
160
  ? {}
175
161
  : { exchangeEnrollment: options.exchangeEnrollment }),
@@ -290,44 +276,23 @@ async function handleRequest(
290
276
  return;
291
277
  }
292
278
 
293
- if (path === "/healthz") {
279
+ if (path === HEALTH_PATH) {
294
280
  if (requestBody.length !== 0) return sendEmpty(response, 400, true);
295
281
  sendEmpty(response, request.method === "GET" ? 204 : 405);
296
282
  return;
297
283
  }
298
284
 
299
- if (path === "/api/protocol") {
285
+ if (path === PROTOCOL_INFO_PATH) {
300
286
  if (requestBody.length !== 0) return sendEmpty(response, 400, true);
301
- const authorized = await context.authorizeProtocol(request.headers.authorization, signal);
302
- trace.authentication = authorized === true ? "accepted" : authorized === false ? "invalid" : authorized;
303
- if (signal.aborted) return;
304
- if (authorized !== true) {
305
- const code = authorized === "revoked"
306
- ? "SESSION_REVOKED"
307
- : authorized === "missing" || request.headers.authorization === undefined
308
- ? "AUTH_MISSING"
309
- : "AUTH_INVALID";
310
- sendJson(response, 401, protocolError(code, "Authentication failed."));
311
- return;
312
- }
313
- const credentialRetry = consumeAuthenticatedRateLimit(
314
- request.headers.authorization,
315
- credentialRateLimiter,
316
- );
317
- if (credentialRetry !== null) return sendRateLimited(response, credentialRetry);
318
287
  if (request.method !== "GET") {
319
288
  sendEmpty(response, 405);
320
289
  return;
321
290
  }
322
- sendJson(response, 200, {
323
- protocol_version: "1",
324
- request_id: "protocol-info",
325
- payload: context.protocolInfo,
326
- });
291
+ sendJson(response, 200, createProtocolTagPreflight());
327
292
  return;
328
293
  }
329
294
 
330
- if (path === "/api/enrollment/exchange") {
295
+ if (path === ENROLLMENT_EXCHANGE_PATH) {
331
296
  if (request.method !== "POST" || context.exchangeEnrollment === undefined) {
332
297
  sendEmpty(response, 405);
333
298
  return;
@@ -346,7 +311,9 @@ async function handleRequest(
346
311
  if (signal.aborted) return;
347
312
  if (result.body === undefined) sendEmpty(response, result.status, result.status === 400);
348
313
  else if (result.status === 400) sendJson(response, 400, result.body, true);
349
- else if (result.status === 401 || result.status === 507) sendJson(response, result.status, result.body);
314
+ else if (result.status === 401 || result.status === 426 || result.status === 507) {
315
+ sendJson(response, result.status, result.body);
316
+ }
350
317
  else sendJson(response, 201, result.body);
351
318
  return;
352
319
  }
@@ -372,6 +339,10 @@ async function handleRequest(
372
339
  trace.authentication = typeof authentication === "string" ? authentication : "accepted";
373
340
  if (signal.aborted) return;
374
341
  if (typeof authentication === "string") {
342
+ if (authentication === "evicted") {
343
+ sendJson(response, 410, protocolError(ErrorCode.DRONE_EVICTED, "Authentication failed."));
344
+ return;
345
+ }
375
346
  const code = authentication === "revoked"
376
347
  ? "SESSION_REVOKED"
377
348
  : authentication === "missing" || authorization === undefined ? "AUTH_MISSING" : "AUTH_INVALID";
@@ -384,8 +355,8 @@ async function handleRequest(
384
355
  : authentication.id}`;
385
356
  const credentialRetry = credentialRateLimiter.consume(clientIdentity);
386
357
  if (credentialRetry !== null) return sendRateLimited(response, credentialRetry);
387
- const cursor = parseCursorParameter(request.url, path);
388
- if (cursor === INVALID_COORDINATION_QUERY) {
358
+ const query = parseCoordinationQuery(request.url, path);
359
+ if (query === INVALID_COORDINATION_QUERY) {
389
360
  sendJson(response, 400, protocolError("INVALID_INPUT", "Invalid query parameters."), true);
390
361
  return;
391
362
  }
@@ -394,7 +365,7 @@ async function handleRequest(
394
365
  path,
395
366
  principal: authentication,
396
367
  ...(decoded === undefined ? {} : { body: decoded }),
397
- ...(cursor === undefined ? {} : { cursor }),
368
+ ...query,
398
369
  signal,
399
370
  });
400
371
  if (signal.aborted) {
@@ -422,7 +393,7 @@ async function handleRequest(
422
393
  interface RequestTrace {
423
394
  readonly route: DebugRoute;
424
395
  readonly method: string;
425
- authentication: "not_required" | "missing" | "invalid" | "revoked" | "accepted";
396
+ authentication: "not_required" | "missing" | "invalid" | "revoked" | "evicted" | "accepted";
426
397
  principal?: Principal;
427
398
  }
428
399
 
@@ -434,15 +405,16 @@ function debugMethod(method: string | undefined): string {
434
405
  function debugRoute(rawUrl: string | undefined): DebugRoute {
435
406
  const path = parseRequestPath(rawUrl);
436
407
  if (path === null) return "unknown";
437
- if (path === "/healthz") return "health";
438
- if (path === "/api/protocol") return "protocol";
439
- if (path === "/api/enrollment/exchange") return "enrollment_exchange";
440
- if (path === "/api/client/attach") return "client_attach";
408
+ if (path === HEALTH_PATH) return "health";
409
+ if (path === PROTOCOL_INFO_PATH) return "protocol";
410
+ if (path === ENROLLMENT_EXCHANGE_PATH) return "enrollment_exchange";
411
+ if (path === ATTACH_PATH) return "client_attach";
441
412
  if (path === "/api/cubes") return "cubes";
442
413
  if (/^\/api\/cubes\/[0-9a-f-]{36}$/iu.test(path)) return "cube";
443
414
  if (/^\/api\/cubes\/[0-9a-f-]{36}\/roles$/iu.test(path)) return "cube_roles";
444
415
  if (/^\/api\/cubes\/[0-9a-f-]{36}\/roles\/[0-9a-f-]{36}$/iu.test(path)) return "cube_role";
445
416
  if (/^\/api\/cubes\/[0-9a-f-]{36}\/roles\/[0-9a-f-]{36}\/section-patch$/iu.test(path)) return "cube_role_section_patch";
417
+ if (/^\/api\/cubes\/[0-9a-f-]{36}\/taxonomy-patch$/iu.test(path)) return "cube_taxonomy_patch";
446
418
  if (/^\/api\/cubes\/[0-9a-f-]{36}\/drones$/iu.test(path)) return "cube_drones";
447
419
  if (/^\/api\/cubes\/[0-9a-f-]{36}\/logs$/iu.test(path)) return "cube_logs";
448
420
  if (/^\/api\/cubes\/[0-9a-f-]{36}\/acks$/iu.test(path)) return "cube_acks";
@@ -590,35 +562,33 @@ function waitForDrain(response: ServerResponse): Promise<void> {
590
562
  }
591
563
 
592
564
  function protocolError(code: string, message: string): object {
593
- return { protocol_version: "1", error: { code, message } };
565
+ return { protocol_version: PROTOCOL_VERSION, error: { code, message } };
594
566
  }
595
567
 
596
568
  const INVALID_COORDINATION_QUERY = Symbol("invalid-coordination-query");
597
569
 
598
- function parseCursorParameter(
570
+ function parseCoordinationQuery(
599
571
  value: string | undefined,
600
572
  path: string,
601
- ): string | undefined | typeof INVALID_COORDINATION_QUERY {
602
- if (value === undefined) return undefined;
573
+ ): { readonly cursor?: string; readonly since?: string } | typeof INVALID_COORDINATION_QUERY {
574
+ if (value === undefined) return {};
603
575
  try {
604
576
  const parsed = new URL(value, "https://local.invalid");
605
577
  const keys = [...parsed.searchParams.keys()];
606
- if (!path.endsWith("/stream")) {
607
- return keys.length === 0 ? undefined : INVALID_COORDINATION_QUERY;
608
- }
609
- if (keys.some((key) => key !== "cursor")) return INVALID_COORDINATION_QUERY;
610
- const values = parsed.searchParams.getAll("cursor");
611
- if (values.length === 0) return undefined;
612
- return values.length === 1 && values[0]!.length > 0
613
- ? values[0]
614
- : INVALID_COORDINATION_QUERY;
578
+ const allowed = path.endsWith("/stream") ? "cursor" : path.endsWith("/drones") ? "since" : null;
579
+ if (allowed === null) return keys.length === 0 ? {} : INVALID_COORDINATION_QUERY;
580
+ if (keys.some((key) => key !== allowed)) return INVALID_COORDINATION_QUERY;
581
+ const values = parsed.searchParams.getAll(allowed);
582
+ if (values.length === 0) return {};
583
+ if (values.length !== 1 || values[0]!.length === 0) return INVALID_COORDINATION_QUERY;
584
+ return allowed === "cursor" ? { cursor: values[0]! } : { since: values[0]! };
615
585
  } catch {
616
586
  return INVALID_COORDINATION_QUERY;
617
587
  }
618
588
  }
619
589
 
620
590
  function isCoordinationPath(path: string | null): path is string {
621
- return path === "/api/client/attach" || path === "/api/cubes" ||
591
+ return path === ATTACH_PATH || path === CUBES_PATH ||
622
592
  path?.startsWith("/api/cubes/") === true;
623
593
  }
624
594
 
@@ -994,14 +964,6 @@ function credentialIdentity(authorization: string | undefined): string | null {
994
964
  return `credential:${createHash("sha256").update(authorization).digest("base64url")}`;
995
965
  }
996
966
 
997
- function consumeAuthenticatedRateLimit(
998
- authorization: string | undefined,
999
- rateLimiter: RequestRateLimiter,
1000
- ): number | null {
1001
- const identity = credentialIdentity(authorization);
1002
- return identity === null ? null : rateLimiter.consume(identity);
1003
- }
1004
-
1005
967
  function listen(server: HttpsServer, port: number, host: string): Promise<void> {
1006
968
  return new Promise((resolve, reject) => {
1007
969
  const onError = (error: Error): void => {