@threahq/bot-runtime-client 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/index.js ADDED
@@ -0,0 +1,2344 @@
1
+ // src/transport.ts
2
+ import * as socketIoClient from "socket.io-client";
3
+
4
+ // src/sealed.ts
5
+ import { ulid } from "ulid";
6
+
7
+ // src/crypto.ts
8
+ import { Aes256Gcm, CipherSuite, HkdfSha256 } from "@hpke/core";
9
+ import { DhkemX25519HkdfSha256 } from "@hpke/dhkem-x25519";
10
+ function bytesToBase64(bytes) {
11
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
12
+ let binary = "";
13
+ for (let i = 0;i < view.length; i++)
14
+ binary += String.fromCharCode(view[i]);
15
+ return btoa(binary);
16
+ }
17
+ function base64ToBytes(b64) {
18
+ const binary = atob(b64);
19
+ const bytes = new Uint8Array(binary.length);
20
+ for (let i = 0;i < binary.length; i++)
21
+ bytes[i] = binary.charCodeAt(i);
22
+ return bytes;
23
+ }
24
+ function utf8Encode(text) {
25
+ return new TextEncoder().encode(text);
26
+ }
27
+ function utf8Decode(bytes) {
28
+ return new TextDecoder().decode(bytes);
29
+ }
30
+ function concatBytes(...parts) {
31
+ let total = 0;
32
+ for (const p of parts)
33
+ total += p.length;
34
+ const out = new Uint8Array(total);
35
+ let offset = 0;
36
+ for (const p of parts) {
37
+ out.set(p, offset);
38
+ offset += p.length;
39
+ }
40
+ return out;
41
+ }
42
+ var suite = null;
43
+ function getSuite() {
44
+ if (!suite) {
45
+ suite = new CipherSuite({
46
+ kem: new DhkemX25519HkdfSha256,
47
+ kdf: new HkdfSha256,
48
+ aead: new Aes256Gcm
49
+ });
50
+ }
51
+ return suite;
52
+ }
53
+ async function generateKeyPair() {
54
+ return getSuite().kem.generateKeyPair();
55
+ }
56
+ async function importRecipientPrivateKey(raw) {
57
+ const buf = raw instanceof Uint8Array ? raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength) : raw;
58
+ return getSuite().kem.deserializePrivateKey(buf);
59
+ }
60
+ async function exportPublicKey(key) {
61
+ return new Uint8Array(await getSuite().kem.serializePublicKey(key));
62
+ }
63
+ async function exportPrivateKey(key) {
64
+ return new Uint8Array(await getSuite().kem.serializePrivateKey(key));
65
+ }
66
+ async function importRecipientPublicKey(raw) {
67
+ const buf = raw instanceof Uint8Array ? raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength) : raw;
68
+ return getSuite().kem.deserializePublicKey(buf);
69
+ }
70
+ async function hpkeOpen(params) {
71
+ const buf = await getSuite().open({ recipientKey: params.recipientPrivateKey, enc: params.enc }, params.ct, params.aad);
72
+ return new Uint8Array(buf);
73
+ }
74
+ async function hpkeSeal(params) {
75
+ const sealed = await getSuite().seal({ recipientPublicKey: params.recipientPublicKey }, params.payload, params.aad);
76
+ return { enc: new Uint8Array(sealed.enc), ct: new Uint8Array(sealed.ct) };
77
+ }
78
+ var STREAM_ENVELOPE_VERSION = 2;
79
+ var SSK_LENGTH = 32;
80
+ var IV_LENGTH = 12;
81
+ function assertBoundAad(fn, aad) {
82
+ if (aad.length === 0) {
83
+ throw new Error(`${fn}: aad must be non-empty (see buildMessageAad/buildWrapAad)`);
84
+ }
85
+ }
86
+ async function sealMessage(input) {
87
+ if (input.key.length !== SSK_LENGTH) {
88
+ throw new Error(`sealMessage: SSK must be ${SSK_LENGTH} bytes, got ${input.key.length}`);
89
+ }
90
+ assertBoundAad("sealMessage", input.aad);
91
+ const iv = new Uint8Array(IV_LENGTH);
92
+ crypto.getRandomValues(iv);
93
+ const plaintext = typeof input.payload === "string" ? utf8Encode(input.payload) : new Uint8Array(input.payload);
94
+ const aad = new Uint8Array(input.aad);
95
+ const sskKey = await crypto.subtle.importKey("raw", new Uint8Array(input.key), { name: "AES-GCM" }, false, [
96
+ "encrypt"
97
+ ]);
98
+ const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: aad }, sskKey, plaintext));
99
+ return {
100
+ envelope: {
101
+ v: STREAM_ENVELOPE_VERSION,
102
+ keyGeneration: input.keyGeneration,
103
+ iv: bytesToBase64(iv),
104
+ aad: bytesToBase64(aad)
105
+ },
106
+ ciphertext
107
+ };
108
+ }
109
+ async function openMessage(input) {
110
+ if (input.envelope.v !== STREAM_ENVELOPE_VERSION) {
111
+ throw new Error(`Unsupported stream envelope version: ${input.envelope.v}`);
112
+ }
113
+ if (input.key.length !== SSK_LENGTH) {
114
+ throw new Error(`openMessage: SSK must be ${SSK_LENGTH} bytes, got ${input.key.length}`);
115
+ }
116
+ const aad = base64ToBytes(input.envelope.aad);
117
+ const sskKey = await crypto.subtle.importKey("raw", new Uint8Array(input.key), { name: "AES-GCM" }, false, [
118
+ "decrypt"
119
+ ]);
120
+ const plaintext = new Uint8Array(await crypto.subtle.decrypt({ name: "AES-GCM", iv: base64ToBytes(input.envelope.iv), additionalData: aad }, sskKey, new Uint8Array(input.ciphertext)));
121
+ return plaintext;
122
+ }
123
+ async function openMessageAsString(input) {
124
+ return utf8Decode(await openMessage(input));
125
+ }
126
+ async function wrapStreamKey(input) {
127
+ assertBoundAad("wrapStreamKey", input.aad);
128
+ if (input.key.length !== SSK_LENGTH) {
129
+ throw new Error(`wrapStreamKey: SSK must be ${SSK_LENGTH} bytes, got ${input.key.length}`);
130
+ }
131
+ return hpkeSeal({ recipientPublicKey: input.recipientPublicKey, payload: new Uint8Array(input.key), aad: input.aad });
132
+ }
133
+ function generateStreamKey() {
134
+ const key = new Uint8Array(SSK_LENGTH);
135
+ crypto.getRandomValues(key);
136
+ return key;
137
+ }
138
+ async function unwrapStreamKey(input) {
139
+ assertBoundAad("unwrapStreamKey", input.aad);
140
+ const key = await hpkeOpen({
141
+ recipientPrivateKey: input.recipientPrivateKey,
142
+ enc: input.enc,
143
+ ct: input.ct,
144
+ aad: input.aad
145
+ });
146
+ if (key.length !== SSK_LENGTH) {
147
+ throw new Error(`unwrapStreamKey: recovered key is ${key.length} bytes, expected ${SSK_LENGTH}`);
148
+ }
149
+ return key;
150
+ }
151
+ function buildWrapAad(parts) {
152
+ if (parts.streamId.length === 0 || parts.recipientKeyId.length === 0) {
153
+ throw new Error("buildWrapAad: streamId and recipientKeyId must be non-empty");
154
+ }
155
+ if (parts.streamId.includes("|") || parts.recipientKeyId.includes("|")) {
156
+ throw new Error("buildWrapAad: streamId and recipientKeyId must not contain '|'");
157
+ }
158
+ if (!Number.isInteger(parts.keyGeneration) || parts.keyGeneration < 0) {
159
+ throw new Error("buildWrapAad: keyGeneration must be a non-negative integer");
160
+ }
161
+ return concatBytes(utf8Encode(parts.streamId), utf8Encode("|"), utf8Encode(String(parts.keyGeneration)), utf8Encode("|"), utf8Encode(parts.recipientKeyId));
162
+ }
163
+ function buildMessageAad(parts) {
164
+ return concatBytes(utf8Encode(parts.streamId), utf8Encode("|"), utf8Encode(parts.messageId), utf8Encode("|"), utf8Encode(parts.senderId));
165
+ }
166
+ function buildDecisionAad(parts) {
167
+ return decisionAad("decision", parts.streamId, parts.decisionId, parts.requesterBotId);
168
+ }
169
+ function buildDecisionNoteAad(parts) {
170
+ return decisionAad("decision-note", parts.streamId, parts.decisionId, parts.decidedBy);
171
+ }
172
+ function decisionAad(label, streamId, decisionId, actorId) {
173
+ return concatBytes(utf8Encode(streamId), utf8Encode("|"), utf8Encode(label), utf8Encode("|"), utf8Encode(decisionId), utf8Encode("|"), utf8Encode(actorId));
174
+ }
175
+ var ATTACHMENT_AAD = utf8Encode("threa-attachment-v1");
176
+ var ATTACHMENT_KEY_GENERATION = 0;
177
+ async function encryptAttachmentBytes(plaintext) {
178
+ const key = generateStreamKey();
179
+ const { envelope, ciphertext } = await sealMessage({
180
+ key,
181
+ keyGeneration: ATTACHMENT_KEY_GENERATION,
182
+ payload: plaintext,
183
+ aad: ATTACHMENT_AAD
184
+ });
185
+ return { ciphertext, key: bytesToBase64(key), iv: envelope.iv };
186
+ }
187
+ async function decryptAttachmentBytes(input) {
188
+ return openMessage({
189
+ key: base64ToBytes(input.key),
190
+ envelope: {
191
+ v: STREAM_ENVELOPE_VERSION,
192
+ keyGeneration: ATTACHMENT_KEY_GENERATION,
193
+ iv: input.iv,
194
+ aad: bytesToBase64(ATTACHMENT_AAD)
195
+ },
196
+ ciphertext: input.ciphertext
197
+ });
198
+ }
199
+ var E2E_PAYLOAD_VERSION = 1;
200
+ function serializeSealedPayload(contentMarkdown, extras) {
201
+ const attachmentRefs = extras?.attachmentRefs;
202
+ const sources = extras?.sources;
203
+ const draftContentJson = extras?.draftContentJson;
204
+ const hasRefs = attachmentRefs !== undefined && attachmentRefs.length > 0;
205
+ const hasSources = sources !== undefined && sources.length > 0;
206
+ const hasDraftBody = draftContentJson !== undefined && draftContentJson !== null;
207
+ if (!hasRefs && !hasSources && !hasDraftBody)
208
+ return contentMarkdown;
209
+ return JSON.stringify({
210
+ __e2ePayload: E2E_PAYLOAD_VERSION,
211
+ contentMarkdown,
212
+ attachmentRefs: attachmentRefs ?? [],
213
+ ...hasSources ? { sources } : {},
214
+ ...hasDraftBody ? { draftContentJson } : {}
215
+ });
216
+ }
217
+ function isAttachmentRef(value) {
218
+ if (typeof value !== "object" || value === null)
219
+ return false;
220
+ const r = value;
221
+ return typeof r.attachmentId === "string" && typeof r.key === "string" && typeof r.iv === "string" && typeof r.filename === "string" && typeof r.mimeType === "string" && typeof r.sizeBytes === "number";
222
+ }
223
+ function isSealedSourceItem(value) {
224
+ if (typeof value !== "object" || value === null)
225
+ return false;
226
+ const s = value;
227
+ return typeof s.title === "string" && typeof s.url === "string" && (s.type === undefined || typeof s.type === "string") && (s.snippet === undefined || typeof s.snippet === "string");
228
+ }
229
+ function isDocLike(value) {
230
+ if (typeof value !== "object" || value === null)
231
+ return false;
232
+ const v = value;
233
+ return v.type === "doc" && Array.isArray(v.content);
234
+ }
235
+ function parseSealedPayload(raw) {
236
+ if (raw.startsWith("{")) {
237
+ try {
238
+ const parsed = JSON.parse(raw);
239
+ if (parsed.__e2ePayload === E2E_PAYLOAD_VERSION && typeof parsed.contentMarkdown === "string") {
240
+ const attachmentRefs = Array.isArray(parsed.attachmentRefs) ? parsed.attachmentRefs.filter(isAttachmentRef) : [];
241
+ const sources = Array.isArray(parsed.sources) ? parsed.sources.filter(isSealedSourceItem) : [];
242
+ const draftContentJson = isDocLike(parsed.draftContentJson) ? parsed.draftContentJson : null;
243
+ return { contentMarkdown: parsed.contentMarkdown, attachmentRefs, sources, draftContentJson };
244
+ }
245
+ } catch {}
246
+ }
247
+ return { contentMarkdown: raw, attachmentRefs: [], sources: [], draftContentJson: null };
248
+ }
249
+
250
+ // src/sealed.ts
251
+ var THREA_CALLBACK_TOKEN_HEADER = "X-Threa-Callback-Token";
252
+ async function mintE2eKeyRecord() {
253
+ const keyPair = await generateKeyPair();
254
+ return {
255
+ keyId: `bik_${ulid()}`,
256
+ publicKey: bytesToBase64(await exportPublicKey(keyPair.publicKey)),
257
+ privateKey: bytesToBase64(await exportPrivateKey(keyPair.privateKey))
258
+ };
259
+ }
260
+
261
+ class BotKeyring {
262
+ buildRecords;
263
+ log;
264
+ records;
265
+ cached = [];
266
+ queue = Promise.resolve([]);
267
+ loaded = false;
268
+ constructor(opts) {
269
+ this.buildRecords = opts.keyring;
270
+ this.log = opts.log ?? ((message) => console.error(message));
271
+ }
272
+ get identities() {
273
+ return this.cached;
274
+ }
275
+ async ensure() {
276
+ if (this.loaded)
277
+ return this.cached;
278
+ return this.enqueue((keyring) => keyring.ensure());
279
+ }
280
+ async ensureForStream(streamId) {
281
+ return this.enqueue((keyring) => keyring.ensureForStream(streamId));
282
+ }
283
+ async identityForStream(streamId) {
284
+ const identities = await this.ensureForStream(streamId);
285
+ const record = this.records?.forStream(streamId);
286
+ return record ? identities.find((identity) => identity.publicKeyId === record.keyId) : undefined;
287
+ }
288
+ async dropStream(streamId) {
289
+ return this.enqueue(async (keyring) => keyring.dropStream(streamId));
290
+ }
291
+ presenceFields() {
292
+ return this.records?.presenceFields() ?? {};
293
+ }
294
+ enqueue(work) {
295
+ const next = this.queue.then(async () => {
296
+ try {
297
+ this.records ??= this.buildRecords();
298
+ this.cached = await importAll(await work(this.records));
299
+ this.loaded = this.cached.length > 0;
300
+ } catch (error) {
301
+ this.log(`Threa sealed: key load/create failed; sealed scratchpads are unavailable: ${String(error)}`);
302
+ }
303
+ return this.cached;
304
+ });
305
+ this.queue = next;
306
+ return next;
307
+ }
308
+ }
309
+ async function importAll(records) {
310
+ const identities = [];
311
+ for (const record of records) {
312
+ identities.push({
313
+ publicKeyId: record.keyId,
314
+ publicKeyBase64: record.publicKey,
315
+ privateKey: await importRecipientPrivateKey(base64ToBytes(record.privateKey))
316
+ });
317
+ }
318
+ return identities;
319
+ }
320
+ function isEnvelope(value) {
321
+ if (typeof value !== "object" || value === null)
322
+ return false;
323
+ const v = value;
324
+ return typeof v.v === "number" && typeof v.keyGeneration === "number" && typeof v.iv === "string" && typeof v.aad === "string";
325
+ }
326
+ function isSealedMessage(value) {
327
+ if (typeof value !== "object" || value === null)
328
+ return false;
329
+ const m = value;
330
+ return typeof m.ciphertext === "string" && isEnvelope(m.envelope);
331
+ }
332
+ function parseSealedTurnContext(raw) {
333
+ if (typeof raw !== "object" || raw === null)
334
+ return;
335
+ const c = raw;
336
+ if (typeof c.callbackToken !== "string" || c.callbackToken.length === 0)
337
+ return;
338
+ if (!Array.isArray(c.wraps))
339
+ return;
340
+ const wraps = [];
341
+ for (const wrap of c.wraps) {
342
+ if (typeof wrap !== "object" || wrap === null)
343
+ return;
344
+ const w = wrap;
345
+ if (typeof w.keyGeneration !== "number" || typeof w.wrapEnc !== "string" || typeof w.wrapCt !== "string") {
346
+ return;
347
+ }
348
+ wraps.push({ keyGeneration: w.keyGeneration, wrapEnc: w.wrapEnc, wrapCt: w.wrapCt });
349
+ }
350
+ if (!isSealedMessage(c.prompt))
351
+ return;
352
+ const reply = c.reply;
353
+ if (!reply || typeof reply.keyGeneration !== "number" || typeof reply.senderId !== "string")
354
+ return;
355
+ const history = [];
356
+ if (c.history !== undefined) {
357
+ if (!Array.isArray(c.history))
358
+ return;
359
+ for (const item of c.history) {
360
+ if (!isSealedMessage(item))
361
+ return;
362
+ const h = item;
363
+ const role = h.role === "assistant" ? "assistant" : "user";
364
+ history.push({
365
+ ciphertext: item.ciphertext,
366
+ envelope: item.envelope,
367
+ role,
368
+ sequence: typeof h.sequence === "string" ? h.sequence : "0"
369
+ });
370
+ }
371
+ }
372
+ const trigger = c.trigger;
373
+ return {
374
+ callbackToken: c.callbackToken,
375
+ wraps,
376
+ history,
377
+ prompt: c.prompt,
378
+ reply: { keyGeneration: reply.keyGeneration, senderId: reply.senderId },
379
+ ...trigger && typeof trigger.messageId === "string" && typeof trigger.authorName === "string" && typeof trigger.authorType === "string" && typeof trigger.createdAt === "string" ? {
380
+ trigger: {
381
+ messageId: trigger.messageId,
382
+ authorName: trigger.authorName,
383
+ authorType: trigger.authorType,
384
+ createdAt: trigger.createdAt
385
+ }
386
+ } : {}
387
+ };
388
+ }
389
+ async function unwrapWithAny(params) {
390
+ const { wrap, identities, streamId } = params;
391
+ for (const identity of identities) {
392
+ try {
393
+ return await unwrapStreamKey({
394
+ enc: base64ToBytes(wrap.wrapEnc),
395
+ ct: base64ToBytes(wrap.wrapCt),
396
+ recipientPrivateKey: identity.privateKey,
397
+ aad: buildWrapAad({ streamId, keyGeneration: wrap.keyGeneration, recipientKeyId: identity.publicKeyId })
398
+ });
399
+ } catch {
400
+ continue;
401
+ }
402
+ }
403
+ return;
404
+ }
405
+ async function openSealedTurnContext(params) {
406
+ const { sealed, identities, streamId } = params;
407
+ const sskByGeneration = new Map;
408
+ for (const wrap of sealed.wraps) {
409
+ const ssk = await unwrapWithAny({ wrap, identities, streamId });
410
+ if (ssk)
411
+ sskByGeneration.set(wrap.keyGeneration, ssk);
412
+ }
413
+ const promptSsk = sskByGeneration.get(sealed.prompt.envelope.keyGeneration);
414
+ if (!promptSsk)
415
+ throw new Error("Sealed claim: no SSK wrap for the prompt's key generation");
416
+ const promptRaw = await openMessageAsString({
417
+ key: promptSsk,
418
+ envelope: sealed.prompt.envelope,
419
+ ciphertext: base64ToBytes(sealed.prompt.ciphertext)
420
+ });
421
+ const promptPayload = parseSealedPayload(promptRaw);
422
+ const replySsk = sskByGeneration.get(sealed.reply.keyGeneration);
423
+ if (!replySsk)
424
+ throw new Error("Sealed claim: no SSK wrap for the reply's key generation");
425
+ const history = [];
426
+ for (const item of sealed.history) {
427
+ const ssk = sskByGeneration.get(item.envelope.keyGeneration);
428
+ if (!ssk)
429
+ continue;
430
+ try {
431
+ const raw = await openMessageAsString({
432
+ key: ssk,
433
+ envelope: item.envelope,
434
+ ciphertext: base64ToBytes(item.ciphertext)
435
+ });
436
+ const payload = parseSealedPayload(raw);
437
+ history.push({
438
+ role: item.role,
439
+ sequence: item.sequence,
440
+ contentMarkdown: payload.contentMarkdown,
441
+ attachmentRefs: payload.attachmentRefs
442
+ });
443
+ } catch {
444
+ continue;
445
+ }
446
+ }
447
+ return {
448
+ promptMarkdown: promptPayload.contentMarkdown,
449
+ promptAttachmentRefs: promptPayload.attachmentRefs,
450
+ history,
451
+ sealing: {
452
+ streamId,
453
+ replyKeyGeneration: sealed.reply.keyGeneration,
454
+ replySenderId: sealed.reply.senderId,
455
+ replySsk,
456
+ callbackToken: sealed.callbackToken
457
+ }
458
+ };
459
+ }
460
+ async function sealReply(sealing, markdown, extras) {
461
+ const messageId = `msg_${ulid()}`;
462
+ const sealed = await sealMessage({
463
+ key: sealing.replySsk,
464
+ keyGeneration: sealing.replyKeyGeneration,
465
+ payload: serializeSealedPayload(markdown, extras),
466
+ aad: buildMessageAad({ streamId: sealing.streamId, messageId, senderId: sealing.replySenderId })
467
+ });
468
+ return { messageId, ciphertext: bytesToBase64(sealed.ciphertext), envelope: sealed.envelope };
469
+ }
470
+ async function sealStep(sealing, stepType, content, opts) {
471
+ const stepId = `step_${ulid()}`;
472
+ const sealed = await sealMessage({
473
+ key: sealing.replySsk,
474
+ keyGeneration: sealing.replyKeyGeneration,
475
+ payload: serializeSealedPayload(content),
476
+ aad: buildMessageAad({ streamId: sealing.streamId, messageId: stepId, senderId: sealing.replySenderId })
477
+ });
478
+ return {
479
+ stepId,
480
+ stepType,
481
+ ciphertext: bytesToBase64(sealed.ciphertext),
482
+ envelope: sealed.envelope,
483
+ ...opts?.durationMs !== undefined ? { durationMs: opts.durationMs } : {}
484
+ };
485
+ }
486
+ async function sealDecision(sealing, card, content) {
487
+ const decisionId = `dreq_${ulid()}`;
488
+ const sealed = await sealMessage({
489
+ key: sealing.replySsk,
490
+ keyGeneration: sealing.replyKeyGeneration,
491
+ payload: JSON.stringify(content),
492
+ aad: buildDecisionAad({ streamId: card.streamId, decisionId, requesterBotId: card.requesterBotId })
493
+ });
494
+ return { decisionId, ciphertext: bytesToBase64(sealed.ciphertext), envelope: sealed.envelope };
495
+ }
496
+ async function openSealedDecisionNote(sealing, note) {
497
+ const expected = bytesToBase64(buildDecisionNoteAad({
498
+ streamId: note.streamId,
499
+ decisionId: note.decisionId,
500
+ decidedBy: note.decidedBy
501
+ }));
502
+ if (note.envelope.aad !== expected)
503
+ return null;
504
+ if (note.envelope.keyGeneration !== sealing.replyKeyGeneration)
505
+ return null;
506
+ try {
507
+ return await openMessageAsString({
508
+ key: sealing.replySsk,
509
+ envelope: note.envelope,
510
+ ciphertext: base64ToBytes(note.ciphertext)
511
+ });
512
+ } catch {
513
+ return null;
514
+ }
515
+ }
516
+ function scrubSealedError(error) {
517
+ return error instanceof Error ? error.name || "Error" : "Error";
518
+ }
519
+ function parseSealedAckContext(raw) {
520
+ if (typeof raw !== "object" || raw === null)
521
+ return;
522
+ const c = raw;
523
+ if (!Array.isArray(c.wraps))
524
+ return;
525
+ const wraps = [];
526
+ for (const wrap of c.wraps) {
527
+ if (typeof wrap !== "object" || wrap === null)
528
+ return;
529
+ const w = wrap;
530
+ if (typeof w.keyGeneration !== "number" || typeof w.wrapEnc !== "string" || typeof w.wrapCt !== "string") {
531
+ return;
532
+ }
533
+ wraps.push({ keyGeneration: w.keyGeneration, wrapEnc: w.wrapEnc, wrapCt: w.wrapCt });
534
+ }
535
+ const reply = c.reply;
536
+ if (!reply || typeof reply.keyGeneration !== "number" || typeof reply.senderId !== "string")
537
+ return;
538
+ return { wraps, reply: { keyGeneration: reply.keyGeneration, senderId: reply.senderId } };
539
+ }
540
+ async function openSealedAck(params) {
541
+ const { ack, identities, streamId } = params;
542
+ let replySsk;
543
+ for (const wrap of ack.wraps) {
544
+ if (wrap.keyGeneration !== ack.reply.keyGeneration)
545
+ continue;
546
+ replySsk = await unwrapWithAny({ wrap, identities, streamId });
547
+ if (replySsk)
548
+ break;
549
+ }
550
+ if (!replySsk)
551
+ throw new Error("Sealed ack: no SSK wrap for the reply's key generation");
552
+ return {
553
+ streamId,
554
+ replyKeyGeneration: ack.reply.keyGeneration,
555
+ replySenderId: ack.reply.senderId,
556
+ replySsk,
557
+ callbackToken: ""
558
+ };
559
+ }
560
+ async function mintStreamKeyWraps(params) {
561
+ const ssk = generateStreamKey();
562
+ const wraps = [];
563
+ for (const recipient of params.recipients) {
564
+ const publicKey = await importRecipientPublicKey(base64ToBytes(recipient.publicKeyBase64));
565
+ const wrapped = await wrapStreamKey({
566
+ key: ssk,
567
+ recipientPublicKey: publicKey,
568
+ aad: buildWrapAad({
569
+ streamId: params.streamId,
570
+ keyGeneration: params.keyGeneration,
571
+ recipientKeyId: recipient.recipientKeyId
572
+ })
573
+ });
574
+ wraps.push({
575
+ recipientKind: recipient.recipientKind,
576
+ recipientKeyId: recipient.recipientKeyId,
577
+ wrapEnc: bytesToBase64(wrapped.enc),
578
+ wrapCt: bytesToBase64(wrapped.ct)
579
+ });
580
+ }
581
+ return { wraps };
582
+ }
583
+
584
+ // src/ws-hint.ts
585
+ function isObject(value) {
586
+ return !!value && typeof value === "object" && !Array.isArray(value);
587
+ }
588
+ function parseWsHint(value) {
589
+ if (!isObject(value))
590
+ return;
591
+ const url = typeof value.url === "string" ? value.url.trim() : "";
592
+ if (!url)
593
+ return;
594
+ const path = typeof value.path === "string" && value.path.trim() ? value.path.trim() : "/socket.io/";
595
+ const namespace = typeof value.namespace === "string" && value.namespace.trim() ? value.namespace.trim() : "/bot";
596
+ return { url, path, namespace };
597
+ }
598
+ function buildBotSocketUrl(hint) {
599
+ const parsed = new URL(hint.url);
600
+ const trimmedPath = parsed.pathname.replace(/\/$/, "");
601
+ parsed.pathname = `${trimmedPath}${hint.namespace}`;
602
+ return parsed.toString();
603
+ }
604
+
605
+ // src/invocation-control.ts
606
+ var BOT_INVOCATION_CANCELLATION_REASONS = [
607
+ "source_deleted",
608
+ "routing_changed",
609
+ "input_restart",
610
+ "input_stale",
611
+ "key_grant_lost"
612
+ ];
613
+
614
+ class InvocationControlManager {
615
+ hooks;
616
+ observations = new Map;
617
+ pendingTerminalGenerations = new Map;
618
+ queue = Promise.resolve();
619
+ stopped = false;
620
+ generation = 0;
621
+ retryDelayMs;
622
+ minRenewDelayMs;
623
+ now;
624
+ scheduler;
625
+ constructor(hooks, options = {}) {
626
+ this.hooks = hooks;
627
+ this.retryDelayMs = options.retryDelayMs ?? 5000;
628
+ this.minRenewDelayMs = options.minRenewDelayMs ?? 1000;
629
+ this.now = options.now ?? Date.now;
630
+ this.scheduler = options.scheduler ?? {
631
+ setTimeout: (callback, delayMs) => setTimeout(callback, delayMs),
632
+ clearTimeout: (handle) => clearTimeout(handle)
633
+ };
634
+ }
635
+ observe(params) {
636
+ this.pendingTerminalGenerations.delete(params.invocationId);
637
+ const prior = this.observations.get(params.invocationId);
638
+ if (prior)
639
+ this.unregister(prior);
640
+ const observation = {
641
+ generation: ++this.generation,
642
+ invocationId: params.invocationId,
643
+ claimTtlSeconds: params.claimTtlSeconds,
644
+ instanceId: params.instanceId,
645
+ claimToken: params.claimToken,
646
+ callbacks: params.callbacks,
647
+ sealedBinding: params.sealed ? {
648
+ identities: params.sealed.identities,
649
+ streamId: params.sealed.streamId,
650
+ callbackToken: params.sealed.callbackToken
651
+ } : undefined,
652
+ appliedRevision: params.sourceRevision,
653
+ highWaterRevision: params.sourceRevision,
654
+ terminal: false,
655
+ dirty: false,
656
+ abortController: new AbortController,
657
+ enqueuedRevisions: new Set
658
+ };
659
+ this.observations.set(params.invocationId, observation);
660
+ this.requestSync(observation);
661
+ return {
662
+ sync: () => this.syncAndDrain(observation),
663
+ unregister: () => this.unregister(observation),
664
+ dispose: () => this.unregister(observation)
665
+ };
666
+ }
667
+ hint(payload, cancelled) {
668
+ const hint = cancelled ? parseInvocationCancellation(payload) : parseUpdateHint(payload);
669
+ if (!hint)
670
+ return;
671
+ const observation = this.observations.get(hint.invocationId);
672
+ if (!observation)
673
+ return;
674
+ if (cancelled) {
675
+ this.terminalizeCancellation(observation, hint);
676
+ return;
677
+ }
678
+ if (hint.sourceRevision <= observation.highWaterRevision)
679
+ return;
680
+ observation.highWaterRevision = hint.sourceRevision;
681
+ this.requestSync(observation);
682
+ }
683
+ async bootstrap(recentCancellations, callback) {
684
+ for (const value of recentCancellations) {
685
+ const cancellation = parseInvocationCancellation(value);
686
+ if (!cancellation)
687
+ continue;
688
+ const observation = this.observations.get(cancellation.invocationId);
689
+ if (observation)
690
+ this.terminalizeCancellation(observation, cancellation);
691
+ }
692
+ for (const observation of this.observations.values())
693
+ this.requestSync(observation);
694
+ await this.enqueueAdapter(callback);
695
+ }
696
+ wake() {
697
+ for (const observation of this.observations.values())
698
+ this.requestSync(observation);
699
+ }
700
+ async enqueueAdapter(callback) {
701
+ await this.awaitStableControls();
702
+ if (!this.stopped) {
703
+ await this.enqueue(async () => {
704
+ if (this.stopped)
705
+ return;
706
+ try {
707
+ callback();
708
+ } catch {
709
+ this.hooks.log("invocation availability callback failed");
710
+ }
711
+ });
712
+ }
713
+ }
714
+ stop() {
715
+ this.stopped = true;
716
+ this.pendingTerminalGenerations.clear();
717
+ for (const observation of [...this.observations.values()])
718
+ this.unregister(observation);
719
+ }
720
+ async syncAndDrain(observation) {
721
+ if (this.isCurrent(observation))
722
+ this.requestSync(observation);
723
+ await this.awaitStableControls();
724
+ }
725
+ requestSync(observation) {
726
+ if (!this.isCurrent(observation))
727
+ return Promise.resolve();
728
+ observation.dirty = true;
729
+ if (observation.drain)
730
+ return observation.drain;
731
+ const drain = Promise.resolve().then(() => this.runDrain(observation));
732
+ observation.drain = drain;
733
+ return drain;
734
+ }
735
+ async runDrain(observation) {
736
+ while (this.isCurrent(observation) && observation.dirty) {
737
+ observation.dirty = false;
738
+ const restartRevision = observation.restartPendingRevision;
739
+ const knownSourceRevision = restartRevision ?? observation.appliedRevision;
740
+ const request = {
741
+ invocationId: observation.invocationId,
742
+ instanceId: observation.instanceId,
743
+ claimToken: observation.claimToken,
744
+ claimTtlSeconds: observation.claimTtlSeconds,
745
+ knownSourceRevision,
746
+ minimumSourceRevision: observation.appliedRevision,
747
+ ...restartRevision === undefined ? {} : { restartRequiredRevision: restartRevision },
748
+ ackTimeoutMs: Math.min(5000, observation.claimTtlSeconds * 1000 / 6),
749
+ signal: observation.abortController.signal
750
+ };
751
+ const authorityBehind = await this.runSync(observation, request);
752
+ if (!this.isCurrent(observation))
753
+ break;
754
+ if (authorityBehind) {
755
+ if (observation.immediateFollowupRevision !== observation.highWaterRevision) {
756
+ observation.immediateFollowupRevision = observation.highWaterRevision;
757
+ observation.dirty = true;
758
+ } else {
759
+ this.schedule(observation, this.retryDelayMs);
760
+ }
761
+ } else {
762
+ observation.immediateFollowupRevision = undefined;
763
+ }
764
+ }
765
+ observation.drain = undefined;
766
+ }
767
+ async runSync(observation, request) {
768
+ let result;
769
+ try {
770
+ result = await this.hooks.sync(request);
771
+ } catch {
772
+ result = { kind: "retry" };
773
+ this.hooks.log(`invocation control sync failed (${observation.invocationId})`);
774
+ }
775
+ if (!this.isCurrent(observation) || result.kind === "aborted")
776
+ return false;
777
+ if (result.kind === "not_found") {
778
+ if (request.restartRequiredRevision !== undefined) {
779
+ observation.restartPendingRevision = undefined;
780
+ observation.dirty = true;
781
+ } else {
782
+ const callback = observation.callbacks?.onClaimLost;
783
+ this.makeTerminal(observation, callback);
784
+ }
785
+ return false;
786
+ }
787
+ if (result.kind === "retry") {
788
+ this.schedule(observation, this.retryDelayMs);
789
+ return false;
790
+ }
791
+ const state = result.state;
792
+ if (state.status === "cancelled") {
793
+ const cancellation = {
794
+ invocationId: state.invocationId,
795
+ sourceRevision: state.sourceRevision,
796
+ reason: state.reason
797
+ };
798
+ if (!this.terminalizeCancellation(observation, cancellation))
799
+ this.schedule(observation, this.retryDelayMs);
800
+ return false;
801
+ }
802
+ const expiresAt = Date.parse(state.claimExpiresAt);
803
+ if (!Number.isFinite(expiresAt)) {
804
+ this.schedule(observation, this.retryDelayMs);
805
+ return false;
806
+ }
807
+ observation.claimExpiresAtMs = expiresAt;
808
+ observation.highWaterRevision = Math.max(observation.highWaterRevision, state.sourceRevision);
809
+ this.schedule(observation);
810
+ if (request.restartRequiredRevision !== undefined) {
811
+ if (observation.restartPendingRevision === request.restartRequiredRevision) {
812
+ this.schedule(observation, this.retryDelayMs);
813
+ }
814
+ return state.sourceRevision < observation.highWaterRevision;
815
+ }
816
+ if (state.sourceRevision > observation.appliedRevision) {
817
+ if (state.update)
818
+ await this.prepareUpdate(observation, state.update);
819
+ else
820
+ this.schedule(observation, this.retryDelayMs);
821
+ }
822
+ return state.sourceRevision < observation.highWaterRevision;
823
+ }
824
+ async prepareUpdate(observation, raw) {
825
+ const revision = parseRevision(isObject(raw) ? raw.sourceRevision : undefined);
826
+ if (revision === undefined || revision <= observation.appliedRevision)
827
+ return;
828
+ const highestEnqueued = Math.max(observation.appliedRevision, ...observation.enqueuedRevisions);
829
+ if (revision <= highestEnqueued || observation.restartPendingRevision !== undefined)
830
+ return;
831
+ let update;
832
+ try {
833
+ update = await openUpdate(observation, raw);
834
+ } catch (error) {
835
+ this.hooks.log(`sealed invocation update failed (${observation.invocationId}): ${scrubSealedError(error)}`);
836
+ this.markRestartPending(observation, revision);
837
+ return;
838
+ }
839
+ if (!this.isCurrent(observation))
840
+ return;
841
+ observation.enqueuedRevisions.add(revision);
842
+ const invocationId = observation.invocationId;
843
+ const generation = observation.generation;
844
+ this.enqueue(async () => {
845
+ const current = this.observations.get(invocationId);
846
+ if (!current || current.generation !== generation || current.terminal || this.stopped)
847
+ return;
848
+ if (revision <= current.appliedRevision || current.restartPendingRevision !== undefined) {
849
+ current.enqueuedRevisions.delete(revision);
850
+ return;
851
+ }
852
+ let disposition = "restart-required";
853
+ try {
854
+ disposition = await current.callbacks?.onInputUpdated(update, current.abortController.signal) ?? "restart-required";
855
+ } catch {
856
+ disposition = "restart-required";
857
+ }
858
+ const stillCurrent = this.observations.get(invocationId);
859
+ if (!stillCurrent || stillCurrent.generation !== generation || stillCurrent.terminal)
860
+ return;
861
+ stillCurrent.enqueuedRevisions.delete(revision);
862
+ if (disposition === "applied") {
863
+ stillCurrent.appliedRevision = revision;
864
+ if (stillCurrent.highWaterRevision > revision)
865
+ this.requestSync(stillCurrent);
866
+ } else {
867
+ this.markRestartPending(stillCurrent, revision);
868
+ }
869
+ });
870
+ }
871
+ markRestartPending(observation, revision) {
872
+ if (!this.isCurrent(observation))
873
+ return;
874
+ if (observation.restartPendingRevision !== undefined && observation.restartPendingRevision >= revision)
875
+ return;
876
+ observation.restartPendingRevision = revision;
877
+ observation.enqueuedRevisions.clear();
878
+ this.requestSync(observation);
879
+ }
880
+ highestLocallyKnownRevision(observation) {
881
+ return Math.max(observation.appliedRevision, observation.highWaterRevision, ...observation.enqueuedRevisions, observation.restartPendingRevision ?? -1);
882
+ }
883
+ terminalizeCancellation(observation, cancellation) {
884
+ if (!this.isCurrent(observation) || cancellation.sourceRevision < this.highestLocallyKnownRevision(observation)) {
885
+ return false;
886
+ }
887
+ this.makeTerminal(observation, observation.callbacks?.onCancelled);
888
+ return true;
889
+ }
890
+ makeTerminal(observation, callback) {
891
+ if (!this.isCurrent(observation))
892
+ return;
893
+ this.teardown(observation);
894
+ if (!callback)
895
+ return;
896
+ const { generation, invocationId } = observation;
897
+ this.pendingTerminalGenerations.set(invocationId, generation);
898
+ this.enqueue(async () => {
899
+ if (this.stopped || this.pendingTerminalGenerations.get(invocationId) !== generation)
900
+ return;
901
+ try {
902
+ await callback();
903
+ } catch {
904
+ this.hooks.log(`invocation cancellation callback failed (${invocationId})`);
905
+ } finally {
906
+ if (this.pendingTerminalGenerations.get(invocationId) === generation) {
907
+ this.pendingTerminalGenerations.delete(invocationId);
908
+ }
909
+ }
910
+ });
911
+ }
912
+ unregister(observation) {
913
+ if (this.pendingTerminalGenerations.get(observation.invocationId) === observation.generation) {
914
+ this.pendingTerminalGenerations.delete(observation.invocationId);
915
+ }
916
+ if (observation.terminal)
917
+ return;
918
+ this.teardown(observation);
919
+ }
920
+ teardown(observation) {
921
+ observation.terminal = true;
922
+ observation.abortController.abort();
923
+ if (observation.timer !== undefined)
924
+ this.scheduler.clearTimeout(observation.timer);
925
+ observation.timer = undefined;
926
+ if (this.observations.get(observation.invocationId) === observation) {
927
+ this.observations.delete(observation.invocationId);
928
+ }
929
+ this.scrub(observation);
930
+ }
931
+ scrub(observation) {
932
+ observation.claimToken = "";
933
+ observation.callbacks = undefined;
934
+ observation.sealedBinding = undefined;
935
+ observation.enqueuedRevisions.clear();
936
+ observation.restartPendingRevision = undefined;
937
+ observation.immediateFollowupRevision = undefined;
938
+ }
939
+ schedule(observation, requestedDelay) {
940
+ if (!this.isCurrent(observation))
941
+ return;
942
+ if (observation.timer !== undefined)
943
+ this.scheduler.clearTimeout(observation.timer);
944
+ const nominalLeaseMs = observation.claimTtlSeconds * 1000;
945
+ const safetyMs = Math.min(30000, Math.max(this.minRenewDelayMs, nominalLeaseMs / 3));
946
+ const authoritativeDelay = observation.claimExpiresAtMs === undefined ? this.retryDelayMs : Math.max(this.minRenewDelayMs, observation.claimExpiresAtMs - this.now() - safetyMs);
947
+ let delay = requestedDelay === undefined ? authoritativeDelay : Math.min(authoritativeDelay, requestedDelay);
948
+ if (!this.hooks.socketReady())
949
+ delay = Math.min(delay, this.retryDelayMs);
950
+ observation.timer = this.scheduler.setTimeout(() => {
951
+ observation.timer = undefined;
952
+ this.requestSync(observation);
953
+ }, Math.max(this.minRenewDelayMs, delay));
954
+ }
955
+ isCurrent(observation) {
956
+ return !this.stopped && !observation.terminal && this.observations.get(observation.invocationId) === observation;
957
+ }
958
+ enqueue(work) {
959
+ const result = this.queue.then(work, work);
960
+ this.queue = result.catch(() => {});
961
+ return result;
962
+ }
963
+ async awaitStableControls() {
964
+ while (true) {
965
+ const drains = [...this.observations.values()].flatMap((observation) => observation.drain ? [observation.drain] : []);
966
+ await Promise.allSettled(drains);
967
+ const adapterQueue = this.queue;
968
+ await adapterQueue;
969
+ if (this.queue === adapterQueue && [...this.observations.values()].every((observation) => observation.drain === undefined)) {
970
+ return;
971
+ }
972
+ }
973
+ }
974
+ }
975
+ async function openUpdate(observation, raw) {
976
+ if (!isObject(raw))
977
+ throw new Error("Invalid update");
978
+ const sourceRevision = parseRevision(raw.sourceRevision);
979
+ if (sourceRevision === undefined)
980
+ throw new Error("Invalid update");
981
+ const expectsSealed = observation.sealedBinding !== undefined;
982
+ if (!expectsSealed && raw.delivery === "plaintext" && typeof raw.promptMarkdown === "string") {
983
+ return {
984
+ sourceRevision,
985
+ delivery: "plaintext",
986
+ promptMarkdown: raw.promptMarkdown,
987
+ attachmentRefs: []
988
+ };
989
+ }
990
+ if (!expectsSealed || raw.delivery !== "sealed" || !observation.sealedBinding) {
991
+ throw new Error("Invalid update delivery");
992
+ }
993
+ const sealed = parseSealedTurnContext({
994
+ callbackToken: observation.sealedBinding.callbackToken,
995
+ wraps: raw.wraps,
996
+ history: [],
997
+ prompt: raw.prompt,
998
+ reply: raw.reply
999
+ });
1000
+ if (!sealed)
1001
+ throw new Error("Invalid sealed update");
1002
+ const opened = await openSealedTurnContext({
1003
+ sealed,
1004
+ identities: observation.sealedBinding.identities,
1005
+ streamId: observation.sealedBinding.streamId
1006
+ });
1007
+ return {
1008
+ sourceRevision,
1009
+ delivery: "sealed",
1010
+ promptMarkdown: opened.promptMarkdown,
1011
+ attachmentRefs: opened.promptAttachmentRefs,
1012
+ sealing: opened.sealing
1013
+ };
1014
+ }
1015
+ var cancellationReasonSet = new Set(BOT_INVOCATION_CANCELLATION_REASONS);
1016
+ function parseCancellationReason(value) {
1017
+ if (value === "adapter_restart_required")
1018
+ return "input_restart";
1019
+ return typeof value === "string" && cancellationReasonSet.has(value) ? value : undefined;
1020
+ }
1021
+ function parseInvocationCancellation(value) {
1022
+ if (!isObject(value) || typeof value.invocationId !== "string")
1023
+ return;
1024
+ const sourceRevision = parseRevision(value.sourceRevision);
1025
+ const reason = parseCancellationReason(value.reason);
1026
+ if (sourceRevision === undefined || !reason)
1027
+ return;
1028
+ return { invocationId: value.invocationId, sourceRevision, reason };
1029
+ }
1030
+ function parseUpdateHint(value) {
1031
+ if (!isObject(value) || typeof value.invocationId !== "string")
1032
+ return;
1033
+ const sourceRevision = parseRevision(value.sourceRevision);
1034
+ return sourceRevision === undefined ? undefined : { invocationId: value.invocationId, sourceRevision };
1035
+ }
1036
+ function parseRevision(value) {
1037
+ return Number.isInteger(value) && value >= 0 ? value : undefined;
1038
+ }
1039
+
1040
+ // src/transport.ts
1041
+ var DEFAULT_WS_ACK_TIMEOUT_MS = 5000;
1042
+ var DEFAULT_RECONNECTION_DELAY_MAX_MS = 30000;
1043
+ var DEFAULT_FETCH_TIMEOUT_MS = 30000;
1044
+ var DEFAULT_STALE_SOCKET_REDIAL_MS = 3 * 60 * 1000;
1045
+
1046
+ class BotRuntimeTransport {
1047
+ base;
1048
+ workspaceId;
1049
+ apiKey;
1050
+ hello;
1051
+ beforeHello;
1052
+ callbacks;
1053
+ wsAckTimeoutMs;
1054
+ reconnectionDelayMaxMs;
1055
+ fetchTimeoutMs;
1056
+ staleSocketRedialMs;
1057
+ logFn;
1058
+ socket;
1059
+ connected = false;
1060
+ helloReady = false;
1061
+ helloInFlight = false;
1062
+ connecting = false;
1063
+ stopped = false;
1064
+ redialTimer;
1065
+ disconnectedAt;
1066
+ cursor;
1067
+ controls;
1068
+ constructor(opts) {
1069
+ this.base = opts.baseUrl.replace(/\/$/, "");
1070
+ this.workspaceId = opts.workspaceId;
1071
+ this.apiKey = opts.apiKey;
1072
+ this.hello = opts.hello;
1073
+ this.beforeHello = opts.beforeHello;
1074
+ this.callbacks = opts.callbacks ?? {};
1075
+ this.wsAckTimeoutMs = opts.wsAckTimeoutMs ?? DEFAULT_WS_ACK_TIMEOUT_MS;
1076
+ this.reconnectionDelayMaxMs = opts.reconnectionDelayMaxMs ?? DEFAULT_RECONNECTION_DELAY_MAX_MS;
1077
+ this.fetchTimeoutMs = opts.fetchTimeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
1078
+ this.staleSocketRedialMs = opts.staleSocketRedialMs ?? DEFAULT_STALE_SOCKET_REDIAL_MS;
1079
+ this.logFn = opts.log ?? (() => {});
1080
+ this.controls = new InvocationControlManager({
1081
+ sync: (request) => this.syncObservedClaim(request),
1082
+ socketReady: () => this.socketConnected,
1083
+ log: this.logFn
1084
+ }, {
1085
+ retryDelayMs: opts.controlRetryDelayMs,
1086
+ minRenewDelayMs: opts.controlMinRenewDelayMs,
1087
+ scheduler: opts.controlScheduler
1088
+ });
1089
+ }
1090
+ get socketConnected() {
1091
+ return this.connected && this.helloReady;
1092
+ }
1093
+ async connect() {
1094
+ if (this.connecting || this.stopped)
1095
+ return;
1096
+ if (this.socket) {
1097
+ if (this.connected) {
1098
+ if (!this.helloReady)
1099
+ this.sendHello();
1100
+ return;
1101
+ }
1102
+ const outageMs = Date.now() - (this.disconnectedAt ?? Date.now());
1103
+ if (outageMs < this.staleSocketRedialMs)
1104
+ return;
1105
+ this.logFn(`socket disconnected for ${Math.round(outageMs / 1000)}s; redialing from a fresh hint`);
1106
+ this.teardownSocket();
1107
+ }
1108
+ this.connecting = true;
1109
+ try {
1110
+ let hint;
1111
+ try {
1112
+ hint = await this.resolveWsHint();
1113
+ } catch (error) {
1114
+ this.logFn(`ws hint resolve failed (staying on HTTP): ${summarize(error)}`);
1115
+ }
1116
+ if (hint)
1117
+ this.attachSocket(hint);
1118
+ } finally {
1119
+ this.connecting = false;
1120
+ }
1121
+ }
1122
+ attachSocket(hint) {
1123
+ if (this.socket || this.stopped)
1124
+ return;
1125
+ let socket;
1126
+ try {
1127
+ socket = socketIoClient.io(buildBotSocketUrl(hint), {
1128
+ path: hint.path,
1129
+ auth: { token: this.apiKey },
1130
+ transports: ["websocket"],
1131
+ reconnection: true,
1132
+ reconnectionDelayMax: this.reconnectionDelayMaxMs
1133
+ });
1134
+ } catch (error) {
1135
+ this.logFn(`socket attach failed (HTTP only): ${summarize(error)}`);
1136
+ return;
1137
+ }
1138
+ this.socket = socket;
1139
+ this.disconnectedAt = Date.now();
1140
+ socket.on("connect", () => {
1141
+ this.connected = true;
1142
+ this.helloReady = false;
1143
+ this.disconnectedAt = undefined;
1144
+ this.sendHello();
1145
+ });
1146
+ socket.on("disconnect", (reason) => {
1147
+ const wasReady = this.socketConnected;
1148
+ this.connected = false;
1149
+ this.helloReady = false;
1150
+ this.helloInFlight = false;
1151
+ this.disconnectedAt ??= Date.now();
1152
+ if (wasReady)
1153
+ this.callbacks.onDisconnected?.();
1154
+ this.controls.wake();
1155
+ if (reason === "io server disconnect")
1156
+ socket.connect();
1157
+ });
1158
+ socket.on("connect_error", (error) => {
1159
+ const wasReady = this.socketConnected;
1160
+ this.connected = false;
1161
+ this.helloReady = false;
1162
+ this.helloInFlight = false;
1163
+ this.disconnectedAt ??= Date.now();
1164
+ if (wasReady)
1165
+ this.callbacks.onDisconnected?.();
1166
+ this.controls.wake();
1167
+ this.logFn(`socket connect_error: ${summarize(error)}`);
1168
+ });
1169
+ socket.on("bot_invocation:available", () => this.controls.enqueueAdapter(() => this.callbacks.onInvocationAvailable?.()));
1170
+ socket.on("bot_invocation:input_updated", (payload) => this.controls.hint(payload, false));
1171
+ socket.on("bot_invocation:cancelled", (payload) => this.controls.hint(payload, true));
1172
+ socket.on("delegation:available", (payload) => this.callbacks.onDelegationAvailable?.(payload));
1173
+ socket.on("bot_invocation:claimed", (payload) => this.callbacks.onInvocationClaimed?.(payload));
1174
+ socket.on("bot:active_actor_changed", (payload) => this.callbacks.onActiveActorChanged?.(payload));
1175
+ socket.on("bot:session_archived", (payload) => this.callbacks.onSessionArchived?.(payload));
1176
+ socket.on("bot:session_restored", (payload) => this.callbacks.onSessionRestored?.(payload));
1177
+ socket.on("decision:resolved", (payload) => this.callbacks.onDecisionResolved?.(payload));
1178
+ socket.on("decision:cancelled", (payload) => this.callbacks.onDecisionCancelled?.(payload));
1179
+ socket.on("bot:e2e_grant", (payload) => this.callbacks.onE2eGrant?.(payload));
1180
+ socket.on("bot:e2e_revoke", (payload) => this.callbacks.onE2eRevoke?.(payload));
1181
+ socket.on("bot:resync", () => {
1182
+ this.callbacks.onResync?.();
1183
+ this.teardownSocket();
1184
+ this.connect();
1185
+ });
1186
+ }
1187
+ sendHello() {
1188
+ const socket = this.socket;
1189
+ if (!socket || this.helloInFlight)
1190
+ return;
1191
+ this.helloInFlight = true;
1192
+ this.beforeHello?.(this.hello);
1193
+ socket.timeout(this.wsAckTimeoutMs).emit("bot:hello", { ...this.hello, ...this.cursor ? { sinceCursor: this.cursor } : {} }, (error, ack) => {
1194
+ if (socket !== this.socket)
1195
+ return;
1196
+ this.helloInFlight = false;
1197
+ if (error || !isObject(ack) || ack.ok !== true) {
1198
+ this.logFn(`bot:hello rejected: ${error ? summarize(error) : isObject(ack) ? String(ack.error) : "no ack"}`);
1199
+ this.teardownSocket();
1200
+ this.scheduleRedial();
1201
+ return;
1202
+ }
1203
+ this.helloReady = true;
1204
+ if (typeof ack.serverGeneratedAt === "string")
1205
+ this.cursor = ack.serverGeneratedAt;
1206
+ const bootstrap = {
1207
+ serverGeneratedAt: typeof ack.serverGeneratedAt === "string" ? ack.serverGeneratedAt : undefined,
1208
+ ...typeof ack.botId === "string" ? { botId: ack.botId } : {},
1209
+ availableInvocations: Array.isArray(ack.availableInvocations) ? ack.availableInvocations : [],
1210
+ ownedClaims: Array.isArray(ack.ownedClaims) ? ack.ownedClaims : [],
1211
+ e2eGrantedStreamIds: Array.isArray(ack.e2eGrantedStreamIds) ? ack.e2eGrantedStreamIds.filter((id) => typeof id === "string") : []
1212
+ };
1213
+ this.controls.bootstrap(Array.isArray(ack.recentCancellations) ? ack.recentCancellations : [], () => this.callbacks.onBootstrap?.(bootstrap));
1214
+ });
1215
+ }
1216
+ disconnect() {
1217
+ this.stopped = true;
1218
+ if (this.redialTimer)
1219
+ clearTimeout(this.redialTimer);
1220
+ this.redialTimer = undefined;
1221
+ this.controls.stop();
1222
+ this.teardownSocket();
1223
+ }
1224
+ scheduleRedial() {
1225
+ if (this.stopped || this.redialTimer)
1226
+ return;
1227
+ this.redialTimer = setTimeout(() => {
1228
+ this.redialTimer = undefined;
1229
+ this.connect();
1230
+ }, this.reconnectionDelayMaxMs);
1231
+ }
1232
+ teardownSocket() {
1233
+ const wasReady = this.socketConnected;
1234
+ this.connected = false;
1235
+ this.helloReady = false;
1236
+ this.helloInFlight = false;
1237
+ this.disconnectedAt = undefined;
1238
+ if (wasReady)
1239
+ this.callbacks.onDisconnected?.();
1240
+ const socket = this.socket;
1241
+ this.socket = undefined;
1242
+ if (socket) {
1243
+ try {
1244
+ socket.removeAllListeners();
1245
+ socket.disconnect();
1246
+ } catch {}
1247
+ }
1248
+ }
1249
+ observeClaim(params) {
1250
+ return this.controls.observe(params);
1251
+ }
1252
+ async recordSteps(invocationId, claimToken, steps, statusText, instanceId = this.hello.instanceId) {
1253
+ if (steps.length === 0)
1254
+ return;
1255
+ const keyed = steps.map((step) => ({ ...step, clientStepId: step.clientStepId ?? crypto.randomUUID() }));
1256
+ const { sent, ack } = await this.emitWrite("bot:invocation:steps", {
1257
+ invocationId,
1258
+ instanceId,
1259
+ claimToken,
1260
+ steps: keyed,
1261
+ ...statusText ? { statusText } : {}
1262
+ });
1263
+ if (ack) {
1264
+ if (!ack.ok)
1265
+ this.logFn(`steps rejected (${ack.code ?? "?"}): ${ack.message ?? ""}`);
1266
+ return;
1267
+ }
1268
+ if (sent) {
1269
+ this.logFn("steps ack timed out; relying on the in-flight frame (no HTTP retry)");
1270
+ return;
1271
+ }
1272
+ await this.httpRecordStepsFallback(invocationId, claimToken, keyed, statusText, instanceId);
1273
+ }
1274
+ async recordSealedSteps(invocationId, callbackToken, steps) {
1275
+ if (steps.length === 0)
1276
+ return;
1277
+ const { sent, ack } = await this.emitWrite("bot:invocation:sealed-steps", {
1278
+ invocationId,
1279
+ callbackToken,
1280
+ steps
1281
+ });
1282
+ if (ack) {
1283
+ if (!ack.ok)
1284
+ this.logFn(`sealed steps rejected (${ack.code ?? "?"}): ${ack.message ?? ""}`);
1285
+ return;
1286
+ }
1287
+ if (sent) {
1288
+ this.logFn("sealed steps ack timed out; relying on the in-flight frame (no HTTP retry)");
1289
+ return;
1290
+ }
1291
+ await this.httpRecordSealedStepsFallback(invocationId, callbackToken, steps);
1292
+ }
1293
+ async renewClaim(invocationId, claimToken, claimTtlSeconds, instanceId = this.hello.instanceId) {
1294
+ const { ack } = await this.emitWrite("bot:invocation:renew", {
1295
+ invocationId,
1296
+ instanceId,
1297
+ claimToken,
1298
+ claimTtlSeconds
1299
+ });
1300
+ if (ack) {
1301
+ if (ack.ok)
1302
+ return { notFound: false, renewed: true };
1303
+ if (ack.code === "NOT_FOUND")
1304
+ return { notFound: true, renewed: false };
1305
+ this.logFn(`renew rejected (${ack.code ?? "?"}); retrying over HTTP`);
1306
+ }
1307
+ return this.httpRenewFallback(invocationId, claimToken, claimTtlSeconds, instanceId);
1308
+ }
1309
+ async updatePresence(body) {
1310
+ const { ack } = await this.emitWrite("bot:presence:update", body);
1311
+ if (ack) {
1312
+ if (!ack.ok)
1313
+ this.logFn(`presence rejected (${ack.code ?? "?"}): ${ack.message ?? ""}`);
1314
+ return;
1315
+ }
1316
+ await this.httpPresenceFallback(body);
1317
+ }
1318
+ emitWrite(event, payload, signal, timeoutMs = this.wsAckTimeoutMs) {
1319
+ const socket = this.socket;
1320
+ if (signal?.aborted)
1321
+ return Promise.resolve({ sent: false, ack: null, aborted: true });
1322
+ if (!socket || !this.connected || !this.helloReady)
1323
+ return Promise.resolve({ sent: false, ack: null });
1324
+ return new Promise((resolve) => {
1325
+ let settled = false;
1326
+ const onAbort = () => done({ sent: true, ack: null, aborted: true });
1327
+ const done = (result) => {
1328
+ if (settled)
1329
+ return;
1330
+ settled = true;
1331
+ signal?.removeEventListener("abort", onAbort);
1332
+ resolve(result);
1333
+ };
1334
+ signal?.addEventListener("abort", onAbort, { once: true });
1335
+ try {
1336
+ socket.timeout(timeoutMs).emit(event, payload, (err, ack) => {
1337
+ done({ sent: true, ack: err ? null : normalizeAck(ack) });
1338
+ });
1339
+ } catch (error) {
1340
+ this.logFn(`socket emit ${event} threw: ${summarize(error)}`);
1341
+ done({ sent: false, ack: null });
1342
+ }
1343
+ });
1344
+ }
1345
+ async resolveWsHint() {
1346
+ const controller = new AbortController;
1347
+ const timeout = setTimeout(() => controller.abort(), this.fetchTimeoutMs);
1348
+ try {
1349
+ const res = await fetch(`${this.base}/api/workspaces/${this.workspaceId}/config`, {
1350
+ method: "GET",
1351
+ signal: controller.signal,
1352
+ headers: { Authorization: `Bearer ${this.apiKey}`, "Content-Type": "application/json" }
1353
+ });
1354
+ if (!res.ok)
1355
+ return;
1356
+ const body = await res.json();
1357
+ return parseWsHint({ url: body.wsUrl });
1358
+ } finally {
1359
+ clearTimeout(timeout);
1360
+ }
1361
+ }
1362
+ async httpRecordStepsFallback(invocationId, claimToken, steps, statusText, instanceId) {
1363
+ for (const step of steps) {
1364
+ try {
1365
+ await this.httpRequest(this.v1Path(`/bot-invocations/${invocationId}/steps`), {
1366
+ method: "POST",
1367
+ body: JSON.stringify({
1368
+ instanceId,
1369
+ claimToken,
1370
+ stepType: step.stepType,
1371
+ content: step.content,
1372
+ ...step.clientStepId ? { clientStepId: step.clientStepId } : {},
1373
+ ...step.phase ? { phase: step.phase } : {},
1374
+ ...step.durationMs !== undefined ? { durationMs: step.durationMs } : {},
1375
+ ...statusText ? { statusText } : {}
1376
+ })
1377
+ });
1378
+ } catch (error) {
1379
+ this.logFn(`step HTTP fallback failed: ${summarize(error)}`);
1380
+ }
1381
+ }
1382
+ }
1383
+ async httpRecordSealedStepsFallback(invocationId, callbackToken, steps) {
1384
+ for (const step of steps) {
1385
+ try {
1386
+ await this.httpRequest(this.v1Path(`/bot-invocations/${invocationId}/sealed-steps`), {
1387
+ method: "POST",
1388
+ headers: { [THREA_CALLBACK_TOKEN_HEADER]: callbackToken },
1389
+ body: JSON.stringify(step)
1390
+ });
1391
+ } catch (error) {
1392
+ this.logFn(`sealed step HTTP fallback failed: ${summarize(error)}`);
1393
+ }
1394
+ }
1395
+ }
1396
+ async httpRenewFallback(invocationId, claimToken, claimTtlSeconds, instanceId) {
1397
+ try {
1398
+ const res = await this.httpRequest(this.v1Path(`/bot-invocations/${invocationId}/renew`), {
1399
+ method: "POST",
1400
+ body: JSON.stringify({ instanceId, claimToken, claimTtlSeconds })
1401
+ });
1402
+ if (res.status === 404)
1403
+ return { notFound: true, renewed: false };
1404
+ if (!res.ok)
1405
+ this.logFn(`renew HTTP fallback ${res.status}`);
1406
+ return { notFound: false, renewed: res.ok };
1407
+ } catch (error) {
1408
+ this.logFn(`renew HTTP fallback failed: ${summarize(error)}`);
1409
+ return { notFound: false, renewed: false };
1410
+ }
1411
+ }
1412
+ async syncObservedClaim(request) {
1413
+ const payload = {
1414
+ invocationId: request.invocationId,
1415
+ instanceId: request.instanceId ?? this.hello.instanceId,
1416
+ claimToken: request.claimToken,
1417
+ claimTtlSeconds: request.claimTtlSeconds,
1418
+ knownSourceRevision: request.knownSourceRevision,
1419
+ ...request.restartRequiredRevision === undefined ? {} : { restartRequiredRevision: request.restartRequiredRevision }
1420
+ };
1421
+ const ws = await this.emitWrite("bot:invocation:renew", payload, request.signal, Math.min(this.wsAckTimeoutMs, request.ackTimeoutMs));
1422
+ if (request.signal.aborted || ws.aborted)
1423
+ return { kind: "aborted" };
1424
+ if (ws.ack?.ok) {
1425
+ const parsed = parseControlState(ws.ack.data, request.invocationId, request.minimumSourceRevision);
1426
+ if (parsed)
1427
+ return { kind: "control", state: parsed };
1428
+ this.logFn(`control state rejected over WS (${request.invocationId}); retrying over HTTP`);
1429
+ } else if (ws.ack?.code === "NOT_FOUND") {
1430
+ return { kind: "not_found" };
1431
+ } else if (ws.ack) {
1432
+ this.logFn(`control renew rejected over WS (${ws.ack.code ?? "?"}); retrying over HTTP`);
1433
+ }
1434
+ if (request.signal.aborted)
1435
+ return { kind: "aborted" };
1436
+ try {
1437
+ const res = await this.httpRequest(this.v1Path(`/bot-invocations/${request.invocationId}/renew`), {
1438
+ method: "POST",
1439
+ body: JSON.stringify({
1440
+ instanceId: payload.instanceId,
1441
+ claimToken: payload.claimToken,
1442
+ claimTtlSeconds: payload.claimTtlSeconds,
1443
+ knownSourceRevision: request.knownSourceRevision,
1444
+ ...request.restartRequiredRevision === undefined ? {} : { restartRequiredRevision: request.restartRequiredRevision }
1445
+ })
1446
+ }, request.signal);
1447
+ if (request.signal.aborted)
1448
+ return { kind: "aborted" };
1449
+ if (res.status === 404)
1450
+ return { kind: "not_found" };
1451
+ if (!res.ok)
1452
+ return { kind: "retry" };
1453
+ const body = await res.json();
1454
+ const parsed = isObject(body) ? parseControlState(body.data, request.invocationId, request.minimumSourceRevision) : undefined;
1455
+ return parsed ? { kind: "control", state: parsed } : { kind: "retry" };
1456
+ } catch {
1457
+ return request.signal.aborted ? { kind: "aborted" } : { kind: "retry" };
1458
+ }
1459
+ }
1460
+ async httpPresenceFallback(body) {
1461
+ try {
1462
+ await this.httpRequest(this.v1Path("/bot-runtime/presence"), { method: "POST", body: JSON.stringify(body) });
1463
+ } catch (error) {
1464
+ this.logFn(`presence HTTP fallback failed: ${summarize(error)}`);
1465
+ }
1466
+ }
1467
+ v1Path(suffix) {
1468
+ return `/api/v1/workspaces/${this.workspaceId}${suffix}`;
1469
+ }
1470
+ async httpRequest(path, init, callerSignal) {
1471
+ const controller = new AbortController;
1472
+ const onCallerAbort = () => controller.abort();
1473
+ callerSignal?.addEventListener("abort", onCallerAbort, { once: true });
1474
+ if (callerSignal?.aborted)
1475
+ controller.abort();
1476
+ const timeout = setTimeout(() => controller.abort(), this.fetchTimeoutMs);
1477
+ try {
1478
+ return await fetch(`${this.base}${path}`, {
1479
+ ...init,
1480
+ signal: controller.signal,
1481
+ headers: {
1482
+ Authorization: `Bearer ${this.apiKey}`,
1483
+ "Content-Type": "application/json",
1484
+ ...init.headers
1485
+ }
1486
+ });
1487
+ } finally {
1488
+ clearTimeout(timeout);
1489
+ callerSignal?.removeEventListener("abort", onCallerAbort);
1490
+ }
1491
+ }
1492
+ }
1493
+ function parseControlState(value, expectedInvocationId, minimumSourceRevision) {
1494
+ if (!isObject(value) || value.invocationId !== expectedInvocationId)
1495
+ return;
1496
+ const sourceRevision = parseRevision(value.sourceRevision);
1497
+ if (sourceRevision === undefined)
1498
+ return;
1499
+ if (value.status === "active" && typeof value.claimExpiresAt === "string") {
1500
+ if (!Number.isFinite(Date.parse(value.claimExpiresAt)))
1501
+ return;
1502
+ if (value.update !== undefined) {
1503
+ if (!isObject(value.update) || parseRevision(value.update.sourceRevision) !== sourceRevision)
1504
+ return;
1505
+ }
1506
+ return {
1507
+ invocationId: expectedInvocationId,
1508
+ status: "active",
1509
+ claimExpiresAt: value.claimExpiresAt,
1510
+ sourceRevision,
1511
+ ...value.update === undefined ? {} : { update: value.update }
1512
+ };
1513
+ }
1514
+ const reason = parseCancellationReason(value.reason);
1515
+ if (value.status === "cancelled" && value.claimExpiresAt === null && sourceRevision >= minimumSourceRevision && reason) {
1516
+ return { invocationId: expectedInvocationId, status: "cancelled", claimExpiresAt: null, sourceRevision, reason };
1517
+ }
1518
+ return;
1519
+ }
1520
+ function normalizeAck(ack) {
1521
+ if (!isObject(ack) || typeof ack.ok !== "boolean")
1522
+ return null;
1523
+ return {
1524
+ ok: ack.ok,
1525
+ data: isObject(ack.data) ? ack.data : undefined,
1526
+ code: typeof ack.code === "string" ? ack.code : undefined,
1527
+ message: typeof ack.message === "string" ? ack.message : undefined
1528
+ };
1529
+ }
1530
+ function summarize(error) {
1531
+ return (error instanceof Error ? error.message : String(error)).slice(0, 200);
1532
+ }
1533
+ // src/archive-grace.ts
1534
+ var ARCHIVE_RESTORE_GRACE_MS = 5 * 60 * 1000;
1535
+ var ARCHIVE_RESTORE_PROBE_MS = 45000;
1536
+ var WS_BACKSTOP_POLL_MS = 15 * 60 * 1000;
1537
+
1538
+ class ArchiveGraceController {
1539
+ hooks;
1540
+ pending;
1541
+ probing = false;
1542
+ stopped = false;
1543
+ transitions = 0;
1544
+ graceMs;
1545
+ constructor(hooks, options = {}) {
1546
+ this.hooks = hooks;
1547
+ this.graceMs = options.graceMs ?? ARCHIVE_RESTORE_GRACE_MS;
1548
+ }
1549
+ get detached() {
1550
+ return this.pending !== undefined;
1551
+ }
1552
+ get pendingRootStreamId() {
1553
+ return this.pending?.rootStreamId;
1554
+ }
1555
+ get generation() {
1556
+ return this.transitions;
1557
+ }
1558
+ get probeDelayMs() {
1559
+ return Math.min(ARCHIVE_RESTORE_PROBE_MS, Math.max(Math.floor(this.graceMs / 4), 10));
1560
+ }
1561
+ async archived(rootStreamId) {
1562
+ if (this.stopped || this.pending)
1563
+ return;
1564
+ const pending = {
1565
+ rootStreamId,
1566
+ deadline: setTimeout(() => void this.windDown(pending), this.graceMs)
1567
+ };
1568
+ this.pending = pending;
1569
+ this.transitions += 1;
1570
+ this.hooks.log(`scratchpad ${rootStreamId} archived — detaching (reattaches if unarchived within ${Math.round(this.graceMs / 1000)}s)`);
1571
+ await this.hooks.onDetached(rootStreamId, this.graceMs);
1572
+ }
1573
+ async restored() {
1574
+ const pending = this.pending;
1575
+ if (this.stopped || !pending)
1576
+ return;
1577
+ clearTimeout(pending.deadline);
1578
+ pending.deadline = setTimeout(() => void this.windDown(pending), this.graceMs);
1579
+ await this.attemptReattach(pending);
1580
+ }
1581
+ async probe(currentRootStreamId) {
1582
+ if (this.stopped || this.probing)
1583
+ return;
1584
+ this.probing = true;
1585
+ try {
1586
+ const pending = this.pending;
1587
+ if (pending) {
1588
+ await this.attemptReattach(pending);
1589
+ return;
1590
+ }
1591
+ if (!currentRootStreamId)
1592
+ return;
1593
+ const archived = await this.hooks.isArchived(currentRootStreamId);
1594
+ if (archived !== true)
1595
+ return;
1596
+ if (this.stopped || this.pending)
1597
+ return;
1598
+ this.hooks.log(`archive backstop: ${currentRootStreamId} is archived with no push received`);
1599
+ await this.archived(currentRootStreamId);
1600
+ } catch (error) {
1601
+ this.hooks.log(`archive probe failed: ${describe(error)}`);
1602
+ } finally {
1603
+ this.probing = false;
1604
+ }
1605
+ }
1606
+ stop() {
1607
+ this.stopped = true;
1608
+ if (this.pending)
1609
+ clearTimeout(this.pending.deadline);
1610
+ this.pending = undefined;
1611
+ }
1612
+ async attemptReattach(pending) {
1613
+ let reattached = false;
1614
+ try {
1615
+ reattached = await this.hooks.reattach(pending.rootStreamId);
1616
+ } catch (error) {
1617
+ this.hooks.log(`reattach failed, staying detached: ${describe(error)}`);
1618
+ return;
1619
+ }
1620
+ if (!reattached || this.stopped || this.pending !== pending)
1621
+ return;
1622
+ clearTimeout(pending.deadline);
1623
+ this.pending = undefined;
1624
+ this.transitions += 1;
1625
+ this.hooks.log(`scratchpad ${pending.rootStreamId} restored — reattached`);
1626
+ await this.hooks.onReattached(pending.rootStreamId);
1627
+ }
1628
+ async windDown(pending) {
1629
+ if (this.stopped || this.pending !== pending)
1630
+ return;
1631
+ clearTimeout(pending.deadline);
1632
+ this.pending = undefined;
1633
+ this.transitions += 1;
1634
+ this.hooks.log(`scratchpad ${pending.rootStreamId} stayed archived — winding down`);
1635
+ try {
1636
+ await this.hooks.onWindDown(pending.rootStreamId);
1637
+ } catch (error) {
1638
+ this.hooks.log(`wind-down failed: ${describe(error)}`);
1639
+ }
1640
+ }
1641
+ }
1642
+ function describe(error) {
1643
+ return error instanceof Error ? error.message : String(error);
1644
+ }
1645
+ // src/attachment-files.ts
1646
+ import { join } from "node:path";
1647
+ var UNSAFE_SEGMENT_CHARS = /[\\/:*?"<>|]/g;
1648
+ var MAX_SEGMENT_BYTES = 180;
1649
+ var MAX_EXTENSION_BYTES = 16;
1650
+ var encoder = new TextEncoder;
1651
+ function byteLength(value) {
1652
+ return encoder.encode(value).length;
1653
+ }
1654
+ function truncateToBytes(value, maxBytes) {
1655
+ if (byteLength(value) <= maxBytes)
1656
+ return value;
1657
+ let out = "";
1658
+ let bytes = 0;
1659
+ for (const char of value) {
1660
+ const size = byteLength(char);
1661
+ if (bytes + size > maxBytes)
1662
+ break;
1663
+ out += char;
1664
+ bytes += size;
1665
+ }
1666
+ return out;
1667
+ }
1668
+ function clean(value) {
1669
+ return value.replace(UNSAFE_SEGMENT_CHARS, "_").replace(/^\.+$/, "_");
1670
+ }
1671
+ function safeSegment(value, fallback) {
1672
+ return truncateToBytes(clean(value), MAX_SEGMENT_BYTES) || fallback;
1673
+ }
1674
+ function safeAttachmentFilename(filename) {
1675
+ const cleaned = clean(filename);
1676
+ const dot = cleaned.lastIndexOf(".");
1677
+ const extension = dot > 0 ? cleaned.slice(dot) : "";
1678
+ if (!extension || byteLength(extension) > MAX_EXTENSION_BYTES) {
1679
+ return truncateToBytes(cleaned, MAX_SEGMENT_BYTES) || "attachment";
1680
+ }
1681
+ const stem = truncateToBytes(cleaned.slice(0, dot), MAX_SEGMENT_BYTES - byteLength(extension));
1682
+ return `${stem}${extension}`;
1683
+ }
1684
+ function attachmentLocalPath(dir, attachmentId, filename) {
1685
+ return join(dir, safeSegment(attachmentId, "attachment"), safeAttachmentFilename(filename));
1686
+ }
1687
+ // src/sealed-stream-client.ts
1688
+ import { ulid as ulid2 } from "ulid";
1689
+ function keyringKeySource(keyring) {
1690
+ const imported = new Map;
1691
+ return {
1692
+ async keysForStream(streamId) {
1693
+ await keyring.ensureForStream(streamId);
1694
+ const held = keyring.forStream(streamId);
1695
+ if (!held)
1696
+ return [];
1697
+ let privateKey = imported.get(held.keyId);
1698
+ if (!privateKey) {
1699
+ privateKey = importRecipientPrivateKey(base64ToBytes(held.privateKey));
1700
+ imported.set(held.keyId, privateKey);
1701
+ }
1702
+ return [{ keyId: held.keyId, privateKey: await privateKey }];
1703
+ }
1704
+ };
1705
+ }
1706
+
1707
+ class SealedStreamApiError extends Error {
1708
+ status;
1709
+ code;
1710
+ constructor(message, status, code) {
1711
+ super(message);
1712
+ this.name = "SealedStreamApiError";
1713
+ this.status = status;
1714
+ this.code = code;
1715
+ }
1716
+ }
1717
+ function reasonOf(error) {
1718
+ return String(error instanceof Error ? error.message : error);
1719
+ }
1720
+
1721
+ class SealedStreamClient {
1722
+ opts;
1723
+ doFetch;
1724
+ roots = new Map;
1725
+ ssks = new Map;
1726
+ generations = new Map;
1727
+ sender;
1728
+ constructor(opts) {
1729
+ this.opts = opts;
1730
+ this.doFetch = opts.fetch ?? globalThis.fetch;
1731
+ }
1732
+ async readMessages(streamId, opts = {}) {
1733
+ const root = await this.resolveRoot(streamId);
1734
+ const query = new URLSearchParams;
1735
+ if (opts.limit !== undefined)
1736
+ query.set("limit", String(opts.limit));
1737
+ if (opts.before)
1738
+ query.set("before", opts.before);
1739
+ if (opts.after)
1740
+ query.set("after", opts.after);
1741
+ const suffix = query.size > 0 ? `?${query.toString()}` : "";
1742
+ const page = await this.request("GET", `/streams/${streamId}/messages${suffix}`);
1743
+ const messages = [];
1744
+ for (const wire of page.data) {
1745
+ messages.push(await this.openMessage(root, wire));
1746
+ }
1747
+ return { messages, hasMore: page.hasMore };
1748
+ }
1749
+ async sendMessage(streamId, contentMarkdown, opts = {}) {
1750
+ const root = await this.resolveRoot(streamId);
1751
+ const keyGeneration = await this.currentGeneration(root);
1752
+ const key = await this.streamKey(root, keyGeneration);
1753
+ const senderId = await this.resolveSenderId();
1754
+ const clientMessageId = opts.clientMessageId ?? `msg_${ulid2()}`;
1755
+ const sealed = await sealMessage({
1756
+ key,
1757
+ keyGeneration,
1758
+ payload: serializeSealedPayload(contentMarkdown, { attachmentRefs: opts.attachmentRefs }),
1759
+ aad: buildMessageAad({ streamId: root, messageId: clientMessageId, senderId })
1760
+ });
1761
+ const created = await this.request("POST", `/streams/${streamId}/messages`, {
1762
+ sealed: { ciphertext: bytesToBase64(sealed.ciphertext), envelope: sealed.envelope },
1763
+ clientMessageId
1764
+ });
1765
+ return { messageId: created.data.id, clientMessageId };
1766
+ }
1767
+ async openSealedBody(streamId, sealed) {
1768
+ const root = await this.resolveRoot(streamId);
1769
+ return this.openBody(root, sealed);
1770
+ }
1771
+ async openBody(root, sealed) {
1772
+ let key;
1773
+ try {
1774
+ key = await this.streamKey(root, sealed.envelope.keyGeneration);
1775
+ } catch (error) {
1776
+ return { contentMarkdown: null, attachmentRefs: [], unreadableReason: reasonOf(error) };
1777
+ }
1778
+ try {
1779
+ const raw = await openMessageAsString({
1780
+ key,
1781
+ ciphertext: base64ToBytes(sealed.ciphertext),
1782
+ envelope: sealed.envelope
1783
+ });
1784
+ const payload = parseSealedPayload(raw);
1785
+ return { contentMarkdown: payload.contentMarkdown, attachmentRefs: payload.attachmentRefs ?? [] };
1786
+ } catch (error) {
1787
+ return { contentMarkdown: null, attachmentRefs: [], unreadableReason: reasonOf(error) };
1788
+ }
1789
+ }
1790
+ async openMessage(root, wire) {
1791
+ const base = {
1792
+ id: wire.id,
1793
+ sequence: wire.sequence,
1794
+ authorId: wire.authorId,
1795
+ authorType: wire.authorType,
1796
+ ...wire.authorDisplayName ? { authorDisplayName: wire.authorDisplayName } : {},
1797
+ createdAt: wire.createdAt,
1798
+ attachmentRefs: []
1799
+ };
1800
+ if (!wire.sealed) {
1801
+ return { ...base, contentMarkdown: null, unreadableReason: "Message carries no stream-key envelope" };
1802
+ }
1803
+ return { ...base, ...await this.openBody(root, wire.sealed) };
1804
+ }
1805
+ async streamKey(root, keyGeneration) {
1806
+ const cached = this.ssks.get(`${root}:${keyGeneration}`);
1807
+ if (cached)
1808
+ return cached;
1809
+ await this.loadWraps(root);
1810
+ const key = this.ssks.get(`${root}:${keyGeneration}`);
1811
+ if (!key) {
1812
+ throw new Error(`No key wrap for generation ${keyGeneration} of ${root} is addressed to a key this client holds`);
1813
+ }
1814
+ return key;
1815
+ }
1816
+ async currentGeneration(root) {
1817
+ await this.loadWraps(root);
1818
+ return this.generations.get(root) ?? 0;
1819
+ }
1820
+ async loadWraps(root) {
1821
+ const identities = await this.opts.keys.keysForStream(root);
1822
+ if (identities.length === 0) {
1823
+ throw new Error(`No end-to-end key available for ${root}`);
1824
+ }
1825
+ const wraps = await this.request("GET", `/streams/${root}/e2e/key-wraps`);
1826
+ this.generations.set(root, wraps.data.currentKeyGeneration);
1827
+ for (const wrap of wraps.data.wraps) {
1828
+ const identity = identities.find((candidate) => candidate.keyId === wrap.recipientKeyId);
1829
+ if (!identity)
1830
+ continue;
1831
+ if (this.ssks.has(`${root}:${wrap.keyGeneration}`))
1832
+ continue;
1833
+ const key = await unwrapStreamKey({
1834
+ enc: base64ToBytes(wrap.wrapEnc),
1835
+ ct: base64ToBytes(wrap.wrapCt),
1836
+ recipientPrivateKey: identity.privateKey,
1837
+ aad: buildWrapAad({
1838
+ streamId: root,
1839
+ keyGeneration: wrap.keyGeneration,
1840
+ recipientKeyId: wrap.recipientKeyId
1841
+ })
1842
+ });
1843
+ this.ssks.set(`${root}:${wrap.keyGeneration}`, key);
1844
+ }
1845
+ }
1846
+ async resolveRoot(streamId) {
1847
+ let pending = this.roots.get(streamId);
1848
+ if (!pending) {
1849
+ pending = this.request("GET", `/streams/${streamId}`).then((stream) => stream.data.rootStreamId ?? stream.data.id).catch((error) => {
1850
+ this.roots.delete(streamId);
1851
+ throw error;
1852
+ });
1853
+ this.roots.set(streamId, pending);
1854
+ }
1855
+ return pending;
1856
+ }
1857
+ async resolveSenderId() {
1858
+ if (this.opts.senderId)
1859
+ return this.opts.senderId;
1860
+ if (!this.sender) {
1861
+ this.sender = this.request("GET", "/me").then((me) => {
1862
+ const id = me.data.kind === "bot" ? me.data.botId : me.data.userId;
1863
+ if (!id)
1864
+ throw new Error("GET /me named no principal id");
1865
+ return id;
1866
+ }).catch((error) => {
1867
+ this.sender = undefined;
1868
+ throw error;
1869
+ });
1870
+ }
1871
+ return this.sender;
1872
+ }
1873
+ async request(method, path, body) {
1874
+ const url = `${this.opts.baseUrl.replace(/\/$/, "")}/api/v1/workspaces/${this.opts.workspaceId}${path}`;
1875
+ const response = await this.doFetch(url, {
1876
+ method,
1877
+ headers: {
1878
+ Authorization: `Bearer ${this.opts.apiKey}`,
1879
+ ...body === undefined ? {} : { "Content-Type": "application/json" }
1880
+ },
1881
+ ...body === undefined ? {} : { body: JSON.stringify(body) }
1882
+ });
1883
+ const text = await response.text();
1884
+ if (!response.ok) {
1885
+ let parsed = {};
1886
+ try {
1887
+ if (text.length > 0)
1888
+ parsed = JSON.parse(text);
1889
+ } catch {
1890
+ parsed = {};
1891
+ }
1892
+ throw new SealedStreamApiError(typeof parsed.message === "string" ? parsed.message : `${method} ${path} failed`, response.status, typeof parsed.code === "string" ? parsed.code : "UNKNOWN");
1893
+ }
1894
+ return text.length > 0 ? JSON.parse(text) : {};
1895
+ }
1896
+ }
1897
+ // src/keyring.ts
1898
+ import { spawnSync } from "node:child_process";
1899
+ import { createHash } from "node:crypto";
1900
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
1901
+ import { dirname, join as join2 } from "node:path";
1902
+ var E2E_KEY_SCOPES = ["host", "identity", "instance", "stream"];
1903
+ var E2E_KEY_STORE_KINDS = ["keychain", "file"];
1904
+ function hash16(value) {
1905
+ return createHash("sha256").update(value).digest("hex").slice(0, 16);
1906
+ }
1907
+ function e2eKeyAccount(params) {
1908
+ switch (params.scope) {
1909
+ case "identity":
1910
+ return `identity-${hash16(params.identitySeed)}`;
1911
+ case "instance":
1912
+ return `instance-${params.instanceId.replace(/[^A-Za-z0-9_-]+/g, "-")}`.slice(0, 96);
1913
+ case "host":
1914
+ return `host-${hash16(params.hostname)}`;
1915
+ case "stream":
1916
+ return null;
1917
+ }
1918
+ }
1919
+ function e2eStreamKeyAccount(streamId) {
1920
+ return `stream-${hash16(streamId)}`;
1921
+ }
1922
+ function e2eUserKeyAccount(workspaceId, userId) {
1923
+ return `user-${hash16(`${workspaceId}:${userId}`)}`;
1924
+ }
1925
+ function decodeRecord(raw) {
1926
+ try {
1927
+ const parsed = JSON.parse(raw);
1928
+ if (typeof parsed.keyId === "string" && typeof parsed.publicKey === "string" && typeof parsed.privateKey === "string") {
1929
+ return { keyId: parsed.keyId, publicKey: parsed.publicKey, privateKey: parsed.privateKey };
1930
+ }
1931
+ } catch {}
1932
+ return;
1933
+ }
1934
+
1935
+ class FileKeyStore {
1936
+ kind = "file";
1937
+ describe;
1938
+ dir;
1939
+ constructor(opts) {
1940
+ this.dir = opts.dir;
1941
+ this.describe = opts.dir;
1942
+ }
1943
+ path(account) {
1944
+ return join2(this.dir, `${account}.json`);
1945
+ }
1946
+ read(account) {
1947
+ const path = this.path(account);
1948
+ if (!existsSync(path))
1949
+ return;
1950
+ return decodeRecord(readFileSync(path, "utf8"));
1951
+ }
1952
+ hasAny() {
1953
+ if (!existsSync(this.dir))
1954
+ return false;
1955
+ return readdirSync(this.dir).some((entry) => entry.endsWith(".json"));
1956
+ }
1957
+ createExclusive(account, record) {
1958
+ const path = this.path(account);
1959
+ mkdirSync(dirname(path), { recursive: true });
1960
+ try {
1961
+ writeFileSync(path, `${JSON.stringify(record, null, 2)}
1962
+ `, { mode: 384, flag: "wx" });
1963
+ return record;
1964
+ } catch (error) {
1965
+ if (error.code !== "EEXIST")
1966
+ throw error;
1967
+ const winner = this.read(account);
1968
+ if (!winner)
1969
+ throw new Error(`${path} exists but could not be read`);
1970
+ return winner;
1971
+ }
1972
+ }
1973
+ write(account, record) {
1974
+ const path = this.path(account);
1975
+ mkdirSync(dirname(path), { recursive: true });
1976
+ writeFileSync(path, `${JSON.stringify(record, null, 2)}
1977
+ `, { mode: 384 });
1978
+ }
1979
+ remove(account) {
1980
+ rmSync(this.path(account), { force: true });
1981
+ }
1982
+ }
1983
+ var KEYCHAIN_COMMAND_TIMEOUT_MS = 5000;
1984
+ function runKeychainCommand(command, args, input, timeoutMs = KEYCHAIN_COMMAND_TIMEOUT_MS) {
1985
+ const result = spawnSync(command, args, { encoding: "utf8", input, timeout: timeoutMs });
1986
+ if (result.error?.code === "ETIMEDOUT") {
1987
+ return {
1988
+ status: -1,
1989
+ stdout: "",
1990
+ stderr: `${command} gave no answer within ${timeoutMs}ms; the keyring is probably locked and waiting on a password prompt`,
1991
+ unavailable: true
1992
+ };
1993
+ }
1994
+ if (result.error) {
1995
+ return { status: -1, stdout: "", stderr: String(result.error), unavailable: true };
1996
+ }
1997
+ return { status: result.status ?? -1, stdout: result.stdout ?? "", stderr: result.stderr ?? "", unavailable: false };
1998
+ }
1999
+ function encodeSecret(record) {
2000
+ return Buffer.from(JSON.stringify(record), "utf8").toString("base64");
2001
+ }
2002
+ function decodeSecret(raw) {
2003
+ const trimmed = raw.trim();
2004
+ if (!trimmed)
2005
+ return;
2006
+ return decodeRecord(Buffer.from(trimmed, "base64").toString("utf8"));
2007
+ }
2008
+ var KEYCHAIN_SERVICE = "threa-e2e";
2009
+ var sameRecord = (a, b) => a.keyId === b.keyId && a.publicKey === b.publicKey && a.privateKey === b.privateKey;
2010
+
2011
+ class MacKeychainStore {
2012
+ kind = "keychain";
2013
+ describe = `macOS keychain (service ${KEYCHAIN_SERVICE})`;
2014
+ exec;
2015
+ constructor(opts = {}) {
2016
+ this.exec = opts.exec ?? runKeychainCommand;
2017
+ }
2018
+ read(account) {
2019
+ const result = this.exec("/usr/bin/security", [
2020
+ "find-generic-password",
2021
+ "-s",
2022
+ KEYCHAIN_SERVICE,
2023
+ "-a",
2024
+ account,
2025
+ "-w"
2026
+ ]);
2027
+ if (result.unavailable)
2028
+ throw new Error(`macOS keychain unavailable: ${result.stderr}`);
2029
+ if (result.status !== 0)
2030
+ return;
2031
+ return decodeSecret(result.stdout);
2032
+ }
2033
+ createExclusive(account, record) {
2034
+ const result = this.exec("/usr/bin/security", ["-i"], `add-generic-password -s ${KEYCHAIN_SERVICE} -a ${account} -w ${encodeSecret(record)}
2035
+ `);
2036
+ if (result.unavailable)
2037
+ throw new Error(`macOS keychain unavailable: ${result.stderr}`);
2038
+ const stored = this.read(account);
2039
+ if (!stored)
2040
+ throw new Error(`macOS keychain accepted no key for ${account}: ${result.stderr || result.stdout}`);
2041
+ return stored;
2042
+ }
2043
+ write(account, record) {
2044
+ const result = this.exec("/usr/bin/security", ["-i"], `add-generic-password -U -s ${KEYCHAIN_SERVICE} -a ${account} -w ${encodeSecret(record)}
2045
+ `);
2046
+ if (result.unavailable)
2047
+ throw new Error(`macOS keychain unavailable: ${result.stderr}`);
2048
+ if (result.status !== 0) {
2049
+ throw new Error(`macOS keychain rejected the key for ${account}: ${result.stderr || result.stdout}`);
2050
+ }
2051
+ const stored = this.read(account);
2052
+ if (!stored || !sameRecord(stored, record)) {
2053
+ throw new Error(`macOS keychain did not store the key for ${account}: ${result.stderr || result.stdout}`);
2054
+ }
2055
+ }
2056
+ remove(account) {
2057
+ const result = this.exec("/usr/bin/security", ["delete-generic-password", "-s", KEYCHAIN_SERVICE, "-a", account]);
2058
+ if (result.unavailable)
2059
+ throw new Error(`macOS keychain unavailable: ${result.stderr}`);
2060
+ if (this.read(account))
2061
+ throw new Error(`macOS keychain kept the key for ${account}: ${result.stderr}`);
2062
+ }
2063
+ }
2064
+
2065
+ class SecretServiceStore {
2066
+ kind = "keychain";
2067
+ describe = `Secret Service keyring (service ${KEYCHAIN_SERVICE})`;
2068
+ exec;
2069
+ constructor(opts = {}) {
2070
+ this.exec = opts.exec ?? runKeychainCommand;
2071
+ }
2072
+ read(account) {
2073
+ const result = this.exec("secret-tool", ["lookup", "service", KEYCHAIN_SERVICE, "account", account]);
2074
+ if (result.unavailable)
2075
+ throw new Error(`Secret Service unavailable: ${result.stderr}`);
2076
+ if (result.status !== 0)
2077
+ return;
2078
+ return decodeSecret(result.stdout);
2079
+ }
2080
+ createExclusive(account, record) {
2081
+ const existing = this.read(account);
2082
+ if (existing)
2083
+ return existing;
2084
+ const result = this.exec("secret-tool", ["store", "--label", `Threa E2E key ${account}`, "service", KEYCHAIN_SERVICE, "account", account], encodeSecret(record));
2085
+ if (result.unavailable)
2086
+ throw new Error(`Secret Service unavailable: ${result.stderr}`);
2087
+ const stored = this.read(account);
2088
+ if (!stored)
2089
+ throw new Error(`Secret Service stored no key for ${account}: ${result.stderr || result.stdout}`);
2090
+ return stored;
2091
+ }
2092
+ write(account, record) {
2093
+ const result = this.exec("secret-tool", ["store", "--label", `Threa E2E key ${account}`, "service", KEYCHAIN_SERVICE, "account", account], encodeSecret(record));
2094
+ if (result.unavailable)
2095
+ throw new Error(`Secret Service unavailable: ${result.stderr}`);
2096
+ if (result.status !== 0) {
2097
+ throw new Error(`Secret Service rejected the key for ${account}: ${result.stderr || result.stdout}`);
2098
+ }
2099
+ const stored = this.read(account);
2100
+ if (!stored || !sameRecord(stored, record)) {
2101
+ throw new Error(`Secret Service did not store the key for ${account}: ${result.stderr || result.stdout}`);
2102
+ }
2103
+ }
2104
+ remove(account) {
2105
+ const result = this.exec("secret-tool", ["clear", "service", KEYCHAIN_SERVICE, "account", account]);
2106
+ if (result.unavailable)
2107
+ throw new Error(`Secret Service unavailable: ${result.stderr}`);
2108
+ if (this.read(account))
2109
+ throw new Error(`Secret Service kept the key for ${account}: ${result.stderr}`);
2110
+ }
2111
+ }
2112
+ function resolveKeyStore(input) {
2113
+ const keychain = () => input.platform === "darwin" ? new MacKeychainStore({ exec: input.exec }) : new SecretServiceStore({ exec: input.exec });
2114
+ if (input.requested === "file")
2115
+ return new FileKeyStore({ dir: input.dir });
2116
+ if (input.requested === "keychain") {
2117
+ const store2 = keychain();
2118
+ store2.read("threa-probe");
2119
+ return store2;
2120
+ }
2121
+ if (input.hasExistingFileKey)
2122
+ return new FileKeyStore({ dir: input.dir });
2123
+ const store = keychain();
2124
+ try {
2125
+ store.read("threa-probe");
2126
+ return store;
2127
+ } catch (error) {
2128
+ throw new Error(`No OS keychain available for Threa's end-to-end keys (${String(error)}). ` + `Set keyStore to "keychain" once one is installed and unlocked, or "file" to keep them in ${input.dir} at mode 0600.`);
2129
+ }
2130
+ }
2131
+
2132
+ class E2eKeyring {
2133
+ opts;
2134
+ log;
2135
+ held = [];
2136
+ inFlight = new Map;
2137
+ loaded = false;
2138
+ constructor(opts) {
2139
+ this.opts = opts;
2140
+ this.log = opts.log ?? ((message) => console.error(message));
2141
+ }
2142
+ get current() {
2143
+ return this.held;
2144
+ }
2145
+ async ensure() {
2146
+ if (this.loaded)
2147
+ return this.held;
2148
+ const account = this.opts.account;
2149
+ if (account === null) {
2150
+ this.loaded = true;
2151
+ return this.held;
2152
+ }
2153
+ await this.loadAccount(account, undefined);
2154
+ this.loaded = this.held.length > 0;
2155
+ return this.held;
2156
+ }
2157
+ async ensureForStream(streamId) {
2158
+ if (this.opts.account !== null)
2159
+ return this.ensure();
2160
+ return this.loadAccount(e2eStreamKeyAccount(streamId), streamId);
2161
+ }
2162
+ dropStream(streamId) {
2163
+ const held = this.held.find((key) => key.streamId === streamId);
2164
+ if (!held)
2165
+ return this.held;
2166
+ this.held = this.held.filter((key) => key !== held);
2167
+ this.opts.store.remove(held.account);
2168
+ this.log(`Threa sealed: dropped ${held.keyId}, the key for revoked stream ${streamId}`);
2169
+ return this.held;
2170
+ }
2171
+ forStream(streamId) {
2172
+ if (this.opts.account !== null)
2173
+ return this.held.find((key) => !key.streamId);
2174
+ return this.held.find((key) => key.streamId === streamId);
2175
+ }
2176
+ presenceFields() {
2177
+ if (this.held.length === 0)
2178
+ return {};
2179
+ const e2eKeys = this.held.map((key) => ({
2180
+ keyId: key.keyId,
2181
+ publicKey: key.publicKey,
2182
+ ...key.streamId ? { streamId: key.streamId } : {}
2183
+ }));
2184
+ const unscoped = this.held.find((key) => !key.streamId);
2185
+ return unscoped ? { e2eKeys, publicKey: unscoped.publicKey, publicKeyId: unscoped.keyId } : { e2eKeys };
2186
+ }
2187
+ async loadAccount(account, streamId) {
2188
+ const existing = this.held.find((key) => key.account === account);
2189
+ if (existing)
2190
+ return this.held;
2191
+ let attempt = this.inFlight.get(account);
2192
+ if (!attempt) {
2193
+ attempt = this.mintAccount(account, streamId).finally(() => this.inFlight.delete(account));
2194
+ this.inFlight.set(account, attempt);
2195
+ }
2196
+ return attempt;
2197
+ }
2198
+ async mintAccount(account, streamId) {
2199
+ const record = this.opts.store.read(account) ?? await this.createRecord(account);
2200
+ if (!this.held.some((key) => key.account === account)) {
2201
+ this.held = [...this.held, { ...record, account, ...streamId ? { streamId } : {} }];
2202
+ }
2203
+ return this.held;
2204
+ }
2205
+ async createRecord(account) {
2206
+ const legacy = account === this.opts.account ? this.opts.legacy?.() : undefined;
2207
+ if (legacy) {
2208
+ const adopted = this.opts.store.createExclusive(account, legacy);
2209
+ this.log(`Threa sealed: adopted this install's existing key ${adopted.keyId} into ${this.opts.store.describe} as ${account}`);
2210
+ return adopted;
2211
+ }
2212
+ const minted = this.opts.store.createExclusive(account, await this.opts.mint());
2213
+ this.log(`Threa sealed: end-to-end key ${minted.keyId} is in ${this.opts.store.describe} as ${account}`);
2214
+ return minted;
2215
+ }
2216
+ }
2217
+ function readLegacyBikFile(path) {
2218
+ if (!existsSync(path))
2219
+ return;
2220
+ try {
2221
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
2222
+ if (typeof parsed.publicKeyId === "string" && typeof parsed.publicKey === "string" && typeof parsed.privateKey === "string") {
2223
+ return { keyId: parsed.publicKeyId, publicKey: parsed.publicKey, privateKey: parsed.privateKey };
2224
+ }
2225
+ } catch {}
2226
+ return;
2227
+ }
2228
+ // src/user-key.ts
2229
+ import { argon2id } from "hash-wasm";
2230
+ var DEFAULT_KDF_PARAMS = {
2231
+ algorithm: "argon2id",
2232
+ m: 64 * 1024,
2233
+ t: 3,
2234
+ p: 1,
2235
+ version: 19
2236
+ };
2237
+ var KEK_LENGTH_BYTES = 32;
2238
+ var PRIVATE_BUNDLE_VERSION = 1;
2239
+ var IV_LENGTH2 = 12;
2240
+ var ARGON2_VERSION = 19;
2241
+ async function deriveKEK(passphrase, salt, params = DEFAULT_KDF_PARAMS) {
2242
+ if (params.algorithm !== "argon2id") {
2243
+ throw new Error(`Unsupported KDF algorithm: ${params.algorithm}`);
2244
+ }
2245
+ if (params.version !== ARGON2_VERSION) {
2246
+ throw new Error(`Unsupported Argon2 version: ${params.version}`);
2247
+ }
2248
+ const raw = await argon2id({
2249
+ password: passphrase,
2250
+ salt,
2251
+ iterations: params.t,
2252
+ parallelism: params.p,
2253
+ memorySize: params.m,
2254
+ hashLength: KEK_LENGTH_BYTES,
2255
+ outputType: "binary"
2256
+ });
2257
+ return crypto.subtle.importKey("raw", new Uint8Array(raw), { name: "AES-GCM" }, false, ["decrypt"]);
2258
+ }
2259
+
2260
+ class WrongPassphraseError extends Error {
2261
+ constructor() {
2262
+ super("Wrapped private bundle did not open with this passphrase");
2263
+ this.name = "WrongPassphraseError";
2264
+ }
2265
+ }
2266
+ async function unwrapPrivate(bundle, kek) {
2267
+ if (bundle.length < 1 + IV_LENGTH2 + 1) {
2268
+ throw new Error("Wrapped private bundle is too short");
2269
+ }
2270
+ const version = bundle[0];
2271
+ if (version !== PRIVATE_BUNDLE_VERSION) {
2272
+ throw new Error(`Unsupported private bundle version: ${version}`);
2273
+ }
2274
+ const iv = bundle.slice(1, 1 + IV_LENGTH2);
2275
+ const ciphertext = bundle.slice(1 + IV_LENGTH2);
2276
+ const plaintext = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, kek, ciphertext).catch(() => {
2277
+ throw new WrongPassphraseError;
2278
+ });
2279
+ const privBytes = new Uint8Array(plaintext);
2280
+ return importRecipientPrivateKey(privBytes);
2281
+ }
2282
+ async function unlockUserKey(input) {
2283
+ const kek = await deriveKEK(input.passphrase, input.kdfSalt, input.kdfParams);
2284
+ return unwrapPrivate(input.encryptedPrivateBundle, kek);
2285
+ }
2286
+ export {
2287
+ unwrapPrivate,
2288
+ unlockUserKey,
2289
+ serializeSealedPayload,
2290
+ sealStep,
2291
+ sealReply,
2292
+ sealMessage,
2293
+ sealDecision,
2294
+ scrubSealedError,
2295
+ safeAttachmentFilename,
2296
+ resolveKeyStore,
2297
+ readLegacyBikFile,
2298
+ parseWsHint,
2299
+ parseSealedTurnContext,
2300
+ parseSealedPayload,
2301
+ parseSealedAckContext,
2302
+ openSealedTurnContext,
2303
+ openSealedDecisionNote,
2304
+ openSealedAck,
2305
+ openMessageAsString,
2306
+ mintStreamKeyWraps,
2307
+ mintE2eKeyRecord,
2308
+ keyringKeySource,
2309
+ isObject,
2310
+ encryptAttachmentBytes,
2311
+ e2eUserKeyAccount,
2312
+ e2eStreamKeyAccount,
2313
+ e2eKeyAccount,
2314
+ deriveKEK,
2315
+ decryptAttachmentBytes,
2316
+ bytesToBase64,
2317
+ buildWrapAad,
2318
+ buildMessageAad,
2319
+ buildDecisionNoteAad,
2320
+ buildDecisionAad,
2321
+ buildBotSocketUrl,
2322
+ base64ToBytes,
2323
+ attachmentLocalPath,
2324
+ WrongPassphraseError,
2325
+ WS_BACKSTOP_POLL_MS,
2326
+ THREA_CALLBACK_TOKEN_HEADER,
2327
+ SecretServiceStore,
2328
+ SealedStreamClient,
2329
+ SealedStreamApiError,
2330
+ MacKeychainStore,
2331
+ FileKeyStore,
2332
+ E2eKeyring,
2333
+ E2E_KEY_STORE_KINDS,
2334
+ E2E_KEY_SCOPES,
2335
+ DEFAULT_KDF_PARAMS,
2336
+ BotRuntimeTransport,
2337
+ BotKeyring,
2338
+ ArchiveGraceController,
2339
+ ARCHIVE_RESTORE_PROBE_MS,
2340
+ ARCHIVE_RESTORE_GRACE_MS
2341
+ };
2342
+
2343
+ //# debugId=A1F550552BD6E62464756E2164756E21
2344
+ //# sourceMappingURL=index.js.map