borgmcp-server 0.1.5 → 0.1.8

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/src/debug-log.ts CHANGED
@@ -21,7 +21,7 @@ export type DebugRoute =
21
21
  export type DebugEvent =
22
22
  | { readonly event: "startup"; readonly bindMode: "loopback" | "lan"; readonly port: number; readonly dataDirectory: "configured" | "tls_only" }
23
23
  | { 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 }
24
+ | { 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
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[] }
26
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 }
27
27
  | { readonly event: "ack_write"; readonly cubeId: string; readonly entryId: string; readonly kind: "ack" | "claim"; readonly principal: Principal }
@@ -160,6 +160,6 @@ function enumValue<const T extends string>(value: unknown, allowed: readonly T[]
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
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", "evicted", "accepted"] as const;
163
+ const AUTH_RESULTS = ["not_required", "missing", "invalid", "expired", "revoked", "evicted", "rejected", "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;
@@ -39,7 +39,7 @@ export interface ServiceLimits {
39
39
 
40
40
  export const DEFAULT_SERVICE_LIMITS: ServiceLimits = {
41
41
  maxConnections: 100,
42
- maxConnectionsPerAddress: 25,
42
+ maxConnectionsPerAddress: 30,
43
43
  maxRequestsPerWindow: 120,
44
44
  maxRequestsPerAddressWindow: 600,
45
45
  maxRequestsGlobalWindow: 5_000,
@@ -48,7 +48,7 @@ export const DEFAULT_SERVICE_LIMITS: ServiceLimits = {
48
48
  maxStreamsPerCredential: 8,
49
49
  maxHeaderBytes: 16_384,
50
50
  maxRequestBodyBytes: 65_536,
51
- maxRequestsPerSocket: 100,
51
+ maxRequestsPerSocket: 0,
52
52
  requestTimeoutMs: 15_000,
53
53
  tlsHandshakeTimeoutMs: 10_000,
54
54
  headersTimeoutMs: 10_000,
@@ -63,7 +63,7 @@ export interface RequestHandlerContext {
63
63
  readonly authorizeCoordination?: (
64
64
  authorization: string | undefined,
65
65
  signal: AbortSignal,
66
- ) => Promise<Principal | "missing" | "invalid" | "revoked" | "evicted">;
66
+ ) => Promise<Principal | "missing" | "invalid" | "expired" | "revoked" | "evicted" | "rejected">;
67
67
  readonly handleCoordination?: (request: CoordinationRequest) => Promise<CoordinationResponse>;
68
68
  readonly debugLogger: DebugLogger;
69
69
  }
@@ -82,6 +82,7 @@ export interface HttpsServerOptions {
82
82
  readonly debugLogger?: DebugLogger;
83
83
  readonly testHooks?: {
84
84
  readonly identifyRemoteAddress?: (socket: Socket) => string;
85
+ readonly identifyConnectionAddress?: (socket: Socket) => string;
85
86
  };
86
87
  }
87
88
 
@@ -100,6 +101,15 @@ export async function startHttpsServer(options: HttpsServerOptions): Promise<Run
100
101
  validateLimits(limits);
101
102
  validateTlsCertificate(options.tls.cert, bind.host, bind.mode, options.tls.ca);
102
103
  const handlerContext = createRequestHandlerContext(options);
104
+ const identifyRemoteAddress = options.testHooks?.identifyRemoteAddress ??
105
+ ((socket: Socket) => socket.remoteAddress ?? "unknown");
106
+ const identifyConnectionAddress = options.testHooks?.identifyConnectionAddress ??
107
+ ((socket: Socket) => socket.remoteAddress ?? "unknown");
108
+ const addressConnectionLimiter = new AddressConnectionLimiter(
109
+ limits.maxConnectionsPerAddress,
110
+ limits.maxConnections,
111
+ identifyConnectionAddress,
112
+ );
103
113
 
104
114
  const server = createServer(
105
115
  {
@@ -112,11 +122,19 @@ export async function startHttpsServer(options: HttpsServerOptions): Promise<Run
112
122
  headersTimeout: limits.headersTimeoutMs,
113
123
  keepAliveTimeout: limits.keepAliveTimeoutMs,
114
124
  },
115
- createRequestListener(handlerContext, limits, options.testHooks?.identifyRemoteAddress),
125
+ createRequestListener(
126
+ handlerContext,
127
+ limits,
128
+ identifyRemoteAddress,
129
+ (socket) => addressConnectionLimiter.isRejected(socket),
130
+ ),
116
131
  );
117
132
 
118
- const acceptedSockets = applyServerLimits(server, limits);
119
- server.on("secureConnection", (socket) => socket.disableRenegotiation());
133
+ const acceptedSockets = applyServerLimits(server, limits, addressConnectionLimiter);
134
+ server.on("secureConnection", (socket) => {
135
+ socket.disableRenegotiation();
136
+ addressConnectionLimiter.admit(socket);
137
+ });
120
138
  server.on("tlsClientError", (_error, socket) => {
121
139
  handlerContext.debugLogger.emit({ event: "transport_rejection", reason: "tls_client_error" });
122
140
  socket.destroy();
@@ -173,6 +191,7 @@ function createRequestListener(
173
191
  context: RequestHandlerContext,
174
192
  limits: ServiceLimits,
175
193
  identifyRemoteAddress: (socket: Socket) => string = (socket) => socket.remoteAddress ?? "unknown",
194
+ isConnectionRejected: (socket: Socket) => boolean = () => false,
176
195
  ): (request: IncomingMessage, response: ServerResponse) => void {
177
196
  const admissionLimiter = new PreAuthAdmissionLimiter(limits);
178
197
  const credentialRateLimiter = new RequestRateLimiter(limits, limits.maxRequestsPerWindow);
@@ -204,6 +223,11 @@ function createRequestListener(
204
223
  };
205
224
  response.once("finish", emitDebug);
206
225
  response.once("close", emitDebug);
226
+ if (isConnectionRejected(request.socket)) {
227
+ request.resume();
228
+ sendRateLimited(response, 1);
229
+ return;
230
+ }
207
231
  const controller = new AbortController();
208
232
  response.once("close", () => controller.abort());
209
233
  let timer: NodeJS.Timeout | undefined;
@@ -343,16 +367,16 @@ async function handleRequest(
343
367
  sendJson(response, 410, protocolError(ErrorCode.DRONE_EVICTED, "Authentication failed."));
344
368
  return;
345
369
  }
346
- const code = authentication === "revoked"
347
- ? "SESSION_REVOKED"
370
+ const code = authentication === "expired"
371
+ ? ErrorCode.AUTH_EXPIRED
372
+ : authentication === "revoked" ? ErrorCode.SESSION_REVOKED
373
+ : authentication === "rejected" ? ErrorCode.SESSION_REJECTED
348
374
  : authentication === "missing" || authorization === undefined ? "AUTH_MISSING" : "AUTH_INVALID";
349
375
  sendJson(response, 401, protocolError(code, "Authentication failed."));
350
376
  return;
351
377
  }
352
378
  trace.principal = authentication;
353
- const clientIdentity = `client:${authentication.kind === "drone-session"
354
- ? authentication.clientId
355
- : authentication.id}`;
379
+ const clientIdentity = credentialRateLimitIdentity(authentication, request.method, path);
356
380
  const credentialRetry = credentialRateLimiter.consume(clientIdentity);
357
381
  if (credentialRetry !== null) return sendRateLimited(response, credentialRetry);
358
382
  const query = parseCoordinationQuery(request.url, path);
@@ -393,7 +417,7 @@ async function handleRequest(
393
417
  interface RequestTrace {
394
418
  readonly route: DebugRoute;
395
419
  readonly method: string;
396
- authentication: "not_required" | "missing" | "invalid" | "revoked" | "evicted" | "accepted";
420
+ authentication: "not_required" | "missing" | "invalid" | "expired" | "revoked" | "evicted" | "rejected" | "accepted";
397
421
  principal?: Principal;
398
422
  }
399
423
 
@@ -592,7 +616,24 @@ function isCoordinationPath(path: string | null): path is string {
592
616
  path?.startsWith("/api/cubes/") === true;
593
617
  }
594
618
 
595
- function applyServerLimits(server: HttpsServer, limits: ServiceLimits): Set<Socket> {
619
+ function credentialRateLimitIdentity(
620
+ principal: Principal,
621
+ method: string | undefined,
622
+ path: string,
623
+ ): string {
624
+ const routineRead = (method === "GET" && !path.endsWith("/stream")) ||
625
+ (method === "PUT" && (path.endsWith("/logs") || path.endsWith("/decisions")));
626
+ if (principal.kind === "drone-session" && routineRead) {
627
+ return `drone-session:${principal.id}`;
628
+ }
629
+ return `client:${principal.kind === "drone-session" ? principal.clientId : principal.id}`;
630
+ }
631
+
632
+ function applyServerLimits(
633
+ server: HttpsServer,
634
+ limits: ServiceLimits,
635
+ addressConnectionLimiter: AddressConnectionLimiter,
636
+ ): Set<Socket> {
596
637
  server.maxConnections = limits.maxConnections;
597
638
  server.maxRequestsPerSocket = limits.maxRequestsPerSocket;
598
639
  server.requestTimeout = limits.requestTimeoutMs;
@@ -602,27 +643,63 @@ function applyServerLimits(server: HttpsServer, limits: ServiceLimits): Set<Sock
602
643
  Math.min(limits.handlerTimeoutMs * 2, 2_147_483_647),
603
644
  (socket) => socket.destroy(),
604
645
  );
605
- const addressConnections = new ConcurrentQuota(limits.maxConnectionsPerAddress);
606
646
  const acceptedSockets = new Set<Socket>();
607
647
  server.on("connection", (socket) => {
608
648
  const tracked = socket as Socket;
609
649
  acceptedSockets.add(tracked);
610
650
  tracked.once("close", () => acceptedSockets.delete(tracked));
611
- const identity = tracked.remoteAddress ?? "unknown";
612
- const release = addressConnections.acquire(identity);
651
+ addressConnectionLimiter.admitRaw(tracked);
652
+ });
653
+ return acceptedSockets;
654
+ }
655
+
656
+ class AddressConnectionLimiter {
657
+ readonly #rawConnections: ConcurrentQuota;
658
+ readonly #connections: ConcurrentQuota;
659
+ readonly #rejected = new WeakSet<Socket>();
660
+ readonly #identifyRemoteAddress: (socket: Socket) => string;
661
+
662
+ constructor(
663
+ limit: number,
664
+ globalLimit: number,
665
+ identifyRemoteAddress: (socket: Socket) => string,
666
+ ) {
667
+ // Keep exactly one per-address raw socket available beyond normal secure
668
+ // admission so it can receive a controlled HTTP 429 after TLS. All later
669
+ // raw sockets are rejected before they can consume the global pool.
670
+ this.#rawConnections = new ConcurrentQuota(Math.min(limit + 1, globalLimit));
671
+ this.#connections = new ConcurrentQuota(limit);
672
+ this.#identifyRemoteAddress = identifyRemoteAddress;
673
+ }
674
+
675
+ admitRaw(socket: Socket): void {
676
+ const release = this.#rawConnections.acquire(this.#identifyRemoteAddress(socket));
613
677
  if (release === null) {
614
678
  socket.destroy();
615
679
  return;
616
680
  }
617
681
  socket.once("close", release);
618
- });
619
- return acceptedSockets;
682
+ }
683
+
684
+ admit(socket: Socket): void {
685
+ const release = this.#connections.acquire(this.#identifyRemoteAddress(socket));
686
+ if (release === null) {
687
+ this.#rejected.add(socket);
688
+ return;
689
+ }
690
+ socket.once("close", release);
691
+ }
692
+
693
+ isRejected(socket: Socket): boolean {
694
+ return this.#rejected.has(socket);
695
+ }
620
696
  }
621
697
 
622
698
  function validateLimits(limits: ServiceLimits): void {
623
699
  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.`);
700
+ if (!Number.isSafeInteger(value) || value < 0 || (value === 0 && name !== "maxRequestsPerSocket")) {
701
+ const range = name === "maxRequestsPerSocket" ? "non-negative" : "positive";
702
+ throw new Error(`${name} must be a ${range} safe integer.`);
626
703
  }
627
704
  }
628
705
  if (limits.headersTimeoutMs > limits.requestTimeoutMs) {
package/src/migrations.ts CHANGED
@@ -378,6 +378,29 @@ export const STORE_MIGRATIONS: readonly Migration[] = Object.freeze([
378
378
  ) STRICT;
379
379
  `,
380
380
  },
381
+ {
382
+ version: 12,
383
+ name: "drone_session_supersession",
384
+ sql: `
385
+ ALTER TABLE drone_sessions ADD COLUMN superseded_at TEXT;
386
+
387
+ WITH lineage AS (
388
+ SELECT rowid AS session_rowid,
389
+ LEAD(created_at) OVER (
390
+ PARTITION BY client_id, cube_id, drone_id
391
+ ORDER BY created_at, rowid
392
+ ) AS successor_at
393
+ FROM drone_sessions
394
+ )
395
+ UPDATE drone_sessions
396
+ SET superseded_at = (
397
+ SELECT successor_at FROM lineage WHERE session_rowid = drone_sessions.rowid
398
+ )
399
+ WHERE rowid IN (
400
+ SELECT session_rowid FROM lineage WHERE successor_at IS NOT NULL
401
+ );
402
+ `,
403
+ },
381
404
  ]);
382
405
 
383
406
  interface AppliedMigrationRow {