@byok-sdk/server 0.1.0

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/dist/index.js ADDED
@@ -0,0 +1,2675 @@
1
+ import { randomBytes, randomUUID, timingSafeEqual, createHmac, createHash, createPublicKey, verify } from 'crypto';
2
+ import { jwtVerify, SignJWT } from 'jose';
3
+ import { mkdir, writeFile, readFile } from 'fs/promises';
4
+ import { mkdtempSync, mkdirSync, existsSync, chmodSync } from 'fs';
5
+ import { tmpdir } from 'os';
6
+ import path, { dirname } from 'path';
7
+ import { Hono } from 'hono';
8
+ import { CAPABILITY_FLAGS, canTransition, PROTOCOL_VERSION, encodeEnvelope, DAEMON_TO_SERVER_TYPES, createEnvelope, TASK_STATES, PairRequestSchema, ChallengeRequestSchema, TokenRequestSchema, CreateBlobRequestSchema, MessagesSendRequestSchema, decodeEnvelope } from '@byok-sdk/protocol';
9
+ import { WebSocketServer } from 'ws';
10
+ import { createRequire } from 'module';
11
+
12
+ // src/auth.ts
13
+ var ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
14
+ var NONCE_TTL_MS = 5 * 60 * 1e3;
15
+ var NONCE_SIGNING_DOMAIN = "byok-nonce-v1\n";
16
+ function createHmacTokenSigner(secret = randomBytes(32)) {
17
+ return {
18
+ async sign(claims, expiresInSeconds) {
19
+ return new SignJWT({ deviceId: claims.deviceId, tenantId: claims.tenantId, productId: claims.productId }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(Math.floor(Date.now() / 1e3) + expiresInSeconds).sign(secret);
20
+ },
21
+ async verify(token) {
22
+ try {
23
+ const { payload } = await jwtVerify(token, secret);
24
+ if (typeof payload.deviceId !== "string") return void 0;
25
+ if (typeof payload.tenantId !== "string") return void 0;
26
+ if (typeof payload.productId !== "string") return void 0;
27
+ return { deviceId: payload.deviceId, tenantId: payload.tenantId, productId: payload.productId };
28
+ } catch {
29
+ return void 0;
30
+ }
31
+ }
32
+ };
33
+ }
34
+ async function mintAccessToken(signer, claims) {
35
+ const accessToken = await signer.sign(claims, ACCESS_TOKEN_TTL_SECONDS);
36
+ const expiresAt = new Date(Date.now() + ACCESS_TOKEN_TTL_SECONDS * 1e3).toISOString();
37
+ return { accessToken, expiresAt };
38
+ }
39
+ var DeviceRegistry = class _DeviceRegistry {
40
+ /** Keyed by {@link DeviceRegistry.key} — `(tenantId, deviceId)`. */
41
+ devices = /* @__PURE__ */ new Map();
42
+ /**
43
+ * Secondary index over the SAME record objects, for the two pre-tenant
44
+ * endpoints only (see {@link resolveByDeviceId}). Holding the same object
45
+ * reference means a revocation applied through the composite key is
46
+ * immediately visible here too — there is no second copy to keep in sync.
47
+ */
48
+ byDeviceId = /* @__PURE__ */ new Map();
49
+ static key(tenantId, deviceId) {
50
+ return `${tenantId}\0${deviceId}`;
51
+ }
52
+ /**
53
+ * Write a device row. Every identity field is required by
54
+ * {@link DeviceRegistration}, so a row with no tenant cannot be constructed
55
+ * — which is the whole point of S1.
56
+ */
57
+ register(device) {
58
+ const record = { ...device, revoked: false };
59
+ this.devices.set(_DeviceRegistry.key(record.tenantId, record.deviceId), record);
60
+ this.byDeviceId.set(record.deviceId, record);
61
+ }
62
+ /** The row `tenantId` owns under `deviceId`, or `undefined` — including when the device exists under a DIFFERENT tenant. */
63
+ get(tenantId, deviceId) {
64
+ return this.devices.get(_DeviceRegistry.key(tenantId, deviceId));
65
+ }
66
+ /**
67
+ * Revoke a device (public API via `createByokServer(...).devices.revoke`).
68
+ * Its next `/byok/challenge`, `/byok/token`, WSS connect, or authed HTTP
69
+ * call gets a 401; the daemon's only recourse is to re-run `/byok/pair`
70
+ * (docs/protocol.md §6.3). A tenant can only revoke its own devices: a
71
+ * (tenantId, deviceId) pair it does not own resolves to nothing and this is
72
+ * a no-op.
73
+ */
74
+ revoke(tenantId, deviceId) {
75
+ const record = this.get(tenantId, deviceId);
76
+ if (record) record.revoked = true;
77
+ }
78
+ /** Every known device row, across tenants — the in-process read model behind `ByokServer.machines.list()`. */
79
+ list() {
80
+ return [...this.devices.values()];
81
+ }
82
+ /**
83
+ * Resolve a device by its globally-unique id alone, WITHOUT a tenant in
84
+ * scope. Exists for exactly two callers — `POST /byok/challenge` and
85
+ * `POST /byok/token` — because those two carry no tenant at all: their
86
+ * request DTOs are the pinned wire contract (docs/protocol.md §6.2), the
87
+ * device authenticates by key possession, and the row itself is what tells
88
+ * the server which tenant to mint the next token for. Everything with a
89
+ * token (and therefore a tenant) in scope goes through {@link get}.
90
+ *
91
+ * Deliberately NOT re-exported from this package's entry point (`index.ts`
92
+ * exports no naked-lookup surface at all), so no embedder can turn it into
93
+ * a cross-tenant device oracle: the only reachable public device surface is
94
+ * tenant-first.
95
+ */
96
+ resolveByDeviceId(deviceId) {
97
+ return this.byDeviceId.get(deviceId);
98
+ }
99
+ };
100
+ var NonceStore = class {
101
+ nonces = /* @__PURE__ */ new Map();
102
+ /** Number of nonce records currently held (post-sweep). Exposed for tests only. */
103
+ get size() {
104
+ return this.nonces.size;
105
+ }
106
+ /**
107
+ * Drop every used or expired record. A long-lived server never calls this
108
+ * on a timer, so `issue()` sweeps inline — a full-Map scan is fine at
109
+ * reference-impl scale (single-digit nonces per device, ~5min TTL).
110
+ */
111
+ sweep(now) {
112
+ for (const [nonce, record] of this.nonces) {
113
+ if (record.used || record.expiresAt <= now) {
114
+ this.nonces.delete(nonce);
115
+ }
116
+ }
117
+ }
118
+ issue(deviceId) {
119
+ const now = Date.now();
120
+ this.sweep(now);
121
+ const nonce = randomBytes(24).toString("base64url");
122
+ this.nonces.set(nonce, { deviceId, expiresAt: now + NONCE_TTL_MS, used: false });
123
+ return nonce;
124
+ }
125
+ /** `true` iff `nonce` exists, belongs to `deviceId`, is unexpired, and hasn't been consumed yet. Does not mutate. */
126
+ validate(deviceId, nonce) {
127
+ const record = this.nonces.get(nonce);
128
+ if (!record) return false;
129
+ if (record.used) return false;
130
+ if (record.deviceId !== deviceId) return false;
131
+ if (Date.now() > record.expiresAt) return false;
132
+ return true;
133
+ }
134
+ /** Mark `nonce` consumed so a replay of the same (deviceId, nonce, signature) is rejected. */
135
+ markUsed(nonce) {
136
+ const record = this.nonces.get(nonce);
137
+ if (record) record.used = true;
138
+ }
139
+ };
140
+ function verifyEd25519Signature(devicePublicKey, message, signature) {
141
+ try {
142
+ const keyObject = createPublicKey({
143
+ key: { kty: "OKP", crv: "Ed25519", x: devicePublicKey },
144
+ format: "jwk"
145
+ });
146
+ return verify(null, Buffer.from(message, "utf8"), keyObject, Buffer.from(signature, "base64url"));
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+ function verifyNonceSignature(devicePublicKey, nonce, signature) {
152
+ return verifyEd25519Signature(devicePublicKey, NONCE_SIGNING_DOMAIN + nonce, signature);
153
+ }
154
+ function extractBearerToken(header) {
155
+ if (!header) return void 0;
156
+ const match = /^Bearer\s+(.+)$/i.exec(header);
157
+ return match?.[1];
158
+ }
159
+ async function authenticateBearer(header, deps) {
160
+ const token = extractBearerToken(header);
161
+ if (!token) return void 0;
162
+ const claims = await deps.tokenSigner.verify(token);
163
+ if (!claims) return void 0;
164
+ const device = deps.devices.get(claims.tenantId, claims.deviceId);
165
+ if (!device || device.revoked) return void 0;
166
+ if (device.productId !== claims.productId) return void 0;
167
+ return { deviceId: device.deviceId, tenantId: device.tenantId, productId: device.productId };
168
+ }
169
+ var BlobDeclarationConflictError = class extends Error {
170
+ constructor(blobId) {
171
+ super(`Blob ${blobId} already binds a different declaration.`);
172
+ this.name = "BlobDeclarationConflictError";
173
+ }
174
+ };
175
+ var DEFAULT_URL_TTL_MS = 15 * 60 * 1e3;
176
+ function sha256Hex(data) {
177
+ return `sha256:${createHash("sha256").update(data).digest("hex")}`;
178
+ }
179
+ var LocalDiskBlobStore = class {
180
+ secret = randomBytes(32);
181
+ directory;
182
+ urlTtlMs;
183
+ blobs = /* @__PURE__ */ new Map();
184
+ ready;
185
+ constructor(opts = {}) {
186
+ this.directory = opts.directory ?? mkdtempSync(path.join(tmpdir(), "byok-blobs-"));
187
+ this.urlTtlMs = opts.urlTtlMs ?? DEFAULT_URL_TTL_MS;
188
+ this.ready = mkdir(this.directory, { recursive: true }).then(() => void 0);
189
+ }
190
+ async createUpload(input, requestedBlobId) {
191
+ await this.ready;
192
+ const blobId = requestedBlobId ?? `blob_${randomUUID()}`;
193
+ const existing = this.blobs.get(blobId);
194
+ if (existing !== void 0) {
195
+ if (existing.meta.size !== input.size || existing.meta.contentType !== input.contentType || existing.meta.contentHash !== input.contentHash) {
196
+ throw new BlobDeclarationConflictError(blobId);
197
+ }
198
+ return { blobId, uploadUrl: this.signUrl(blobId, "put") };
199
+ }
200
+ this.blobs.set(blobId, { meta: input, uploaded: false });
201
+ return { blobId, uploadUrl: this.signUrl(blobId, "put") };
202
+ }
203
+ async getDownloadUrl(blobId) {
204
+ const record = this.blobs.get(blobId);
205
+ if (!record?.uploaded) return void 0;
206
+ return this.signUrl(blobId, "get");
207
+ }
208
+ async exists(blobId) {
209
+ return this.blobs.get(blobId)?.uploaded ?? false;
210
+ }
211
+ verifySignedUrl(blobId, action, sig, exp) {
212
+ if (!Number.isFinite(exp) || Date.now() > exp) return false;
213
+ const expected = this.computeSig(blobId, action, exp);
214
+ const expectedBuf = Buffer.from(expected, "base64url");
215
+ const actualBuf = Buffer.from(sig, "base64url");
216
+ if (expectedBuf.length !== actualBuf.length) return false;
217
+ return timingSafeEqual(expectedBuf, actualBuf);
218
+ }
219
+ async writeContent(blobId, data) {
220
+ await this.ready;
221
+ const record = this.blobs.get(blobId);
222
+ if (!record) return { ok: false, reason: "unknown blobId" };
223
+ if (record.uploaded) return { ok: false, reason: "blob already uploaded" };
224
+ if (data.length !== record.meta.size) {
225
+ return { ok: false, reason: `size mismatch: declared ${record.meta.size}, received ${data.length}` };
226
+ }
227
+ const actualHash = sha256Hex(data);
228
+ if (actualHash !== record.meta.contentHash) {
229
+ return { ok: false, reason: "contentHash mismatch" };
230
+ }
231
+ await writeFile(this.pathFor(blobId), data);
232
+ record.uploaded = true;
233
+ return { ok: true };
234
+ }
235
+ async readContent(blobId) {
236
+ const record = this.blobs.get(blobId);
237
+ if (!record?.uploaded) return void 0;
238
+ const data = await readFile(this.pathFor(blobId));
239
+ return { data, contentType: record.meta.contentType };
240
+ }
241
+ pathFor(blobId) {
242
+ return path.join(this.directory, blobId);
243
+ }
244
+ computeSig(blobId, action, exp) {
245
+ return createHmac("sha256", this.secret).update(`${blobId}:${action}:${exp}`).digest("base64url");
246
+ }
247
+ signUrl(blobId, action) {
248
+ const exp = Date.now() + this.urlTtlMs;
249
+ const sig = this.computeSig(blobId, action, exp);
250
+ return `/byok/blobs/${blobId}/content?sig=${sig}&exp=${exp}`;
251
+ }
252
+ };
253
+ var PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
254
+ function generatePairingCode(length = 8) {
255
+ const bytes = randomBytes(length);
256
+ let out = "";
257
+ for (let i = 0; i < length; i++) {
258
+ out += PAIRING_CODE_ALPHABET[bytes[i] % PAIRING_CODE_ALPHABET.length];
259
+ }
260
+ return out;
261
+ }
262
+ function generateDeviceId() {
263
+ return `dev_${randomUUID()}`;
264
+ }
265
+ function generateTaskId() {
266
+ return `task_${randomUUID()}`;
267
+ }
268
+
269
+ // src/pairing.ts
270
+ var PAIRING_CODE_TTL_MS = 10 * 60 * 1e3;
271
+ var PairingCodeInvalidError = class extends Error {
272
+ constructor(reason) {
273
+ super(`invalid pairing code: ${reason}`);
274
+ this.name = "PairingCodeInvalidError";
275
+ }
276
+ };
277
+ var PairingManager = class {
278
+ codes = /* @__PURE__ */ new Map();
279
+ /**
280
+ * Mint a single-use code bound to `claims`. Claims are REQUIRED — a
281
+ * claimless mint is a compile error, and (for a JS caller, or a claims
282
+ * object assembled from untyped config) a runtime {@link TypeError}. There
283
+ * is no default tenant and no default product: a device with no tenant
284
+ * must be inexpressible, so the failure happens here, at the mint, rather
285
+ * than being filled in downstream.
286
+ */
287
+ createPairingCode(claims) {
288
+ const validated = validatePairingCodeClaims(claims);
289
+ const code = generatePairingCode();
290
+ const expiresAt = Date.now() + PAIRING_CODE_TTL_MS;
291
+ this.codes.set(code, { code, claims: validated, expiresAt, used: false });
292
+ return { code, expiresAt: new Date(expiresAt).toISOString() };
293
+ }
294
+ /**
295
+ * Validate and consume a pairing code, returning the {@link PairingCodeClaims}
296
+ * it was minted with. Throws {@link PairingCodeInvalidError} if the code is
297
+ * unknown, expired, or already used — callers (the HTTP handler) map that to
298
+ * a 401. Single-use is what makes the caller's "redeem, then register the
299
+ * device row with these claims" sequence safe: a second redeem of the same
300
+ * code can never reach the registration step at all.
301
+ */
302
+ redeemPairingCode(code) {
303
+ const record = this.codes.get(code);
304
+ if (!record) {
305
+ throw new PairingCodeInvalidError("unknown code");
306
+ }
307
+ if (record.used) {
308
+ throw new PairingCodeInvalidError("code already used");
309
+ }
310
+ if (Date.now() > record.expiresAt) {
311
+ throw new PairingCodeInvalidError("code expired");
312
+ }
313
+ record.used = true;
314
+ return record.claims;
315
+ }
316
+ };
317
+ function validatePairingCodeClaims(claims) {
318
+ if (typeof claims !== "object" || claims === null) {
319
+ throw new TypeError("createPairingCode requires { tenantId, productId } claims");
320
+ }
321
+ const { tenantId, productId } = claims;
322
+ if (typeof tenantId !== "string" || tenantId.length === 0) {
323
+ throw new TypeError("createPairingCode requires a non-empty tenantId");
324
+ }
325
+ if (typeof productId !== "string" || productId.length === 0) {
326
+ throw new TypeError("createPairingCode requires a non-empty productId");
327
+ }
328
+ return { tenantId, productId };
329
+ }
330
+
331
+ // src/http.ts
332
+ async function readJsonBody(c) {
333
+ try {
334
+ return await c.req.json();
335
+ } catch {
336
+ return void 0;
337
+ }
338
+ }
339
+ function buildHonoApp(deps) {
340
+ const app = new Hono();
341
+ const serverStartedAtMs = Date.now();
342
+ if (deps.healthzRoute) {
343
+ app.get("/healthz", (c) => c.json({ ok: true, uptimeMs: Date.now() - serverStartedAtMs }, 200));
344
+ }
345
+ app.post("/byok/pair", async (c) => {
346
+ const parsed = PairRequestSchema.safeParse(await readJsonBody(c));
347
+ if (!parsed.success) {
348
+ return c.json({ error: "pairingCode, deviceName, and devicePublicKey are required strings" }, 400);
349
+ }
350
+ const { pairingCode, deviceName, devicePublicKey } = parsed.data;
351
+ let claims;
352
+ try {
353
+ claims = deps.pairing.redeemPairingCode(pairingCode);
354
+ } catch (err) {
355
+ if (err instanceof PairingCodeInvalidError) {
356
+ return c.json({ error: err.message }, 401);
357
+ }
358
+ throw err;
359
+ }
360
+ const deviceId = generateDeviceId();
361
+ deps.devices.register({
362
+ tenantId: claims.tenantId,
363
+ productId: claims.productId,
364
+ deviceId,
365
+ deviceName,
366
+ devicePublicKey
367
+ });
368
+ const { accessToken, expiresAt } = await mintAccessToken(deps.tokenSigner, {
369
+ deviceId,
370
+ tenantId: claims.tenantId,
371
+ productId: claims.productId
372
+ });
373
+ const response = { deviceId, accessToken, refreshHint: expiresAt };
374
+ return c.json(response, 200);
375
+ });
376
+ app.post("/byok/challenge", async (c) => {
377
+ const parsed = ChallengeRequestSchema.safeParse(await readJsonBody(c));
378
+ if (!parsed.success) return c.json({ error: "deviceId is required" }, 400);
379
+ const { deviceId } = parsed.data;
380
+ const device = deps.devices.resolveByDeviceId(deviceId);
381
+ if (!device || device.revoked) {
382
+ return c.json({ error: "unknown or revoked device" }, 401);
383
+ }
384
+ const nonce = deps.nonces.issue(deviceId);
385
+ const response = { nonce };
386
+ return c.json(response, 200);
387
+ });
388
+ app.post("/byok/token", async (c) => {
389
+ const parsed = TokenRequestSchema.safeParse(await readJsonBody(c));
390
+ if (!parsed.success) return c.json({ error: "deviceId, nonce, and signature are required" }, 400);
391
+ const { deviceId, nonce, signature } = parsed.data;
392
+ const device = deps.devices.resolveByDeviceId(deviceId);
393
+ if (!device || device.revoked) {
394
+ return c.json({ error: "unknown or revoked device" }, 401);
395
+ }
396
+ if (!deps.nonces.validate(deviceId, nonce)) {
397
+ return c.json({ error: "invalid, expired, or already-used nonce" }, 401);
398
+ }
399
+ if (!verifyNonceSignature(device.devicePublicKey, nonce, signature)) {
400
+ return c.json({ error: "invalid signature" }, 401);
401
+ }
402
+ deps.nonces.markUsed(nonce);
403
+ const { accessToken, expiresAt } = await mintAccessToken(deps.tokenSigner, {
404
+ deviceId: device.deviceId,
405
+ tenantId: device.tenantId,
406
+ productId: device.productId
407
+ });
408
+ const response = { accessToken, expiresAt };
409
+ return c.json(response, 200);
410
+ });
411
+ app.post("/byok/blobs", async (c) => {
412
+ const principal = await authenticateBearer(c.req.header("authorization"), deps);
413
+ if (!principal) return c.json({ error: "unauthorized" }, 401);
414
+ const parsed = CreateBlobRequestSchema.safeParse(await readJsonBody(c));
415
+ if (!parsed.success) return c.json({ error: "size, contentType, and contentHash are required" }, 400);
416
+ if (parsed.data.size > deps.maxBlobSizeBytes) {
417
+ return c.json({ error: `blob exceeds max size of ${deps.maxBlobSizeBytes} bytes` }, 413);
418
+ }
419
+ const reservationId = c.req.header("idempotency-key");
420
+ if (!reservationId || reservationId.length > 200) {
421
+ return c.json({ error: "Idempotency-Key header is required" }, 400);
422
+ }
423
+ const blobId = reservationBlobId(principal.tenantId, reservationId);
424
+ try {
425
+ const created = await deps.blobStore.createUpload(parsed.data, blobId);
426
+ const response = created;
427
+ return c.json(response, 200);
428
+ } catch (error) {
429
+ if (error instanceof BlobDeclarationConflictError) {
430
+ return c.json({ error: "storage_integrity_mismatch" }, 422);
431
+ }
432
+ throw error;
433
+ }
434
+ });
435
+ app.post("/byok/blobs/:id/finalize", async (c) => {
436
+ const principal = await authenticateBearer(c.req.header("authorization"), deps);
437
+ if (!principal) return c.json({ error: "unauthorized" }, 401);
438
+ const reservationId = c.req.header("idempotency-key");
439
+ if (!reservationId || reservationId.length > 200) {
440
+ return c.json({ error: "Idempotency-Key header is required" }, 400);
441
+ }
442
+ const blobId = c.req.param("id");
443
+ if (reservationBlobId(principal.tenantId, reservationId) !== blobId) {
444
+ return c.json({ error: "storage_integrity_mismatch" }, 422);
445
+ }
446
+ if (!await deps.blobStore.exists(blobId)) {
447
+ return c.json({ error: "storage_reservation_not_found" }, 404);
448
+ }
449
+ return c.body(null, 204);
450
+ });
451
+ app.get("/byok/blobs/:id/url", async (c) => {
452
+ const principal = await authenticateBearer(c.req.header("authorization"), deps);
453
+ if (!principal) return c.json({ error: "unauthorized" }, 401);
454
+ const downloadUrl = await deps.blobStore.getDownloadUrl(c.req.param("id"));
455
+ if (!downloadUrl) return c.json({ error: "blob not found" }, 404);
456
+ const response = { downloadUrl };
457
+ return c.json(response, 200);
458
+ });
459
+ app.put("/byok/blobs/:id/content", async (c) => {
460
+ const blobId = c.req.param("id");
461
+ const { sig, exp } = signedUrlParams(c.req.query("sig"), c.req.query("exp"));
462
+ if (!sig || exp === void 0 || !deps.blobStore.verifySignedUrl(blobId, "put", sig, exp)) {
463
+ return c.json({ error: "invalid or expired signature" }, 401);
464
+ }
465
+ const data = Buffer.from(await c.req.arrayBuffer());
466
+ const result = await deps.blobStore.writeContent(blobId, data);
467
+ if (!result.ok) return c.json({ error: result.reason }, 422);
468
+ return c.body(null, 204);
469
+ });
470
+ app.get("/byok/blobs/:id/content", async (c) => {
471
+ const blobId = c.req.param("id");
472
+ const { sig, exp } = signedUrlParams(c.req.query("sig"), c.req.query("exp"));
473
+ if (!sig || exp === void 0 || !deps.blobStore.verifySignedUrl(blobId, "get", sig, exp)) {
474
+ return c.json({ error: "invalid or expired signature" }, 401);
475
+ }
476
+ const content = await deps.blobStore.readContent(blobId);
477
+ if (!content) return c.json({ error: "blob not found" }, 404);
478
+ return c.body(new Uint8Array(content.data), 200, { "content-type": content.contentType });
479
+ });
480
+ app.get("/byok/events", async (c) => {
481
+ const principal = await authenticateBearer(c.req.header("authorization"), deps);
482
+ if (!principal) return c.json({ error: "unauthorized" }, 401);
483
+ const cursorRaw = c.req.query("cursor");
484
+ let cursor = 0;
485
+ if (cursorRaw !== void 0) {
486
+ const parsedCursor = Number(cursorRaw);
487
+ if (!Number.isInteger(parsedCursor) || parsedCursor < 0) return c.json({ error: "invalid cursor" }, 400);
488
+ cursor = parsedCursor;
489
+ }
490
+ const result = await deps.hub.pollEvents(principal.deviceId, cursor, deps.longPollHoldMs);
491
+ const response = result;
492
+ return c.json(response, 200);
493
+ });
494
+ app.post("/byok/messages", async (c) => {
495
+ const principal = await authenticateBearer(c.req.header("authorization"), deps);
496
+ if (!principal) return c.json({ error: "unauthorized" }, 401);
497
+ const parsed = MessagesSendRequestSchema.safeParse(await readJsonBody(c));
498
+ if (!parsed.success) return c.json({ error: "messages must be an array of envelopes" }, 400);
499
+ let accepted = 0;
500
+ let rejected = 0;
501
+ for (const envelope of parsed.data.messages) {
502
+ const result = deps.hub.handleInbound(principal.deviceId, envelope);
503
+ if (result === "rate_limited") {
504
+ return c.json({ error: "rate limit exceeded" }, 429);
505
+ }
506
+ if (result === "rejected") rejected++;
507
+ else accepted++;
508
+ }
509
+ const response = rejected > 0 ? { accepted, rejected } : { accepted };
510
+ return c.json(response, 200);
511
+ });
512
+ return app;
513
+ }
514
+ function reservationBlobId(tenantId, reservationId) {
515
+ return `blob_${createHash("sha256").update(`${tenantId}\0${reservationId}`).digest("hex")}`;
516
+ }
517
+ function signedUrlParams(sig, expRaw) {
518
+ if (!sig || expRaw === void 0) return {};
519
+ const exp = Number(expRaw);
520
+ if (!Number.isFinite(exp)) return {};
521
+ return { sig, exp };
522
+ }
523
+
524
+ // src/event-queue.ts
525
+ var AsyncEventQueue = class {
526
+ buffer = [];
527
+ closed = false;
528
+ waiters = [];
529
+ push(value) {
530
+ if (this.closed) return;
531
+ this.buffer.push(value);
532
+ this.wake();
533
+ }
534
+ close() {
535
+ if (this.closed) return;
536
+ this.closed = true;
537
+ this.wake();
538
+ }
539
+ wake() {
540
+ const waiters = this.waiters;
541
+ this.waiters = [];
542
+ for (const resolve of waiters) resolve();
543
+ }
544
+ waitForMore() {
545
+ return new Promise((resolve) => this.waiters.push(resolve));
546
+ }
547
+ /** Async-iterate the buffer from index 0, waiting for new pushes until closed. */
548
+ subscribe() {
549
+ const queue = this;
550
+ return {
551
+ [Symbol.asyncIterator]() {
552
+ let index = 0;
553
+ return {
554
+ async next() {
555
+ for (; ; ) {
556
+ if (index < queue.buffer.length) {
557
+ return { value: queue.buffer[index++], done: false };
558
+ }
559
+ if (queue.closed) {
560
+ return { value: void 0, done: true };
561
+ }
562
+ await queue.waitForMore();
563
+ }
564
+ }
565
+ };
566
+ }
567
+ };
568
+ }
569
+ };
570
+
571
+ // src/rate-limiter.ts
572
+ var DEFAULT_MESSAGES_PER_SECOND = 50;
573
+ var DEFAULT_BURST = 100;
574
+ var DEFAULT_MAX_TRACKED_DEVICES = 1e4;
575
+ var EVICTION_SWEEP_EVERY_N_CALLS = 1e3;
576
+ var RateLimiter = class {
577
+ messagesPerSecond;
578
+ burst;
579
+ buckets = /* @__PURE__ */ new Map();
580
+ /**
581
+ * Wall-clock idle duration (ms) after which a bucket is GUARANTEED to
582
+ * already be refilled to `burst`, regardless of its actual token count at
583
+ * last touch — i.e. the time to go from 0 tokens to `burst` at this
584
+ * instance's configured rate. `evictIdleBucketsIfDue` uses this as the
585
+ * eviction threshold: dropping an entry idle at least this long and
586
+ * recreating it fresh (tokens = burst) on the next `consume()` is
587
+ * therefore behaviorally IDENTICAL to refilling it in place would have
588
+ * been — both cap at `burst` — so eviction is semantically invisible to
589
+ * the caller.
590
+ */
591
+ idleEvictionThresholdMs;
592
+ /** Finding R5: hard cap on `buckets.size` — see {@link DEFAULT_MAX_TRACKED_DEVICES}'s own doc comment. */
593
+ maxTrackedDevices;
594
+ /** Calls to `consume()` since the last sweep — see `EVICTION_SWEEP_EVERY_N_CALLS`. */
595
+ callsSinceSweep = 0;
596
+ constructor(opts = {}) {
597
+ const messagesPerSecond = opts.messagesPerSecond ?? DEFAULT_MESSAGES_PER_SECOND;
598
+ const burst = opts.burst ?? DEFAULT_BURST;
599
+ const maxTrackedDevices = opts.maxTrackedDevices ?? DEFAULT_MAX_TRACKED_DEVICES;
600
+ if (!Number.isFinite(messagesPerSecond) || messagesPerSecond <= 0) {
601
+ throw new TypeError(`RateLimiter: messagesPerSecond must be a finite number > 0, got ${messagesPerSecond}`);
602
+ }
603
+ if (!Number.isFinite(burst) || burst < 1) {
604
+ throw new TypeError(`RateLimiter: burst must be a finite number >= 1, got ${burst}`);
605
+ }
606
+ if (!Number.isFinite(maxTrackedDevices) || maxTrackedDevices < 1) {
607
+ throw new TypeError(`RateLimiter: maxTrackedDevices must be a finite number >= 1, got ${maxTrackedDevices}`);
608
+ }
609
+ this.messagesPerSecond = messagesPerSecond;
610
+ this.burst = burst;
611
+ this.maxTrackedDevices = maxTrackedDevices;
612
+ this.idleEvictionThresholdMs = burst / messagesPerSecond * 1e3;
613
+ }
614
+ /**
615
+ * Debit one token from `key`'s bucket, refilling first for however much
616
+ * wall-clock time has elapsed since its last refill. Returns `false`
617
+ * (and debits nothing) when the bucket is currently empty — the caller is
618
+ * over budget right now.
619
+ */
620
+ consume(key) {
621
+ const now = Date.now();
622
+ this.evictIdleBucketsIfDue(now);
623
+ let bucket = this.buckets.get(key);
624
+ if (!bucket) {
625
+ this.evictOldestIfAtCapacity();
626
+ bucket = { tokens: this.burst, lastRefillMs: now };
627
+ this.buckets.set(key, bucket);
628
+ } else {
629
+ const elapsedMs = now - bucket.lastRefillMs;
630
+ if (elapsedMs > 0) {
631
+ bucket.tokens = Math.min(this.burst, bucket.tokens + elapsedMs / 1e3 * this.messagesPerSecond);
632
+ bucket.lastRefillMs = now;
633
+ }
634
+ }
635
+ if (bucket.tokens < 1) return false;
636
+ bucket.tokens -= 1;
637
+ return true;
638
+ }
639
+ /**
640
+ * Every `EVICTION_SWEEP_EVERY_N_CALLS` calls to `consume()`, drops every
641
+ * bucket idle for at least `idleEvictionThresholdMs` (see that field's doc
642
+ * comment for why this is safe). Without this, `buckets` would hold one
643
+ * permanent entry per historical key forever — every device that ever
644
+ * connected, even long after it disconnected for good — growing without
645
+ * bound over a long-lived server's lifetime.
646
+ */
647
+ evictIdleBucketsIfDue(now) {
648
+ this.callsSinceSweep++;
649
+ if (this.callsSinceSweep < EVICTION_SWEEP_EVERY_N_CALLS) return;
650
+ this.callsSinceSweep = 0;
651
+ for (const [key, bucket] of this.buckets) {
652
+ if (now - bucket.lastRefillMs >= this.idleEvictionThresholdMs) {
653
+ this.buckets.delete(key);
654
+ }
655
+ }
656
+ }
657
+ /**
658
+ * Finding R5 (cross-model re-review — F10 residual): called right before
659
+ * inserting a bucket for a genuinely NEW key, evicting the single
660
+ * LEAST-RECENTLY-refilled entry if `buckets` is already at
661
+ * `maxTrackedDevices` — an O(n) scan, but one that only ever runs once
662
+ * the map is already at its hard ceiling (a rare/bounded event under
663
+ * ordinary operation, not a per-call cost), mirroring this codebase's own
664
+ * established "acceptable O(n) for a rare/bounded case" precedent (e.g.
665
+ * `audit-log.ts`'s `compactPreservingLiveTasks` during rotation).
666
+ *
667
+ * Equivalence split (stated explicitly, not left implied):
668
+ * - For any evicted bucket that was ALREADY idle for at least
669
+ * `idleEvictionThresholdMs` (i.e. `evictIdleBucketsIfDue` would have
670
+ * reclaimed it anyway, just not yet — sweeps only run every
671
+ * `EVICTION_SWEEP_EVERY_N_CALLS` calls, not continuously), eviction is
672
+ * PROVABLY equivalent to an in-place refill: both cap at `burst`, so a
673
+ * caller can never observe the difference (see `idleEvictionThresholdMs`'s
674
+ * own doc comment for the identical reasoning `evictIdleBucketsIfDue`
675
+ * already relies on).
676
+ * - For a bucket evicted EARLY — still within its idle threshold, forced
677
+ * out only because `buckets` is at capacity (many thousands of
678
+ * genuinely-distinct, actively-used keys, not a quiet one) — this is
679
+ * BEST-EFFORT, not equivalence-preserving: whatever partial token debt
680
+ * that key had is discarded, and its very next `consume()` call starts
681
+ * completely fresh (`tokens: this.burst`), a strictly MORE permissive
682
+ * outcome than if it had kept its place. This is an accepted,
683
+ * deliberately bounded trade-off — it only ever engages under
684
+ * cardinality far beyond any plausible real deployment — favoring
685
+ * bounded memory over perfect per-key continuity in that one extreme
686
+ * case.
687
+ */
688
+ evictOldestIfAtCapacity() {
689
+ if (this.buckets.size < this.maxTrackedDevices) return;
690
+ let oldestKey;
691
+ let oldestLastRefillMs = Infinity;
692
+ for (const [key, bucket] of this.buckets) {
693
+ if (bucket.lastRefillMs < oldestLastRefillMs) {
694
+ oldestLastRefillMs = bucket.lastRefillMs;
695
+ oldestKey = key;
696
+ }
697
+ }
698
+ if (oldestKey !== void 0) this.buckets.delete(oldestKey);
699
+ }
700
+ };
701
+
702
+ // src/hub.ts
703
+ var DEFAULT_POLICY = { mode: "confirm" };
704
+ var OUTBOX_RING_CAPACITY = 500;
705
+ var DEDUP_RING_CAPACITY = 1024;
706
+ var MAX_LEASE_REAPER_SWEEP_INTERVAL_MS = 3e4;
707
+ function isTerminal(state) {
708
+ return state === "Complete" || state === "Failed" || state === "Cancelled";
709
+ }
710
+ function isClaimedState(state) {
711
+ return state === "Claimed" || state === "Running" || state === "AwaitApproval";
712
+ }
713
+ var UnknownTaskError = class extends Error {
714
+ constructor(taskId) {
715
+ super(`unknown taskId: ${taskId}`);
716
+ this.taskId = taskId;
717
+ this.name = "UnknownTaskError";
718
+ }
719
+ taskId;
720
+ };
721
+ var TaskNotAwaitingApprovalError = class extends Error {
722
+ constructor(taskId, state, verb) {
723
+ super(`cannot ${verb} task ${taskId}: not awaiting approval (state ${state})`);
724
+ this.taskId = taskId;
725
+ this.state = state;
726
+ this.name = "TaskNotAwaitingApprovalError";
727
+ }
728
+ taskId;
729
+ state;
730
+ };
731
+ var StaleApprovalError = class extends Error {
732
+ constructor(taskId, requestedApprovalId, currentApprovalId) {
733
+ super(
734
+ `cannot resolve approval ${requestedApprovalId} for task ${taskId}: the currently pending approval is ${currentApprovalId ?? "(none recorded)"}`
735
+ );
736
+ this.taskId = taskId;
737
+ this.requestedApprovalId = requestedApprovalId;
738
+ this.currentApprovalId = currentApprovalId;
739
+ this.name = "StaleApprovalError";
740
+ }
741
+ taskId;
742
+ requestedApprovalId;
743
+ currentApprovalId;
744
+ };
745
+ function steerRejectionMessage(taskId, code, state, runtime) {
746
+ switch (code) {
747
+ case "task_terminal":
748
+ return `cannot steer task ${taskId}: task is already terminal (state ${state})`;
749
+ case "task_not_running":
750
+ return `cannot steer task ${taskId}: not running (state ${state})`;
751
+ case "steer_unsupported_runtime":
752
+ return `cannot steer task ${taskId}: claimed runtime ${runtime ?? "(unknown)"} does not support steering`;
753
+ }
754
+ }
755
+ var SteerRejectedError = class extends Error {
756
+ constructor(taskId, code, state, runtime) {
757
+ super(steerRejectionMessage(taskId, code, state, runtime));
758
+ this.taskId = taskId;
759
+ this.code = code;
760
+ this.state = state;
761
+ this.runtime = runtime;
762
+ this.name = "SteerRejectedError";
763
+ }
764
+ taskId;
765
+ code;
766
+ state;
767
+ runtime;
768
+ };
769
+ var ConnectionHub = class {
770
+ constructor(taskStore, devices, taskLeaseMs, rateLimiter = new RateLimiter()) {
771
+ this.taskStore = taskStore;
772
+ this.devices = devices;
773
+ this.taskLeaseMs = taskLeaseMs;
774
+ this.rateLimiter = rateLimiter;
775
+ const sweepIntervalMs = Math.min(Math.max(taskLeaseMs, 10), MAX_LEASE_REAPER_SWEEP_INTERVAL_MS);
776
+ this.leaseReaperTimer = setInterval(() => this.sweepLeases(), sweepIntervalMs);
777
+ this.leaseReaperTimer.unref?.();
778
+ }
779
+ taskStore;
780
+ devices;
781
+ taskLeaseMs;
782
+ rateLimiter;
783
+ connections = /* @__PURE__ */ new Map();
784
+ outboxes = /* @__PURE__ */ new Map();
785
+ /** Idempotency window per device (N3) — recent inbound envelope ids, capped at {@link DEDUP_RING_CAPACITY}. */
786
+ dedupRings = /* @__PURE__ */ new Map();
787
+ longPollWaiters = /* @__PURE__ */ new Map();
788
+ runtimes = /* @__PURE__ */ new Map();
789
+ serverEvents = new AsyncEventQueue();
790
+ /**
791
+ * Per-task last-inbound-activity timestamp (epoch ms) — the task-lease
792
+ * reaper's condition (c), see the "task-lease reaper" section below. Reset
793
+ * on every accepted inbound `task.*` envelope ({@link recordTaskActivity},
794
+ * called from {@link dispatchToHandler}); cleared once the task reaches a
795
+ * terminal state ({@link onStateChange}), so this map only ever holds
796
+ * entries for currently non-terminal claimed tasks.
797
+ */
798
+ taskActivity = /* @__PURE__ */ new Map();
799
+ /** The task-lease reaper's own periodic sweep timer — see the constructor and `sweepLeases` below. */
800
+ leaseReaperTimer;
801
+ /** {@link ConnectionHub.stats}'s `uptimeMs` origin — this hub's own construction instant. */
802
+ startedAtMs = Date.now();
803
+ /** {@link ConnectionHub.stats}'s `envelopesIn` — every {@link handleInbound} call, every outcome. */
804
+ envelopesInCount = 0;
805
+ /** {@link ConnectionHub.stats}'s `envelopesOut` — every envelope built via the single outbound choke point, {@link sendToDevice}. */
806
+ envelopesOutCount = 0;
807
+ /** {@link ConnectionHub.stats}'s `dedupDrops` (N3). */
808
+ dedupDropCount = 0;
809
+ /** {@link ConnectionHub.stats}'s `rateLimitEvents` — see {@link handleRateLimited}. */
810
+ rateLimitEventCount = 0;
811
+ /**
812
+ * M4 Phase 4 (gatekeeper LOW advisory): devices that have already had a
813
+ * `device.rate_limited` embedder event emitted for their CURRENT
814
+ * over-budget episode — see {@link handleRateLimited}'s own doc comment.
815
+ * Coalescing state only; {@link rateLimitEventCount} still counts every
816
+ * single hit regardless of what this suppresses.
817
+ */
818
+ rateLimitEventEmittedFor = /* @__PURE__ */ new Set();
819
+ /**
820
+ * Stop the task-lease reaper's sweep timer — called by `ByokServer.stop()`
821
+ * (`index.ts`) on shutdown. Idempotent: clearing an already-cleared
822
+ * interval is a safe no-op.
823
+ */
824
+ stopLeaseReaper() {
825
+ clearInterval(this.leaseReaperTimer);
826
+ }
827
+ /** The top-level `events` feed returned by `createByokServer` — see {@link ByokServerEvent}. */
828
+ subscribeServerEvents() {
829
+ return this.serverEvents.subscribe();
830
+ }
831
+ // ---------------------------------------------------------------------
832
+ // connection lifecycle — called from ws-server.ts / http.ts
833
+ // ---------------------------------------------------------------------
834
+ /**
835
+ * A daemon completed the WS handshake (`conn.hello`). Does not itself send
836
+ * `conn.ack` or redeliver — see {@link sendConnAck}/{@link redeliverAfterReconnect}.
837
+ *
838
+ * `capabilities` (M5, hello-capability plumbing): the daemon's own
839
+ * `conn.hello.capabilities` — previously silently ignored end to end (a
840
+ * verified gap: `ws-server.ts` forwarded only `runtimes`). Optional so
841
+ * every pre-M5 direct-construction call site (several tests construct a
842
+ * `ConnectionHub` and call this directly) keeps working unchanged; a
843
+ * connection this hub never learns capabilities for simply reads back
844
+ * `undefined` from {@link getDeviceCapabilities}.
845
+ */
846
+ registerConnection(deviceId, ws, runtimes, capabilities) {
847
+ const at = (/* @__PURE__ */ new Date()).toISOString();
848
+ this.connections.set(deviceId, { ws, connected: true, lastSeen: at, runtimes, capabilities });
849
+ this.serverEvents.push({ kind: "device.connected", deviceId, at });
850
+ this.settleLongPollWaiter(deviceId);
851
+ }
852
+ sendConnAck(deviceId, capabilities) {
853
+ this.sendToDevice(
854
+ deviceId,
855
+ "conn.ack",
856
+ {
857
+ protocolVersion: PROTOCOL_VERSION,
858
+ capabilities,
859
+ serverTime: (/* @__PURE__ */ new Date()).toISOString()
860
+ },
861
+ {}
862
+ // conn.ack needs neither taskId nor sessionRef
863
+ );
864
+ }
865
+ /**
866
+ * Reconnection procedure step 3 (§9): redeliver, in `seq` order, every
867
+ * retained envelope with `seq > cursor` that still belongs to a
868
+ * non-terminal task. Called after `conn.ack` (step 2), per the spec.
869
+ */
870
+ redeliverAfterReconnect(deviceId, cursor) {
871
+ const conn = this.connections.get(deviceId);
872
+ if (!conn?.ws || !conn.connected) return;
873
+ for (const envelope of this.collectRelevant(deviceId, cursor)) {
874
+ conn.ws.send(encodeEnvelope(envelope));
875
+ }
876
+ }
877
+ /**
878
+ * A device's WS socket closed. `ws` identifies *which* socket closed: if
879
+ * it's no longer the one this device's connection state points at (a
880
+ * newer WS reconnected, or long-poll took over — "last transport wins"),
881
+ * this close is for a stale/superseded socket and the device isn't
882
+ * actually gone, so the bookkeeping below is skipped entirely.
883
+ *
884
+ * M1 note: the M0 server force-failed/cancelled every in-flight task for a
885
+ * device the instant it disconnected, on the stated premise that "a task
886
+ * still in flight for a device that just disconnected can't be resumed, so
887
+ * it's terminated" — true only in the absence of a redelivery cursor. M1
888
+ * adds exactly that (§9): a task's in-flight state is retained
889
+ * independently of any one connection, specifically so it can survive a
890
+ * disconnect and resume via redelivery once the device reconnects. Failing
891
+ * tasks here would make that feature unreachable in practice (nothing
892
+ * would ever still be non-terminal by the time a reconnect happened), so
893
+ * this now only updates connection bookkeeping and leaves task state
894
+ * alone. A task left in-flight by a device that never reconnects stays
895
+ * that way until the SaaS embedder explicitly cancels it — no
896
+ * disconnect-timeout is specified by the protocol, so none is invented
897
+ * here (see the M1-2 report's contract-gap notes).
898
+ */
899
+ handleDisconnect(deviceId, ws) {
900
+ const conn = this.connections.get(deviceId);
901
+ if (!conn || conn.ws !== ws) return;
902
+ conn.connected = false;
903
+ conn.ws = void 0;
904
+ conn.lastSeen = (/* @__PURE__ */ new Date()).toISOString();
905
+ conn.darkSince = Date.now();
906
+ this.serverEvents.push({ kind: "device.disconnected", deviceId, at: conn.lastSeen });
907
+ this.settleLongPollWaiter(deviceId);
908
+ }
909
+ // ---------------------------------------------------------------------
910
+ // long-poll fallback (§8) — GET /byok/events, called from http.ts
911
+ // ---------------------------------------------------------------------
912
+ /**
913
+ * Resolve immediately if there are already-relevant events past `cursor`;
914
+ * otherwise hold for up to `holdMs` and resolve with an empty result if
915
+ * nothing arrives. A device may be connected via WS or long-poll, not
916
+ * both simultaneously — a poll here supersedes (closes) any live WS for
917
+ * this device ("last one wins", documented at the type level on
918
+ * {@link ConnectionState}).
919
+ */
920
+ async pollEvents(deviceId, cursor, holdMs) {
921
+ this.takeOverAsLongPoll(deviceId);
922
+ this.settleLongPollWaiter(deviceId);
923
+ const immediate = this.collectRelevant(deviceId, cursor);
924
+ if (immediate.length > 0) {
925
+ return { events: immediate, cursor: this.currentCursor(deviceId) };
926
+ }
927
+ return new Promise((resolve) => {
928
+ const timer = setTimeout(() => {
929
+ this.longPollWaiters.delete(deviceId);
930
+ resolve({ events: [], cursor: this.currentCursor(deviceId) });
931
+ }, holdMs);
932
+ timer.unref?.();
933
+ this.longPollWaiters.set(deviceId, { cursor, resolve, timer });
934
+ });
935
+ }
936
+ /** Make long-poll this device's active transport, closing any live WS ("last one wins", §8). */
937
+ takeOverAsLongPoll(deviceId) {
938
+ const conn = this.connections.get(deviceId);
939
+ const at = (/* @__PURE__ */ new Date()).toISOString();
940
+ const wasFreshlyConnected = !conn || conn.ws !== void 0 || !conn.connected;
941
+ if (conn?.ws) {
942
+ const ws = conn.ws;
943
+ this.connections.set(deviceId, { connected: true, lastSeen: at, runtimes: conn.runtimes, capabilities: conn.capabilities });
944
+ ws.close(1e3, "superseded by long-poll connection");
945
+ } else if (!conn) {
946
+ this.connections.set(deviceId, { connected: true, lastSeen: at });
947
+ } else {
948
+ conn.connected = true;
949
+ conn.lastSeen = at;
950
+ conn.darkSince = void 0;
951
+ }
952
+ if (wasFreshlyConnected) {
953
+ this.serverEvents.push({ kind: "device.connected", deviceId, at });
954
+ }
955
+ }
956
+ /** Resolve (settle) any long-poll request currently held open for `deviceId`, if one exists. */
957
+ settleLongPollWaiter(deviceId) {
958
+ const waiter = this.longPollWaiters.get(deviceId);
959
+ if (!waiter) return;
960
+ this.longPollWaiters.delete(deviceId);
961
+ clearTimeout(waiter.timer);
962
+ waiter.resolve({ events: this.collectRelevant(deviceId, waiter.cursor), cursor: this.currentCursor(deviceId) });
963
+ }
964
+ // ---------------------------------------------------------------------
965
+ // inbound envelopes from a connected daemon
966
+ // ---------------------------------------------------------------------
967
+ /**
968
+ * Single inbound choke point for every daemon -> server envelope (N2/N3/
969
+ * P2) — called by both the WS path (`ws-server.ts`) and the long-poll send
970
+ * path (`POST /byok/messages`, `http.ts`) in place of reaching into
971
+ * per-type handlers directly. Runs a fixed gate, in order:
972
+ *
973
+ * 0. **rate limit (M4 Phase 4, part A)** — one token debited from this
974
+ * device's bucket ({@link rateLimiter}) for EVERY inbound envelope,
975
+ * before anything else runs (including the type-allow check below) —
976
+ * a flood of garbage-typed envelopes must cost the same budget as a
977
+ * flood of well-formed ones. Checked first specifically so an
978
+ * over-budget device is turned away as cheaply as possible, before any
979
+ * taskStore lookup or dedup bookkeeping. See {@link handleRateLimited}
980
+ * for what happens on exceed (never a silent drop).
981
+ * 1. **type-allow (P2)** — only {@link DAEMON_TO_SERVER_TYPES} may pass; a
982
+ * server -> daemon type (or anything unrecognized, e.g. a stale/future
983
+ * `conn.hello` outside the handshake) arriving inbound is rejected
984
+ * before it's dispatched or counted accepted.
985
+ * 2. **ownership (N2)** — an envelope for a task already owned by a
986
+ * *different* device is dropped (logged), never force-failed:
987
+ * force-failing on an authz mismatch would let an attacker who merely
988
+ * guesses a `taskId` kill the real owner's task (a DoS). A task with no
989
+ * owner yet, or that doesn't exist at all, is not rejected here — the
990
+ * per-type handler's own no-op-on-missing-record behavior covers the
991
+ * latter.
992
+ * 3. **dedup (N3)** — an envelope `id` already seen from this device is a
993
+ * no-op: the wire is at-least-once (§9), this makes server-side
994
+ * processing at-most-once. Check-and-record is synchronous (Node is
995
+ * single-threaded), so it's atomic with respect to any other envelope
996
+ * for this device.
997
+ * 4. **dispatch** — handed to the existing per-type `on*` handler.
998
+ *
999
+ * Returns which outcome applied. A duplicate still counts as `accepted` on
1000
+ * the `POST /byok/messages` wire (§8.2) — an idempotent replay is a
1001
+ * wire-level success even though no handler ran a second time; only
1002
+ * `rejected`/`rate_limited` (gate steps 0-2) are excluded from that count.
1003
+ */
1004
+ handleInbound(deviceId, envelope) {
1005
+ this.envelopesInCount++;
1006
+ if (!this.rateLimiter.consume(deviceId)) {
1007
+ this.handleRateLimited(deviceId);
1008
+ return "rate_limited";
1009
+ }
1010
+ this.rateLimitEventEmittedFor.delete(deviceId);
1011
+ if (!DAEMON_TO_SERVER_TYPES.includes(envelope.type)) {
1012
+ return "rejected";
1013
+ }
1014
+ const taskId = envelope.task_id;
1015
+ if (taskId === void 0) return "rejected";
1016
+ const record = this.taskStore.get(taskId);
1017
+ if (record && record.deviceId !== void 0 && record.deviceId !== deviceId) {
1018
+ console.warn(`[byok/server] dropping ${envelope.type} for ${taskId}: owned by a different device`);
1019
+ return "rejected";
1020
+ }
1021
+ if (this.checkAndRecordDuplicate(deviceId, envelope.id)) {
1022
+ this.dedupDropCount++;
1023
+ return "duplicate";
1024
+ }
1025
+ this.dispatchToHandler(deviceId, taskId, envelope);
1026
+ return "accepted";
1027
+ }
1028
+ /**
1029
+ * M4 Phase 4 (part A): `deviceId` just exceeded its inbound-envelope rate
1030
+ * limit. Never a silent drop: counts the occurrence
1031
+ * ({@link rateLimitEventCount}, surfaced via {@link stats} — every single
1032
+ * hit, unconditionally) and, the FIRST time in this over-budget episode
1033
+ * only, emits an embedder-facing `device.rate_limited`
1034
+ * {@link ByokServerEvent} — see that variant's own doc comment (`types.ts`)
1035
+ * for the full per-transport enforcement shape.
1036
+ *
1037
+ * Gatekeeper LOW advisory (event amplification): a single flood can make
1038
+ * `handleInbound` call this many times in a row — e.g. several WS frames
1039
+ * already in flight before the close below actually lands, or a
1040
+ * long-poll device retrying its `POST /byok/messages` before its bucket
1041
+ * has refilled. Without coalescing, an embedder subscribed to
1042
+ * `events.subscribe()` would see one `device.rate_limited` per hit, which
1043
+ * is noisy for what is really ONE ongoing episode of one device
1044
+ * flooding. `rateLimitEventEmittedFor` suppresses the repeats: this
1045
+ * method only pushes the event the first time it sees a given `deviceId`
1046
+ * since `handleInbound`'s own success path last cleared it (i.e. since
1047
+ * this device was last confirmed back under budget) — the COUNTER above
1048
+ * is entirely unaffected by this and still increments on every call,
1049
+ * unconditionally.
1050
+ *
1051
+ * This method only handles the WS half of the enforcement shape (closing
1052
+ * the live connection, if any, so the client's existing backoff+reconnect
1053
+ * takes over — mirrors `takeOverAsLongPoll`'s own `ws.close`, the only
1054
+ * other place this hub closes a device's socket directly); a long-poll
1055
+ * device has no live `ws` to close here at all (`conn.ws` is `undefined`
1056
+ * while long-polling — see {@link ConnectionState}), so `http.ts`'s
1057
+ * `/byok/messages` handler maps this same `'rate_limited'` `handleInbound`
1058
+ * outcome to an HTTP 429 for that transport instead.
1059
+ */
1060
+ handleRateLimited(deviceId) {
1061
+ this.rateLimitEventCount++;
1062
+ if (!this.rateLimitEventEmittedFor.has(deviceId)) {
1063
+ this.rateLimitEventEmittedFor.add(deviceId);
1064
+ const at = (/* @__PURE__ */ new Date()).toISOString();
1065
+ this.serverEvents.push({ kind: "device.rate_limited", deviceId, at });
1066
+ }
1067
+ const conn = this.connections.get(deviceId);
1068
+ if (conn?.ws) {
1069
+ conn.ws.close(1008, "rate limit exceeded");
1070
+ }
1071
+ }
1072
+ /**
1073
+ * Idempotency check-and-record (N3): `true` (duplicate) if `id` was
1074
+ * already seen for `deviceId`; otherwise records it and returns `false`.
1075
+ * Bounded to {@link DEDUP_RING_CAPACITY} ids per device — a ring, not an
1076
+ * unbounded set — evicting the oldest once full.
1077
+ */
1078
+ checkAndRecordDuplicate(deviceId, id) {
1079
+ let seen = this.dedupRings.get(deviceId);
1080
+ if (!seen) {
1081
+ seen = /* @__PURE__ */ new Set();
1082
+ this.dedupRings.set(deviceId, seen);
1083
+ }
1084
+ if (seen.has(id)) return true;
1085
+ seen.add(id);
1086
+ if (seen.size > DEDUP_RING_CAPACITY) {
1087
+ const oldest = seen.values().next().value;
1088
+ if (oldest !== void 0) seen.delete(oldest);
1089
+ }
1090
+ return false;
1091
+ }
1092
+ /**
1093
+ * Route one already-gated envelope (see {@link handleInbound}) to its
1094
+ * per-type handler. Type-allow/ownership/dedup have already run by the
1095
+ * time this executes, so the handlers below no longer need their own
1096
+ * device-mismatch checks — that authz decision now lives solely in
1097
+ * `handleInbound` (N2).
1098
+ *
1099
+ * Also the task-lease reaper's activity checkpoint
1100
+ * ({@link recordTaskActivity}): every envelope for a task that currently
1101
+ * *exists and is non-terminal* counts as proof of life for `taskId`'s
1102
+ * lease, regardless of what its per-type handler below ends up doing with
1103
+ * it (including a no-op/stale drop) — see the "task-lease reaper" section
1104
+ * further down for why. Deliberately gated on the record's existence and
1105
+ * non-terminal state *here*, before dispatch: `taskActivity` must never
1106
+ * gain an entry for a taskId that doesn't exist (a nonexistent/garbage id
1107
+ * an authenticated-but-malicious daemon could send indefinitely — an
1108
+ * unbounded-growth vector, since `taskId`s aren't deduped the way envelope
1109
+ * `id`s are) or for one that's already terminal (a stale/late message for
1110
+ * a finished task — `onStateChange` deletes the entry on the *real*
1111
+ * terminal transition, but a stale message arriving *after* that would
1112
+ * otherwise silently recreate it, since every per-type handler's own
1113
+ * terminal/unknown-task guard runs — and early-returns — only *after*
1114
+ * this would already have recorded activity).
1115
+ */
1116
+ dispatchToHandler(deviceId, taskId, envelope) {
1117
+ const record = this.taskStore.get(taskId);
1118
+ if (record && !isTerminal(record.state)) {
1119
+ this.recordTaskActivity(taskId);
1120
+ }
1121
+ switch (envelope.type) {
1122
+ case "task.claim":
1123
+ this.onClaim(deviceId, envelope.task_id, envelope.payload);
1124
+ return;
1125
+ case "task.started":
1126
+ this.onStarted(envelope.task_id, envelope.payload);
1127
+ return;
1128
+ case "task.decline":
1129
+ this.onDecline(envelope.task_id, envelope.payload);
1130
+ return;
1131
+ case "task.progress":
1132
+ this.onProgress(envelope.task_id, envelope.payload);
1133
+ return;
1134
+ case "task.artifact":
1135
+ this.onArtifact(envelope.task_id, envelope.payload);
1136
+ return;
1137
+ case "task.await_approval":
1138
+ this.onAwaitApproval(envelope.task_id, envelope.payload);
1139
+ return;
1140
+ case "task.complete":
1141
+ this.onComplete(envelope.task_id, envelope.payload);
1142
+ return;
1143
+ case "task.fail":
1144
+ this.onFail(envelope.task_id, envelope.payload);
1145
+ return;
1146
+ case "task.cancelled":
1147
+ this.onCancelled(envelope.task_id, envelope.payload);
1148
+ return;
1149
+ case "task.approval_resolved":
1150
+ this.onApprovalResolved(envelope.task_id, envelope.payload);
1151
+ return;
1152
+ default:
1153
+ return;
1154
+ }
1155
+ }
1156
+ /** Reset the task-lease reaper's per-task clock (condition (c) in the "task-lease reaper" section below). */
1157
+ recordTaskActivity(taskId) {
1158
+ this.taskActivity.set(taskId, Date.now());
1159
+ }
1160
+ /**
1161
+ * Ownership (record.deviceId matching the connection's authenticated
1162
+ * deviceId) is enforced centrally by {@link handleInbound} (N2) before this
1163
+ * runs; only the idempotent-claim CAS and the first-claim device patch
1164
+ * happen here.
1165
+ *
1166
+ * M5 (claimed runtime, docs/protocol.md §3.1): `payload.runtime` — the
1167
+ * ACTUAL adapter the daemon selected (`TaskRunner.pickAdapter`,
1168
+ * `packages/client`'s `task-runner.ts`) — is recorded into
1169
+ * `TaskSnapshot.claimedRuntime` alongside the device patch, distinct from
1170
+ * the pre-existing `TaskSnapshot.runtime` (the merely REQUESTED runtime,
1171
+ * untouched here and set only once, at `dispatch()` time). Only ever
1172
+ * written on the FIRST real claim: the idempotent-CAS early return above
1173
+ * fires before this for a retried claim from a device that already owns
1174
+ * the task, so a redelivered/retried `task.claim` can never overwrite an
1175
+ * already-recorded `claimedRuntime` — including with a stale or absent
1176
+ * value from an out-of-order retry.
1177
+ *
1178
+ * S0/D-4 (claim-time capability snapshot): `payload.capabilities` — the
1179
+ * claiming adapter's OWN self-report, carried on this same `task.claim`
1180
+ * (docs/protocol.md §2.4) — supplies
1181
+ * `TaskSnapshot.claimedRuntimeCapabilities`, written in the same patch and
1182
+ * therefore under the same write-exactly-once property as `claimedRuntime`.
1183
+ *
1184
+ * Taken from the payload and from nowhere else. This hub deliberately does
1185
+ * NOT consult connection state (`conn.hello.runtimes[]`) for it — see
1186
+ * {@link SteerRejectedError} for why that source is structurally wrong for a
1187
+ * control decision, and that field's own doc comment (`types.ts`) for why
1188
+ * this is snapshotted rather than read live at steer time. A claim that
1189
+ * carries no `capabilities` (a pre-D-4 daemon) records `undefined`, which
1190
+ * the gate reads as "unknown" and refuses.
1191
+ */
1192
+ onClaim(deviceId, taskId, payload) {
1193
+ const record = this.taskStore.get(taskId);
1194
+ if (!record) return;
1195
+ if (record.state === "Claimed" || record.state === "Running") return;
1196
+ this.applyOrFail(taskId, "Claimed", {
1197
+ deviceId,
1198
+ claimedRuntime: payload.runtime,
1199
+ claimedRuntimeCapabilities: payload.capabilities
1200
+ });
1201
+ }
1202
+ /**
1203
+ * `Claimed -> Running` (§3.1) — a daemon actually starting the runtime
1204
+ * session, distinct from merely claiming. Ownership is already enforced
1205
+ * by {@link handleInbound} (N2) before this runs.
1206
+ */
1207
+ onStarted(taskId, _payload) {
1208
+ const record = this.taskStore.get(taskId);
1209
+ if (!record) return;
1210
+ if (record.state === "Running") return;
1211
+ if (isTerminal(record.state)) return;
1212
+ this.applyOrFail(taskId, "Running", {});
1213
+ }
1214
+ /**
1215
+ * `Offered -> Failed` (§3.2) — a fail-closed pre-claim rejection. Only
1216
+ * ever legal from `Offered`; anything else is stale. Ownership is already
1217
+ * enforced by {@link handleInbound} (N2) before this runs.
1218
+ */
1219
+ onDecline(taskId, payload) {
1220
+ const record = this.taskStore.get(taskId);
1221
+ if (!record) return;
1222
+ if (record.state !== "Offered") return;
1223
+ this.applyOrFail(taskId, "Failed", {
1224
+ result: { state: "Failed", reason: payload.reason, retryable: payload.retryable }
1225
+ });
1226
+ }
1227
+ onProgress(taskId, payload) {
1228
+ const record = this.taskStore.get(taskId);
1229
+ if (!record) return;
1230
+ const resumed = this.resumeIfImplicitlyApproved(record);
1231
+ if (resumed.state !== "Running") {
1232
+ this.forceFailOrDrop(taskId, "task.progress received while not Running");
1233
+ return;
1234
+ }
1235
+ const runtime = this.runtimes.get(taskId);
1236
+ if (!runtime) return;
1237
+ for (const event of payload.events) {
1238
+ runtime.queue.push({ kind: "agent", event });
1239
+ }
1240
+ }
1241
+ onArtifact(taskId, payload) {
1242
+ const record = this.taskStore.get(taskId);
1243
+ if (!record) return;
1244
+ const resumed = this.resumeIfImplicitlyApproved(record);
1245
+ if (resumed.state !== "Running") {
1246
+ this.forceFailOrDrop(taskId, "task.artifact received while not Running");
1247
+ return;
1248
+ }
1249
+ const runtime = this.runtimes.get(taskId);
1250
+ if (!runtime) return;
1251
+ runtime.queue.push({ kind: "artifact", artifact: payload });
1252
+ }
1253
+ onAwaitApproval(taskId, payload) {
1254
+ const record = this.taskStore.get(taskId);
1255
+ if (!record) return;
1256
+ if (record.state === "AwaitApproval") {
1257
+ if (payload.approvalId !== void 0 && payload.approvalId !== record.pendingApprovalId) {
1258
+ this.taskStore.setPendingApprovalId?.(taskId, payload.approvalId);
1259
+ this.runtimes.get(taskId)?.queue.push({ kind: "await_approval", summary: payload.summary });
1260
+ }
1261
+ return;
1262
+ }
1263
+ this.applyOrFail(taskId, "AwaitApproval", { pendingApprovalId: payload.approvalId });
1264
+ const after = this.taskStore.get(taskId);
1265
+ if (after?.state !== "AwaitApproval") return;
1266
+ this.runtimes.get(taskId)?.queue.push({ kind: "await_approval", summary: payload.summary });
1267
+ }
1268
+ onComplete(taskId, payload) {
1269
+ const record = this.taskStore.get(taskId);
1270
+ if (!record) return;
1271
+ if (isTerminal(record.state)) return;
1272
+ this.resumeIfImplicitlyApproved(record);
1273
+ const result = {
1274
+ state: "Complete",
1275
+ summary: payload.summary,
1276
+ sessionRef: payload.sessionRef,
1277
+ artifactRefs: payload.artifactRefs
1278
+ };
1279
+ this.applyOrFail(taskId, "Complete", { result, sessionRef: payload.sessionRef });
1280
+ }
1281
+ onFail(taskId, payload) {
1282
+ const record = this.taskStore.get(taskId);
1283
+ if (!record) return;
1284
+ if (isTerminal(record.state)) return;
1285
+ const result = { state: "Failed", reason: payload.reason, retryable: payload.retryable };
1286
+ this.applyOrFail(taskId, "Failed", { result });
1287
+ }
1288
+ /**
1289
+ * Dual-purpose on receipt (§3.3): if the server already moved this task to
1290
+ * `Cancelled` on its own action (the common case — `cancelTask()` is
1291
+ * authoritative immediately, §4), this is a late idempotent ack — silent,
1292
+ * not a warning (this is the other half of the M0 gatekeeper finding this
1293
+ * change resolves). Otherwise it's the authoritative trigger for a
1294
+ * cancellation the daemon observed that the server didn't initiate.
1295
+ * Ownership is already enforced by {@link handleInbound} (N2) before this
1296
+ * runs.
1297
+ */
1298
+ onCancelled(taskId, payload) {
1299
+ const record = this.taskStore.get(taskId);
1300
+ if (!record) return;
1301
+ if (record.state === "Cancelled") return;
1302
+ if (isTerminal(record.state)) return;
1303
+ this.applyOrFail(taskId, "Cancelled", { result: { state: "Cancelled", reason: payload.reason } });
1304
+ }
1305
+ /**
1306
+ * M4 (additive-minor, `task.approval_resolved`): the EXPLICIT counterpart
1307
+ * to {@link resumeIfImplicitlyApproved} — a daemon that resolved a pending
1308
+ * approval entirely LOCALLY now reports it immediately, instead of the
1309
+ * server only finding out after the fact once evidence (a later
1310
+ * `task.progress`/`task.artifact`/`task.complete`) proves it.
1311
+ *
1312
+ * Relationship to the implicit path (both stay, permanently — this is not
1313
+ * a replacement): {@link resumeIfImplicitlyApproved} remains completely
1314
+ * untouched as the fallback for (a) an old daemon that predates this
1315
+ * message, and (b) a daemon connected to an old server that never
1316
+ * advertised the `approval_resolved` capability flag (`version.ts`) at
1317
+ * handshake time — in either case the daemon never sends this message at
1318
+ * all (see `packages/client`'s `task-runner.ts`), and the server keeps
1319
+ * inferring the resolution from evidence exactly as it did before this
1320
+ * message existed. When THIS message does arrive first, it already moves
1321
+ * the record out of `AwaitApproval` (see below) — so by the time any
1322
+ * following `task.progress`/etc. reaches `onProgress`/`onArtifact`/
1323
+ * `onComplete`, `resumeIfImplicitlyApproved`'s own `record.state !==
1324
+ * 'AwaitApproval'` guard is already true and it no-ops, never firing its
1325
+ * own `task.approval_resolved_implicit` event a second time for the same
1326
+ * resolution. The two mechanisms race harmlessly: whichever one the
1327
+ * server processes first is the one that actually performs the
1328
+ * transition; the other is naturally inert once it runs.
1329
+ *
1330
+ * Three outcomes, mirroring this file's existing per-type idempotency
1331
+ * conventions:
1332
+ * - `AwaitApproval` (the expected case): legal transition to `Running`
1333
+ * (an existing `TASK_TRANSITIONS` edge, the same one `approveTask`
1334
+ * itself uses) plus a `task.approval_resolved` {@link ByokServerEvent}
1335
+ * carrying `approvalId`/`decision`/`resolvedBy` for an embedder to
1336
+ * observe.
1337
+ * - Already `Running` (evidence — or the implicit path — already beat
1338
+ * this message to it): idempotent no-op, silent, mirroring
1339
+ * `onStarted`'s own already-running guard.
1340
+ * - Terminal, or a state that was never `AwaitApproval` in the first
1341
+ * place (`Offered`/`Claimed` — a genuinely out-of-sequence report):
1342
+ * stale no-op with a `console.warn`, matching this file's existing
1343
+ * stale-message convention (`forceFailOrDrop`, `handleInbound`'s
1344
+ * ownership-mismatch drop) — never force-failed, since a late/
1345
+ * redelivered report about a task that has already moved on is not
1346
+ * evidence of anything currently wrong with it.
1347
+ *
1348
+ * This is also the residual-race resolution the accompanying protocol/docs
1349
+ * update documents: a SaaS decision (`approveTask`/`rejectTask`) already in
1350
+ * flight when the local resolution happens can still land on the server
1351
+ * FIRST and move the record to a terminal state before this message
1352
+ * arrives — in that case this message hits the terminal branch above and
1353
+ * is a stale no-op, exactly like any other late message for an
1354
+ * already-terminal task. The window for that crossing is now
1355
+ * network-latency-sized (how long this message takes to arrive), not
1356
+ * "until the next progress message" the way the pre-existing implicit-only
1357
+ * inference left it.
1358
+ */
1359
+ onApprovalResolved(taskId, payload) {
1360
+ const record = this.taskStore.get(taskId);
1361
+ if (!record) return;
1362
+ if (record.state === "Running") return;
1363
+ if (record.state !== "AwaitApproval") {
1364
+ console.warn(
1365
+ `[byok/server] dropping task.approval_resolved for ${taskId}: not awaiting approval (state ${record.state})`
1366
+ );
1367
+ return;
1368
+ }
1369
+ if (payload.approvalId !== void 0 && record.pendingApprovalId !== void 0 && payload.approvalId !== record.pendingApprovalId) {
1370
+ console.warn(
1371
+ `[byok/server] stale task.approval_resolved for ${taskId}: reported approvalId ${payload.approvalId} does not match the currently pending ${record.pendingApprovalId}`
1372
+ );
1373
+ return;
1374
+ }
1375
+ this.applyOrFail(taskId, "Running", {});
1376
+ const after = this.taskStore.get(taskId);
1377
+ if (after?.state !== "Running") return;
1378
+ const targeted = record.deviceId !== void 0 && (this.getDeviceCapabilities(record.deviceId)?.includes("approval-targeting") ?? false);
1379
+ this.serverEvents.push({
1380
+ kind: "task.approval_resolved",
1381
+ taskId,
1382
+ approvalId: payload.approvalId,
1383
+ decision: payload.decision,
1384
+ resolvedBy: payload.resolvedBy,
1385
+ at: after.updatedAt,
1386
+ targeted
1387
+ });
1388
+ }
1389
+ // ---------------------------------------------------------------------
1390
+ // transition helpers — the single place "illegal transition" is handled
1391
+ // ---------------------------------------------------------------------
1392
+ /**
1393
+ * M5 (approval targeting): single low-level wrapper around
1394
+ * `TaskStore.transition` that every ACTUAL state-changing write in this
1395
+ * file goes through — {@link applyOrFail}'s legal-transition branch,
1396
+ * {@link forceFailOrDrop}, and {@link resumeIfImplicitlyApproved} (the one
1397
+ * caller that transitions WITHOUT going through `applyOrFail` at all).
1398
+ * Two responsibilities, folded in here once rather than duplicated at
1399
+ * each call site:
1400
+ *
1401
+ * 1. Clears `pendingApprovalId` whenever `record` is LEAVING
1402
+ * `AwaitApproval` (`record.state === 'AwaitApproval' && to !==
1403
+ * 'AwaitApproval'`) — the id this hub last recorded for a task's
1404
+ * pending approval ({@link onAwaitApproval}) is meaningless the
1405
+ * instant that task is no longer awaiting it. Clearing it here,
1406
+ * centrally, is what guarantees a FUTURE `AwaitApproval` cycle for
1407
+ * the SAME task always starts from a clean slate instead of silently
1408
+ * inheriting a stale id from a previous cycle (which would make a
1409
+ * stale-approval check against the NEW cycle's real pending id
1410
+ * spuriously pass just because a leftover value happened to still be
1411
+ * sitting in the record).
1412
+ * 2. Calls {@link onStateChange} — every call site already did this
1413
+ * immediately after its own `transition` call; folding it in here
1414
+ * removes the duplication and the chance of a future call site
1415
+ * forgetting it.
1416
+ */
1417
+ transitionTask(taskId, record, to, patch) {
1418
+ const finalPatch = record.state === "AwaitApproval" && to !== "AwaitApproval" ? { ...patch, pendingApprovalId: void 0 } : patch;
1419
+ const updated = this.taskStore.transition(taskId, to, finalPatch);
1420
+ this.onStateChange(updated);
1421
+ return updated;
1422
+ }
1423
+ /**
1424
+ * Apply `taskId`'s state -> `target`. If that's illegal per
1425
+ * `TASK_TRANSITIONS`, fall back to `Failed` (if reachable from the current
1426
+ * state); this is the "illegal transition = error + task.fail path" rule.
1427
+ */
1428
+ applyOrFail(taskId, target, patch) {
1429
+ const record = this.taskStore.get(taskId);
1430
+ if (!record) return;
1431
+ if (canTransition(record.state, target)) {
1432
+ this.transitionTask(taskId, record, target, patch);
1433
+ return;
1434
+ }
1435
+ this.forceFailOrDrop(taskId, `illegal transition ${record.state} -> ${target}`);
1436
+ }
1437
+ /**
1438
+ * M4 Phase 3 hardening (orchestrator-directed fix for the server-state-
1439
+ * machine trace finding): a task can be resolved entirely OUT-OF-BAND, on
1440
+ * the daemon side only (M4 Phase 3's local `approvals.resolve`
1441
+ * control-socket path, `packages/client`) — the server never sees a wire
1442
+ * `task.approve`/`task.reject` for it, so its own record sits in
1443
+ * `AwaitApproval` even though the daemon already resumed and moved on.
1444
+ *
1445
+ * The daemon is the execution authority in this security model (the SaaS
1446
+ * only ever *proposes* — see docs/spec.md); the daemon sending ANY further
1447
+ * task.* traffic for a task the server still thinks is `AwaitApproval` is
1448
+ * itself sufficient proof the approval was resolved locally, one way or
1449
+ * another. Rather than force-failing/dropping that traffic (the pre-fix
1450
+ * behavior — `onProgress`/`onArtifact`'s own `!== 'Running'` guard,
1451
+ * `onComplete`'s illegal-transition fallback), this applies the exact same
1452
+ * `AwaitApproval -> Running` edge `approveTask` already uses (a
1453
+ * pre-existing legal `TASK_TRANSITIONS` edge, not a new one) through the
1454
+ * normal transition path — `taskStore.transition` + `onStateChange`, same
1455
+ * as `applyOrFail`'s own legal-transition branch — so every existing
1456
+ * consumer of task state (§, `TaskHandle.events()`, the lease reaper's
1457
+ * `taskActivity`) observes it exactly as it would a real wire
1458
+ * `task.approve`. Then emits `task.approval_resolved_implicit` (a
1459
+ * `ByokServerEvent`, NOT a wire message — see that type's own doc comment)
1460
+ * so an embedder can distinguish this from an operator-driven approval.
1461
+ *
1462
+ * M4 (additive-minor, superseding this method's own former "deferred"
1463
+ * framing): a first-class `task.approval_resolved` WIRE notification now
1464
+ * exists (`onApprovalResolved`, below) — a daemon that supports it, talking
1465
+ * to a server that advertised the `approval_resolved` capability flag
1466
+ * (`version.ts`), reports a local resolution explicitly and immediately
1467
+ * instead of leaving the server to infer it here. This method is
1468
+ * UNTOUCHED and remains the permanent fallback for the N/N-1 cases where
1469
+ * that explicit report never arrives (an old daemon, or an old server this
1470
+ * daemon is talking to) — see `onApprovalResolved`'s own doc comment for
1471
+ * the full relationship between the two paths, including why they can
1472
+ * never both fire for the same resolution.
1473
+ *
1474
+ * No-op (returns `record` unchanged) for any state other than
1475
+ * `AwaitApproval` — every other guard (terminal, pre-claim, already-
1476
+ * Running) keeps exactly its current behavior. `onFail`/`onCancelled`
1477
+ * never call this: `Failed`/`Cancelled` are already direct, legal edges
1478
+ * from `AwaitApproval`, so they never hit the illegal-transition path this
1479
+ * exists to avoid in the first place.
1480
+ */
1481
+ resumeIfImplicitlyApproved(record) {
1482
+ if (record.state !== "AwaitApproval") return record;
1483
+ const updated = this.transitionTask(record.taskId, record, "Running", {});
1484
+ this.serverEvents.push({ kind: "task.approval_resolved_implicit", taskId: record.taskId, at: updated.updatedAt });
1485
+ return updated;
1486
+ }
1487
+ /**
1488
+ * A daemon message didn't fit the task's current state (e.g. progress
1489
+ * while AwaitApproval). Force the task to `Failed` if that's reachable;
1490
+ * otherwise it's already terminal (or `Offered`, which has no Failed edge)
1491
+ * and there's nothing safe to do but log + drop.
1492
+ */
1493
+ forceFailOrDrop(taskId, reason) {
1494
+ const record = this.taskStore.get(taskId);
1495
+ if (!record) return;
1496
+ if (canTransition(record.state, "Failed")) {
1497
+ this.transitionTask(taskId, record, "Failed", {
1498
+ result: { state: "Failed", reason, retryable: false }
1499
+ });
1500
+ return;
1501
+ }
1502
+ console.warn(`[byok/server] dropping message for ${taskId} (state ${record.state}): ${reason}`);
1503
+ }
1504
+ onStateChange(record) {
1505
+ this.serverEvents.push({
1506
+ kind: "task.state",
1507
+ taskId: record.taskId,
1508
+ state: record.state,
1509
+ at: record.updatedAt,
1510
+ // M5 (claimed runtime): mirrors the snapshot's own field verbatim —
1511
+ // see ByokServerEvent's 'task.state' variant doc comment (types.ts).
1512
+ claimedRuntime: record.claimedRuntime
1513
+ });
1514
+ if (isTerminal(record.state)) {
1515
+ this.taskActivity.delete(record.taskId);
1516
+ }
1517
+ const runtime = this.runtimes.get(record.taskId);
1518
+ if (!runtime) return;
1519
+ runtime.queue.push({ kind: "state", state: record.state, at: record.updatedAt });
1520
+ if (isTerminal(record.state)) {
1521
+ runtime.resolveResult(record.result ?? { state: record.state });
1522
+ runtime.queue.close();
1523
+ }
1524
+ }
1525
+ // ---------------------------------------------------------------------
1526
+ // task-lease reaper (Decision: Failed(retryable:true) on dark-device
1527
+ // timeout — no new task state, no new wire message)
1528
+ // ---------------------------------------------------------------------
1529
+ /**
1530
+ * Task lease: a backstop for a device that goes dark mid-task and never
1531
+ * comes back — distinct from, and layered on top of, M1's redelivery
1532
+ * (docs/protocol.md §9), which already handles "device reconnects within
1533
+ * the window, nothing lost." Decision (user+design): reuse the existing
1534
+ * `Failed` terminal state and its `retryable` flag —
1535
+ * `Failed(retryable: true, reason: 'lease-expired')` — exactly like any
1536
+ * other `task.fail`. The embedder is expected to treat this exactly like
1537
+ * any other retryable failure: re-dispatch as a brand-new task.
1538
+ *
1539
+ * Implemented as a periodic sweep (see the constructor), not a per-task
1540
+ * timer, so a device that goes dark *after* being idle-but-connected for a
1541
+ * while is still caught on a later tick without needing extra bookkeeping
1542
+ * at disconnect time. `sweepLeases` reaps a task only when ALL of the
1543
+ * following hold, checked fresh on every tick (never cached):
1544
+ *
1545
+ * (a) the task is in a non-terminal *claimed* state — `Claimed`,
1546
+ * `Running`, or `AwaitApproval` ({@link isClaimedState}). `Offered`
1547
+ * is excluded: it has no owning device yet, so there's nothing to
1548
+ * be "dark".
1549
+ * (b) the owning device is dark right now ({@link deviceDarkSince}
1550
+ * returns a timestamp rather than `undefined`) — disconnected
1551
+ * outright, or (long-poll only) hasn't been seen since before the
1552
+ * lease window. A live WS connection is never dark from the
1553
+ * reaper's point of view: `heartbeat.ts` already independently
1554
+ * proves liveness at the transport level and flips
1555
+ * `connected: false` via `handleDisconnect` once it stops getting
1556
+ * pongs — the reaper just reads that flag rather than re-deriving
1557
+ * it. `deviceDarkSince` also returns *when* darkness started
1558
+ * ({@link ConnectionState.darkSince}, set the instant
1559
+ * `handleDisconnect` flips the connection dark) — that instant
1560
+ * feeds condition (c), below.
1561
+ * (c) a full `taskLeaseMs` has elapsed since the *later* of: the task's
1562
+ * own last inbound-activity timestamp ({@link taskActivity}, reset
1563
+ * in {@link dispatchToHandler} on every accepted envelope for a
1564
+ * known, non-terminal task — claim, started, progress, artifact,
1565
+ * await_approval, anything), and (b)'s dark-since instant. Taking
1566
+ * the *later* of the two — not the activity timestamp alone — is
1567
+ * what makes a device going dark start a fresh, full countdown
1568
+ * instead of reusing whatever (possibly already-stale) activity
1569
+ * timestamp the task happened to have: a task can be legitimately
1570
+ * idle *while connected* for longer than `taskLeaseMs` (a long turn
1571
+ * with no progress events, or just a quiet stretch) without being
1572
+ * touched — see (b) — but the instant such a task's device
1573
+ * disconnects, that stale activity timestamp must NOT immediately
1574
+ * satisfy (c) on its own, or the task would get reaped within one
1575
+ * sweep tick of disconnect instead of waiting the full window. That
1576
+ * was a real bug (a disconnect-after-long-idle reap effectively
1577
+ * indistinguishable from the M0 disconnect-alone-fails-the-task
1578
+ * behavior M1 removed, below); anchoring (c) to
1579
+ * `max(lastActivity, darkSince)` fixes it — idle time that elapsed
1580
+ * *before* the device went dark no longer counts toward the lease,
1581
+ * only silence *after* dark-start does.
1582
+ *
1583
+ * (b) and (c) are deliberately independent clocks, not one merged check.
1584
+ * The property this most exists to protect: a *connected*, momentarily
1585
+ * idle device mid-turn must never be reaped, no matter how long
1586
+ * `taskLeaseMs` is — condition (b) alone blocks that regardless of (c).
1587
+ * This is also what keeps this from reintroducing the M0 bug M1
1588
+ * deliberately removed (see `handleDisconnect`'s own doc comment above) —
1589
+ * M0 force-failed a task the instant its device disconnected; M1
1590
+ * correctly stopped doing that so a task could survive a disconnect and
1591
+ * resume via redelivery. This reaper does not revert that: disconnect
1592
+ * ALONE still does nothing here either — (c) still has to independently
1593
+ * hold, and per the `max(...)` above it only will once a full
1594
+ * `taskLeaseMs` has genuinely elapsed *since the device went dark*, no
1595
+ * matter how stale the task's own activity timestamp already was at that
1596
+ * moment.
1597
+ *
1598
+ * Interaction with redelivery (§9): redelivery is what handles "the
1599
+ * device came back within the window" — nothing to reap, normal traffic
1600
+ * resumes. This reaper is what handles "it never came back." Idempotent
1601
+ * claim (`onClaim`'s CAS) still protects server-side bookkeeping if a
1602
+ * device wakes up *after* its task was already reaped and retries a stale
1603
+ * claim/progress/etc. for it: every per-type handler's existing
1604
+ * stale/terminal-task guard (§9) drops it as a no-op, same as any other
1605
+ * late message for an already-terminal task — no new guard was needed for
1606
+ * that here.
1607
+ *
1608
+ * Accepted residual (by design, not a bug): idempotent claim protects
1609
+ * *server-side* state, not the device's own local side effects. A dark
1610
+ * device that wakes up after its task has already been reaped may still
1611
+ * be mid-way through running real local work (file writes, shell
1612
+ * commands, whatever the runtime adapter was doing) for a task the server
1613
+ * has since moved on from — and that the embedder may have already
1614
+ * re-dispatched elsewhere. There is no way to remotely guarantee a
1615
+ * truly-dark device stops running; the mitigation is entirely
1616
+ * `taskLeaseMs` being set far larger than any realistic task duration, so
1617
+ * this can only happen to a device that was genuinely gone for a very
1618
+ * long time, not a normal slow turn.
1619
+ */
1620
+ sweepLeases() {
1621
+ const now = Date.now();
1622
+ for (const record of this.taskStore.list()) {
1623
+ if (!isClaimedState(record.state) || !record.deviceId) continue;
1624
+ const darkSince = this.deviceDarkSince(record.deviceId, now);
1625
+ if (darkSince === void 0) continue;
1626
+ const lastActivity = this.taskActivity.get(record.taskId) ?? Date.parse(record.updatedAt);
1627
+ const silentSince = Math.max(lastActivity, darkSince);
1628
+ if (now - silentSince < this.taskLeaseMs) continue;
1629
+ this.reapTask(record.taskId);
1630
+ }
1631
+ }
1632
+ /**
1633
+ * Condition (b) above: `undefined` while `deviceId`'s connection counts as
1634
+ * alive (never reapable, no matter how stale (c) is); otherwise the
1635
+ * epoch-ms instant it began counting as "dark" for lease purposes.
1636
+ * `sweepLeases` combines this with (c)'s own last-activity instant via
1637
+ * `max(...)` so the full `taskLeaseMs` silence window is always measured
1638
+ * from whichever of the two happened later.
1639
+ */
1640
+ deviceDarkSince(deviceId, nowMs) {
1641
+ const conn = this.connections.get(deviceId);
1642
+ if (!conn || !conn.connected) {
1643
+ return conn?.darkSince ?? 0;
1644
+ }
1645
+ if (conn.ws) return void 0;
1646
+ const lastSeenMs = Date.parse(conn.lastSeen);
1647
+ return nowMs - lastSeenMs >= this.taskLeaseMs ? lastSeenMs : void 0;
1648
+ }
1649
+ /** Reap one lease-expired task through the exact same TaskStore/canTransition path — and terminal-event emission — as any other `task.fail` (see {@link applyOrFail}). */
1650
+ reapTask(taskId) {
1651
+ const record = this.taskStore.get(taskId);
1652
+ if (!record || isTerminal(record.state)) return;
1653
+ this.applyOrFail(taskId, "Failed", {
1654
+ result: { state: "Failed", reason: "lease-expired", retryable: true }
1655
+ });
1656
+ }
1657
+ // ---------------------------------------------------------------------
1658
+ // dispatch() and the TaskHandle it returns
1659
+ // ---------------------------------------------------------------------
1660
+ async dispatch(input) {
1661
+ const deviceId = input.deviceId ?? this.pickFirstConnectedDevice();
1662
+ if (!deviceId || !this.connections.get(deviceId)?.connected) {
1663
+ throw new Error(
1664
+ deviceId ? `device ${deviceId} is not connected` : "no connected device to dispatch to (M0 does not queue tasks until a device connects)"
1665
+ );
1666
+ }
1667
+ const taskId = generateTaskId();
1668
+ const policy = input.policy ?? DEFAULT_POLICY;
1669
+ const record = this.taskStore.create({
1670
+ taskId,
1671
+ instruction: input.instruction,
1672
+ runtime: input.runtime,
1673
+ policy,
1674
+ deviceId,
1675
+ sessionRef: input.sessionRef
1676
+ });
1677
+ const queue = new AsyncEventQueue();
1678
+ let resolveResult;
1679
+ const result = new Promise((resolve) => {
1680
+ resolveResult = resolve;
1681
+ });
1682
+ this.runtimes.set(taskId, { queue, resolveResult, result });
1683
+ queue.push({ kind: "state", state: record.state, at: record.createdAt });
1684
+ this.serverEvents.push({ kind: "task.created", taskId, at: record.createdAt });
1685
+ this.sendToDevice(
1686
+ deviceId,
1687
+ "task.offer",
1688
+ {
1689
+ instruction: input.instruction,
1690
+ policy,
1691
+ runtime: input.runtime,
1692
+ sessionRef: input.sessionRef
1693
+ },
1694
+ { taskId, sessionRef: input.sessionRef }
1695
+ );
1696
+ return this.buildTaskHandle(taskId);
1697
+ }
1698
+ buildTaskHandle(taskId) {
1699
+ const hub = this;
1700
+ return {
1701
+ taskId,
1702
+ events() {
1703
+ const runtime = hub.runtimes.get(taskId);
1704
+ if (!runtime) throw new Error(`unknown taskId: ${taskId}`);
1705
+ return runtime.queue.subscribe();
1706
+ },
1707
+ cancel(reason) {
1708
+ return hub.cancelTask(taskId, reason);
1709
+ },
1710
+ approve(opts) {
1711
+ return hub.approveTask(taskId, opts);
1712
+ },
1713
+ reject(reason, opts) {
1714
+ return hub.rejectTask(taskId, reason, opts);
1715
+ },
1716
+ steer(text) {
1717
+ return hub.steerTask(taskId, text);
1718
+ },
1719
+ result() {
1720
+ const runtime = hub.runtimes.get(taskId);
1721
+ if (!runtime) throw new Error(`unknown taskId: ${taskId}`);
1722
+ return runtime.result;
1723
+ }
1724
+ };
1725
+ }
1726
+ /** Idempotent: cancelling an already-terminal task is a no-op, not an error. */
1727
+ async cancelTask(taskId, reason) {
1728
+ const record = this.taskStore.get(taskId);
1729
+ if (!record) throw new Error(`unknown taskId: ${taskId}`);
1730
+ if (isTerminal(record.state)) return;
1731
+ this.applyOrFail(taskId, "Cancelled", { result: { state: "Cancelled", reason } });
1732
+ if (record.deviceId) {
1733
+ this.sendToDevice(record.deviceId, "task.cancel", { reason }, { taskId });
1734
+ }
1735
+ }
1736
+ /**
1737
+ * M4 Phase 3: made public (was private through M3) so an embedder can call
1738
+ * it directly from its own operator-facing surface — there is no
1739
+ * bearer-authed HTTP route for this on `http.ts`'s own app (see
1740
+ * `UnknownTaskError`'s own doc comment for why, and
1741
+ * `examples/basic/server.ts`'s `/api/tasks/:taskId/approve` for the
1742
+ * intended shape of that embedder-built surface). See this file's own
1743
+ * `UnknownTaskError`/`TaskNotAwaitingApprovalError` doc comments for why
1744
+ * the two failure modes are now distinct typed errors rather than a
1745
+ * single generic `Error`. Every thrown message's TEXT is byte-for-byte
1746
+ * unchanged from M2/M3 — only the error's type changed (this is still also
1747
+ * reachable via `TaskHandle.approve()`, unaffected).
1748
+ */
1749
+ /**
1750
+ * M5 (approval targeting, docs/protocol.md §5.3): `opts.approvalId`
1751
+ * targets a SPECIFIC pending approval rather than "whichever one is
1752
+ * currently pending" (the pre-M5 default, unchanged when `opts` is
1753
+ * omitted). Validated FIRST, before any state change or wire send: if
1754
+ * `opts.approvalId` is supplied and this hub has a recorded
1755
+ * `pendingApprovalId` for `taskId` that DIFFERS, throws
1756
+ * {@link StaleApprovalError} — no transition, no `task.approve` sent. If
1757
+ * this hub never recorded a `pendingApprovalId` (a legacy daemon that
1758
+ * never reported one), the call proceeds untargeted exactly as before.
1759
+ * The outgoing `task.approve` carries `approvalId`: the caller-supplied
1760
+ * one if given, else this hub's own recorded one, else omitted entirely
1761
+ * (legacy wire shape) — so the daemon can apply its own exact-match check
1762
+ * whenever this server has an id to offer at all.
1763
+ */
1764
+ async approveTask(taskId, opts) {
1765
+ const record = this.taskStore.get(taskId);
1766
+ if (!record) throw new UnknownTaskError(taskId);
1767
+ if (record.state !== "AwaitApproval") {
1768
+ throw new TaskNotAwaitingApprovalError(taskId, record.state, "approve");
1769
+ }
1770
+ if (opts?.approvalId !== void 0 && record.pendingApprovalId !== void 0 && opts.approvalId !== record.pendingApprovalId) {
1771
+ throw new StaleApprovalError(taskId, opts.approvalId, record.pendingApprovalId);
1772
+ }
1773
+ this.applyOrFail(taskId, "Running", {});
1774
+ if (record.deviceId) {
1775
+ const approvalId = opts?.approvalId ?? record.pendingApprovalId;
1776
+ this.sendToDevice(record.deviceId, "task.approve", { approvalId }, { taskId });
1777
+ }
1778
+ }
1779
+ /**
1780
+ * M4 Phase 3: made public — see {@link ConnectionHub.approveTask}'s own
1781
+ * doc comment for the full rationale (identical reasoning applies here).
1782
+ * M5: same `opts.approvalId` targeting semantics as `approveTask` above —
1783
+ * see that method's own doc comment.
1784
+ */
1785
+ async rejectTask(taskId, reason, opts) {
1786
+ const record = this.taskStore.get(taskId);
1787
+ if (!record) throw new UnknownTaskError(taskId);
1788
+ if (record.state !== "AwaitApproval") {
1789
+ throw new TaskNotAwaitingApprovalError(taskId, record.state, "reject");
1790
+ }
1791
+ if (opts?.approvalId !== void 0 && record.pendingApprovalId !== void 0 && opts.approvalId !== record.pendingApprovalId) {
1792
+ throw new StaleApprovalError(taskId, opts.approvalId, record.pendingApprovalId);
1793
+ }
1794
+ this.applyOrFail(taskId, "Failed", {
1795
+ result: { state: "Failed", reason: reason ?? "approval rejected", retryable: false }
1796
+ });
1797
+ if (record.deviceId) {
1798
+ const approvalId = opts?.approvalId ?? record.pendingApprovalId;
1799
+ this.sendToDevice(record.deviceId, "task.reject", { reason, approvalId }, { taskId });
1800
+ }
1801
+ }
1802
+ /**
1803
+ * S0 (GAP-002): a task-level gate, evaluated in full before any envelope is
1804
+ * built — see {@link SteerRejectedError} for the gap this closes and why an
1805
+ * unknown capability must refuse rather than proceed. Order matters:
1806
+ *
1807
+ * 1. unknown task — unchanged pre-S0 `Error` (this is not a steer-policy
1808
+ * decision, and `TaskHandle.steer` can only be reached with a taskId
1809
+ * this hub minted, so it's a programming error, not an operator one);
1810
+ * 2. terminal (`Complete`/`Failed`/`Cancelled`) -> `task_terminal`,
1811
+ * checked BEFORE the `Running` check so a steer racing a terminal
1812
+ * transition always resolves terminal-first;
1813
+ * 3. not `Running` (`Offered`/`Claimed`/`AwaitApproval`) ->
1814
+ * `task_not_running`;
1815
+ * 4. the claim-time snapshot does not positively say `steer: true` ->
1816
+ * `steer_unsupported_runtime`, including when there is no snapshot at
1817
+ * all (fail-closed);
1818
+ * 5. only then, the pre-existing device-liveness check and the send.
1819
+ *
1820
+ * Step 4 reads `TaskSnapshot.claimedRuntimeCapabilities` — the per-runtime,
1821
+ * per-task value frozen at claim time from the claiming adapter's own
1822
+ * `task.claim.capabilities` — and reads NO connection state whatsoever:
1823
+ * not {@link getDeviceCapabilities}, not `ConnectionState.runtimes`, and
1824
+ * with no fallback to either when the snapshot is absent. See
1825
+ * {@link SteerRejectedError} for why a connection-sourced input is wrong
1826
+ * both in scope (describes a daemon build, not this task's runtime) and in
1827
+ * reach (absent entirely on long-poll-only daemons).
1828
+ */
1829
+ async steerTask(taskId, text) {
1830
+ const record = this.taskStore.get(taskId);
1831
+ if (!record) throw new Error(`unknown taskId: ${taskId}`);
1832
+ if (isTerminal(record.state)) {
1833
+ throw new SteerRejectedError(taskId, "task_terminal", record.state, record.claimedRuntime);
1834
+ }
1835
+ if (record.state !== "Running") {
1836
+ throw new SteerRejectedError(taskId, "task_not_running", record.state, record.claimedRuntime);
1837
+ }
1838
+ if (record.claimedRuntimeCapabilities?.steer !== true) {
1839
+ throw new SteerRejectedError(taskId, "steer_unsupported_runtime", record.state, record.claimedRuntime);
1840
+ }
1841
+ if (!record.deviceId || !this.connections.get(record.deviceId)?.connected) {
1842
+ throw new Error(`device for task ${taskId} is not connected`);
1843
+ }
1844
+ this.sendToDevice(record.deviceId, "task.steer", { text }, { taskId });
1845
+ }
1846
+ pickFirstConnectedDevice() {
1847
+ for (const [deviceId, conn] of this.connections) {
1848
+ if (conn.connected) return deviceId;
1849
+ }
1850
+ return void 0;
1851
+ }
1852
+ // ---------------------------------------------------------------------
1853
+ // outbound envelope delivery + per-device seq/redelivery bookkeeping (§1.2, §9)
1854
+ // ---------------------------------------------------------------------
1855
+ /**
1856
+ * Build a server -> daemon envelope with a fresh per-device `seq`, retain
1857
+ * it in that device's outbox ring buffer, and deliver it now if a live
1858
+ * transport is available (WS send, or wake a pending long-poll).
1859
+ *
1860
+ * `opts`'s type mirrors `createEnvelope`'s own per-type conditional
1861
+ * requiredness (finding F1) minus `seq` (computed fresh right here on
1862
+ * every call, never caller-supplied) — so every one of this method's 6
1863
+ * callers below must supply `taskId` for the 5 types that need it
1864
+ * (everything except `conn.ack`), same as calling `createEnvelope`
1865
+ * directly would require.
1866
+ */
1867
+ sendToDevice(deviceId, type, payload, opts) {
1868
+ this.envelopesOutCount++;
1869
+ const outbox = this.getOrCreateOutbox(deviceId);
1870
+ const seq = outbox.nextSeq++;
1871
+ const combinedOpts = { ...opts, seq };
1872
+ const envelope = createEnvelope(type, payload, combinedOpts);
1873
+ const taskId = opts.taskId;
1874
+ const redeliverThroughTerminal = type === "task.cancel" || type === "task.reject";
1875
+ outbox.ring.push({ seq, taskId, envelope, redeliverThroughTerminal });
1876
+ if (outbox.ring.length > OUTBOX_RING_CAPACITY) outbox.ring.shift();
1877
+ this.deliverToDevice(deviceId, envelope);
1878
+ return envelope;
1879
+ }
1880
+ deliverToDevice(deviceId, envelope) {
1881
+ const conn = this.connections.get(deviceId);
1882
+ if (conn?.connected && conn.ws) {
1883
+ conn.ws.send(encodeEnvelope(envelope));
1884
+ }
1885
+ this.settleLongPollWaiter(deviceId);
1886
+ }
1887
+ /**
1888
+ * Retained envelopes for `deviceId` with `seq > cursor` that still belong
1889
+ * to a non-terminal task — OR are explicitly exempted from that filter
1890
+ * (`redeliverThroughTerminal`, N1/F4: `task.cancel`/`task.reject`) — in
1891
+ * `seq` order. The `seq > cursor` bound is what naturally stops an
1892
+ * exempted entry from redelivering forever: once the daemon acks it (its
1893
+ * reported cursor advances past that `seq`), it no longer qualifies here
1894
+ * on any future reconnect/poll.
1895
+ */
1896
+ collectRelevant(deviceId, cursor) {
1897
+ const outbox = this.outboxes.get(deviceId);
1898
+ if (!outbox) return [];
1899
+ return outbox.ring.filter(
1900
+ (entry) => entry.seq > cursor && entry.taskId !== void 0 && (!this.isTaskTerminal(entry.taskId) || entry.redeliverThroughTerminal)
1901
+ ).map((entry) => entry.envelope);
1902
+ }
1903
+ isTaskTerminal(taskId) {
1904
+ const record = this.taskStore.get(taskId);
1905
+ return !record || isTerminal(record.state);
1906
+ }
1907
+ /** The highest `seq` assigned to `deviceId` so far — the redelivery cursor to hand back on a poll/reconnect. */
1908
+ currentCursor(deviceId) {
1909
+ const outbox = this.outboxes.get(deviceId);
1910
+ return outbox ? outbox.nextSeq - 1 : 0;
1911
+ }
1912
+ getOrCreateOutbox(deviceId) {
1913
+ let outbox = this.outboxes.get(deviceId);
1914
+ if (!outbox) {
1915
+ outbox = { nextSeq: 1, ring: [] };
1916
+ this.outboxes.set(deviceId, outbox);
1917
+ }
1918
+ return outbox;
1919
+ }
1920
+ // ---------------------------------------------------------------------
1921
+ // read-only accessors backing the public `machines` / `tasks` API
1922
+ // ---------------------------------------------------------------------
1923
+ listMachines() {
1924
+ return this.devices.list().map(({ deviceId, deviceName }) => {
1925
+ const conn = this.connections.get(deviceId);
1926
+ return {
1927
+ deviceId,
1928
+ deviceName,
1929
+ connected: conn?.connected ?? false,
1930
+ lastSeen: conn?.lastSeen,
1931
+ runtimes: conn?.runtimes
1932
+ };
1933
+ });
1934
+ }
1935
+ /**
1936
+ * M5 (approval targeting, hello-capability plumbing): the capability flags
1937
+ * `deviceId`'s CURRENT connection advertised in its `conn.hello` —
1938
+ * `undefined` if this hub has no connection state for the device at all,
1939
+ * or one that never had capabilities recorded (a pre-M5 daemon, or a
1940
+ * device this hub only ever saw over long-poll with no prior WS hello —
1941
+ * see `ConnectionState.capabilities`'s own doc comment). Read fresh from
1942
+ * live connection state, mirroring `listMachines()`'s own convention; an
1943
+ * embedder can use this to distinguish a targeting-capable device from a
1944
+ * legacy one for its own observability/UI purposes (see `version.ts`'s
1945
+ * `approval-targeting` flag doc comment for why this is informational
1946
+ * only, never a correctness gate).
1947
+ */
1948
+ getDeviceCapabilities(deviceId) {
1949
+ return this.connections.get(deviceId)?.capabilities;
1950
+ }
1951
+ getTask(taskId) {
1952
+ return this.taskStore.get(taskId);
1953
+ }
1954
+ listTasks() {
1955
+ return this.taskStore.list();
1956
+ }
1957
+ // ---------------------------------------------------------------------
1958
+ // observability (M4 Phase 4, part B.1) — in-process only; see
1959
+ // `types.ts`'s `HubStats`/`CreateByokServerOptions.healthzRoute` doc
1960
+ // comments for why this is never exposed over HTTP by this SDK itself.
1961
+ // ---------------------------------------------------------------------
1962
+ /**
1963
+ * A plain, serializable snapshot of this hub's current state, derived from
1964
+ * existing structures (`connections`, `taskStore`) plus the small counters
1965
+ * this file already maintains for exactly this purpose — no new
1966
+ * bookkeeping structures beyond those counters. See {@link HubStats}
1967
+ * (`types.ts`) for the full field-by-field contract.
1968
+ */
1969
+ stats() {
1970
+ const taskCountsByState = Object.fromEntries(TASK_STATES.map((state) => [state, 0]));
1971
+ for (const record of this.taskStore.list()) {
1972
+ taskCountsByState[record.state]++;
1973
+ }
1974
+ let connectedDeviceCount = 0;
1975
+ for (const conn of this.connections.values()) {
1976
+ if (conn.connected) connectedDeviceCount++;
1977
+ }
1978
+ return {
1979
+ connectedDeviceCount,
1980
+ taskCountsByState,
1981
+ envelopesIn: this.envelopesInCount,
1982
+ envelopesOut: this.envelopesOutCount,
1983
+ dedupDrops: this.dedupDropCount,
1984
+ rateLimitEvents: this.rateLimitEventCount,
1985
+ uptimeMs: Date.now() - this.startedAtMs
1986
+ };
1987
+ }
1988
+ };
1989
+ var IllegalTaskTransitionError = class extends Error {
1990
+ constructor(taskId, from, to) {
1991
+ super(`illegal task transition for ${taskId}: ${from} -> ${to}`);
1992
+ this.taskId = taskId;
1993
+ this.from = from;
1994
+ this.to = to;
1995
+ this.name = "IllegalTaskTransitionError";
1996
+ }
1997
+ taskId;
1998
+ from;
1999
+ to;
2000
+ };
2001
+ var InMemoryTaskStore = class {
2002
+ tasks = /* @__PURE__ */ new Map();
2003
+ create(input) {
2004
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2005
+ const record = {
2006
+ taskId: input.taskId,
2007
+ state: "Offered",
2008
+ instruction: input.instruction,
2009
+ runtime: input.runtime,
2010
+ policy: input.policy,
2011
+ deviceId: input.deviceId,
2012
+ sessionRef: input.sessionRef,
2013
+ createdAt: now,
2014
+ updatedAt: now
2015
+ };
2016
+ this.tasks.set(record.taskId, record);
2017
+ return record;
2018
+ }
2019
+ get(taskId) {
2020
+ return this.tasks.get(taskId);
2021
+ }
2022
+ list() {
2023
+ return [...this.tasks.values()];
2024
+ }
2025
+ /**
2026
+ * Apply `taskId`'s state -> `to`, merging `patch` into the record. Throws
2027
+ * {@link IllegalTaskTransitionError} if the move isn't legal per
2028
+ * `TASK_TRANSITIONS`, and if the task doesn't exist at all.
2029
+ */
2030
+ transition(taskId, to, patch = {}) {
2031
+ const record = this.tasks.get(taskId);
2032
+ if (!record) {
2033
+ throw new Error(`unknown taskId: ${taskId}`);
2034
+ }
2035
+ if (!canTransition(record.state, to)) {
2036
+ throw new IllegalTaskTransitionError(taskId, record.state, to);
2037
+ }
2038
+ const updated = {
2039
+ ...record,
2040
+ ...patch,
2041
+ state: to,
2042
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2043
+ };
2044
+ this.tasks.set(taskId, updated);
2045
+ return updated;
2046
+ }
2047
+ /**
2048
+ * See {@link TaskStore.setPendingApprovalId}'s own doc comment for the
2049
+ * full rationale. State-guarded (S3 hardening): a write only applies while
2050
+ * `taskId` is still `AwaitApproval` — mirrors `SqliteTaskStore`'s own `AND
2051
+ * state = 'AwaitApproval'` CAS predicate (`sqlite-task-store.ts`) for
2052
+ * symmetry between the two reference implementations, guarding against a
2053
+ * laggard caller resurrecting a pending id after the task already left
2054
+ * `AwaitApproval` (e.g. a queued/delayed `task.await_approval` processed
2055
+ * after a real `approveTask`/`rejectTask` already transitioned it
2056
+ * elsewhere). A non-matching call is a no-op: returns the record exactly
2057
+ * as it currently stands, not the caller's requested (rejected) value.
2058
+ */
2059
+ setPendingApprovalId(taskId, pendingApprovalId) {
2060
+ const record = this.tasks.get(taskId);
2061
+ if (!record) return void 0;
2062
+ if (record.state !== "AwaitApproval") return record;
2063
+ const updated = { ...record, pendingApprovalId, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
2064
+ this.tasks.set(taskId, updated);
2065
+ return updated;
2066
+ }
2067
+ };
2068
+
2069
+ // src/heartbeat.ts
2070
+ var DEFAULT_INTERVAL_MS = 3e4;
2071
+ var DEFAULT_MAX_MISSED_PONGS = 2;
2072
+ function startHeartbeat(ws, opts = {}) {
2073
+ const intervalMs = opts.intervalMs ?? DEFAULT_INTERVAL_MS;
2074
+ const maxMissedPongs = opts.maxMissedPongs ?? DEFAULT_MAX_MISSED_PONGS;
2075
+ let awaitingPong = false;
2076
+ let missed = 0;
2077
+ const onPong = () => {
2078
+ awaitingPong = false;
2079
+ missed = 0;
2080
+ };
2081
+ ws.on("pong", onPong);
2082
+ const timer = setInterval(() => {
2083
+ if (awaitingPong) {
2084
+ missed++;
2085
+ if (missed >= maxMissedPongs) {
2086
+ clearInterval(timer);
2087
+ ws.terminate();
2088
+ return;
2089
+ }
2090
+ }
2091
+ awaitingPong = true;
2092
+ ws.ping();
2093
+ }, intervalMs);
2094
+ timer.unref?.();
2095
+ return {
2096
+ stop() {
2097
+ clearInterval(timer);
2098
+ ws.off("pong", onPong);
2099
+ }
2100
+ };
2101
+ }
2102
+
2103
+ // src/ws-server.ts
2104
+ var WS_PATH = "/byok/ws";
2105
+ var SUPPORTED_CAPABILITIES = [...CAPABILITY_FLAGS];
2106
+ function matchesWsPath(url) {
2107
+ return url.split("?")[0] === WS_PATH;
2108
+ }
2109
+ function rejectUpgrade(socket, code, message) {
2110
+ const body = message;
2111
+ const headers = [
2112
+ `HTTP/1.1 ${code} ${message}`,
2113
+ "Connection: close",
2114
+ "Content-Type: text/plain",
2115
+ `Content-Length: ${Buffer.byteLength(body)}`,
2116
+ "",
2117
+ ""
2118
+ ].join("\r\n");
2119
+ socket.once("finish", () => socket.destroy());
2120
+ socket.end(headers + body);
2121
+ }
2122
+ function toDecodable(data) {
2123
+ if (typeof data === "string") return data;
2124
+ if (Array.isArray(data)) return new Uint8Array(Buffer.concat(data));
2125
+ if (data instanceof ArrayBuffer) return new Uint8Array(data);
2126
+ return data;
2127
+ }
2128
+ function attachWebSocket(server, deps) {
2129
+ const wss = new WebSocketServer({ noServer: true });
2130
+ server.on("upgrade", (req, socket, head) => {
2131
+ if (!matchesWsPath(req.url ?? "")) return;
2132
+ void (async () => {
2133
+ const principal = await authenticateBearer(req.headers.authorization, deps);
2134
+ if (!principal) {
2135
+ rejectUpgrade(socket, 401, "Unauthorized");
2136
+ return;
2137
+ }
2138
+ wss.handleUpgrade(req, socket, head, (ws) => {
2139
+ handleConnection(ws, principal, deps);
2140
+ });
2141
+ })();
2142
+ });
2143
+ }
2144
+ function handleConnection(ws, principal, deps) {
2145
+ const { deviceId } = principal;
2146
+ let helloReceived = false;
2147
+ let heartbeat;
2148
+ ws.once("message", (data) => {
2149
+ let envelope;
2150
+ try {
2151
+ envelope = decodeEnvelope(toDecodable(data));
2152
+ } catch {
2153
+ ws.close(1002, "expected conn.hello");
2154
+ return;
2155
+ }
2156
+ if (envelope.type !== "conn.hello") {
2157
+ ws.close(1002, "expected conn.hello");
2158
+ return;
2159
+ }
2160
+ const payload = envelope.payload;
2161
+ if (!payload.protocolVersions.includes(PROTOCOL_VERSION)) {
2162
+ ws.close(1002, "unsupported protocol version");
2163
+ return;
2164
+ }
2165
+ if (payload.productId !== deps.productId) {
2166
+ ws.close(1002, "productId mismatch");
2167
+ return;
2168
+ }
2169
+ if (payload.productId !== principal.productId) {
2170
+ ws.close(1002, "productId does not match the device record");
2171
+ return;
2172
+ }
2173
+ if (payload.deviceId !== deviceId) {
2174
+ ws.close(1002, "deviceId does not match authenticated token");
2175
+ return;
2176
+ }
2177
+ helloReceived = true;
2178
+ deps.hub.registerConnection(deviceId, ws, payload.runtimes, payload.capabilities);
2179
+ deps.hub.sendConnAck(deviceId, SUPPORTED_CAPABILITIES);
2180
+ if (payload.cursor !== void 0) {
2181
+ deps.hub.redeliverAfterReconnect(deviceId, payload.cursor);
2182
+ }
2183
+ heartbeat = startHeartbeat(ws, { intervalMs: deps.heartbeatIntervalMs });
2184
+ ws.on("message", (msgData) => {
2185
+ let msg;
2186
+ try {
2187
+ msg = decodeEnvelope(toDecodable(msgData));
2188
+ } catch (err) {
2189
+ console.warn(`[byok/server] dropping unparsable frame from device ${deviceId}:`, err);
2190
+ return;
2191
+ }
2192
+ deps.hub.handleInbound(deviceId, msg);
2193
+ });
2194
+ });
2195
+ ws.on("close", () => {
2196
+ heartbeat?.stop();
2197
+ if (helloReceived) {
2198
+ deps.hub.handleDisconnect(deviceId, ws);
2199
+ }
2200
+ });
2201
+ }
2202
+ var SqliteUnavailableError = class extends Error {
2203
+ constructor(cause) {
2204
+ super(
2205
+ "node:sqlite is unavailable in this Node.js runtime. The SQLite-backed reference stores (SqliteTaskStore/SqliteBlobStore) require Node.js 22.5+ with the built-in `node:sqlite` module (no native dependency is used or allowed here). Upgrade Node.js, or use the default InMemoryTaskStore / LocalDiskBlobStore instead."
2206
+ );
2207
+ this.name = "SqliteUnavailableError";
2208
+ this.cause = cause;
2209
+ }
2210
+ };
2211
+ var MIN_NODE_MAJOR = 22;
2212
+ var MIN_NODE_MINOR = 5;
2213
+ function isSqliteCapableNodeVersion(nodeVersion) {
2214
+ const [majorStr, minorStr] = nodeVersion.split(".");
2215
+ const major = Number(majorStr);
2216
+ const minor = Number(minorStr);
2217
+ if (!Number.isFinite(major) || !Number.isFinite(minor)) return true;
2218
+ return major > MIN_NODE_MAJOR || major === MIN_NODE_MAJOR && minor >= MIN_NODE_MINOR;
2219
+ }
2220
+ var sqliteModule;
2221
+ function loadSqliteModule() {
2222
+ if (sqliteModule) return sqliteModule;
2223
+ if (!isSqliteCapableNodeVersion(process.versions.node)) {
2224
+ throw new SqliteUnavailableError(
2225
+ new Error(
2226
+ `node:sqlite requires Node.js ${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}+; detected ${process.versions.node}`
2227
+ )
2228
+ );
2229
+ }
2230
+ try {
2231
+ const require2 = createRequire(import.meta.url);
2232
+ sqliteModule = require2("node:sqlite");
2233
+ return sqliteModule;
2234
+ } catch (err) {
2235
+ throw new SqliteUnavailableError(err);
2236
+ }
2237
+ }
2238
+ var DEFAULT_BUSY_TIMEOUT_MS = 5e3;
2239
+ var SECURE_FILE_MODE = 384;
2240
+ var SECURE_DIR_MODE = 448;
2241
+ function openSqliteDatabase(path2, options) {
2242
+ const { DatabaseSync } = loadSqliteModule();
2243
+ if (path2 !== ":memory:") {
2244
+ mkdirSync(dirname(path2), { recursive: true, mode: SECURE_DIR_MODE });
2245
+ }
2246
+ const db = new DatabaseSync(path2, { timeout: DEFAULT_BUSY_TIMEOUT_MS, ...options });
2247
+ if (path2 !== ":memory:") {
2248
+ db.exec("PRAGMA journal_mode = WAL;");
2249
+ }
2250
+ return db;
2251
+ }
2252
+ function secureSqliteFilePermissions(dbPath) {
2253
+ if (dbPath === ":memory:") return;
2254
+ for (const candidate of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
2255
+ if (existsSync(candidate)) {
2256
+ chmodSync(candidate, SECURE_FILE_MODE);
2257
+ }
2258
+ }
2259
+ }
2260
+
2261
+ // src/sqlite-task-store.ts
2262
+ var SCHEMA = `
2263
+ CREATE TABLE IF NOT EXISTS tasks (
2264
+ task_id TEXT PRIMARY KEY,
2265
+ state TEXT NOT NULL,
2266
+ instruction TEXT NOT NULL,
2267
+ runtime TEXT,
2268
+ policy_json TEXT NOT NULL,
2269
+ device_id TEXT,
2270
+ session_ref TEXT,
2271
+ created_at TEXT NOT NULL,
2272
+ updated_at TEXT NOT NULL,
2273
+ result_json TEXT,
2274
+ pending_approval_id TEXT,
2275
+ claimed_runtime TEXT,
2276
+ claimed_runtime_capabilities_json TEXT
2277
+ );
2278
+ `;
2279
+ var ADDITIVE_COLUMNS = [
2280
+ { name: "pending_approval_id", ddl: "ALTER TABLE tasks ADD COLUMN pending_approval_id TEXT" },
2281
+ { name: "claimed_runtime", ddl: "ALTER TABLE tasks ADD COLUMN claimed_runtime TEXT" },
2282
+ // S0 (GAP-002): the claim-time `RuntimeCapabilities` snapshot, stored as
2283
+ // the JSON text of that closed, protocol-validated object (same idiom as
2284
+ // `policy_json`/`result_json` above — no column-per-flag, so a future
2285
+ // capability flag needs no further schema move). NULL means "no snapshot",
2286
+ // which the steer gate (`hub.ts`) treats as a refusal, never as a default.
2287
+ {
2288
+ name: "claimed_runtime_capabilities_json",
2289
+ ddl: "ALTER TABLE tasks ADD COLUMN claimed_runtime_capabilities_json TEXT"
2290
+ }
2291
+ ];
2292
+ function currentTaskColumns(db) {
2293
+ return new Set(db.prepare("PRAGMA table_info(tasks)").all().map((c) => c.name));
2294
+ }
2295
+ function ensureAdditiveColumns(db) {
2296
+ const existing = currentTaskColumns(db);
2297
+ for (const column of ADDITIVE_COLUMNS) {
2298
+ if (existing.has(column.name)) continue;
2299
+ try {
2300
+ db.exec(column.ddl);
2301
+ } catch (err) {
2302
+ const message = err instanceof Error ? err.message : String(err);
2303
+ if (!/duplicate column name/i.test(message)) {
2304
+ throw err;
2305
+ }
2306
+ if (!currentTaskColumns(db).has(column.name)) {
2307
+ throw err;
2308
+ }
2309
+ }
2310
+ }
2311
+ }
2312
+ function rowToRecord(row) {
2313
+ const resultJson = row.result_json;
2314
+ const claimedRuntimeCapabilitiesJson = row.claimed_runtime_capabilities_json;
2315
+ return {
2316
+ taskId: row.task_id,
2317
+ state: row.state,
2318
+ instruction: row.instruction,
2319
+ runtime: row.runtime ?? void 0,
2320
+ policy: JSON.parse(row.policy_json),
2321
+ deviceId: row.device_id ?? void 0,
2322
+ sessionRef: row.session_ref ?? void 0,
2323
+ createdAt: row.created_at,
2324
+ updatedAt: row.updated_at,
2325
+ result: resultJson ? JSON.parse(resultJson) : void 0,
2326
+ pendingApprovalId: row.pending_approval_id ?? void 0,
2327
+ claimedRuntime: row.claimed_runtime ?? void 0,
2328
+ claimedRuntimeCapabilities: claimedRuntimeCapabilitiesJson ? JSON.parse(claimedRuntimeCapabilitiesJson) : void 0
2329
+ };
2330
+ }
2331
+ var SqliteTaskStore = class {
2332
+ db;
2333
+ insertStmt;
2334
+ updateStmt;
2335
+ selectStmt;
2336
+ selectAllStmt;
2337
+ updatePendingApprovalIdStmt;
2338
+ constructor(opts) {
2339
+ this.db = openSqliteDatabase(opts.path);
2340
+ this.db.exec(SCHEMA);
2341
+ ensureAdditiveColumns(this.db);
2342
+ secureSqliteFilePermissions(opts.path);
2343
+ this.insertStmt = this.db.prepare(
2344
+ `INSERT INTO tasks
2345
+ (task_id, state, instruction, runtime, policy_json, device_id, session_ref, created_at, updated_at, result_json)
2346
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
2347
+ );
2348
+ this.updateStmt = this.db.prepare(
2349
+ `UPDATE tasks SET
2350
+ state = ?, instruction = ?, runtime = ?, policy_json = ?, device_id = ?,
2351
+ session_ref = ?, created_at = ?, updated_at = ?, result_json = ?, pending_approval_id = ?,
2352
+ claimed_runtime = ?, claimed_runtime_capabilities_json = ?
2353
+ WHERE task_id = ? AND state = ?`
2354
+ );
2355
+ this.selectStmt = this.db.prepare("SELECT * FROM tasks WHERE task_id = ?");
2356
+ this.selectAllStmt = this.db.prepare("SELECT * FROM tasks ORDER BY rowid ASC");
2357
+ this.updatePendingApprovalIdStmt = this.db.prepare(
2358
+ "UPDATE tasks SET pending_approval_id = ?, updated_at = ? WHERE task_id = ? AND state = 'AwaitApproval'"
2359
+ );
2360
+ }
2361
+ create(input) {
2362
+ const now = (/* @__PURE__ */ new Date()).toISOString();
2363
+ const record = {
2364
+ taskId: input.taskId,
2365
+ state: "Offered",
2366
+ instruction: input.instruction,
2367
+ runtime: input.runtime,
2368
+ policy: input.policy,
2369
+ deviceId: input.deviceId,
2370
+ sessionRef: input.sessionRef,
2371
+ createdAt: now,
2372
+ updatedAt: now
2373
+ };
2374
+ this.insertStmt.run(
2375
+ record.taskId,
2376
+ record.state,
2377
+ record.instruction,
2378
+ record.runtime ?? null,
2379
+ JSON.stringify(record.policy),
2380
+ record.deviceId ?? null,
2381
+ record.sessionRef ?? null,
2382
+ record.createdAt,
2383
+ record.updatedAt,
2384
+ record.result ? JSON.stringify(record.result) : null
2385
+ );
2386
+ return record;
2387
+ }
2388
+ get(taskId) {
2389
+ const row = this.selectStmt.get(taskId);
2390
+ return row ? rowToRecord(row) : void 0;
2391
+ }
2392
+ list() {
2393
+ return this.selectAllStmt.all().map(rowToRecord);
2394
+ }
2395
+ /**
2396
+ * Apply `taskId`'s state -> `to`, merging `patch` into the record. Throws
2397
+ * {@link IllegalTaskTransitionError} if the move isn't legal per
2398
+ * `TASK_TRANSITIONS`, and if the task doesn't exist at all — identical
2399
+ * contract and error shapes to `InMemoryTaskStore.transition`.
2400
+ *
2401
+ * Implemented as a compare-and-set retry loop rather than a single
2402
+ * read-validate-write, because two separate connections (two processes,
2403
+ * or two `SqliteTaskStore` instances in this one) can both read the same
2404
+ * current state and both validate a move against it before either writes.
2405
+ * An unconditional `UPDATE` would let whichever commits last silently win
2406
+ * — including an illegal terminal -> terminal transition neither
2407
+ * validation call would have allowed with up-to-date information. Each
2408
+ * iteration here reads the CURRENT state fresh, validates `to` against
2409
+ * it, then writes with `WHERE state = <the state just validated>`
2410
+ * (`updateStmt`). If zero rows changed, some other writer committed
2411
+ * between this read and this write, so the loop re-reads and either
2412
+ * re-validates `to` against whatever the state actually is now, or throws
2413
+ * {@link IllegalTaskTransitionError} against it — the same outcome a
2414
+ * caller would get if it happened to run a moment later.
2415
+ */
2416
+ transition(taskId, to, patch = {}) {
2417
+ const MAX_CAS_ATTEMPTS = 100;
2418
+ for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt++) {
2419
+ const record = this.get(taskId);
2420
+ if (!record) {
2421
+ throw new Error(`unknown taskId: ${taskId}`);
2422
+ }
2423
+ if (!canTransition(record.state, to)) {
2424
+ throw new IllegalTaskTransitionError(taskId, record.state, to);
2425
+ }
2426
+ const updated = {
2427
+ ...record,
2428
+ ...patch,
2429
+ state: to,
2430
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
2431
+ };
2432
+ const { changes } = this.updateStmt.run(
2433
+ updated.state,
2434
+ updated.instruction,
2435
+ updated.runtime ?? null,
2436
+ JSON.stringify(updated.policy),
2437
+ updated.deviceId ?? null,
2438
+ updated.sessionRef ?? null,
2439
+ updated.createdAt,
2440
+ updated.updatedAt,
2441
+ updated.result ? JSON.stringify(updated.result) : null,
2442
+ updated.pendingApprovalId ?? null,
2443
+ updated.claimedRuntime ?? null,
2444
+ updated.claimedRuntimeCapabilities ? JSON.stringify(updated.claimedRuntimeCapabilities) : null,
2445
+ updated.taskId,
2446
+ record.state
2447
+ );
2448
+ if (Number(changes) > 0) {
2449
+ return updated;
2450
+ }
2451
+ }
2452
+ throw new Error(
2453
+ `transition contention: taskId ${taskId} kept changing state under concurrent writers after ${MAX_CAS_ATTEMPTS} attempts`
2454
+ );
2455
+ }
2456
+ /**
2457
+ * See {@link TaskStore.setPendingApprovalId}'s own doc comment for the
2458
+ * full rationale, and `updatePendingApprovalIdStmt`'s own doc comment
2459
+ * (constructor, above) for the S3 CAS-guard rationale. The guarded
2460
+ * statement affecting 0 rows means `taskId` is no longer `AwaitApproval`
2461
+ * (or vanished) as of the write — a legitimate no-op, not an error: this
2462
+ * method never throws for a state mismatch (best-effort bookkeeping, same
2463
+ * as its unconditional pre-S3 form). Returns a FRESH read in that case
2464
+ * rather than the caller's now-stale pre-write snapshot, so a caller sees
2465
+ * what's actually stored.
2466
+ */
2467
+ setPendingApprovalId(taskId, pendingApprovalId) {
2468
+ const record = this.get(taskId);
2469
+ if (!record) return void 0;
2470
+ const updatedAt = (/* @__PURE__ */ new Date()).toISOString();
2471
+ const { changes } = this.updatePendingApprovalIdStmt.run(pendingApprovalId ?? null, updatedAt, taskId);
2472
+ if (Number(changes) === 0) {
2473
+ return this.get(taskId);
2474
+ }
2475
+ return { ...record, pendingApprovalId, updatedAt };
2476
+ }
2477
+ /**
2478
+ * Close the underlying database connection. Not part of the `TaskStore`
2479
+ * interface (an in-memory store has nothing to close) — call this
2480
+ * explicitly when a store instance is done, e.g. before opening a second
2481
+ * instance against the same file, or on process shutdown.
2482
+ */
2483
+ close() {
2484
+ this.db.close();
2485
+ }
2486
+ };
2487
+ var SCHEMA2 = `
2488
+ CREATE TABLE IF NOT EXISTS meta (
2489
+ key TEXT PRIMARY KEY,
2490
+ value TEXT NOT NULL
2491
+ );
2492
+ CREATE TABLE IF NOT EXISTS blobs (
2493
+ blob_id TEXT PRIMARY KEY,
2494
+ size INTEGER NOT NULL,
2495
+ content_type TEXT NOT NULL,
2496
+ content_hash TEXT NOT NULL,
2497
+ uploaded INTEGER NOT NULL DEFAULT 0,
2498
+ data BLOB
2499
+ );
2500
+ `;
2501
+ var DEFAULT_URL_TTL_MS2 = 15 * 60 * 1e3;
2502
+ var SIGNING_SECRET_META_KEY = "signing_secret";
2503
+ function sha256Hex2(data) {
2504
+ return `sha256:${createHash("sha256").update(data).digest("hex")}`;
2505
+ }
2506
+ function toBuffer(value) {
2507
+ const view = value;
2508
+ return Buffer.from(view.buffer, view.byteOffset, view.byteLength);
2509
+ }
2510
+ function loadOrCreateSigningSecret(db, generateCandidate = () => randomBytes(32)) {
2511
+ const selectStmt = db.prepare("SELECT value FROM meta WHERE key = ?");
2512
+ const existing = selectStmt.get(SIGNING_SECRET_META_KEY);
2513
+ if (existing) return Buffer.from(existing.value, "hex");
2514
+ const candidate = generateCandidate();
2515
+ db.prepare("INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)").run(
2516
+ SIGNING_SECRET_META_KEY,
2517
+ candidate.toString("hex")
2518
+ );
2519
+ const persisted = selectStmt.get(SIGNING_SECRET_META_KEY);
2520
+ if (!persisted) {
2521
+ throw new Error("failed to initialize SQLite blob store signing secret");
2522
+ }
2523
+ return Buffer.from(persisted.value, "hex");
2524
+ }
2525
+ var SqliteBlobStore = class {
2526
+ db;
2527
+ urlTtlMs;
2528
+ secret;
2529
+ insertBlobStmt;
2530
+ selectBlobStmt;
2531
+ selectUploadedStmt;
2532
+ writeContentStmt;
2533
+ constructor(opts) {
2534
+ this.db = openSqliteDatabase(opts.path);
2535
+ this.db.exec(SCHEMA2);
2536
+ secureSqliteFilePermissions(opts.path);
2537
+ this.urlTtlMs = opts.urlTtlMs ?? DEFAULT_URL_TTL_MS2;
2538
+ this.secret = opts.signingKey ?? loadOrCreateSigningSecret(this.db);
2539
+ this.insertBlobStmt = this.db.prepare(
2540
+ `INSERT INTO blobs (blob_id, size, content_type, content_hash, uploaded, data)
2541
+ VALUES (?, ?, ?, ?, 0, NULL)`
2542
+ );
2543
+ this.selectBlobStmt = this.db.prepare(
2544
+ "SELECT size, content_type, content_hash, uploaded, data FROM blobs WHERE blob_id = ?"
2545
+ );
2546
+ this.selectUploadedStmt = this.db.prepare("SELECT uploaded FROM blobs WHERE blob_id = ?");
2547
+ this.writeContentStmt = this.db.prepare("UPDATE blobs SET data = ?, uploaded = 1 WHERE blob_id = ?");
2548
+ }
2549
+ async createUpload(input, requestedBlobId) {
2550
+ const blobId = requestedBlobId ?? `blob_${randomUUID()}`;
2551
+ const existing = this.selectBlobStmt.get(blobId);
2552
+ if (existing !== void 0) {
2553
+ if (existing.size !== input.size || existing.content_type !== input.contentType || existing.content_hash !== input.contentHash) {
2554
+ throw new BlobDeclarationConflictError(blobId);
2555
+ }
2556
+ return { blobId, uploadUrl: this.signUrl(blobId, "put") };
2557
+ }
2558
+ this.insertBlobStmt.run(blobId, input.size, input.contentType, input.contentHash);
2559
+ return { blobId, uploadUrl: this.signUrl(blobId, "put") };
2560
+ }
2561
+ async getDownloadUrl(blobId) {
2562
+ const row = this.selectUploadedStmt.get(blobId);
2563
+ if (!row?.uploaded) return void 0;
2564
+ return this.signUrl(blobId, "get");
2565
+ }
2566
+ async exists(blobId) {
2567
+ const row = this.selectUploadedStmt.get(blobId);
2568
+ return Boolean(row?.uploaded);
2569
+ }
2570
+ verifySignedUrl(blobId, action, sig, exp) {
2571
+ if (!Number.isFinite(exp) || Date.now() > exp) return false;
2572
+ const expected = this.computeSig(blobId, action, exp);
2573
+ const expectedBuf = Buffer.from(expected, "base64url");
2574
+ const actualBuf = Buffer.from(sig, "base64url");
2575
+ if (expectedBuf.length !== actualBuf.length) return false;
2576
+ return timingSafeEqual(expectedBuf, actualBuf);
2577
+ }
2578
+ async writeContent(blobId, data) {
2579
+ const row = this.selectBlobStmt.get(blobId);
2580
+ if (!row) return { ok: false, reason: "unknown blobId" };
2581
+ if (row.uploaded) return { ok: false, reason: "blob already uploaded" };
2582
+ if (data.length !== row.size) {
2583
+ return { ok: false, reason: `size mismatch: declared ${row.size}, received ${data.length}` };
2584
+ }
2585
+ const actualHash = sha256Hex2(data);
2586
+ if (actualHash !== row.content_hash) {
2587
+ return { ok: false, reason: "contentHash mismatch" };
2588
+ }
2589
+ this.writeContentStmt.run(data, blobId);
2590
+ return { ok: true };
2591
+ }
2592
+ async readContent(blobId) {
2593
+ const row = this.selectBlobStmt.get(blobId);
2594
+ if (!row?.uploaded || row.data === null || row.data === void 0) return void 0;
2595
+ return { data: toBuffer(row.data), contentType: row.content_type };
2596
+ }
2597
+ /** Close the underlying database connection — see `SqliteTaskStore.close`'s doc comment; same rationale. */
2598
+ close() {
2599
+ this.db.close();
2600
+ }
2601
+ computeSig(blobId, action, exp) {
2602
+ return createHmac("sha256", this.secret).update(`${blobId}:${action}:${exp}`).digest("base64url");
2603
+ }
2604
+ signUrl(blobId, action) {
2605
+ const exp = Date.now() + this.urlTtlMs;
2606
+ const sig = this.computeSig(blobId, action, exp);
2607
+ return `/byok/blobs/${blobId}/content?sig=${sig}&exp=${exp}`;
2608
+ }
2609
+ };
2610
+
2611
+ // src/index.ts
2612
+ var DEFAULT_MAX_BLOB_SIZE_BYTES = 100 * 1024 * 1024;
2613
+ var DEFAULT_LONG_POLL_HOLD_MS = 5e4;
2614
+ var DEFAULT_TASK_LEASE_MS = 30 * 6e4;
2615
+ function createByokServer(opts) {
2616
+ const pairing = new PairingManager();
2617
+ const devices = new DeviceRegistry();
2618
+ const nonces = new NonceStore();
2619
+ const tokenSigner = opts.tokenSigner ?? createHmacTokenSigner();
2620
+ const blobStore = opts.blobStore ?? new LocalDiskBlobStore();
2621
+ const maxBlobSizeBytes = opts.maxBlobSizeBytes ?? DEFAULT_MAX_BLOB_SIZE_BYTES;
2622
+ const longPollHoldMs = opts.longPollHoldMs ?? DEFAULT_LONG_POLL_HOLD_MS;
2623
+ const taskLeaseMs = opts.taskLeaseMs ?? DEFAULT_TASK_LEASE_MS;
2624
+ const rateLimiter = new RateLimiter(opts.rateLimit);
2625
+ const taskStore = opts.taskStore ?? new InMemoryTaskStore();
2626
+ const hub = new ConnectionHub(taskStore, devices, taskLeaseMs, rateLimiter);
2627
+ const hono = buildHonoApp({
2628
+ pairing,
2629
+ devices,
2630
+ nonces,
2631
+ tokenSigner,
2632
+ blobStore,
2633
+ maxBlobSizeBytes,
2634
+ longPollHoldMs,
2635
+ hub,
2636
+ healthzRoute: opts.healthzRoute ?? false
2637
+ });
2638
+ return {
2639
+ hono,
2640
+ attachWebSocket(server) {
2641
+ attachWebSocket(server, {
2642
+ devices,
2643
+ tokenSigner,
2644
+ hub,
2645
+ productId: opts.productId,
2646
+ heartbeatIntervalMs: opts.heartbeatIntervalMs
2647
+ });
2648
+ },
2649
+ pairing: {
2650
+ createPairingCode: (claims) => pairing.createPairingCode(claims)
2651
+ },
2652
+ dispatch: (input) => hub.dispatch(input),
2653
+ tasks: {
2654
+ get: (taskId) => hub.getTask(taskId),
2655
+ list: () => hub.listTasks()
2656
+ },
2657
+ machines: {
2658
+ list: () => hub.listMachines()
2659
+ },
2660
+ events: {
2661
+ subscribe: () => hub.subscribeServerEvents()
2662
+ },
2663
+ devices: {
2664
+ revoke: (tenantId, deviceId) => devices.revoke(tenantId, deviceId)
2665
+ },
2666
+ stop() {
2667
+ hub.stopLeaseReaper();
2668
+ },
2669
+ stats: () => hub.stats()
2670
+ };
2671
+ }
2672
+
2673
+ export { IllegalTaskTransitionError, InMemoryTaskStore, LocalDiskBlobStore, PairingCodeInvalidError, SqliteBlobStore, SqliteTaskStore, SqliteUnavailableError, StaleApprovalError, SteerRejectedError, createByokServer, createHmacTokenSigner };
2674
+ //# sourceMappingURL=index.js.map
2675
+ //# sourceMappingURL=index.js.map