borgmcp-server 0.1.7 → 0.1.9

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 (70) hide show
  1. package/README.md +61 -46
  2. package/SECURITY.md +12 -1
  3. package/THIRD_PARTY_NOTICES.md +1 -1
  4. package/dist/bootstrap.d.ts +8 -1
  5. package/dist/bootstrap.js +39 -6
  6. package/dist/bootstrap.js.map +1 -1
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +176 -1
  9. package/dist/cli.js.map +1 -1
  10. package/dist/coordination-api.js +10 -1
  11. package/dist/coordination-api.js.map +1 -1
  12. package/dist/credentials.d.ts +5 -1
  13. package/dist/credentials.js +35 -3
  14. package/dist/credentials.js.map +1 -1
  15. package/dist/debug-log.d.ts +2 -2
  16. package/dist/debug-log.js +2 -2
  17. package/dist/debug-log.js.map +1 -1
  18. package/dist/https-server.d.ts +5 -1
  19. package/dist/https-server.js +93 -27
  20. package/dist/https-server.js.map +1 -1
  21. package/dist/index.d.ts +14 -1
  22. package/dist/index.js +8 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/main.js +1 -0
  25. package/dist/main.js.map +1 -1
  26. package/dist/managed-service.d.ts +20 -0
  27. package/dist/managed-service.js +80 -0
  28. package/dist/managed-service.js.map +1 -0
  29. package/dist/migrations.js +23 -0
  30. package/dist/migrations.js.map +1 -1
  31. package/dist/portable-credential-store.d.ts +16 -0
  32. package/dist/portable-credential-store.js +305 -0
  33. package/dist/portable-credential-store.js.map +1 -0
  34. package/dist/registry-artifact.d.ts +11 -0
  35. package/dist/registry-artifact.js +99 -0
  36. package/dist/registry-artifact.js.map +1 -0
  37. package/dist/runtime-identity.d.ts +19 -0
  38. package/dist/runtime-identity.js +61 -0
  39. package/dist/runtime-identity.js.map +1 -0
  40. package/dist/runtime-lifecycle.d.ts +56 -0
  41. package/dist/runtime-lifecycle.js +486 -0
  42. package/dist/runtime-lifecycle.js.map +1 -0
  43. package/dist/runtime-operator.d.ts +24 -0
  44. package/dist/runtime-operator.js +95 -0
  45. package/dist/runtime-operator.js.map +1 -0
  46. package/dist/service.d.ts +53 -5
  47. package/dist/service.js +326 -15
  48. package/dist/service.js.map +1 -1
  49. package/dist/store.d.ts +16 -7
  50. package/dist/store.js +93 -83
  51. package/dist/store.js.map +1 -1
  52. package/npm-shrinkwrap.json +6 -6
  53. package/package.json +2 -2
  54. package/src/bootstrap.ts +46 -5
  55. package/src/cli.ts +171 -1
  56. package/src/coordination-api.ts +12 -0
  57. package/src/credentials.ts +36 -5
  58. package/src/debug-log.ts +4 -3
  59. package/src/https-server.ts +124 -27
  60. package/src/index.ts +46 -1
  61. package/src/main.ts +1 -0
  62. package/src/managed-service.ts +107 -0
  63. package/src/migrations.ts +23 -0
  64. package/src/portable-credential-store.ts +306 -0
  65. package/src/registry-artifact.ts +111 -0
  66. package/src/runtime-identity.ts +82 -0
  67. package/src/runtime-lifecycle.ts +567 -0
  68. package/src/runtime-operator.ts +124 -0
  69. package/src/service.ts +401 -20
  70. package/src/store.ts +100 -83
package/src/cli.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import type { ServerService } from "./service.js";
2
+ import { RuntimeUpdateFailure } from "./runtime-operator.js";
2
3
 
3
4
  export interface CliIo {
4
5
  readonly stdout: (message: string) => void;
5
6
  readonly stderr: (message: string) => void;
6
7
  readonly readSecret?: (prompt: string) => Promise<string>;
8
+ readonly isTTY?: boolean;
7
9
  }
8
10
 
9
11
  const usage = `Usage: borg-mcp-server <command> [options]
@@ -11,6 +13,9 @@ const usage = `Usage: borg-mcp-server <command> [options]
11
13
  Commands:
12
14
  setup [--reinitialize] Prepare an offline server installation
13
15
  start Start the server process
16
+ status [--json] Report exact local runtime evidence
17
+ update [--json] Verify and activate the latest server artifact
18
+ invite Create a single-use invitation in an interactive terminal.
14
19
  client-rotate <client-id> Rotate one client credential offline
15
20
  client-revoke <client-id> Revoke one client and its credentials offline
16
21
  client-grant <client-id> <cube-id> <read|write|manage> Set one offline cube grant
@@ -58,11 +63,109 @@ export async function runCli(
58
63
  return 1;
59
64
  }
60
65
  const result = await service.setup({ reinitialize: extraArgs[0] === "--reinitialize" });
61
- io.stdout(`Server setup complete.\nRecovery credential (shown once; keep offline): ${result.recoveryCredential}\nOwner enrollment invitation (single-use, shown once; enroll the owner client): ${result.initialInvitation}\nSetup created no cube.\nNext: start the server with \`borg-mcp-server start\`.`);
66
+ const artifactIdentity = result.artifact === undefined
67
+ ? "borgmcp-server@unavailable"
68
+ : `borgmcp-server@${result.artifact.version}`;
69
+ const artifact = result.artifact === undefined
70
+ ? "Artifact: unavailable"
71
+ : `Artifact: ${artifactIdentity} (${result.artifact.integrity})`;
72
+ if (io.isTTY === false) {
73
+ io.stdout(JSON.stringify({
74
+ status: "prepared",
75
+ artifact: artifactIdentity,
76
+ build_identity: result.artifact?.sourceSha ?? null,
77
+ owner_access: "prepared",
78
+ process: "stopped",
79
+ }));
80
+ return 0;
81
+ }
82
+ if ("existing" in result) {
83
+ io.stdout([
84
+ "Local server is already prepared.",
85
+ artifact,
86
+ `Build identity: ${result.artifact?.sourceSha ?? "unavailable"}`,
87
+ "Data and identity: unchanged",
88
+ "No server process started.",
89
+ "Next: borg-mcp-server start",
90
+ ].join("\n"));
91
+ return 0;
92
+ }
93
+ io.stdout([
94
+ "Local server setup completed.",
95
+ artifact,
96
+ "Local owner access: prepared.",
97
+ "No server process started.",
98
+ "Next: start the server, then run borg assimilate.",
99
+ ].join("\n"));
62
100
  return 0;
63
101
  case "start":
64
102
  await service.start(extraArgs);
65
103
  return 0;
104
+ case "status": {
105
+ if (extraArgs.length > 1 || (extraArgs.length === 1 && extraArgs[0] !== "--json") ||
106
+ service.status === undefined) return invalidArguments(io);
107
+ const status = await service.status();
108
+ if (extraArgs[0] === "--json" || io.isTTY === false) {
109
+ io.stdout(JSON.stringify({
110
+ status: status.status,
111
+ artifact: status.artifact === null
112
+ ? null
113
+ : `borgmcp-server@${status.artifact.version}`,
114
+ artifact_integrity: status.artifact?.integrity ?? null,
115
+ build_identity: status.buildIdentity,
116
+ endpoint: status.endpoint,
117
+ mode: status.mode,
118
+ data_identity: status.dataIdentity,
119
+ }));
120
+ } else {
121
+ io.stdout(renderRuntimeStatus(status));
122
+ }
123
+ return 0;
124
+ }
125
+ case "update": {
126
+ if (extraArgs.length > 1 || (extraArgs.length === 1 && extraArgs[0] !== "--json") ||
127
+ service.update === undefined) return invalidArguments(io);
128
+ let result: Awaited<ReturnType<NonNullable<ServerService["update"]>>>;
129
+ try {
130
+ result = await service.update();
131
+ } catch (error) {
132
+ if (!(error instanceof RuntimeUpdateFailure)) throw error;
133
+ renderUpdateFailure(error, io, extraArgs[0] === "--json" || io.isTTY === false);
134
+ return 1;
135
+ }
136
+ if (extraArgs[0] === "--json" || io.isTTY === false) {
137
+ io.stdout(JSON.stringify({
138
+ status: result.outcome,
139
+ artifact: `borgmcp-server@${result.artifact.version}`,
140
+ artifact_integrity: result.artifact.integrity,
141
+ build_identity: result.runningIdentity?.source_sha ?? result.artifact.sourceSha,
142
+ mode: result.outcome === "updated" ? "managed" : "stopped",
143
+ data_identity: result.dataIdentity,
144
+ }));
145
+ } else if (result.outcome === "updated") {
146
+ io.stdout([
147
+ `Verifying borgmcp-server@${result.artifact.version}...`,
148
+ "Artifact verified and activated.",
149
+ "Restarting the verified local server...",
150
+ `Local server is running.`,
151
+ `Artifact: borgmcp-server@${result.artifact.version} (${result.artifact.integrity})`,
152
+ `Build identity: ${result.runningIdentity?.source_sha ?? "unavailable"}`,
153
+ "Data and identity: preserved",
154
+ "Next: borg-mcp-server status",
155
+ ].join("\n"));
156
+ } else {
157
+ io.stdout([
158
+ `Verifying borgmcp-server@${result.artifact.version}...`,
159
+ "Artifact verified and activated.",
160
+ "No server process started.",
161
+ `Artifact: borgmcp-server@${result.artifact.version} (${result.artifact.integrity})`,
162
+ `Build identity: ${result.artifact.sourceSha ?? "unavailable"}`,
163
+ "Data and identity: preserved",
164
+ "Next: borg-mcp-server start",
165
+ ].join("\n"));
166
+ }
167
+ return 0;
168
+ }
66
169
  case "client-rotate":
67
170
  if (extraArgs.length !== 1 || service.rotateClient === undefined) return invalidArguments(io);
68
171
  io.stdout(`Client credential rotated (shown once): ${await service.rotateClient(extraArgs[0]!)}`);
@@ -115,6 +218,16 @@ export async function runCli(
115
218
  }
116
219
  return 0;
117
220
  }
221
+ case "invite": {
222
+ if (extraArgs.length !== 0 || service.invite === undefined) return invalidArguments(io);
223
+ if (io.isTTY !== true) {
224
+ io.stderr("Invitation creation requires an interactive terminal.");
225
+ return 1;
226
+ }
227
+ const invitation = await service.invite();
228
+ io.stdout(`Invitation (single-use; shown once): ${invitation}\nShare it only with the intended recipient.`);
229
+ return 0;
230
+ }
118
231
  case "help":
119
232
  case "--help":
120
233
  case "-h":
@@ -127,6 +240,63 @@ export async function runCli(
127
240
  }
128
241
  }
129
242
 
243
+ function renderUpdateFailure(failure: RuntimeUpdateFailure, io: CliIo, machine: boolean): void {
244
+ const state = failure.code === "ARTIFACT_VERIFICATION_FAILED"
245
+ ? "verification_failed"
246
+ : failure.recovery === "restored"
247
+ ? "restored"
248
+ : failure.recovery === "stopped"
249
+ ? "stopped"
250
+ : "recovery_failed";
251
+ if (machine) {
252
+ io.stdout(JSON.stringify({
253
+ status: "failed",
254
+ error_code: failure.code,
255
+ recovery: state,
256
+ data_identity: "preserved",
257
+ }));
258
+ return;
259
+ }
260
+ if (failure.code === "ARTIFACT_VERIFICATION_FAILED") {
261
+ io.stderr([
262
+ "Update stopped: artifact verification failed.",
263
+ "No activation occurred.",
264
+ "The last verified runtime remains available.",
265
+ "Next: borg-mcp-server status",
266
+ ].join("\n"));
267
+ return;
268
+ }
269
+ io.stderr([
270
+ "Update stopped: activation did not complete.",
271
+ failure.recovery === "restored"
272
+ ? "The last verified runtime was restored."
273
+ : failure.recovery === "stopped"
274
+ ? "The server stopped safely."
275
+ : "Recovery did not complete; inspect server status.",
276
+ "Data and identity: preserved",
277
+ "Next: borg-mcp-server status",
278
+ ].join("\n"));
279
+ }
280
+
281
+ function renderRuntimeStatus(status: Awaited<ReturnType<NonNullable<ServerService["status"]>>>): string {
282
+ const heading = status.status === "running"
283
+ ? status.buildIdentity === null
284
+ ? "Local server is reachable, but its running build identity is unavailable."
285
+ : "Local server is running."
286
+ : "Local server is stopped.";
287
+ const artifact = status.artifact === null
288
+ ? "Artifact: unavailable"
289
+ : `Artifact: borgmcp-server@${status.artifact.version} (${status.artifact.integrity})`;
290
+ return [
291
+ heading,
292
+ artifact,
293
+ `Build identity: ${status.buildIdentity ?? "unavailable"}`,
294
+ `Endpoint: ${status.endpoint ?? "unavailable"}`,
295
+ `Mode: ${status.mode}`,
296
+ `Data and identity: ${status.dataIdentity}`,
297
+ ].join("\n");
298
+ }
299
+
130
300
  function parseClientInviteArguments(
131
301
  args: readonly string[],
132
302
  ): { readonly cubeSelector?: string; readonly access?: "read" | "write" | "manage" } | undefined {
@@ -21,7 +21,10 @@ import {
21
21
  import type { CredentialAuthority } from "./credentials.js";
22
22
  import {
23
23
  CursorExpiredError,
24
+ AttachDroneEvictedError,
25
+ AttachSessionExpiredError,
24
26
  AttachSessionRejectedError,
27
+ AttachSessionRevokedError,
25
28
  AccessDeniedError,
26
29
  CreateCubeConflictError,
27
30
  DefaultRoleRequiredError,
@@ -132,6 +135,15 @@ export class CoordinationApi {
132
135
  ? failure(426, error.code, "Unsupported protocol version.", requestId)
133
136
  : failure(400, "INVALID_INPUT", "Invalid protocol request.", requestId);
134
137
  }
138
+ if (error instanceof AttachDroneEvictedError) {
139
+ return failure(410, ErrorCode.DRONE_EVICTED, error.message, requestId);
140
+ }
141
+ if (error instanceof AttachSessionExpiredError) {
142
+ return failure(401, ErrorCode.AUTH_EXPIRED, error.message, requestId);
143
+ }
144
+ if (error instanceof AttachSessionRevokedError) {
145
+ return failure(401, ErrorCode.SESSION_REVOKED, error.message, requestId);
146
+ }
135
147
  if (error instanceof AttachSessionRejectedError) {
136
148
  return failure(401, ErrorCode.SESSION_REJECTED, error.message, requestId);
137
149
  }
@@ -43,6 +43,9 @@ export interface CubeInvitationResult extends InvitationCubeScope {
43
43
 
44
44
  export type SeatAttachResponse = SeatAttachRecord;
45
45
 
46
+ export const DRONE_SESSION_TTL_MS = 86_400_000;
47
+ export const DRONE_SESSION_RENEWAL_WINDOW_MS = DRONE_SESSION_TTL_MS / 2;
48
+
46
49
  export class CredentialDigester {
47
50
  readonly #key: Buffer;
48
51
 
@@ -124,6 +127,22 @@ export class LiveCredentialRegistry {
124
127
  }
125
128
  }
126
129
 
130
+ renew(identity: string, expiresInMs: number): void {
131
+ const sessions = this.#sessions.get(identity);
132
+ if (sessions === undefined) return;
133
+ for (const controller of [...sessions]) {
134
+ const timer = this.#timers.get(controller);
135
+ if (timer !== undefined) clearTimeout(timer);
136
+ if (expiresInMs <= 0) {
137
+ this.#expire(controller);
138
+ continue;
139
+ }
140
+ const renewedTimer = setTimeout(() => this.#expire(controller), expiresInMs);
141
+ renewedTimer.unref();
142
+ this.#timers.set(controller, renewedTimer);
143
+ }
144
+ }
145
+
127
146
  activeSessionCount(clientId: string): number {
128
147
  return this.#sessions.get(clientId)?.size ?? 0;
129
148
  }
@@ -191,6 +210,13 @@ export class CredentialAuthority {
191
210
  return this.#createInvitation("client", ttlMs);
192
211
  }
193
212
 
213
+ createInvitationForOwnerCredential(clientCredential: string, ttlMs: number): string | null {
214
+ const principal = this.authenticate(`Bearer ${clientCredential}`);
215
+ if (principal?.kind !== "client" ||
216
+ !this.#store.clientHasServerCapability(principal.id, "create_cube")) return null;
217
+ return this.#createInvitation("client", ttlMs);
218
+ }
219
+
194
220
  createCubeInvitation(
195
221
  recoveryCredential: string,
196
222
  cubeSelector: { readonly kind: "id" | "name"; readonly value: string },
@@ -287,7 +313,7 @@ export class CredentialAuthority {
287
313
 
288
314
  authenticateStatus(
289
315
  authorization: string | undefined,
290
- ): Principal | "missing" | "invalid" | "revoked" | "evicted" {
316
+ ): Principal | "missing" | "invalid" | "expired" | "revoked" | "evicted" | "rejected" {
291
317
  if (authorization === undefined) return "missing";
292
318
  const secret = bearerSecret(authorization);
293
319
  const clientDigest = safeDigest(this.#digester, secret, "client");
@@ -304,9 +330,9 @@ export class CredentialAuthority {
304
330
  }
305
331
  if (droneValid) {
306
332
  if (drone!.evictedAt !== null) return "evicted";
307
- if (drone!.revokedAt != null || drone!.expiresAt <= this.#clock().toISOString()) {
308
- return "revoked";
309
- }
333
+ if (drone!.revokedAt != null) return "revoked";
334
+ if (drone!.takenOver) return "rejected";
335
+ if (drone!.expiresAt <= this.#clock().toISOString()) return "expired";
310
336
  return droneSessionPrincipal({
311
337
  id: drone!.sessionId,
312
338
  clientId: drone!.clientId,
@@ -326,6 +352,7 @@ export class CredentialAuthority {
326
352
  readonly priorDroneId?: string;
327
353
  },
328
354
  ): SeatAttachResponse {
355
+ const now = this.#clock();
329
356
  const record = store.attachSeat({
330
357
  cubeId: request.cubeId,
331
358
  roleId: request.roleId,
@@ -334,8 +361,12 @@ export class CredentialAuthority {
334
361
  sessionId: randomUUID(),
335
362
  credentialId: randomUUID(),
336
363
  credentialDigest: this.#digester.digest(request.sessionCredential, "drone-session"),
337
- expiresAt: new Date(this.#clock().getTime() + 86_400_000).toISOString(),
364
+ expiresAt: new Date(now.getTime() + DRONE_SESSION_TTL_MS).toISOString(),
365
+ renewIfExpiresAtOrBefore: new Date(
366
+ now.getTime() + DRONE_SESSION_RENEWAL_WINDOW_MS,
367
+ ).toISOString(),
338
368
  });
369
+ this.#registry.renew(record.sessionId, new Date(record.expiresAt).getTime() - now.getTime());
339
370
  if (record.result === "created") {
340
371
  this.#debugLogger.emit({
341
372
  event: "credential",
package/src/debug-log.ts CHANGED
@@ -3,6 +3,7 @@ import type { Principal } from "./principal.js";
3
3
  export type DebugRoute =
4
4
  | "health"
5
5
  | "protocol"
6
+ | "runtime"
6
7
  | "enrollment_exchange"
7
8
  | "client_attach"
8
9
  | "cubes"
@@ -21,7 +22,7 @@ export type DebugRoute =
21
22
  export type DebugEvent =
22
23
  | { readonly event: "startup"; readonly bindMode: "loopback" | "lan"; readonly port: number; readonly dataDirectory: "configured" | "tls_only" }
23
24
  | { readonly event: "lifecycle"; readonly action: "listening" | "stopped" }
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 }
25
+ | { readonly event: "request"; readonly route: DebugRoute; readonly method: string; readonly authentication: "not_required" | "missing" | "invalid" | "expired" | "revoked" | "evicted" | "rejected" | "accepted"; readonly authorization: "not_checked" | "accepted" | "denied_or_not_found"; readonly principal?: Principal; readonly status: number; readonly durationMs: number }
25
26
  | { 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[] }
26
27
  | { readonly event: "cursor_replay"; readonly mode: "page" | "sse"; readonly cubeId: string; readonly cursorId: string | null; readonly returnedCount: number; readonly behindBy: number; readonly truncated: boolean }
27
28
  | { readonly event: "ack_write"; readonly cubeId: string; readonly entryId: string; readonly kind: "ack" | "claim"; readonly principal: Principal }
@@ -158,8 +159,8 @@ function enumValue<const T extends string>(value: unknown, allowed: readonly T[]
158
159
  }
159
160
 
160
161
  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_taxonomy_patch", "cube_drones", "cube_logs", "cube_acks", "cube_decisions", "cube_stream", "unknown"];
162
+ const DEBUG_ROUTES: readonly DebugRoute[] = ["health", "protocol", "runtime", "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
163
  const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "OTHER"] as const;
163
- const AUTH_RESULTS = ["not_required", "missing", "invalid", "revoked", "evicted", "accepted"] as const;
164
+ const AUTH_RESULTS = ["not_required", "missing", "invalid", "expired", "revoked", "evicted", "rejected", "accepted"] as const;
164
165
  const AUTHZ_RESULTS = ["not_checked", "accepted", "denied_or_not_found"] as const;
165
166
  const CREDENTIAL_ACTIONS = ["invitation_created", "enrollment_accepted", "enrollment_rejected", "session_created", "session_revoked", "client_rotated", "client_revoked"] as const;
@@ -17,6 +17,7 @@ import { resolveBindOptions, type BindOptionsInput } from "./network-policy.js";
17
17
  import type { CoordinationRequest, CoordinationResponse } from "./coordination-api.js";
18
18
  import type { Principal } from "./principal.js";
19
19
  import { disabledDebugLogger, type DebugLogger, type DebugRoute } from "./debug-log.js";
20
+ import { RUNTIME_INFO_PATH, type RuntimeBuildIdentity } from "./runtime-identity.js";
20
21
 
21
22
  export interface ServiceLimits {
22
23
  readonly maxConnections: number;
@@ -39,7 +40,7 @@ export interface ServiceLimits {
39
40
 
40
41
  export const DEFAULT_SERVICE_LIMITS: ServiceLimits = {
41
42
  maxConnections: 100,
42
- maxConnectionsPerAddress: 25,
43
+ maxConnectionsPerAddress: 30,
43
44
  maxRequestsPerWindow: 120,
44
45
  maxRequestsPerAddressWindow: 600,
45
46
  maxRequestsGlobalWindow: 5_000,
@@ -48,7 +49,7 @@ export const DEFAULT_SERVICE_LIMITS: ServiceLimits = {
48
49
  maxStreamsPerCredential: 8,
49
50
  maxHeaderBytes: 16_384,
50
51
  maxRequestBodyBytes: 65_536,
51
- maxRequestsPerSocket: 100,
52
+ maxRequestsPerSocket: 0,
52
53
  requestTimeoutMs: 15_000,
53
54
  tlsHandshakeTimeoutMs: 10_000,
54
55
  headersTimeoutMs: 10_000,
@@ -63,9 +64,10 @@ export interface RequestHandlerContext {
63
64
  readonly authorizeCoordination?: (
64
65
  authorization: string | undefined,
65
66
  signal: AbortSignal,
66
- ) => Promise<Principal | "missing" | "invalid" | "revoked" | "evicted">;
67
+ ) => Promise<Principal | "missing" | "invalid" | "expired" | "revoked" | "evicted" | "rejected">;
67
68
  readonly handleCoordination?: (request: CoordinationRequest) => Promise<CoordinationResponse>;
68
69
  readonly debugLogger: DebugLogger;
70
+ readonly runtimeIdentity?: RuntimeBuildIdentity;
69
71
  }
70
72
 
71
73
  export interface HttpsServerOptions {
@@ -80,8 +82,10 @@ export interface HttpsServerOptions {
80
82
  readonly handleCoordination?: RequestHandlerContext["handleCoordination"];
81
83
  readonly limits?: ServiceLimits;
82
84
  readonly debugLogger?: DebugLogger;
85
+ readonly runtimeIdentity?: RuntimeBuildIdentity;
83
86
  readonly testHooks?: {
84
87
  readonly identifyRemoteAddress?: (socket: Socket) => string;
88
+ readonly identifyConnectionAddress?: (socket: Socket) => string;
85
89
  };
86
90
  }
87
91
 
@@ -100,6 +104,15 @@ export async function startHttpsServer(options: HttpsServerOptions): Promise<Run
100
104
  validateLimits(limits);
101
105
  validateTlsCertificate(options.tls.cert, bind.host, bind.mode, options.tls.ca);
102
106
  const handlerContext = createRequestHandlerContext(options);
107
+ const identifyRemoteAddress = options.testHooks?.identifyRemoteAddress ??
108
+ ((socket: Socket) => socket.remoteAddress ?? "unknown");
109
+ const identifyConnectionAddress = options.testHooks?.identifyConnectionAddress ??
110
+ ((socket: Socket) => socket.remoteAddress ?? "unknown");
111
+ const addressConnectionLimiter = new AddressConnectionLimiter(
112
+ limits.maxConnectionsPerAddress,
113
+ limits.maxConnections,
114
+ identifyConnectionAddress,
115
+ );
103
116
 
104
117
  const server = createServer(
105
118
  {
@@ -112,11 +125,19 @@ export async function startHttpsServer(options: HttpsServerOptions): Promise<Run
112
125
  headersTimeout: limits.headersTimeoutMs,
113
126
  keepAliveTimeout: limits.keepAliveTimeoutMs,
114
127
  },
115
- createRequestListener(handlerContext, limits, options.testHooks?.identifyRemoteAddress),
128
+ createRequestListener(
129
+ handlerContext,
130
+ limits,
131
+ identifyRemoteAddress,
132
+ (socket) => addressConnectionLimiter.isRejected(socket),
133
+ ),
116
134
  );
117
135
 
118
- const acceptedSockets = applyServerLimits(server, limits);
119
- server.on("secureConnection", (socket) => socket.disableRenegotiation());
136
+ const acceptedSockets = applyServerLimits(server, limits, addressConnectionLimiter);
137
+ server.on("secureConnection", (socket) => {
138
+ socket.disableRenegotiation();
139
+ addressConnectionLimiter.admit(socket);
140
+ });
120
141
  server.on("tlsClientError", (_error, socket) => {
121
142
  handlerContext.debugLogger.emit({ event: "transport_rejection", reason: "tls_client_error" });
122
143
  socket.destroy();
@@ -165,6 +186,7 @@ export function createRequestHandlerContext(
165
186
  ...(options.handleCoordination === undefined
166
187
  ? {}
167
188
  : { handleCoordination: options.handleCoordination }),
189
+ ...(options.runtimeIdentity === undefined ? {} : { runtimeIdentity: options.runtimeIdentity }),
168
190
  debugLogger: options.debugLogger ?? disabledDebugLogger,
169
191
  });
170
192
  }
@@ -173,6 +195,7 @@ function createRequestListener(
173
195
  context: RequestHandlerContext,
174
196
  limits: ServiceLimits,
175
197
  identifyRemoteAddress: (socket: Socket) => string = (socket) => socket.remoteAddress ?? "unknown",
198
+ isConnectionRejected: (socket: Socket) => boolean = () => false,
176
199
  ): (request: IncomingMessage, response: ServerResponse) => void {
177
200
  const admissionLimiter = new PreAuthAdmissionLimiter(limits);
178
201
  const credentialRateLimiter = new RequestRateLimiter(limits, limits.maxRequestsPerWindow);
@@ -204,6 +227,11 @@ function createRequestListener(
204
227
  };
205
228
  response.once("finish", emitDebug);
206
229
  response.once("close", emitDebug);
230
+ if (isConnectionRejected(request.socket)) {
231
+ request.resume();
232
+ sendRateLimited(response, 1);
233
+ return;
234
+ }
207
235
  const controller = new AbortController();
208
236
  response.once("close", () => controller.abort());
209
237
  let timer: NodeJS.Timeout | undefined;
@@ -318,7 +346,7 @@ async function handleRequest(
318
346
  return;
319
347
  }
320
348
 
321
- if (isCoordinationPath(path) && context.handleCoordination !== undefined) {
349
+ if (isAuthenticatedPath(path) && context.authorizeCoordination !== undefined) {
322
350
  let decoded: unknown;
323
351
  if (requestBody.length === 0) {
324
352
  decoded = undefined;
@@ -330,12 +358,7 @@ async function handleRequest(
330
358
  }
331
359
  }
332
360
  const authorization = request.headers.authorization;
333
- const authorizeCoordination = context.authorizeCoordination;
334
- if (authorizeCoordination === undefined) {
335
- sendJson(response, 500, protocolError("INTERNAL_ERROR", "Coordination authentication is unavailable."), true);
336
- return;
337
- }
338
- const authentication = await authorizeCoordination(authorization, signal);
361
+ const authentication = await context.authorizeCoordination(authorization, signal);
339
362
  trace.authentication = typeof authentication === "string" ? authentication : "accepted";
340
363
  if (signal.aborted) return;
341
364
  if (typeof authentication === "string") {
@@ -343,16 +366,32 @@ async function handleRequest(
343
366
  sendJson(response, 410, protocolError(ErrorCode.DRONE_EVICTED, "Authentication failed."));
344
367
  return;
345
368
  }
346
- const code = authentication === "revoked"
347
- ? "SESSION_REVOKED"
369
+ const code = authentication === "expired"
370
+ ? ErrorCode.AUTH_EXPIRED
371
+ : authentication === "revoked" ? ErrorCode.SESSION_REVOKED
372
+ : authentication === "rejected" ? ErrorCode.SESSION_REJECTED
348
373
  : authentication === "missing" || authorization === undefined ? "AUTH_MISSING" : "AUTH_INVALID";
349
374
  sendJson(response, 401, protocolError(code, "Authentication failed."));
350
375
  return;
351
376
  }
352
377
  trace.principal = authentication;
353
- const clientIdentity = `client:${authentication.kind === "drone-session"
354
- ? authentication.clientId
355
- : authentication.id}`;
378
+ if (path === RUNTIME_INFO_PATH) {
379
+ if (request.method !== "GET" || requestBody.length !== 0) {
380
+ sendEmpty(response, request.method === "GET" ? 400 : 405, true);
381
+ return;
382
+ }
383
+ if (context.runtimeIdentity === undefined) {
384
+ sendEmpty(response, 404);
385
+ return;
386
+ }
387
+ sendJson(response, 200, context.runtimeIdentity);
388
+ return;
389
+ }
390
+ if (context.handleCoordination === undefined) {
391
+ sendJson(response, 500, protocolError("INTERNAL_ERROR", "Coordination handling is unavailable."), true);
392
+ return;
393
+ }
394
+ const clientIdentity = credentialRateLimitIdentity(authentication, request.method, path);
356
395
  const credentialRetry = credentialRateLimiter.consume(clientIdentity);
357
396
  if (credentialRetry !== null) return sendRateLimited(response, credentialRetry);
358
397
  const query = parseCoordinationQuery(request.url, path);
@@ -393,7 +432,7 @@ async function handleRequest(
393
432
  interface RequestTrace {
394
433
  readonly route: DebugRoute;
395
434
  readonly method: string;
396
- authentication: "not_required" | "missing" | "invalid" | "revoked" | "evicted" | "accepted";
435
+ authentication: "not_required" | "missing" | "invalid" | "expired" | "revoked" | "evicted" | "rejected" | "accepted";
397
436
  principal?: Principal;
398
437
  }
399
438
 
@@ -407,6 +446,7 @@ function debugRoute(rawUrl: string | undefined): DebugRoute {
407
446
  if (path === null) return "unknown";
408
447
  if (path === HEALTH_PATH) return "health";
409
448
  if (path === PROTOCOL_INFO_PATH) return "protocol";
449
+ if (path === RUNTIME_INFO_PATH) return "runtime";
410
450
  if (path === ENROLLMENT_EXCHANGE_PATH) return "enrollment_exchange";
411
451
  if (path === ATTACH_PATH) return "client_attach";
412
452
  if (path === "/api/cubes") return "cubes";
@@ -592,7 +632,28 @@ function isCoordinationPath(path: string | null): path is string {
592
632
  path?.startsWith("/api/cubes/") === true;
593
633
  }
594
634
 
595
- function applyServerLimits(server: HttpsServer, limits: ServiceLimits): Set<Socket> {
635
+ function isAuthenticatedPath(path: string | null): path is string {
636
+ return path === RUNTIME_INFO_PATH || isCoordinationPath(path);
637
+ }
638
+
639
+ function credentialRateLimitIdentity(
640
+ principal: Principal,
641
+ method: string | undefined,
642
+ path: string,
643
+ ): string {
644
+ const routineRead = (method === "GET" && !path.endsWith("/stream")) ||
645
+ (method === "PUT" && (path.endsWith("/logs") || path.endsWith("/decisions")));
646
+ if (principal.kind === "drone-session" && routineRead) {
647
+ return `drone-session:${principal.id}`;
648
+ }
649
+ return `client:${principal.kind === "drone-session" ? principal.clientId : principal.id}`;
650
+ }
651
+
652
+ function applyServerLimits(
653
+ server: HttpsServer,
654
+ limits: ServiceLimits,
655
+ addressConnectionLimiter: AddressConnectionLimiter,
656
+ ): Set<Socket> {
596
657
  server.maxConnections = limits.maxConnections;
597
658
  server.maxRequestsPerSocket = limits.maxRequestsPerSocket;
598
659
  server.requestTimeout = limits.requestTimeoutMs;
@@ -602,27 +663,63 @@ function applyServerLimits(server: HttpsServer, limits: ServiceLimits): Set<Sock
602
663
  Math.min(limits.handlerTimeoutMs * 2, 2_147_483_647),
603
664
  (socket) => socket.destroy(),
604
665
  );
605
- const addressConnections = new ConcurrentQuota(limits.maxConnectionsPerAddress);
606
666
  const acceptedSockets = new Set<Socket>();
607
667
  server.on("connection", (socket) => {
608
668
  const tracked = socket as Socket;
609
669
  acceptedSockets.add(tracked);
610
670
  tracked.once("close", () => acceptedSockets.delete(tracked));
611
- const identity = tracked.remoteAddress ?? "unknown";
612
- const release = addressConnections.acquire(identity);
671
+ addressConnectionLimiter.admitRaw(tracked);
672
+ });
673
+ return acceptedSockets;
674
+ }
675
+
676
+ class AddressConnectionLimiter {
677
+ readonly #rawConnections: ConcurrentQuota;
678
+ readonly #connections: ConcurrentQuota;
679
+ readonly #rejected = new WeakSet<Socket>();
680
+ readonly #identifyRemoteAddress: (socket: Socket) => string;
681
+
682
+ constructor(
683
+ limit: number,
684
+ globalLimit: number,
685
+ identifyRemoteAddress: (socket: Socket) => string,
686
+ ) {
687
+ // Keep exactly one per-address raw socket available beyond normal secure
688
+ // admission so it can receive a controlled HTTP 429 after TLS. All later
689
+ // raw sockets are rejected before they can consume the global pool.
690
+ this.#rawConnections = new ConcurrentQuota(Math.min(limit + 1, globalLimit));
691
+ this.#connections = new ConcurrentQuota(limit);
692
+ this.#identifyRemoteAddress = identifyRemoteAddress;
693
+ }
694
+
695
+ admitRaw(socket: Socket): void {
696
+ const release = this.#rawConnections.acquire(this.#identifyRemoteAddress(socket));
613
697
  if (release === null) {
614
698
  socket.destroy();
615
699
  return;
616
700
  }
617
701
  socket.once("close", release);
618
- });
619
- return acceptedSockets;
702
+ }
703
+
704
+ admit(socket: Socket): void {
705
+ const release = this.#connections.acquire(this.#identifyRemoteAddress(socket));
706
+ if (release === null) {
707
+ this.#rejected.add(socket);
708
+ return;
709
+ }
710
+ socket.once("close", release);
711
+ }
712
+
713
+ isRejected(socket: Socket): boolean {
714
+ return this.#rejected.has(socket);
715
+ }
620
716
  }
621
717
 
622
718
  function validateLimits(limits: ServiceLimits): void {
623
719
  for (const [name, value] of Object.entries(limits)) {
624
- if (!Number.isSafeInteger(value) || value <= 0) {
625
- throw new Error(`${name} must be a positive safe integer.`);
720
+ if (!Number.isSafeInteger(value) || value < 0 || (value === 0 && name !== "maxRequestsPerSocket")) {
721
+ const range = name === "maxRequestsPerSocket" ? "non-negative" : "positive";
722
+ throw new Error(`${name} must be a ${range} safe integer.`);
626
723
  }
627
724
  }
628
725
  if (limits.headersTimeoutMs > limits.requestTimeoutMs) {