@byok-sdk/server 0.11.0 → 0.12.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/blob-store.d.ts +23 -11
- package/dist/hub.d.ts +17 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +388 -59
- package/dist/index.js.map +1 -1
- package/dist/pairing.d.ts +29 -0
- package/dist/sqlite-blob-store.d.ts +8 -4
- package/dist/types.d.ts +6 -0
- package/dist/ws-server.d.ts +3 -0
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
+
import { CAPABILITY_FLAGS, byokBlobContentPath, canTransition, RESULT_DOCUMENT_MAX_BYTES, PROTOCOL_VERSION, encodeEnvelope, DAEMON_TO_SERVER_TYPES, AGENT_EGRESS_POLICY_CAPABILITY, AGENT_EGRESS_RELIABLE_ACK_CAPABILITY, AGENT_MESSAGE_EGRESS_CAPABILITY, AgentRefSchema, AgentEgressPolicySchema, AgentMessageEgressRequirementSchema, TerminalProjectionSelectionSchema, AgentMessageServerContextSchema, DispatchSelectionSchema, RequiredToolsetsSchema, STRICT_AGENT_ONLY_CAPABILITY, AGENT_EGRESS_FRESH_SESSION_CAPABILITY, TERMINAL_PROJECTION_SELECTION_CAPABILITY, AgentContentReadPayloadSchema, AgentHomeProjectionPayloadSchema, AGENT_HOME_PROJECTION_CAPABILITY, AgentHomeProjectionReadbackSchema, AgentHomeProjectionCompletionRequestSchema, createEnvelope, TASK_STATES, BYOK_PAIR_PATH, PairRequestSchema, PairResponseSchema, BYOK_CHALLENGE_PATH, ChallengeRequestSchema, BYOK_TOKEN_PATH, TokenRequestSchema, BYOK_BLOBS_PATH, CreateBlobRequestSchema, BYOK_BLOB_FINALIZE_ROUTE, BYOK_BLOB_URL_ROUTE, BYOK_BLOB_CONTENT_ROUTE, BYOK_EVENTS_PATH, BYOK_MESSAGES_PATH, MessagesSendRequestSchema, BYOK_AGENT_HOME_PROJECTION_COMPLETION_ROUTE, AGENT_CONTENT_ARTIFACT_READ_CAPABILITY, AGENT_CONTENT_TRANSCRIPT_READ_CAPABILITY, AGENT_CONTENT_WORKSPACE_READ_CAPABILITY, PairResponseTenantIdSchema, decodeEnvelope, BYOK_WS_PATH } from '@byok-sdk/protocol';
|
|
1
2
|
import { randomBytes, randomUUID, timingSafeEqual, createHmac, createHash, createPublicKey, verify } from 'crypto';
|
|
2
3
|
import { nonceSigningBytes } from '@byok-sdk/core';
|
|
3
4
|
import { jwtVerify, SignJWT } from 'jose';
|
|
4
|
-
import { mkdir, writeFile, readFile } from 'fs/promises';
|
|
5
|
+
import { mkdir, writeFile, readFile, rename } from 'fs/promises';
|
|
5
6
|
import { mkdtempSync, mkdirSync, existsSync, chmodSync } from 'fs';
|
|
6
7
|
import { tmpdir } from 'os';
|
|
7
8
|
import path, { dirname } from 'path';
|
|
8
|
-
import { CAPABILITY_FLAGS, byokBlobContentPath, canTransition, PROTOCOL_VERSION, encodeEnvelope, DAEMON_TO_SERVER_TYPES, AGENT_EGRESS_POLICY_CAPABILITY, AGENT_EGRESS_RELIABLE_ACK_CAPABILITY, AGENT_MESSAGE_EGRESS_CAPABILITY, AgentRefSchema, AgentEgressPolicySchema, AgentMessageEgressRequirementSchema, TerminalProjectionSelectionSchema, AgentMessageServerContextSchema, DispatchSelectionSchema, RequiredToolsetsSchema, STRICT_AGENT_ONLY_CAPABILITY, AGENT_EGRESS_FRESH_SESSION_CAPABILITY, TERMINAL_PROJECTION_SELECTION_CAPABILITY, AgentContentReadPayloadSchema, AgentHomeProjectionPayloadSchema, AGENT_HOME_PROJECTION_CAPABILITY, AgentHomeProjectionReadbackSchema, AgentHomeProjectionCompletionRequestSchema, createEnvelope, TASK_STATES, BYOK_PAIR_PATH, PairRequestSchema, PairResponseSchema, BYOK_CHALLENGE_PATH, ChallengeRequestSchema, BYOK_TOKEN_PATH, TokenRequestSchema, BYOK_BLOBS_PATH, CreateBlobRequestSchema, BYOK_BLOB_FINALIZE_ROUTE, BYOK_BLOB_URL_ROUTE, BYOK_BLOB_CONTENT_ROUTE, BYOK_EVENTS_PATH, BYOK_MESSAGES_PATH, MessagesSendRequestSchema, BYOK_AGENT_HOME_PROJECTION_COMPLETION_ROUTE, AGENT_CONTENT_ARTIFACT_READ_CAPABILITY, AGENT_CONTENT_TRANSCRIPT_READ_CAPABILITY, AGENT_CONTENT_WORKSPACE_READ_CAPABILITY, PairResponseTenantIdSchema, decodeEnvelope, BYOK_WS_PATH } from '@byok-sdk/protocol';
|
|
9
9
|
import { Hono } from 'hono';
|
|
10
10
|
import { WebSocketServer } from 'ws';
|
|
11
11
|
import { createRequire } from 'module';
|
|
12
12
|
|
|
13
|
-
// src/
|
|
13
|
+
// src/index.ts
|
|
14
14
|
var ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
|
|
15
15
|
var NONCE_TTL_MS = 5 * 60 * 1e3;
|
|
16
16
|
function createHmacTokenSigner(secret = randomBytes(32)) {
|
|
@@ -208,34 +208,46 @@ function sha256Hex(data) {
|
|
|
208
208
|
var LocalDiskBlobStore = class {
|
|
209
209
|
secret = randomBytes(32);
|
|
210
210
|
directory;
|
|
211
|
+
metadataPath;
|
|
211
212
|
urlTtlMs;
|
|
212
213
|
blobs = /* @__PURE__ */ new Map();
|
|
213
214
|
ready;
|
|
215
|
+
metadataWriteTail = Promise.resolve();
|
|
214
216
|
constructor(opts = {}) {
|
|
215
217
|
this.directory = opts.directory ?? mkdtempSync(path.join(tmpdir(), "byok-blobs-"));
|
|
218
|
+
this.metadataPath = path.join(this.directory, "metadata.json");
|
|
216
219
|
this.urlTtlMs = opts.urlTtlMs ?? DEFAULT_URL_TTL_MS;
|
|
217
|
-
this.ready = mkdir(this.directory, { recursive: true }).then(() =>
|
|
220
|
+
this.ready = mkdir(this.directory, { recursive: true }).then(() => this.loadMetadata());
|
|
218
221
|
}
|
|
219
|
-
async createUpload(input, requestedBlobId) {
|
|
222
|
+
async createUpload(tenantId, input, requestedBlobId) {
|
|
220
223
|
await this.ready;
|
|
221
224
|
const blobId = requestedBlobId ?? `blob_${randomUUID()}`;
|
|
222
225
|
const existing = this.blobs.get(blobId);
|
|
223
226
|
if (existing !== void 0) {
|
|
224
|
-
if (existing.meta.size !== input.size || existing.meta.contentType !== input.contentType || existing.meta.contentHash !== input.contentHash) {
|
|
227
|
+
if (existing.tenantId !== tenantId || existing.meta.size !== input.size || existing.meta.contentType !== input.contentType || existing.meta.contentHash !== input.contentHash) {
|
|
225
228
|
throw new BlobDeclarationConflictError(blobId);
|
|
226
229
|
}
|
|
227
230
|
return { blobId, uploadUrl: this.signUrl(blobId, "put") };
|
|
228
231
|
}
|
|
229
|
-
this.blobs.set(blobId, { meta: input, uploaded: false });
|
|
232
|
+
this.blobs.set(blobId, { tenantId, meta: input, uploaded: false });
|
|
233
|
+
await this.persistMetadata();
|
|
230
234
|
return { blobId, uploadUrl: this.signUrl(blobId, "put") };
|
|
231
235
|
}
|
|
232
|
-
async getDownloadUrl(blobId) {
|
|
236
|
+
async getDownloadUrl(tenantId, blobId) {
|
|
237
|
+
await this.ready;
|
|
233
238
|
const record = this.blobs.get(blobId);
|
|
234
|
-
if (!record?.uploaded) return void 0;
|
|
239
|
+
if (!record?.uploaded || record.tenantId !== tenantId) return void 0;
|
|
235
240
|
return this.signUrl(blobId, "get");
|
|
236
241
|
}
|
|
237
|
-
async exists(blobId) {
|
|
238
|
-
|
|
242
|
+
async exists(tenantId, blobId) {
|
|
243
|
+
await this.ready;
|
|
244
|
+
const record = this.blobs.get(blobId);
|
|
245
|
+
return record?.tenantId === tenantId && record.uploaded;
|
|
246
|
+
}
|
|
247
|
+
async getUploadReservation(blobId) {
|
|
248
|
+
await this.ready;
|
|
249
|
+
const record = this.blobs.get(blobId);
|
|
250
|
+
return record === void 0 ? void 0 : { size: record.meta.size };
|
|
239
251
|
}
|
|
240
252
|
verifySignedUrl(blobId, action, sig, exp) {
|
|
241
253
|
if (!Number.isFinite(exp) || Date.now() > exp) return false;
|
|
@@ -259,9 +271,11 @@ var LocalDiskBlobStore = class {
|
|
|
259
271
|
}
|
|
260
272
|
await writeFile(this.pathFor(blobId), data);
|
|
261
273
|
record.uploaded = true;
|
|
274
|
+
await this.persistMetadata();
|
|
262
275
|
return { ok: true };
|
|
263
276
|
}
|
|
264
277
|
async readContent(blobId) {
|
|
278
|
+
await this.ready;
|
|
265
279
|
const record = this.blobs.get(blobId);
|
|
266
280
|
if (!record?.uploaded) return void 0;
|
|
267
281
|
const data = await readFile(this.pathFor(blobId));
|
|
@@ -270,6 +284,41 @@ var LocalDiskBlobStore = class {
|
|
|
270
284
|
pathFor(blobId) {
|
|
271
285
|
return path.join(this.directory, blobId);
|
|
272
286
|
}
|
|
287
|
+
async loadMetadata() {
|
|
288
|
+
let raw;
|
|
289
|
+
try {
|
|
290
|
+
raw = await readFile(this.metadataPath, "utf8");
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (error.code === "ENOENT") return;
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
const parsed = JSON.parse(raw);
|
|
296
|
+
if (!isBlobMetadataDocument(parsed)) {
|
|
297
|
+
throw new Error("invalid LocalDiskBlobStore metadata document");
|
|
298
|
+
}
|
|
299
|
+
for (const entry of parsed.blobs) {
|
|
300
|
+
this.blobs.set(entry.blobId, {
|
|
301
|
+
tenantId: entry.tenantId,
|
|
302
|
+
meta: entry.meta,
|
|
303
|
+
uploaded: entry.uploaded
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
this.secret = Buffer.from(parsed.signingSecret, "base64url");
|
|
307
|
+
}
|
|
308
|
+
async persistMetadata() {
|
|
309
|
+
const write = this.metadataWriteTail.then(async () => {
|
|
310
|
+
const metadata = {
|
|
311
|
+
version: 1,
|
|
312
|
+
signingSecret: this.secret.toString("base64url"),
|
|
313
|
+
blobs: [...this.blobs.entries()].map(([blobId, record]) => ({ blobId, ...record }))
|
|
314
|
+
};
|
|
315
|
+
const temporaryPath = `${this.metadataPath}.${randomUUID()}.tmp`;
|
|
316
|
+
await writeFile(temporaryPath, JSON.stringify(metadata), "utf8");
|
|
317
|
+
await rename(temporaryPath, this.metadataPath);
|
|
318
|
+
});
|
|
319
|
+
this.metadataWriteTail = write.catch(() => void 0);
|
|
320
|
+
await write;
|
|
321
|
+
}
|
|
273
322
|
computeSig(blobId, action, exp) {
|
|
274
323
|
return createHmac("sha256", this.secret).update(`${blobId}:${action}:${exp}`).digest("base64url");
|
|
275
324
|
}
|
|
@@ -279,6 +328,21 @@ var LocalDiskBlobStore = class {
|
|
|
279
328
|
return `${byokBlobContentPath(blobId)}?sig=${sig}&exp=${exp}`;
|
|
280
329
|
}
|
|
281
330
|
};
|
|
331
|
+
function isBlobMetadataDocument(value) {
|
|
332
|
+
if (typeof value !== "object" || value === null) return false;
|
|
333
|
+
const document = value;
|
|
334
|
+
if (document.version !== 1 || typeof document.signingSecret !== "string" || !Array.isArray(document.blobs)) return false;
|
|
335
|
+
const signingSecret = Buffer.from(document.signingSecret, "base64url");
|
|
336
|
+
if (signingSecret.length !== 32) return false;
|
|
337
|
+
return document.blobs.every((entry) => {
|
|
338
|
+
if (typeof entry !== "object" || entry === null) return false;
|
|
339
|
+
const candidate = entry;
|
|
340
|
+
if (typeof candidate.blobId !== "string" || typeof candidate.tenantId !== "string" || typeof candidate.uploaded !== "boolean") return false;
|
|
341
|
+
if (typeof candidate.meta !== "object" || candidate.meta === null) return false;
|
|
342
|
+
const meta = candidate.meta;
|
|
343
|
+
return typeof meta.size === "number" && Number.isSafeInteger(meta.size) && meta.size >= 0 && typeof meta.contentType === "string" && typeof meta.contentHash === "string";
|
|
344
|
+
});
|
|
345
|
+
}
|
|
282
346
|
|
|
283
347
|
// src/event-queue.ts
|
|
284
348
|
var AsyncEventQueue = class {
|
|
@@ -567,6 +631,16 @@ var AgentHomeProjectionCompletionError = class extends Error {
|
|
|
567
631
|
}
|
|
568
632
|
code;
|
|
569
633
|
};
|
|
634
|
+
var ReplayGapError = class extends Error {
|
|
635
|
+
constructor(cursor, recoverableFrom) {
|
|
636
|
+
super(`cursor_too_old: cursor ${String(cursor)} is older than recoverable sequence ${String(recoverableFrom)}`);
|
|
637
|
+
this.cursor = cursor;
|
|
638
|
+
this.recoverableFrom = recoverableFrom;
|
|
639
|
+
this.name = "ReplayGapError";
|
|
640
|
+
}
|
|
641
|
+
cursor;
|
|
642
|
+
recoverableFrom;
|
|
643
|
+
};
|
|
570
644
|
var ConnectionHub = class {
|
|
571
645
|
constructor(taskStore, devices, taskLeaseMs, rateLimiter = new RateLimiter(), agentMessage) {
|
|
572
646
|
this.taskStore = taskStore;
|
|
@@ -584,6 +658,7 @@ var ConnectionHub = class {
|
|
|
584
658
|
rateLimiter;
|
|
585
659
|
agentMessage;
|
|
586
660
|
connections = /* @__PURE__ */ new Map();
|
|
661
|
+
connectionEpochs = /* @__PURE__ */ new Map();
|
|
587
662
|
outboxes = /* @__PURE__ */ new Map();
|
|
588
663
|
/** Idempotency window per device (N3) — recent inbound envelope ids, capped at {@link DEDUP_RING_CAPACITY}. */
|
|
589
664
|
dedupRings = /* @__PURE__ */ new Map();
|
|
@@ -661,8 +736,11 @@ var ConnectionHub = class {
|
|
|
661
736
|
*/
|
|
662
737
|
registerConnection(deviceId, ws, runtimes, capabilities, configuredToolsets, clientVersion) {
|
|
663
738
|
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
739
|
+
const prior = this.connections.get(deviceId);
|
|
740
|
+
const epoch = this.nextConnectionEpoch(deviceId);
|
|
664
741
|
this.connections.set(deviceId, {
|
|
665
742
|
ws,
|
|
743
|
+
epoch,
|
|
666
744
|
connected: true,
|
|
667
745
|
lastSeen: at,
|
|
668
746
|
clientVersion,
|
|
@@ -672,6 +750,15 @@ var ConnectionHub = class {
|
|
|
672
750
|
});
|
|
673
751
|
this.serverEvents.push({ kind: "device.connected", deviceId, at });
|
|
674
752
|
this.settleLongPollWaiter(deviceId);
|
|
753
|
+
if (prior?.ws && prior.ws !== ws) {
|
|
754
|
+
prior.ws.close(1e3, "superseded by newer websocket connection");
|
|
755
|
+
}
|
|
756
|
+
return epoch;
|
|
757
|
+
}
|
|
758
|
+
/** True only while this exact WS registration remains the device authority. */
|
|
759
|
+
isCurrentConnection(deviceId, ws, epoch) {
|
|
760
|
+
const connection = this.connections.get(deviceId);
|
|
761
|
+
return connection?.connected === true && connection.ws === ws && connection.epoch === epoch;
|
|
675
762
|
}
|
|
676
763
|
sendConnAck(deviceId, capabilities) {
|
|
677
764
|
this.sendToDevice(
|
|
@@ -692,6 +779,7 @@ var ConnectionHub = class {
|
|
|
692
779
|
* non-terminal task. Called after `conn.ack` (step 2), per the spec.
|
|
693
780
|
*/
|
|
694
781
|
redeliverAfterReconnect(deviceId, cursor) {
|
|
782
|
+
this.assertReplayAvailable(deviceId, cursor);
|
|
695
783
|
const conn = this.connections.get(deviceId);
|
|
696
784
|
if (!conn?.ws || !conn.connected) return;
|
|
697
785
|
for (const envelope of this.collectRelevant(deviceId, cursor)) {
|
|
@@ -752,6 +840,7 @@ var ConnectionHub = class {
|
|
|
752
840
|
forgetDevice(deviceId) {
|
|
753
841
|
const conn = this.connections.get(deviceId);
|
|
754
842
|
this.connections.delete(deviceId);
|
|
843
|
+
this.connectionEpochs.delete(deviceId);
|
|
755
844
|
this.outboxes.delete(deviceId);
|
|
756
845
|
this.dedupRings.delete(deviceId);
|
|
757
846
|
this.rateLimitEventEmittedFor.delete(deviceId);
|
|
@@ -770,19 +859,25 @@ var ConnectionHub = class {
|
|
|
770
859
|
* {@link ConnectionState}).
|
|
771
860
|
*/
|
|
772
861
|
async pollEvents(deviceId, cursor, holdMs) {
|
|
862
|
+
this.assertReplayAvailable(deviceId, cursor);
|
|
773
863
|
this.takeOverAsLongPoll(deviceId);
|
|
774
864
|
this.settleLongPollWaiter(deviceId);
|
|
775
865
|
const immediate = this.collectRelevant(deviceId, cursor);
|
|
776
866
|
if (immediate.length > 0) {
|
|
777
867
|
return { events: immediate, cursor: this.currentCursor(deviceId) };
|
|
778
868
|
}
|
|
779
|
-
return new Promise((resolve) => {
|
|
869
|
+
return new Promise((resolve, reject) => {
|
|
780
870
|
const timer = setTimeout(() => {
|
|
781
871
|
this.longPollWaiters.delete(deviceId);
|
|
782
|
-
|
|
872
|
+
try {
|
|
873
|
+
this.assertReplayAvailable(deviceId, cursor);
|
|
874
|
+
resolve({ events: [], cursor: this.currentCursor(deviceId) });
|
|
875
|
+
} catch (error) {
|
|
876
|
+
reject(error);
|
|
877
|
+
}
|
|
783
878
|
}, holdMs);
|
|
784
879
|
timer.unref?.();
|
|
785
|
-
this.longPollWaiters.set(deviceId, { cursor, resolve, timer });
|
|
880
|
+
this.longPollWaiters.set(deviceId, { cursor, resolve, reject, timer });
|
|
786
881
|
});
|
|
787
882
|
}
|
|
788
883
|
/** Make long-poll this device's active transport, closing any live WS ("last one wins", §8). */
|
|
@@ -793,6 +888,7 @@ var ConnectionHub = class {
|
|
|
793
888
|
if (conn?.ws) {
|
|
794
889
|
const ws = conn.ws;
|
|
795
890
|
this.connections.set(deviceId, {
|
|
891
|
+
epoch: this.nextConnectionEpoch(deviceId),
|
|
796
892
|
connected: true,
|
|
797
893
|
lastSeen: at,
|
|
798
894
|
runtimes: conn.runtimes,
|
|
@@ -801,7 +897,7 @@ var ConnectionHub = class {
|
|
|
801
897
|
});
|
|
802
898
|
ws.close(1e3, "superseded by long-poll connection");
|
|
803
899
|
} else if (!conn) {
|
|
804
|
-
this.connections.set(deviceId, { connected: true, lastSeen: at });
|
|
900
|
+
this.connections.set(deviceId, { epoch: this.nextConnectionEpoch(deviceId), connected: true, lastSeen: at });
|
|
805
901
|
} else {
|
|
806
902
|
conn.connected = true;
|
|
807
903
|
conn.lastSeen = at;
|
|
@@ -817,7 +913,12 @@ var ConnectionHub = class {
|
|
|
817
913
|
if (!waiter) return;
|
|
818
914
|
this.longPollWaiters.delete(deviceId);
|
|
819
915
|
clearTimeout(waiter.timer);
|
|
820
|
-
|
|
916
|
+
try {
|
|
917
|
+
this.assertReplayAvailable(deviceId, waiter.cursor);
|
|
918
|
+
waiter.resolve({ events: this.collectRelevant(deviceId, waiter.cursor), cursor: this.currentCursor(deviceId) });
|
|
919
|
+
} catch (error) {
|
|
920
|
+
waiter.reject(error);
|
|
921
|
+
}
|
|
821
922
|
}
|
|
822
923
|
// ---------------------------------------------------------------------
|
|
823
924
|
// inbound envelopes from a connected daemon
|
|
@@ -939,7 +1040,6 @@ var ConnectionHub = class {
|
|
|
939
1040
|
if (binding === void 0 || record === void 0 || record.deviceId !== deviceId || binding.deviceId !== deviceId || !sameAgentRef(binding.agentRef, payload.agentRef) || binding.sessionRef !== void 0 && binding.sessionRef !== payload.sessionRef || binding.requirement.contract !== payload.contract || binding.requirement.contentType !== payload.contentType || payload.byteCount > binding.requirement.maxBytes) return "rejected";
|
|
940
1041
|
const taskPayload = this.agentMessageTaskPayloads.get(taskId);
|
|
941
1042
|
if (taskPayload !== void 0 && JSON.stringify(taskPayload) !== JSON.stringify(payload)) return "rejected";
|
|
942
|
-
if (taskPayload === void 0) this.agentMessageTaskPayloads.set(taskId, payload);
|
|
943
1043
|
const key = `${deviceId}\0${payload.messageId}`;
|
|
944
1044
|
const previous = this.agentMessageReceipts.get(key);
|
|
945
1045
|
if (previous !== void 0) {
|
|
@@ -947,6 +1047,9 @@ var ConnectionHub = class {
|
|
|
947
1047
|
this.sendAgentMessageDisposition(deviceId, taskId, previous.disposition);
|
|
948
1048
|
return "duplicate";
|
|
949
1049
|
}
|
|
1050
|
+
if (isTerminal(record.state)) return "rejected";
|
|
1051
|
+
if (taskPayload !== void 0) return "rejected";
|
|
1052
|
+
this.agentMessageTaskPayloads.set(taskId, payload);
|
|
950
1053
|
const decision = this.agentMessage?.consume({ deviceId, taskId, context: binding.context, payload }) ?? { outcome: "held", reasonCode: "consumer_unavailable" };
|
|
951
1054
|
const disposition = {
|
|
952
1055
|
agentRef: payload.agentRef,
|
|
@@ -2172,7 +2275,12 @@ var ConnectionHub = class {
|
|
|
2172
2275
|
const redeliverThroughTerminal = type === "task.cancel" || type === "task.reject";
|
|
2173
2276
|
const redeliverWithoutTask = type === "agent.egress.ack" || type === "agent.content.read" || type === "agent.home.projection" || type === "agent.message.disposition";
|
|
2174
2277
|
outbox.ring.push({ seq, taskId, envelope, redeliverThroughTerminal, redeliverWithoutTask });
|
|
2175
|
-
if (outbox.ring.length > OUTBOX_RING_CAPACITY)
|
|
2278
|
+
if (outbox.ring.length > OUTBOX_RING_CAPACITY) {
|
|
2279
|
+
const evicted = outbox.ring.shift();
|
|
2280
|
+
if (evicted !== void 0 && this.isRecoverableEntry(evicted)) {
|
|
2281
|
+
outbox.recoverableFrom = Math.max(outbox.recoverableFrom, evicted.seq + 1);
|
|
2282
|
+
}
|
|
2283
|
+
}
|
|
2176
2284
|
this.deliverToDevice(deviceId, envelope);
|
|
2177
2285
|
return envelope;
|
|
2178
2286
|
}
|
|
@@ -2199,6 +2307,26 @@ var ConnectionHub = class {
|
|
|
2199
2307
|
(entry) => entry.seq > cursor && (entry.taskId === void 0 ? entry.redeliverWithoutTask === true : !this.isTaskTerminal(entry.taskId) || entry.redeliverThroughTerminal)
|
|
2200
2308
|
).map((entry) => entry.envelope);
|
|
2201
2309
|
}
|
|
2310
|
+
/**
|
|
2311
|
+
* A caller at `recoverableFrom - 1` can still receive the first retained
|
|
2312
|
+
* control. Anything earlier would be a partial tail and must fail closed.
|
|
2313
|
+
*/
|
|
2314
|
+
assertReplayAvailable(deviceId, cursor, pendingOutbound = 0) {
|
|
2315
|
+
const outbox = this.outboxes.get(deviceId);
|
|
2316
|
+
if (outbox === void 0) return;
|
|
2317
|
+
let recoverableFrom = outbox.recoverableFrom;
|
|
2318
|
+
const evictions = Math.max(0, outbox.ring.length + pendingOutbound - OUTBOX_RING_CAPACITY);
|
|
2319
|
+
for (let index = 0; index < evictions; index++) {
|
|
2320
|
+
const evicted = outbox.ring[index];
|
|
2321
|
+
if (evicted !== void 0 && this.isRecoverableEntry(evicted)) {
|
|
2322
|
+
recoverableFrom = Math.max(recoverableFrom, evicted.seq + 1);
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
if (cursor < recoverableFrom - 1) throw new ReplayGapError(cursor, recoverableFrom);
|
|
2326
|
+
}
|
|
2327
|
+
isRecoverableEntry(entry) {
|
|
2328
|
+
return entry.redeliverWithoutTask === true || entry.taskId !== void 0 && (!this.isTaskTerminal(entry.taskId) || entry.redeliverThroughTerminal === true);
|
|
2329
|
+
}
|
|
2202
2330
|
isTaskTerminal(taskId) {
|
|
2203
2331
|
const record = this.taskStore.get(taskId);
|
|
2204
2332
|
return !record || isTerminal(record.state);
|
|
@@ -2211,11 +2339,16 @@ var ConnectionHub = class {
|
|
|
2211
2339
|
getOrCreateOutbox(deviceId) {
|
|
2212
2340
|
let outbox = this.outboxes.get(deviceId);
|
|
2213
2341
|
if (!outbox) {
|
|
2214
|
-
outbox = { nextSeq: 1, ring: [] };
|
|
2342
|
+
outbox = { nextSeq: 1, recoverableFrom: 1, ring: [] };
|
|
2215
2343
|
this.outboxes.set(deviceId, outbox);
|
|
2216
2344
|
}
|
|
2217
2345
|
return outbox;
|
|
2218
2346
|
}
|
|
2347
|
+
nextConnectionEpoch(deviceId) {
|
|
2348
|
+
const epoch = (this.connectionEpochs.get(deviceId) ?? 0) + 1;
|
|
2349
|
+
this.connectionEpochs.set(deviceId, epoch);
|
|
2350
|
+
return epoch;
|
|
2351
|
+
}
|
|
2219
2352
|
// ---------------------------------------------------------------------
|
|
2220
2353
|
// read-only accessors backing the public `machines` / `tasks` API
|
|
2221
2354
|
// ---------------------------------------------------------------------
|
|
@@ -2301,6 +2434,12 @@ var PairingCodeInvalidError = class extends Error {
|
|
|
2301
2434
|
this.name = "PairingCodeInvalidError";
|
|
2302
2435
|
}
|
|
2303
2436
|
};
|
|
2437
|
+
var PairingAttemptConflictError = class extends Error {
|
|
2438
|
+
constructor() {
|
|
2439
|
+
super("pairing code is already bound to a different pairing attempt");
|
|
2440
|
+
this.name = "PairingAttemptConflictError";
|
|
2441
|
+
}
|
|
2442
|
+
};
|
|
2304
2443
|
var PairingManager = class {
|
|
2305
2444
|
codes = /* @__PURE__ */ new Map();
|
|
2306
2445
|
/**
|
|
@@ -2340,6 +2479,46 @@ var PairingManager = class {
|
|
|
2340
2479
|
record.used = true;
|
|
2341
2480
|
return record.claims;
|
|
2342
2481
|
}
|
|
2482
|
+
/**
|
|
2483
|
+
* Bind a code to one exact pairing request, returning any completion that a
|
|
2484
|
+
* prior response/token failure left recoverable. This mutation is entirely
|
|
2485
|
+
* synchronous: the HTTP route cannot yield between binding, registration,
|
|
2486
|
+
* and completion recording, so concurrent requests have one winner.
|
|
2487
|
+
*/
|
|
2488
|
+
beginPairingAttempt(code, binding) {
|
|
2489
|
+
const record = this.codes.get(code);
|
|
2490
|
+
if (!record) throw new PairingCodeInvalidError("unknown code");
|
|
2491
|
+
if (Date.now() > record.expiresAt) throw new PairingCodeInvalidError("code expired");
|
|
2492
|
+
validatePairingAttemptBinding(binding);
|
|
2493
|
+
if (record.attempt !== void 0) {
|
|
2494
|
+
if (!samePairingAttemptBinding(record.attempt.binding, binding)) throw new PairingAttemptConflictError();
|
|
2495
|
+
return { claims: record.claims, completion: record.attempt.completion };
|
|
2496
|
+
}
|
|
2497
|
+
if (record.used) throw new PairingCodeInvalidError("code already used");
|
|
2498
|
+
record.used = true;
|
|
2499
|
+
record.attempt = { binding: { ...binding } };
|
|
2500
|
+
return { claims: record.claims };
|
|
2501
|
+
}
|
|
2502
|
+
/** Record the one device identity a bound pairing attempt completed as. */
|
|
2503
|
+
completePairingAttempt(code, binding, completion) {
|
|
2504
|
+
const record = this.codes.get(code);
|
|
2505
|
+
if (!record?.attempt || !samePairingAttemptBinding(record.attempt.binding, binding)) {
|
|
2506
|
+
throw new PairingAttemptConflictError();
|
|
2507
|
+
}
|
|
2508
|
+
const expected = {
|
|
2509
|
+
deviceId: completion.deviceId,
|
|
2510
|
+
tenantId: record.claims.tenantId,
|
|
2511
|
+
productId: record.claims.productId,
|
|
2512
|
+
deviceName: binding.deviceName,
|
|
2513
|
+
devicePublicKey: binding.devicePublicKey
|
|
2514
|
+
};
|
|
2515
|
+
if (record.attempt.completion !== void 0) {
|
|
2516
|
+
if (!samePairingCompletion(record.attempt.completion, expected)) throw new PairingAttemptConflictError();
|
|
2517
|
+
return record.attempt.completion;
|
|
2518
|
+
}
|
|
2519
|
+
record.attempt.completion = expected;
|
|
2520
|
+
return expected;
|
|
2521
|
+
}
|
|
2343
2522
|
};
|
|
2344
2523
|
function validatePairingCodeClaims(claims) {
|
|
2345
2524
|
if (typeof claims !== "object" || claims === null) {
|
|
@@ -2355,6 +2534,17 @@ function validatePairingCodeClaims(claims) {
|
|
|
2355
2534
|
}
|
|
2356
2535
|
return { tenantId: tenantResult.data, productId };
|
|
2357
2536
|
}
|
|
2537
|
+
function validatePairingAttemptBinding(binding) {
|
|
2538
|
+
if (typeof binding !== "object" || binding === null || typeof binding.deviceName !== "string" || binding.deviceName.length === 0 || typeof binding.devicePublicKey !== "string" || binding.devicePublicKey.length === 0) {
|
|
2539
|
+
throw new TypeError("pairing attempt requires a non-empty deviceName and devicePublicKey");
|
|
2540
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
function samePairingAttemptBinding(left, right) {
|
|
2543
|
+
return left.deviceName === right.deviceName && left.devicePublicKey === right.devicePublicKey;
|
|
2544
|
+
}
|
|
2545
|
+
function samePairingCompletion(left, right) {
|
|
2546
|
+
return left.deviceId === right.deviceId && left.tenantId === right.tenantId && left.productId === right.productId && left.deviceName === right.deviceName && left.devicePublicKey === right.devicePublicKey;
|
|
2547
|
+
}
|
|
2358
2548
|
|
|
2359
2549
|
// src/http.ts
|
|
2360
2550
|
async function readJsonBody(c) {
|
|
@@ -2364,6 +2554,34 @@ async function readJsonBody(c) {
|
|
|
2364
2554
|
return void 0;
|
|
2365
2555
|
}
|
|
2366
2556
|
}
|
|
2557
|
+
function declaredLengthExceeds(value, limit) {
|
|
2558
|
+
if (value === void 0 || !/^(?:0|[1-9]\d*)$/.test(value)) return false;
|
|
2559
|
+
const parsed = Number(value);
|
|
2560
|
+
return Number.isSafeInteger(parsed) && parsed > limit;
|
|
2561
|
+
}
|
|
2562
|
+
async function readBoundedUpload(c, limit) {
|
|
2563
|
+
const body = c.req.raw.body;
|
|
2564
|
+
if (body === null) return { kind: "ok", data: Buffer.alloc(0) };
|
|
2565
|
+
const reader = body.getReader();
|
|
2566
|
+
const target = Buffer.allocUnsafe(limit);
|
|
2567
|
+
let received = 0;
|
|
2568
|
+
try {
|
|
2569
|
+
while (true) {
|
|
2570
|
+
const { done, value } = await reader.read();
|
|
2571
|
+
if (done) return { kind: "ok", data: target.subarray(0, received) };
|
|
2572
|
+
if (received + value.byteLength > limit) {
|
|
2573
|
+
void reader.cancel().catch(() => void 0);
|
|
2574
|
+
return { kind: "too_large" };
|
|
2575
|
+
}
|
|
2576
|
+
target.set(value, received);
|
|
2577
|
+
received += value.byteLength;
|
|
2578
|
+
}
|
|
2579
|
+
} catch {
|
|
2580
|
+
return { kind: "read_error" };
|
|
2581
|
+
} finally {
|
|
2582
|
+
reader.releaseLock();
|
|
2583
|
+
}
|
|
2584
|
+
}
|
|
2367
2585
|
function buildHonoApp(deps) {
|
|
2368
2586
|
const app = new Hono();
|
|
2369
2587
|
const serverStartedAtMs = Date.now();
|
|
@@ -2376,24 +2594,32 @@ function buildHonoApp(deps) {
|
|
|
2376
2594
|
return c.json({ error: "pairingCode, deviceName, and devicePublicKey are required strings" }, 400);
|
|
2377
2595
|
}
|
|
2378
2596
|
const { pairingCode, deviceName, devicePublicKey } = parsed.data;
|
|
2379
|
-
|
|
2597
|
+
const binding = { deviceName, devicePublicKey };
|
|
2598
|
+
let attempt;
|
|
2380
2599
|
try {
|
|
2381
|
-
|
|
2600
|
+
attempt = deps.pairing.beginPairingAttempt(pairingCode, binding);
|
|
2382
2601
|
} catch (err) {
|
|
2383
2602
|
if (err instanceof PairingCodeInvalidError) {
|
|
2384
2603
|
return c.json({ error: err.message }, 401);
|
|
2385
2604
|
}
|
|
2605
|
+
if (err instanceof PairingAttemptConflictError) {
|
|
2606
|
+
return c.json({ error: "pairing_attempt_conflict" }, 409);
|
|
2607
|
+
}
|
|
2386
2608
|
throw err;
|
|
2387
2609
|
}
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2610
|
+
let completion = attempt.completion;
|
|
2611
|
+
if (completion === void 0) {
|
|
2612
|
+
const candidate = {
|
|
2613
|
+
tenantId: attempt.claims.tenantId,
|
|
2614
|
+
productId: attempt.claims.productId,
|
|
2615
|
+
deviceId: generateDeviceId(),
|
|
2616
|
+
deviceName,
|
|
2617
|
+
devicePublicKey
|
|
2618
|
+
};
|
|
2619
|
+
deps.devices.register(candidate);
|
|
2620
|
+
completion = deps.pairing.completePairingAttempt(pairingCode, binding, candidate);
|
|
2621
|
+
}
|
|
2622
|
+
const device = deps.devices.get(completion.tenantId, completion.deviceId);
|
|
2397
2623
|
if (device === void 0) {
|
|
2398
2624
|
throw new Error("paired device row was not persisted");
|
|
2399
2625
|
}
|
|
@@ -2459,7 +2685,7 @@ function buildHonoApp(deps) {
|
|
|
2459
2685
|
}
|
|
2460
2686
|
const blobId = reservationBlobId(principal.tenantId, reservationId);
|
|
2461
2687
|
try {
|
|
2462
|
-
const created = await deps.blobStore.createUpload(parsed.data, blobId);
|
|
2688
|
+
const created = await deps.blobStore.createUpload(principal.tenantId, parsed.data, blobId);
|
|
2463
2689
|
const response = created;
|
|
2464
2690
|
return c.json(response, 200);
|
|
2465
2691
|
} catch (error) {
|
|
@@ -2480,7 +2706,7 @@ function buildHonoApp(deps) {
|
|
|
2480
2706
|
if (reservationBlobId(principal.tenantId, reservationId) !== blobId) {
|
|
2481
2707
|
return c.json({ error: "storage_integrity_mismatch" }, 422);
|
|
2482
2708
|
}
|
|
2483
|
-
if (!await deps.blobStore.exists(blobId)) {
|
|
2709
|
+
if (!await deps.blobStore.exists(principal.tenantId, blobId)) {
|
|
2484
2710
|
return c.json({ error: "storage_reservation_not_found" }, 404);
|
|
2485
2711
|
}
|
|
2486
2712
|
return c.body(null, 204);
|
|
@@ -2488,7 +2714,7 @@ function buildHonoApp(deps) {
|
|
|
2488
2714
|
app.get(BYOK_BLOB_URL_ROUTE, async (c) => {
|
|
2489
2715
|
const principal = await authenticateBearer(c.req.header("authorization"), deps);
|
|
2490
2716
|
if (!principal) return c.json({ error: "unauthorized" }, 401);
|
|
2491
|
-
const downloadUrl = await deps.blobStore.getDownloadUrl(c.req.param("id"));
|
|
2717
|
+
const downloadUrl = await deps.blobStore.getDownloadUrl(principal.tenantId, c.req.param("id"));
|
|
2492
2718
|
if (!downloadUrl) return c.json({ error: "blob not found" }, 404);
|
|
2493
2719
|
const response = { downloadUrl };
|
|
2494
2720
|
return c.json(response, 200);
|
|
@@ -2499,8 +2725,18 @@ function buildHonoApp(deps) {
|
|
|
2499
2725
|
if (!sig || exp === void 0 || !deps.blobStore.verifySignedUrl(blobId, "put", sig, exp)) {
|
|
2500
2726
|
return c.json({ error: "invalid or expired signature" }, 401);
|
|
2501
2727
|
}
|
|
2502
|
-
const
|
|
2503
|
-
|
|
2728
|
+
const reservation = await deps.blobStore.getUploadReservation(blobId);
|
|
2729
|
+
if (reservation === void 0) return c.json({ error: "blob not found" }, 404);
|
|
2730
|
+
if (reservation.size > deps.maxBlobSizeBytes) {
|
|
2731
|
+
return c.json({ error: `blob exceeds max size of ${deps.maxBlobSizeBytes} bytes` }, 413);
|
|
2732
|
+
}
|
|
2733
|
+
if (declaredLengthExceeds(c.req.header("content-length"), reservation.size)) {
|
|
2734
|
+
return c.json({ error: "upload body exceeds reservation" }, 413);
|
|
2735
|
+
}
|
|
2736
|
+
const body = await readBoundedUpload(c, reservation.size);
|
|
2737
|
+
if (body.kind === "too_large") return c.json({ error: "upload body exceeds reservation" }, 413);
|
|
2738
|
+
if (body.kind === "read_error") return c.json({ error: "failed to read upload body" }, 400);
|
|
2739
|
+
const result = await deps.blobStore.writeContent(blobId, body.data);
|
|
2504
2740
|
if (!result.ok) return c.json({ error: result.reason }, 422);
|
|
2505
2741
|
return c.body(null, 204);
|
|
2506
2742
|
});
|
|
@@ -2524,9 +2760,16 @@ function buildHonoApp(deps) {
|
|
|
2524
2760
|
if (!Number.isInteger(parsedCursor) || parsedCursor < 0) return c.json({ error: "invalid cursor" }, 400);
|
|
2525
2761
|
cursor = parsedCursor;
|
|
2526
2762
|
}
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2763
|
+
try {
|
|
2764
|
+
const result = await deps.hub.pollEvents(principal.deviceId, cursor, deps.longPollHoldMs);
|
|
2765
|
+
const response = { ...result, capabilities: [...CAPABILITY_FLAGS] };
|
|
2766
|
+
return c.json(response, 200);
|
|
2767
|
+
} catch (error) {
|
|
2768
|
+
if (error instanceof ReplayGapError) {
|
|
2769
|
+
return c.json({ error: "cursor_too_old", recoverableFrom: error.recoverableFrom }, 409);
|
|
2770
|
+
}
|
|
2771
|
+
throw error;
|
|
2772
|
+
}
|
|
2530
2773
|
});
|
|
2531
2774
|
app.post(BYOK_MESSAGES_PATH, async (c) => {
|
|
2532
2775
|
const principal = await authenticateBearer(c.req.header("authorization"), deps);
|
|
@@ -2716,8 +2959,14 @@ function toDecodable(data) {
|
|
|
2716
2959
|
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
2717
2960
|
return data;
|
|
2718
2961
|
}
|
|
2962
|
+
function rawDataByteLength(data) {
|
|
2963
|
+
if (typeof data === "string") return Buffer.byteLength(data);
|
|
2964
|
+
if (Array.isArray(data)) return data.reduce((total, part) => total + part.byteLength, 0);
|
|
2965
|
+
return data.byteLength;
|
|
2966
|
+
}
|
|
2719
2967
|
function attachWebSocket(server, deps) {
|
|
2720
|
-
const wss = new WebSocketServer({ noServer: true });
|
|
2968
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload: deps.maxPayloadBytes });
|
|
2969
|
+
let pendingHelloConnections = 0;
|
|
2721
2970
|
server.on("upgrade", (req, socket, head) => {
|
|
2722
2971
|
if (!matchesWsPath(req.url ?? "")) return;
|
|
2723
2972
|
void (async () => {
|
|
@@ -2726,17 +2975,44 @@ function attachWebSocket(server, deps) {
|
|
|
2726
2975
|
rejectUpgrade(socket, 401, "Unauthorized");
|
|
2727
2976
|
return;
|
|
2728
2977
|
}
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2978
|
+
if (pendingHelloConnections >= deps.maxPendingWebSockets) {
|
|
2979
|
+
rejectUpgrade(socket, 503, "WebSocket hello admission limit reached");
|
|
2980
|
+
return;
|
|
2981
|
+
}
|
|
2982
|
+
pendingHelloConnections++;
|
|
2983
|
+
let released = false;
|
|
2984
|
+
const releasePendingHello = () => {
|
|
2985
|
+
if (released) return;
|
|
2986
|
+
released = true;
|
|
2987
|
+
pendingHelloConnections--;
|
|
2988
|
+
};
|
|
2989
|
+
try {
|
|
2990
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
2991
|
+
handleConnection(ws, principal, deps, releasePendingHello);
|
|
2992
|
+
});
|
|
2993
|
+
} catch {
|
|
2994
|
+
releasePendingHello();
|
|
2995
|
+
socket.destroy();
|
|
2996
|
+
}
|
|
2732
2997
|
})();
|
|
2733
2998
|
});
|
|
2734
2999
|
}
|
|
2735
|
-
function handleConnection(ws, principal, deps) {
|
|
3000
|
+
function handleConnection(ws, principal, deps, releasePendingHello) {
|
|
2736
3001
|
const { deviceId } = principal;
|
|
2737
3002
|
let helloReceived = false;
|
|
3003
|
+
let epoch;
|
|
2738
3004
|
let heartbeat;
|
|
3005
|
+
const helloDeadline = setTimeout(() => {
|
|
3006
|
+
if (!helloReceived) ws.close(1008, "hello_timeout");
|
|
3007
|
+
}, deps.helloTimeoutMs);
|
|
3008
|
+
helloDeadline.unref?.();
|
|
3009
|
+
const clearHelloDeadline = () => clearTimeout(helloDeadline);
|
|
3010
|
+
ws.on("error", () => void 0);
|
|
2739
3011
|
ws.once("message", (data) => {
|
|
3012
|
+
if (rawDataByteLength(data) > deps.maxPayloadBytes) {
|
|
3013
|
+
ws.close(1009, "payload_too_large");
|
|
3014
|
+
return;
|
|
3015
|
+
}
|
|
2740
3016
|
let envelope;
|
|
2741
3017
|
try {
|
|
2742
3018
|
envelope = decodeEnvelope(toDecodable(data));
|
|
@@ -2765,8 +3041,16 @@ function handleConnection(ws, principal, deps) {
|
|
|
2765
3041
|
ws.close(1002, "deviceId does not match authenticated token");
|
|
2766
3042
|
return;
|
|
2767
3043
|
}
|
|
3044
|
+
if (payload.cursor !== void 0) {
|
|
3045
|
+
try {
|
|
3046
|
+
deps.hub.assertReplayAvailable(deviceId, payload.cursor, 1);
|
|
3047
|
+
} catch {
|
|
3048
|
+
ws.close(1008, "cursor_too_old");
|
|
3049
|
+
return;
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
2768
3052
|
helloReceived = true;
|
|
2769
|
-
deps.hub.registerConnection(
|
|
3053
|
+
epoch = deps.hub.registerConnection(
|
|
2770
3054
|
deviceId,
|
|
2771
3055
|
ws,
|
|
2772
3056
|
payload.runtimes,
|
|
@@ -2774,12 +3058,19 @@ function handleConnection(ws, principal, deps) {
|
|
|
2774
3058
|
payload.configuredToolsets,
|
|
2775
3059
|
payload.clientVersion
|
|
2776
3060
|
);
|
|
3061
|
+
clearHelloDeadline();
|
|
3062
|
+
releasePendingHello();
|
|
2777
3063
|
deps.hub.sendConnAck(deviceId, SUPPORTED_CAPABILITIES);
|
|
2778
3064
|
if (payload.cursor !== void 0) {
|
|
2779
3065
|
deps.hub.redeliverAfterReconnect(deviceId, payload.cursor);
|
|
2780
3066
|
}
|
|
2781
3067
|
heartbeat = startHeartbeat(ws, { intervalMs: deps.heartbeatIntervalMs });
|
|
2782
3068
|
ws.on("message", (msgData) => {
|
|
3069
|
+
if (epoch === void 0 || !deps.hub.isCurrentConnection(deviceId, ws, epoch)) return;
|
|
3070
|
+
if (rawDataByteLength(msgData) > deps.maxPayloadBytes) {
|
|
3071
|
+
ws.close(1009, "payload_too_large");
|
|
3072
|
+
return;
|
|
3073
|
+
}
|
|
2783
3074
|
let msg;
|
|
2784
3075
|
try {
|
|
2785
3076
|
msg = decodeEnvelope(toDecodable(msgData));
|
|
@@ -2787,12 +3078,16 @@ function handleConnection(ws, principal, deps) {
|
|
|
2787
3078
|
console.warn(`[byok/server] dropping unparsable frame from device ${deviceId}:`, err);
|
|
2788
3079
|
return;
|
|
2789
3080
|
}
|
|
2790
|
-
deps.hub.
|
|
3081
|
+
if (deps.hub.isCurrentConnection(deviceId, ws, epoch)) {
|
|
3082
|
+
deps.hub.handleInbound(deviceId, msg);
|
|
3083
|
+
}
|
|
2791
3084
|
});
|
|
2792
3085
|
});
|
|
2793
3086
|
ws.on("close", () => {
|
|
3087
|
+
clearHelloDeadline();
|
|
3088
|
+
releasePendingHello();
|
|
2794
3089
|
heartbeat?.stop();
|
|
2795
|
-
if (helloReceived) {
|
|
3090
|
+
if (helloReceived && epoch !== void 0) {
|
|
2796
3091
|
deps.hub.handleDisconnect(deviceId, ws);
|
|
2797
3092
|
}
|
|
2798
3093
|
});
|
|
@@ -3133,6 +3428,7 @@ CREATE TABLE IF NOT EXISTS meta (
|
|
|
3133
3428
|
);
|
|
3134
3429
|
CREATE TABLE IF NOT EXISTS blobs (
|
|
3135
3430
|
blob_id TEXT PRIMARY KEY,
|
|
3431
|
+
tenant_id TEXT NOT NULL,
|
|
3136
3432
|
size INTEGER NOT NULL,
|
|
3137
3433
|
content_type TEXT NOT NULL,
|
|
3138
3434
|
content_hash TEXT NOT NULL,
|
|
@@ -3149,6 +3445,12 @@ function toBuffer(value) {
|
|
|
3149
3445
|
const view = value;
|
|
3150
3446
|
return Buffer.from(view.buffer, view.byteOffset, view.byteLength);
|
|
3151
3447
|
}
|
|
3448
|
+
function ensureTenantOwnershipColumn(db) {
|
|
3449
|
+
const columns = db.prepare("PRAGMA table_info(blobs)").all();
|
|
3450
|
+
if (!columns.some((column) => column.name === "tenant_id")) {
|
|
3451
|
+
db.exec("ALTER TABLE blobs ADD COLUMN tenant_id TEXT");
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3152
3454
|
function loadOrCreateSigningSecret(db, generateCandidate = () => randomBytes(32)) {
|
|
3153
3455
|
const selectStmt = db.prepare("SELECT value FROM meta WHERE key = ?");
|
|
3154
3456
|
const existing = selectStmt.get(SIGNING_SECRET_META_KEY);
|
|
@@ -3177,17 +3479,18 @@ var SqliteBlobStore = class {
|
|
|
3177
3479
|
this.db = openSqliteDatabase(opts.path);
|
|
3178
3480
|
try {
|
|
3179
3481
|
this.db.exec(SCHEMA2);
|
|
3482
|
+
ensureTenantOwnershipColumn(this.db);
|
|
3180
3483
|
secureSqliteFilePermissions(opts.path);
|
|
3181
3484
|
this.urlTtlMs = opts.urlTtlMs ?? DEFAULT_URL_TTL_MS2;
|
|
3182
3485
|
this.secret = opts.signingKey ?? loadOrCreateSigningSecret(this.db);
|
|
3183
3486
|
this.insertBlobStmt = this.db.prepare(
|
|
3184
|
-
`INSERT INTO blobs (blob_id, size, content_type, content_hash, uploaded, data)
|
|
3185
|
-
VALUES (?, ?, ?, ?, 0, NULL)`
|
|
3487
|
+
`INSERT INTO blobs (blob_id, tenant_id, size, content_type, content_hash, uploaded, data)
|
|
3488
|
+
VALUES (?, ?, ?, ?, ?, 0, NULL)`
|
|
3186
3489
|
);
|
|
3187
3490
|
this.selectBlobStmt = this.db.prepare(
|
|
3188
|
-
"SELECT size, content_type, content_hash, uploaded, data FROM blobs WHERE blob_id = ?"
|
|
3491
|
+
"SELECT tenant_id, size, content_type, content_hash, uploaded, data FROM blobs WHERE blob_id = ?"
|
|
3189
3492
|
);
|
|
3190
|
-
this.selectUploadedStmt = this.db.prepare("SELECT uploaded FROM blobs WHERE blob_id = ?");
|
|
3493
|
+
this.selectUploadedStmt = this.db.prepare("SELECT tenant_id, uploaded FROM blobs WHERE blob_id = ?");
|
|
3191
3494
|
this.writeContentStmt = this.db.prepare("UPDATE blobs SET data = ?, uploaded = 1 WHERE blob_id = ?");
|
|
3192
3495
|
} catch (error) {
|
|
3193
3496
|
closeSqliteDatabaseAfterInitializationFailure(
|
|
@@ -3197,26 +3500,30 @@ var SqliteBlobStore = class {
|
|
|
3197
3500
|
);
|
|
3198
3501
|
}
|
|
3199
3502
|
}
|
|
3200
|
-
async createUpload(input, requestedBlobId) {
|
|
3503
|
+
async createUpload(tenantId, input, requestedBlobId) {
|
|
3201
3504
|
const blobId = requestedBlobId ?? `blob_${randomUUID()}`;
|
|
3202
3505
|
const existing = this.selectBlobStmt.get(blobId);
|
|
3203
3506
|
if (existing !== void 0) {
|
|
3204
|
-
if (existing.size !== input.size || existing.content_type !== input.contentType || existing.content_hash !== input.contentHash) {
|
|
3507
|
+
if (existing.tenant_id !== tenantId || existing.size !== input.size || existing.content_type !== input.contentType || existing.content_hash !== input.contentHash) {
|
|
3205
3508
|
throw new BlobDeclarationConflictError(blobId);
|
|
3206
3509
|
}
|
|
3207
3510
|
return { blobId, uploadUrl: this.signUrl(blobId, "put") };
|
|
3208
3511
|
}
|
|
3209
|
-
this.insertBlobStmt.run(blobId, input.size, input.contentType, input.contentHash);
|
|
3512
|
+
this.insertBlobStmt.run(blobId, tenantId, input.size, input.contentType, input.contentHash);
|
|
3210
3513
|
return { blobId, uploadUrl: this.signUrl(blobId, "put") };
|
|
3211
3514
|
}
|
|
3212
|
-
async getDownloadUrl(blobId) {
|
|
3515
|
+
async getDownloadUrl(tenantId, blobId) {
|
|
3213
3516
|
const row = this.selectUploadedStmt.get(blobId);
|
|
3214
|
-
if (!row?.uploaded) return void 0;
|
|
3517
|
+
if (!row?.uploaded || row.tenant_id !== tenantId) return void 0;
|
|
3215
3518
|
return this.signUrl(blobId, "get");
|
|
3216
3519
|
}
|
|
3217
|
-
async exists(blobId) {
|
|
3520
|
+
async exists(tenantId, blobId) {
|
|
3218
3521
|
const row = this.selectUploadedStmt.get(blobId);
|
|
3219
|
-
return Boolean(row
|
|
3522
|
+
return row?.tenant_id === tenantId && Boolean(row.uploaded);
|
|
3523
|
+
}
|
|
3524
|
+
async getUploadReservation(blobId) {
|
|
3525
|
+
const row = this.selectBlobStmt.get(blobId);
|
|
3526
|
+
return row === void 0 ? void 0 : { size: row.size };
|
|
3220
3527
|
}
|
|
3221
3528
|
verifySignedUrl(blobId, action, sig, exp) {
|
|
3222
3529
|
if (!Number.isFinite(exp) || Date.now() > exp) return false;
|
|
@@ -3265,6 +3572,9 @@ var SqliteBlobStore = class {
|
|
|
3265
3572
|
var DEFAULT_MAX_BLOB_SIZE_BYTES = 100 * 1024 * 1024;
|
|
3266
3573
|
var DEFAULT_LONG_POLL_HOLD_MS = 5e4;
|
|
3267
3574
|
var DEFAULT_TASK_LEASE_MS = 30 * 6e4;
|
|
3575
|
+
var DEFAULT_WEBSOCKET_HELLO_TIMEOUT_MS = 5e3;
|
|
3576
|
+
var DEFAULT_MAX_PENDING_WEBSOCKETS = 32;
|
|
3577
|
+
var DEFAULT_MAX_WEBSOCKET_PAYLOAD_BYTES = RESULT_DOCUMENT_MAX_BYTES + 64 * 1024;
|
|
3268
3578
|
function createByokServer(opts) {
|
|
3269
3579
|
const pairing = new PairingManager();
|
|
3270
3580
|
const devices = new DeviceRegistry();
|
|
@@ -3274,6 +3584,18 @@ function createByokServer(opts) {
|
|
|
3274
3584
|
const maxBlobSizeBytes = opts.maxBlobSizeBytes ?? DEFAULT_MAX_BLOB_SIZE_BYTES;
|
|
3275
3585
|
const longPollHoldMs = opts.longPollHoldMs ?? DEFAULT_LONG_POLL_HOLD_MS;
|
|
3276
3586
|
const taskLeaseMs = opts.taskLeaseMs ?? DEFAULT_TASK_LEASE_MS;
|
|
3587
|
+
const webSocketHelloTimeoutMs = positiveSafeInteger(
|
|
3588
|
+
opts.webSocketHelloTimeoutMs ?? DEFAULT_WEBSOCKET_HELLO_TIMEOUT_MS,
|
|
3589
|
+
"webSocketHelloTimeoutMs"
|
|
3590
|
+
);
|
|
3591
|
+
const maxPendingWebSockets = positiveSafeInteger(
|
|
3592
|
+
opts.maxPendingWebSockets ?? DEFAULT_MAX_PENDING_WEBSOCKETS,
|
|
3593
|
+
"maxPendingWebSockets"
|
|
3594
|
+
);
|
|
3595
|
+
const maxWebSocketPayloadBytes = positiveSafeInteger(
|
|
3596
|
+
opts.maxWebSocketPayloadBytes ?? DEFAULT_MAX_WEBSOCKET_PAYLOAD_BYTES,
|
|
3597
|
+
"maxWebSocketPayloadBytes"
|
|
3598
|
+
);
|
|
3277
3599
|
const rateLimiter = new RateLimiter(opts.rateLimit);
|
|
3278
3600
|
const taskStore = opts.taskStore ?? new InMemoryTaskStore();
|
|
3279
3601
|
const hub = new ConnectionHub(taskStore, devices, taskLeaseMs, rateLimiter, opts.agentMessage);
|
|
@@ -3301,7 +3623,10 @@ function createByokServer(opts) {
|
|
|
3301
3623
|
tokenSigner,
|
|
3302
3624
|
hub,
|
|
3303
3625
|
productId: opts.productId,
|
|
3304
|
-
heartbeatIntervalMs: opts.heartbeatIntervalMs
|
|
3626
|
+
heartbeatIntervalMs: opts.heartbeatIntervalMs,
|
|
3627
|
+
helloTimeoutMs: webSocketHelloTimeoutMs,
|
|
3628
|
+
maxPendingWebSockets,
|
|
3629
|
+
maxPayloadBytes: maxWebSocketPayloadBytes
|
|
3305
3630
|
});
|
|
3306
3631
|
},
|
|
3307
3632
|
pairing: {
|
|
@@ -3338,7 +3663,11 @@ function createByokServer(opts) {
|
|
|
3338
3663
|
stats: () => hub.stats()
|
|
3339
3664
|
};
|
|
3340
3665
|
}
|
|
3666
|
+
function positiveSafeInteger(value, name) {
|
|
3667
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new TypeError(`${name} must be a positive safe integer`);
|
|
3668
|
+
return value;
|
|
3669
|
+
}
|
|
3341
3670
|
|
|
3342
|
-
export { AgentHomeProjectionCompletionError, IllegalTaskTransitionError, InMemoryTaskStore, LocalDiskBlobStore, PairingCodeInvalidError, SqliteBlobStore, SqliteTaskStore, SqliteUnavailableError, StaleApprovalError, SteerRejectedError, createByokServer, createHmacTokenSigner };
|
|
3671
|
+
export { AgentHomeProjectionCompletionError, IllegalTaskTransitionError, InMemoryTaskStore, LocalDiskBlobStore, PairingAttemptConflictError, PairingCodeInvalidError, SqliteBlobStore, SqliteTaskStore, SqliteUnavailableError, StaleApprovalError, SteerRejectedError, createByokServer, createHmacTokenSigner };
|
|
3343
3672
|
//# sourceMappingURL=index.js.map
|
|
3344
3673
|
//# sourceMappingURL=index.js.map
|