borgmcp-server 0.1.7 → 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/README.md +10 -9
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/dist/coordination-api.js +10 -1
- package/dist/coordination-api.js.map +1 -1
- package/dist/credentials.d.ts +4 -1
- package/dist/credentials.js +28 -3
- package/dist/credentials.js.map +1 -1
- package/dist/debug-log.d.ts +1 -1
- package/dist/debug-log.js +1 -1
- package/dist/debug-log.js.map +1 -1
- package/dist/https-server.d.ts +2 -1
- package/dist/https-server.js +68 -20
- package/dist/https-server.js.map +1 -1
- package/dist/migrations.js +23 -0
- package/dist/migrations.js.map +1 -1
- package/dist/store.d.ts +15 -7
- package/dist/store.js +85 -83
- package/dist/store.js.map +1 -1
- package/npm-shrinkwrap.json +6 -6
- package/package.json +2 -2
- package/src/coordination-api.ts +12 -0
- package/src/credentials.ts +29 -5
- package/src/debug-log.ts +2 -2
- package/src/https-server.ts +97 -20
- package/src/migrations.ts +23 -0
- package/src/store.ts +90 -83
package/src/https-server.ts
CHANGED
|
@@ -39,7 +39,7 @@ export interface ServiceLimits {
|
|
|
39
39
|
|
|
40
40
|
export const DEFAULT_SERVICE_LIMITS: ServiceLimits = {
|
|
41
41
|
maxConnections: 100,
|
|
42
|
-
maxConnectionsPerAddress:
|
|
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:
|
|
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(
|
|
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) =>
|
|
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 === "
|
|
347
|
-
?
|
|
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 =
|
|
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
|
|
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
|
-
|
|
612
|
-
|
|
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
|
-
|
|
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
|
|
625
|
-
|
|
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 {
|
package/src/store.ts
CHANGED
|
@@ -65,7 +65,6 @@ export type ActivityNotificationRecord = EnrichedActivityRecord & (
|
|
|
65
65
|
readonly claimant_drone_id?: string;
|
|
66
66
|
readonly claimant_role?: string | null;
|
|
67
67
|
}
|
|
68
|
-
| { readonly kind: "heartbeat_ping" }
|
|
69
68
|
);
|
|
70
69
|
|
|
71
70
|
export type ActivityStreamRecord = EnrichedActivityRecord | ActivityNotificationRecord;
|
|
@@ -180,11 +179,7 @@ export interface StoreRuntime {
|
|
|
180
179
|
}
|
|
181
180
|
|
|
182
181
|
export interface LivenessStore {
|
|
183
|
-
readonly scan: (
|
|
184
|
-
readonly silentMs?: number;
|
|
185
|
-
readonly cooldownMs?: number;
|
|
186
|
-
readonly limit?: number;
|
|
187
|
-
}) => ActivityNotificationRecord[];
|
|
182
|
+
readonly scan: () => ActivityNotificationRecord[];
|
|
188
183
|
}
|
|
189
184
|
|
|
190
185
|
export interface DigestPair {
|
|
@@ -237,6 +232,7 @@ export interface StoredDroneSessionDigest extends StoredSecretDigest {
|
|
|
237
232
|
readonly droneId: string;
|
|
238
233
|
readonly expiresAt: string;
|
|
239
234
|
readonly evictedAt: string | null;
|
|
235
|
+
readonly takenOver: boolean;
|
|
240
236
|
}
|
|
241
237
|
|
|
242
238
|
export interface CredentialStore {
|
|
@@ -362,6 +358,7 @@ export interface SeatAttachInput {
|
|
|
362
358
|
readonly credentialId: string;
|
|
363
359
|
readonly credentialDigest: DigestPair;
|
|
364
360
|
readonly expiresAt: string;
|
|
361
|
+
readonly renewIfExpiresAtOrBefore: string;
|
|
365
362
|
}
|
|
366
363
|
|
|
367
364
|
export interface SeatAttachRecord {
|
|
@@ -527,6 +524,33 @@ export class AttachSessionRejectedError extends Error {
|
|
|
527
524
|
}
|
|
528
525
|
}
|
|
529
526
|
|
|
527
|
+
export class AttachSessionExpiredError extends Error {
|
|
528
|
+
readonly code = "AUTH_EXPIRED";
|
|
529
|
+
|
|
530
|
+
constructor() {
|
|
531
|
+
super("Authentication failed.");
|
|
532
|
+
this.name = "AttachSessionExpiredError";
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
export class AttachSessionRevokedError extends Error {
|
|
537
|
+
readonly code = "SESSION_REVOKED";
|
|
538
|
+
|
|
539
|
+
constructor() {
|
|
540
|
+
super("Authentication failed.");
|
|
541
|
+
this.name = "AttachSessionRevokedError";
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
export class AttachDroneEvictedError extends Error {
|
|
546
|
+
readonly code = "DRONE_EVICTED";
|
|
547
|
+
|
|
548
|
+
constructor() {
|
|
549
|
+
super("Authentication failed.");
|
|
550
|
+
this.name = "AttachDroneEvictedError";
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
530
554
|
export class StorageCapacityError extends Error {
|
|
531
555
|
readonly code = "CAPACITY_EXCEEDED";
|
|
532
556
|
|
|
@@ -645,7 +669,7 @@ export async function openStore(options: OpenStoreOptions): Promise<StoreRuntime
|
|
|
645
669
|
);
|
|
646
670
|
const activityHub = new ActivityHub();
|
|
647
671
|
const maintenance = new SqliteMaintenanceStore(database, clock);
|
|
648
|
-
const liveness = new
|
|
672
|
+
const liveness = new IdleLivenessStore();
|
|
649
673
|
const credentials = new SqliteCredentialStore(database, clock, capacityGuard, options.mutationHook);
|
|
650
674
|
return Object.freeze({
|
|
651
675
|
forPrincipal: (principal: Principal) => {
|
|
@@ -1283,7 +1307,6 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1283
1307
|
this.#database.prepare(`
|
|
1284
1308
|
UPDATE drones SET last_seen = ? WHERE id = ? AND cube_id = ? AND evicted_at IS NULL
|
|
1285
1309
|
`).run(createdAt, droneId, cubeId);
|
|
1286
|
-
this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
|
|
1287
1310
|
}
|
|
1288
1311
|
if (recipients.length > 0) {
|
|
1289
1312
|
const valid = this.#database.prepare(`
|
|
@@ -1470,6 +1493,7 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1470
1493
|
assertCanonicalUuid(input.credentialId, "Drone session credential id");
|
|
1471
1494
|
validateDigest(input.credentialDigest);
|
|
1472
1495
|
validateTimestamp(input.expiresAt);
|
|
1496
|
+
validateTimestamp(input.renewIfExpiresAtOrBefore);
|
|
1473
1497
|
const scope = this.#scope("read");
|
|
1474
1498
|
const authorizedRole = this.#database.prepare(`
|
|
1475
1499
|
SELECT 1 FROM cubes AS c JOIN roles AS role ON role.cube_id = c.id
|
|
@@ -1492,7 +1516,7 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1492
1516
|
SELECT credential.verifier_digest, credential.revoked_at AS credential_revoked_at,
|
|
1493
1517
|
session.id AS session_id, session.client_id, session.cube_id, session.drone_id,
|
|
1494
1518
|
session.expires_at, session.revoked_at AS session_revoked_at,
|
|
1495
|
-
drone.role_id, drone.label, drone.evicted_at
|
|
1519
|
+
session.superseded_at, drone.role_id, drone.label, drone.evicted_at
|
|
1496
1520
|
FROM drone_session_credentials AS credential
|
|
1497
1521
|
JOIN drone_sessions AS session ON session.id = credential.session_id
|
|
1498
1522
|
JOIN drones AS drone ON drone.id = session.drone_id
|
|
@@ -1502,10 +1526,6 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1502
1526
|
if (bound !== undefined) {
|
|
1503
1527
|
const verifier = requiredBuffer(bound, "verifier_digest");
|
|
1504
1528
|
if (!timingSafeEqual(verifier, input.credentialDigest.verifier) ||
|
|
1505
|
-
optionalText(bound, "credential_revoked_at") !== null ||
|
|
1506
|
-
optionalText(bound, "session_revoked_at") !== null ||
|
|
1507
|
-
optionalText(bound, "evicted_at") !== null ||
|
|
1508
|
-
requiredText(bound, "expires_at") <= now ||
|
|
1509
1529
|
requiredText(bound, "client_id") !== this.#principal.id ||
|
|
1510
1530
|
requiredText(bound, "cube_id") !== input.cubeId ||
|
|
1511
1531
|
requiredText(bound, "role_id") !== input.roleId ||
|
|
@@ -1513,9 +1533,27 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1513
1533
|
requiredText(bound, "drone_id") !== input.priorDroneId)) {
|
|
1514
1534
|
throw new AttachSessionRejectedError();
|
|
1515
1535
|
}
|
|
1536
|
+
if (optionalText(bound, "evicted_at") !== null) throw new AttachDroneEvictedError();
|
|
1537
|
+
if (optionalText(bound, "credential_revoked_at") !== null ||
|
|
1538
|
+
optionalText(bound, "session_revoked_at") !== null) {
|
|
1539
|
+
throw new AttachSessionRevokedError();
|
|
1540
|
+
}
|
|
1541
|
+
if (optionalText(bound, "superseded_at") !== null) {
|
|
1542
|
+
throw new AttachSessionRejectedError();
|
|
1543
|
+
}
|
|
1544
|
+
if (requiredText(bound, "expires_at") <= now) throw new AttachSessionExpiredError();
|
|
1516
1545
|
const droneId = requiredText(bound, "drone_id");
|
|
1546
|
+
const sessionId = requiredText(bound, "session_id");
|
|
1547
|
+
const previousExpiry = requiredText(bound, "expires_at");
|
|
1548
|
+
const committedExpiry = input.expiresAt > previousExpiry &&
|
|
1549
|
+
previousExpiry <= input.renewIfExpiresAtOrBefore
|
|
1550
|
+
? input.expiresAt
|
|
1551
|
+
: previousExpiry;
|
|
1552
|
+
if (committedExpiry !== previousExpiry) {
|
|
1553
|
+
this.#database.prepare("UPDATE drone_sessions SET expires_at = ? WHERE id = ?")
|
|
1554
|
+
.run(committedExpiry, sessionId);
|
|
1555
|
+
}
|
|
1517
1556
|
this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
|
|
1518
|
-
this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
|
|
1519
1557
|
const attachedRoleRow = this.#database.prepare(`
|
|
1520
1558
|
SELECT id AS role_id, name AS role_name, role_class, is_human_seat
|
|
1521
1559
|
FROM roles WHERE id = ? AND cube_id = ?
|
|
@@ -1538,33 +1576,53 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1538
1576
|
is_human_seat: requiredInteger(attachedRoleRow, "is_human_seat") === 1,
|
|
1539
1577
|
},
|
|
1540
1578
|
drone: { id: droneId, label: requiredText(bound, "label") },
|
|
1541
|
-
sessionId
|
|
1542
|
-
expiresAt:
|
|
1579
|
+
sessionId,
|
|
1580
|
+
expiresAt: committedExpiry,
|
|
1543
1581
|
result: "reused",
|
|
1544
1582
|
};
|
|
1545
1583
|
}
|
|
1546
1584
|
|
|
1547
1585
|
const priorSeat = input.priorDroneId === undefined ? undefined : this.#database.prepare(`
|
|
1548
|
-
SELECT id, role_id, label FROM drones
|
|
1549
|
-
WHERE id = ? AND client_id = ? AND cube_id = ?
|
|
1586
|
+
SELECT id, role_id, label, evicted_at FROM drones
|
|
1587
|
+
WHERE id = ? AND client_id = ? AND cube_id = ?
|
|
1550
1588
|
`).get(input.priorDroneId, this.#principal.id, input.cubeId);
|
|
1551
1589
|
let droneId: string;
|
|
1552
1590
|
let droneLabel: string;
|
|
1553
|
-
if (
|
|
1591
|
+
if (input.priorDroneId !== undefined) {
|
|
1592
|
+
if (priorSeat === undefined) throw new AttachSessionRejectedError();
|
|
1593
|
+
if (optionalText(priorSeat, "evicted_at") !== null) throw new AttachDroneEvictedError();
|
|
1554
1594
|
const priorSeatId = requiredText(priorSeat, "id");
|
|
1555
1595
|
if (requiredText(priorSeat, "role_id") !== input.roleId) {
|
|
1556
1596
|
throw new AttachSessionRejectedError();
|
|
1557
1597
|
}
|
|
1558
|
-
const
|
|
1559
|
-
SELECT
|
|
1598
|
+
const latestSession = this.#database.prepare(`
|
|
1599
|
+
SELECT session.id, session.expires_at, session.revoked_at AS session_revoked_at,
|
|
1600
|
+
MAX(CASE WHEN credential.revoked_at IS NOT NULL THEN 1 ELSE 0 END)
|
|
1601
|
+
AS credential_revoked
|
|
1602
|
+
FROM drone_sessions AS session
|
|
1560
1603
|
JOIN drone_session_credentials AS credential ON credential.session_id = session.id
|
|
1561
1604
|
WHERE session.drone_id = ? AND session.client_id = ? AND session.cube_id = ?
|
|
1562
|
-
AND session.
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1605
|
+
AND session.superseded_at IS NULL
|
|
1606
|
+
GROUP BY session.id
|
|
1607
|
+
LIMIT 1
|
|
1608
|
+
`).get(priorSeatId, this.#principal.id, input.cubeId);
|
|
1609
|
+
if (latestSession === undefined) throw new AttachSessionRejectedError();
|
|
1610
|
+
if (optionalText(latestSession, "session_revoked_at") !== null ||
|
|
1611
|
+
requiredInteger(latestSession, "credential_revoked") === 1) {
|
|
1612
|
+
throw new AttachSessionRevokedError();
|
|
1613
|
+
}
|
|
1614
|
+
if (requiredText(latestSession, "expires_at") > now) {
|
|
1615
|
+
throw new AttachSessionRejectedError();
|
|
1616
|
+
}
|
|
1566
1617
|
droneId = priorSeatId;
|
|
1567
1618
|
droneLabel = requiredText(priorSeat, "label");
|
|
1619
|
+
const superseded = this.#database.prepare(`
|
|
1620
|
+
UPDATE drone_sessions SET superseded_at = ?
|
|
1621
|
+
WHERE id = ? AND superseded_at IS NULL
|
|
1622
|
+
`).run(now, requiredText(latestSession, "id"));
|
|
1623
|
+
if (Number(superseded.changes) !== 1) {
|
|
1624
|
+
throw new Error("Expired session supersession was not atomic.");
|
|
1625
|
+
}
|
|
1568
1626
|
this.#database.prepare("UPDATE drones SET last_seen = ? WHERE id = ?").run(now, droneId);
|
|
1569
1627
|
} else {
|
|
1570
1628
|
droneId = input.droneId;
|
|
@@ -1576,8 +1634,6 @@ class SqliteScopedStore implements ScopedStore {
|
|
|
1576
1634
|
droneId, input.cubeId, input.roleId, this.#principal.id, droneLabel, now, now,
|
|
1577
1635
|
);
|
|
1578
1636
|
}
|
|
1579
|
-
this.#database.prepare("DELETE FROM silent_seat_ping_state WHERE drone_id = ?").run(droneId);
|
|
1580
|
-
|
|
1581
1637
|
this.#database.prepare(`
|
|
1582
1638
|
INSERT INTO drone_sessions (
|
|
1583
1639
|
id, client_id, cube_id, drone_id, created_at, expires_at
|
|
@@ -2194,61 +2250,11 @@ class ActivityHub {
|
|
|
2194
2250
|
}
|
|
2195
2251
|
}
|
|
2196
2252
|
|
|
2197
|
-
class
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
readonly capacityGuard: StorageCapacityGuard,
|
|
2203
|
-
) {}
|
|
2204
|
-
|
|
2205
|
-
scan(options: { silentMs?: number; cooldownMs?: number; limit?: number } = {}): ActivityNotificationRecord[] {
|
|
2206
|
-
const now = this.clock();
|
|
2207
|
-
const silentBefore = new Date(now.getTime() - (options.silentMs ?? 600_000)).toISOString();
|
|
2208
|
-
const cooldownBefore = new Date(now.getTime() - (options.cooldownMs ?? 600_000)).toISOString();
|
|
2209
|
-
const limit = Math.max(1, Math.min(Math.trunc(options.limit ?? 25), 25));
|
|
2210
|
-
const candidates = this.database.prepare(`
|
|
2211
|
-
SELECT drone.id AS drone_id, drone.cube_id, COALESCE(state.attempts, 0) AS attempts
|
|
2212
|
-
FROM drones AS drone
|
|
2213
|
-
JOIN clients AS client ON client.id = drone.client_id AND client.revoked_at IS NULL
|
|
2214
|
-
JOIN client_cube_grants AS grant_row ON grant_row.client_id = drone.client_id
|
|
2215
|
-
AND grant_row.cube_id = drone.cube_id AND grant_row.access IN ('write', 'manage')
|
|
2216
|
-
LEFT JOIN silent_seat_ping_state AS state ON state.drone_id = drone.id
|
|
2217
|
-
WHERE drone.evicted_at IS NULL AND COALESCE(drone.last_seen, drone.created_at) < ?
|
|
2218
|
-
AND COALESCE(state.attempts, 0) < 3
|
|
2219
|
-
AND (state.last_ping_at IS NULL OR state.last_ping_at < ?)
|
|
2220
|
-
AND EXISTS (SELECT 1 FROM drone_sessions AS session
|
|
2221
|
-
WHERE session.drone_id = drone.id AND session.revoked_at IS NULL AND session.expires_at > ?)
|
|
2222
|
-
ORDER BY COALESCE(drone.last_seen, drone.created_at), drone.id LIMIT ?
|
|
2223
|
-
`).all(silentBefore, cooldownBefore, now.toISOString(), limit);
|
|
2224
|
-
const published: ActivityNotificationRecord[] = [];
|
|
2225
|
-
for (const candidate of candidates) {
|
|
2226
|
-
const droneId = requiredText(candidate, "drone_id");
|
|
2227
|
-
const cubeId = requiredText(candidate, "cube_id");
|
|
2228
|
-
const message = "[HEARTBEAT-PING] No recent activity; confirm this seat is responsive.";
|
|
2229
|
-
this.capacityGuard.assertCanGrow(256);
|
|
2230
|
-
const id = randomUUID();
|
|
2231
|
-
const createdAt = now.toISOString();
|
|
2232
|
-
this.database.exec("BEGIN IMMEDIATE");
|
|
2233
|
-
try {
|
|
2234
|
-
this.database.prepare(`INSERT INTO silent_seat_ping_state (drone_id, attempts, last_ping_at)
|
|
2235
|
-
VALUES (?, 1, ?) ON CONFLICT(drone_id) DO UPDATE
|
|
2236
|
-
SET attempts = attempts + 1, last_ping_at = excluded.last_ping_at
|
|
2237
|
-
`).run(droneId, now.toISOString());
|
|
2238
|
-
this.database.exec("COMMIT");
|
|
2239
|
-
} catch (error) {
|
|
2240
|
-
try { this.database.exec("ROLLBACK"); } catch { /* Preserve original failure. */ }
|
|
2241
|
-
throw error;
|
|
2242
|
-
}
|
|
2243
|
-
const entry: ActivityNotificationRecord = {
|
|
2244
|
-
kind: "heartbeat_ping",
|
|
2245
|
-
id, cube_id: cubeId, drone_id: null, message, visibility: "direct", created_at: createdAt,
|
|
2246
|
-
drone_label: null, role_name: null, recipient_drone_ids: [droneId],
|
|
2247
|
-
};
|
|
2248
|
-
this.hub.publish(cubeId, entry);
|
|
2249
|
-
published.push(entry);
|
|
2250
|
-
}
|
|
2251
|
-
return published;
|
|
2253
|
+
class IdleLivenessStore implements LivenessStore {
|
|
2254
|
+
scan(): ActivityNotificationRecord[] {
|
|
2255
|
+
// Silence is not a delivery failure. Keep the scheduler's lifecycle seam without
|
|
2256
|
+
// creating a directed event that crosses the client/model wake boundary.
|
|
2257
|
+
return [];
|
|
2252
2258
|
}
|
|
2253
2259
|
}
|
|
2254
2260
|
|
|
@@ -2577,7 +2583,7 @@ class SqliteCredentialStore implements CredentialStore {
|
|
|
2577
2583
|
credential.session_id, session.client_id, session.cube_id, session.drone_id,
|
|
2578
2584
|
session.expires_at,
|
|
2579
2585
|
COALESCE(credential.revoked_at, session.revoked_at, client.revoked_at) AS revoked_at,
|
|
2580
|
-
drone.evicted_at
|
|
2586
|
+
drone.evicted_at, session.superseded_at
|
|
2581
2587
|
FROM drone_session_credentials AS credential
|
|
2582
2588
|
JOIN drone_sessions AS session ON session.id = credential.session_id
|
|
2583
2589
|
JOIN clients AS client ON client.id = session.client_id
|
|
@@ -3055,6 +3061,7 @@ function storedDroneSessionDigest(row: Record<string, unknown>): StoredDroneSess
|
|
|
3055
3061
|
droneId: requiredText(row, "drone_id"),
|
|
3056
3062
|
expiresAt: requiredText(row, "expires_at"),
|
|
3057
3063
|
evictedAt: nullableText(row, "evicted_at"),
|
|
3064
|
+
takenOver: nullableText(row, "superseded_at") !== null,
|
|
3058
3065
|
};
|
|
3059
3066
|
}
|
|
3060
3067
|
|