@arbidocs/client 0.4.128 → 0.4.130

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -93,11 +93,17 @@ async function initSodium() {
93
93
  function base64ToBytes(base64String) {
94
94
  return sodium.from_base64(base64String, sodium.base64_variants.ORIGINAL);
95
95
  }
96
- function bytesToBase64(bytes) {
97
- return sodium.to_base64(bytes, sodium.base64_variants.ORIGINAL);
96
+ function bytesToBase64(bytes2) {
97
+ return sodium.to_base64(bytes2, sodium.base64_variants.ORIGINAL);
98
98
  }
99
- function base64Encode(bytes) {
100
- const binString = Array.from(bytes, (byte) => String.fromCodePoint(byte)).join("");
99
+ function bytesToBase64Url(bytes2) {
100
+ return sodium.to_base64(bytes2, sodium.base64_variants.URLSAFE_NO_PADDING);
101
+ }
102
+ function base64UrlToBytes(encoded) {
103
+ return sodium.from_base64(encoded, sodium.base64_variants.URLSAFE_NO_PADDING);
104
+ }
105
+ function base64Encode(bytes2) {
106
+ const binString = Array.from(bytes2, (byte) => String.fromCodePoint(byte)).join("");
101
107
  return btoa(binString);
102
108
  }
103
109
  function base64Decode(base64) {
@@ -207,6 +213,20 @@ function deriveEncryptionKeypairFromSigning(signingKeyPair) {
207
213
  secretKey: encryptionPrivateKey
208
214
  };
209
215
  }
216
+ function encryptionKeypairFromSigningKey(signingPrivateKey) {
217
+ return deriveEncryptionKeypairFromSigning({
218
+ publicKey: signingPrivateKey.slice(32, 64),
219
+ secretKey: signingPrivateKey
220
+ });
221
+ }
222
+ function unwrapWorkspaceKey(wrappedKey, signingPrivateKey) {
223
+ const { secretKey } = encryptionKeypairFromSigningKey(signingPrivateKey);
224
+ try {
225
+ return sealedBoxDecrypt(wrappedKey, secretKey);
226
+ } finally {
227
+ secretKey.fill(0);
228
+ }
229
+ }
210
230
  function generateRandomSigningKeypair() {
211
231
  const kp = sodium.crypto_sign_keypair();
212
232
  return { publicKey: kp.publicKey, secretKey: kp.privateKey };
@@ -302,32 +322,23 @@ async function openSealedCallKey(sealedBase64, encryptionSecretKey) {
302
322
  }
303
323
  async function encryptCallTitle(title, key) {
304
324
  await initSodium();
305
- const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
306
- const box = sodium.crypto_secretbox_easy(sodium.from_string(title), nonce, key);
307
- const out = new Uint8Array(nonce.length + box.length);
308
- out.set(nonce, 0);
309
- out.set(box, nonce.length);
310
- return bytesToBase64(out);
325
+ return bytesToBase64(encryptFile(sodium.from_string(title), key));
311
326
  }
312
327
  async function decryptCallTitle(ciphertext, key) {
313
328
  await initSodium();
314
329
  try {
315
- const raw = base64ToBytes(ciphertext);
316
- const nonce = raw.slice(0, sodium.crypto_secretbox_NONCEBYTES);
317
- const box = raw.slice(sodium.crypto_secretbox_NONCEBYTES);
318
- return sodium.to_string(sodium.crypto_secretbox_open_easy(box, nonce, key));
330
+ return sodium.to_string(decryptFile(base64ToBytes(ciphertext), key));
319
331
  } catch {
320
332
  return null;
321
333
  }
322
334
  }
323
335
  function encodeCallKey(key) {
324
- return bytesToBase64(key).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
336
+ return bytesToBase64Url(key);
325
337
  }
326
338
  function decodeCallKey(encoded) {
327
339
  if (!/^[A-Za-z0-9_-]+$/.test(encoded)) return null;
328
- const b64 = encoded.replace(/-/g, "+").replace(/_/g, "/");
329
340
  try {
330
- const key = base64ToBytes(b64 + "=".repeat((4 - b64.length % 4) % 4));
341
+ const key = base64UrlToBytes(encoded);
331
342
  return key.length === CALL_KEY_BYTES ? key : null;
332
343
  } catch {
333
344
  return null;
@@ -366,19 +377,11 @@ async function openSealedChatKey(sealedBase64, mySecretKey) {
366
377
  }
367
378
  async function encryptWithChatKey(plaintext, key) {
368
379
  await initSodium();
369
- const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
370
- const box = sodium.crypto_secretbox_easy(sodium.from_string(plaintext), nonce, key);
371
- const out = new Uint8Array(nonce.length + box.length);
372
- out.set(nonce, 0);
373
- out.set(box, nonce.length);
374
- return bytesToBase64(out);
380
+ return bytesToBase64(encryptFile(sodium.from_string(plaintext), key));
375
381
  }
376
382
  async function decryptWithChatKey(ciphertext, key) {
377
383
  await initSodium();
378
- const raw = base64ToBytes(ciphertext);
379
- const nonce = raw.slice(0, sodium.crypto_secretbox_NONCEBYTES);
380
- const box = raw.slice(sodium.crypto_secretbox_NONCEBYTES);
381
- return sodium.to_string(sodium.crypto_secretbox_open_easy(box, nonce, key));
384
+ return sodium.to_string(decryptFile(base64ToBytes(ciphertext), key));
382
385
  }
383
386
 
384
387
  // src/crypto/keypairs.ts
@@ -426,12 +429,12 @@ function wrapIdentityKey(identitySecretKey, passwordSecretKey) {
426
429
  }
427
430
  function unwrapIdentityKey(wrapped, passwordSecretKey) {
428
431
  if (!wrapped) return passwordSecretKey;
429
- const bytes = base64Decode(wrapped);
430
- const nonce = bytes.slice(0, sodium.crypto_secretbox_NONCEBYTES);
432
+ const bytes2 = base64Decode(wrapped);
433
+ const nonce = bytes2.slice(0, sodium.crypto_secretbox_NONCEBYTES);
431
434
  const key = identityWrappingKey(passwordSecretKey);
432
435
  try {
433
436
  return sodium.crypto_secretbox_open_easy(
434
- bytes.slice(sodium.crypto_secretbox_NONCEBYTES),
437
+ bytes2.slice(sodium.crypto_secretbox_NONCEBYTES),
435
438
  nonce,
436
439
  key
437
440
  );
@@ -493,379 +496,303 @@ async function generatePasswordChangeCredentials(params, deploymentDomain) {
493
496
  }
494
497
  };
495
498
  }
496
-
497
- // src/middleware/bearer-auth.ts
498
- function createBearerAuthMiddleware(config) {
499
+ var UNENCRYPTED_RECOVERY_KEY_SIZE = 64;
500
+ var ENCRYPTED_RECOVERY_KEY_SIZE = 104;
501
+ var RECOVERY_KEY_ERRORS = {
502
+ INVALID_SIZE: `Invalid recovery key: file must be ${UNENCRYPTED_RECOVERY_KEY_SIZE} or ${ENCRYPTED_RECOVERY_KEY_SIZE} bytes`,
503
+ INVALID_FORMAT: "Invalid recovery key format",
504
+ READ_ERROR: "Failed to read recovery key file",
505
+ AUTH_FAILED: "Recovery key does not match this account",
506
+ ENCRYPTED_KEY_REQUIRES_SSO: 'This recovery key is encrypted with your SSO identity. Please use SSO login and then click "Forgot Password" to use this key.',
507
+ DECRYPTION_FAILED: "Failed to decrypt recovery key. This key may belong to a different SSO account."
508
+ };
509
+ function assertSigningKey(signingPrivateKey) {
510
+ if (signingPrivateKey.length !== UNENCRYPTED_RECOVERY_KEY_SIZE) {
511
+ throw new Error("Invalid signing key: must be exactly 64 bytes");
512
+ }
513
+ }
514
+ function ssoWrappingKey(auth0Sub) {
515
+ return sodium.crypto_hash_sha256(sodium.from_string(auth0Sub));
516
+ }
517
+ async function encryptRecoveryKey(signingPrivateKey, auth0Sub) {
518
+ await initSodium();
519
+ assertSigningKey(signingPrivateKey);
520
+ return encryptFile(signingPrivateKey, ssoWrappingKey(auth0Sub));
521
+ }
522
+ async function decryptRecoveryKey(encryptedKey, auth0Sub) {
523
+ await initSodium();
524
+ if (encryptedKey.length !== ENCRYPTED_RECOVERY_KEY_SIZE) {
525
+ throw new Error(`Invalid encrypted key: expected ${ENCRYPTED_RECOVERY_KEY_SIZE} bytes`);
526
+ }
527
+ try {
528
+ return decryptFile(encryptedKey, ssoWrappingKey(auth0Sub));
529
+ } catch {
530
+ throw new Error(RECOVERY_KEY_ERRORS.DECRYPTION_FAILED);
531
+ }
532
+ }
533
+ function isEncryptedRecoveryKey(byteLength) {
534
+ return byteLength === ENCRYPTED_RECOVERY_KEY_SIZE;
535
+ }
536
+ function getRecoveryKeyFilename() {
537
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19);
538
+ return `arbi-recovery-${timestamp}.html`;
539
+ }
540
+ function buildRecoveryUrl(args) {
541
+ const params = new URLSearchParams({ email: args.email });
542
+ if (args.sso) params.set("sso", "1");
543
+ return `${args.origin}/recover?${params.toString()}#KEY=${bytesToBase64Url(args.keyBytes)}`;
544
+ }
545
+ function parseRecoveryUrl(search, hash) {
546
+ const params = new URLSearchParams(search);
547
+ const email = params.get("email");
548
+ const keyMatch = /(?:^#|[&#])KEY=([A-Za-z0-9_-]+)/.exec(hash);
549
+ if (!email || !keyMatch) return null;
550
+ try {
551
+ return {
552
+ email,
553
+ recoveryKey: base64UrlToBytes(keyMatch[1]),
554
+ isEncrypted: params.get("sso") === "1"
555
+ };
556
+ } catch {
557
+ return null;
558
+ }
559
+ }
560
+ function escapeHtml(s) {
561
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
562
+ }
563
+ function buildRecoveryHtml(args) {
564
+ const email = escapeHtml(args.email);
565
+ const url = escapeHtml(args.url);
566
+ const ssoNote = args.ssoEncrypted ? '<p class="note">This recovery file is encrypted with your SSO identity \u2014 opening it on a different account will not work.</p>' : "";
567
+ return `<!DOCTYPE html>
568
+ <html lang="en">
569
+ <head>
570
+ <meta charset="utf-8">
571
+ <title>ARBI Recovery</title>
572
+ <meta name="viewport" content="width=device-width,initial-scale=1">
573
+ <style>
574
+ :root { color-scheme: light dark; }
575
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
576
+ max-width: 32rem; margin: 4rem auto; padding: 1.5rem; line-height: 1.5; }
577
+ h1 { font-size: 1.25rem; margin-bottom: 0.25rem; }
578
+ .email { font-weight: 600; }
579
+ .btn { display: inline-block; padding: 0.75rem 1.25rem; margin-top: 1.5rem;
580
+ background: #2563eb; color: #fff; text-decoration: none;
581
+ border-radius: 0.5rem; font-weight: 600; }
582
+ .btn:hover { background: #1d4ed8; }
583
+ .note { font-size: 0.85rem; color: #6b7280; margin-top: 1.5rem; }
584
+ details { margin-top: 1rem; font-size: 0.85rem; color: #6b7280; }
585
+ code { word-break: break-all; }
586
+ </style>
587
+ </head>
588
+ <body>
589
+ <h1>ARBI account recovery</h1>
590
+ <p>Click below to set a new password for <span class="email">${email}</span>.</p>
591
+ <p><a class="btn" href="${url}">Recover account</a></p>
592
+ ${ssoNote}
593
+ <details>
594
+ <summary>If the button doesn&apos;t work, open this URL manually</summary>
595
+ <p><code>${url}</code></p>
596
+ </details>
597
+ </body>
598
+ </html>
599
+ `;
600
+ }
601
+ async function createRecoveryUrl(params) {
602
+ assertSigningKey(params.signingPrivateKey);
603
+ const keyBytes = params.auth0Sub ? await encryptRecoveryKey(params.signingPrivateKey, params.auth0Sub) : params.signingPrivateKey;
604
+ return buildRecoveryUrl({
605
+ origin: params.origin,
606
+ email: params.email,
607
+ keyBytes,
608
+ sso: !!params.auth0Sub
609
+ });
610
+ }
611
+ async function createRecoveryFile(params) {
612
+ const url = await createRecoveryUrl(params);
499
613
  return {
500
- async onRequest({ request }) {
501
- const accessToken = config.tokenProvider.getAccessToken();
502
- if (accessToken) {
503
- request.headers.set("Authorization", `Bearer ${accessToken}`);
504
- }
505
- return request;
506
- }
614
+ filename: getRecoveryKeyFilename(),
615
+ html: buildRecoveryHtml({ email: params.email, url, ssoEncrypted: !!params.auth0Sub }),
616
+ url
507
617
  };
508
618
  }
509
-
510
- // src/middleware/auto-relogin.ts
511
- var NO_ACTIVE_WORKSPACE = /no active workspace/i;
512
- async function isNoActiveWorkspace(response) {
619
+ async function validateRecoveryKeyFile(file) {
620
+ if (file.size !== UNENCRYPTED_RECOVERY_KEY_SIZE && file.size !== ENCRYPTED_RECOVERY_KEY_SIZE) {
621
+ return {
622
+ valid: false,
623
+ isEncrypted: false,
624
+ data: null,
625
+ error: RECOVERY_KEY_ERRORS.INVALID_SIZE
626
+ };
627
+ }
513
628
  try {
514
- return NO_ACTIVE_WORKSPACE.test(await response.clone().text());
629
+ const data = new Uint8Array(await file.arrayBuffer());
630
+ return { valid: true, isEncrypted: isEncryptedRecoveryKey(data.length), data };
515
631
  } catch {
516
- return false;
632
+ return { valid: false, isEncrypted: false, data: null, error: RECOVERY_KEY_ERRORS.READ_ERROR };
517
633
  }
518
634
  }
519
- function safeClone(request) {
635
+ var SECURE_MAIL_KEY_BYTES = 32;
636
+ async function sealSecureMail(raw, metadata, senderPublicKey) {
637
+ await initSodium();
638
+ const key = sodium.randombytes_buf(SECURE_MAIL_KEY_BYTES);
520
639
  try {
521
- return request.clone();
640
+ return {
641
+ ciphertext: bytesToBase64(encryptFile(raw, key)),
642
+ metadata_ciphertext: bytesToBase64(
643
+ encryptFile(sodium.from_string(JSON.stringify(metadata)), key)
644
+ ),
645
+ fragment_key: bytesToBase64(key),
646
+ sender_wrapped_key: sealedBoxEncrypt(key, senderPublicKey)
647
+ };
648
+ } finally {
649
+ key.fill(0);
650
+ }
651
+ }
652
+ function decodeSecureMailKey(fragmentKey) {
653
+ let key;
654
+ try {
655
+ key = base64ToBytes(fragmentKey);
522
656
  } catch {
523
- return null;
657
+ throw new Error("Invalid secure message link");
658
+ }
659
+ if (key.length !== SECURE_MAIL_KEY_BYTES) {
660
+ key.fill(0);
661
+ throw new Error("Invalid secure message link");
524
662
  }
663
+ return key;
525
664
  }
526
- function createAutoReloginMiddleware(config) {
527
- const cloneCache = /* @__PURE__ */ new WeakMap();
665
+ async function openSecureMail(payload, key) {
666
+ await initSodium();
667
+ const raw = decryptFile(base64ToBytes(payload.ciphertext), key);
668
+ const meta = decryptFile(base64ToBytes(payload.metadata_ciphertext), key);
669
+ try {
670
+ return { raw, metadata: JSON.parse(sodium.to_string(meta)) };
671
+ } finally {
672
+ meta.fill(0);
673
+ }
674
+ }
675
+ async function secureMailClaim(key, recipientPublicKey) {
676
+ await initSodium();
528
677
  return {
529
- async onRequest({ request }) {
530
- try {
531
- cloneCache.set(request, request.clone());
532
- } catch {
678
+ claim_hash: sodium.to_hex(sodium.crypto_hash_sha256(key)),
679
+ wrapped_key: sealedBoxEncrypt(key, recipientPublicKey),
680
+ recipient_public_key: bytesToBase64(recipientPublicKey)
681
+ };
682
+ }
683
+
684
+ // src/storage/indexed-db.ts
685
+ var DB_NAME = "arbi-crypto";
686
+ var DB_VERSION = 2;
687
+ var db = null;
688
+ var memorySession = null;
689
+ function canPersistSession() {
690
+ return !!crypto?.subtle;
691
+ }
692
+ async function initializeDatabase() {
693
+ return new Promise((resolve, reject) => {
694
+ const request = indexedDB.open(DB_NAME, DB_VERSION);
695
+ request.onerror = () => {
696
+ console.error("[STORAGE] Failed to open IndexedDB:", request.error);
697
+ reject(request.error);
698
+ };
699
+ request.onsuccess = () => {
700
+ db = request.result;
701
+ resolve(db);
702
+ };
703
+ request.onupgradeneeded = (event) => {
704
+ const database = event.target.result;
705
+ if (!database.objectStoreNames.contains("session")) {
706
+ database.createObjectStore("session", { keyPath: "id" });
533
707
  }
534
- },
535
- async onResponse({ response, request }) {
536
- if (response.status === 400 && await isNoActiveWorkspace(response)) {
537
- let token = null;
538
- let recovered = await config.reopenWorkspaceHandler?.() ?? false;
539
- if (!recovered) {
540
- token = await config.reloginHandler();
541
- recovered = token !== null;
542
- }
543
- if (!recovered) {
544
- config.onReloginFailed?.();
545
- return response;
546
- }
547
- const retry = cloneCache.get(request) ?? safeClone(request);
548
- if (retry) {
549
- if (token) retry.headers.set("Authorization", `Bearer ${token}`);
550
- return fetch(retry);
551
- }
552
- config.onRetryCloneFailed?.();
553
- return response;
708
+ if (!database.objectStoreNames.contains("wrapping-key")) {
709
+ database.createObjectStore("wrapping-key", { keyPath: "id" });
554
710
  }
555
- if (response.status === 400) {
556
- const isSafeMethod = ["GET", "HEAD", "DELETE"].includes(request.method);
557
- if (isSafeMethod) {
558
- const newToken = await config.reloginHandler();
559
- if (newToken) {
560
- const retryRequest = cloneCache.get(request) ?? request.clone();
561
- retryRequest.headers.set("Authorization", `Bearer ${newToken}`);
562
- return fetch(retryRequest);
563
- }
711
+ };
712
+ });
713
+ }
714
+ async function ensureDatabase() {
715
+ if (!db) {
716
+ await initializeDatabase();
717
+ }
718
+ }
719
+ async function getOrCreateWrappingKey() {
720
+ await ensureDatabase();
721
+ if (!db) {
722
+ throw new Error("Database not initialized");
723
+ }
724
+ return new Promise((resolve, reject) => {
725
+ if (!db) {
726
+ reject(new Error("Database not initialized"));
727
+ return;
728
+ }
729
+ const transaction = db.transaction(["wrapping-key"], "readwrite");
730
+ const store = transaction.objectStore("wrapping-key");
731
+ const request = store.get("master");
732
+ request.onsuccess = () => {
733
+ const result = request.result;
734
+ if (result?.key) {
735
+ resolve(result.key);
736
+ } else {
737
+ if (!crypto?.subtle) {
738
+ reject(
739
+ new Error(
740
+ "Web Crypto API not available. Ensure the page is served over HTTPS or localhost."
741
+ )
742
+ );
743
+ return;
564
744
  }
565
- }
566
- if (response.status === 401) {
567
- const excludePatterns = config.reloginExcludePatterns ?? [];
568
- const isExcluded = excludePatterns.some((pattern) => request.url.includes(pattern));
569
- if (!isExcluded) {
570
- const newToken = await config.reloginHandler();
571
- if (newToken) {
572
- const cached = cloneCache.get(request);
573
- if (cached) {
574
- cached.headers.set("Authorization", `Bearer ${newToken}`);
575
- return fetch(cached);
576
- }
577
- try {
578
- const retryRequest = request.clone();
579
- retryRequest.headers.set("Authorization", `Bearer ${newToken}`);
580
- return fetch(retryRequest);
581
- } catch {
582
- console.warn("[API] Could not retry request after re-login (body already consumed)");
583
- config.onRetryCloneFailed?.();
584
- }
585
- } else {
586
- config.onReloginFailed?.();
745
+ crypto.subtle.generateKey(
746
+ {
747
+ name: "AES-GCM",
748
+ length: 256
749
+ },
750
+ false,
751
+ // ❌ NON-EXTRACTABLE - Can't be exported!
752
+ ["encrypt", "decrypt"]
753
+ ).then((key) => {
754
+ if (!db) {
755
+ reject(new Error("Database not initialized"));
756
+ return;
587
757
  }
588
- }
758
+ const putTransaction = db.transaction(["wrapping-key"], "readwrite");
759
+ const putStore = putTransaction.objectStore("wrapping-key");
760
+ const putRequest = putStore.put({
761
+ id: "master",
762
+ key,
763
+ created: Date.now()
764
+ });
765
+ putRequest.onsuccess = () => {
766
+ resolve(key);
767
+ };
768
+ putRequest.onerror = () => {
769
+ reject(new Error("Failed to store wrapping key"));
770
+ };
771
+ }).catch((error) => {
772
+ const errorMsg = error instanceof Error ? error.message : String(error);
773
+ reject(new Error(`Failed to generate wrapping key: ${errorMsg}`));
774
+ });
589
775
  }
590
- return response;
591
- }
592
- };
593
- }
594
-
595
- // src/relogin/handler.ts
596
- var RELOGIN_COOLDOWN_MS = 5e3;
597
- function createReloginHandler(deps) {
598
- let reloginPromise = null;
599
- let lastSuccessToken = null;
600
- let lastSuccessTime = 0;
601
- return function instantReLogin() {
602
- if (reloginPromise) {
603
- return reloginPromise;
604
- }
605
- const elapsed = Date.now() - lastSuccessTime;
606
- if (lastSuccessToken && elapsed < RELOGIN_COOLDOWN_MS) {
607
- return Promise.resolve(lastSuccessToken);
608
- }
609
- const userEmail = deps.authState.getUserEmail();
610
- if (!userEmail) {
611
- console.warn("[API] Re-login aborted: no user email");
612
- return Promise.resolve(null);
613
- }
614
- reloginPromise = (async () => {
615
- try {
616
- await deps.crypto.ensureReady();
617
- const session = await deps.sessionStorage.getSession();
618
- if (!session) {
619
- console.warn("[API] Re-login aborted: no session");
620
- return null;
621
- }
622
- const ed25519PublicKey = session.signingPrivateKey.slice(32, 64);
623
- const encryptionKeyPair = deps.crypto.deriveEncryptionKeypair({
624
- publicKey: ed25519PublicKey,
625
- secretKey: session.signingPrivateKey
626
- });
627
- const timestamp = Math.floor(Date.now() / 1e3);
628
- const message = `${userEmail}|${timestamp}`;
629
- const signature = deps.crypto.signMessage(message, session.signingPrivateKey);
630
- const ssoState = deps.authState.getSsoState();
631
- const isSsoAccount = ssoState.isSsoMode || ssoState.isAuth0Authenticated || !!ssoState.auth0AccessToken;
632
- let ssoToken = null;
633
- if (deps.ssoTokenProvider) {
634
- try {
635
- ssoToken = await deps.ssoTokenProvider.getToken();
636
- } catch (error) {
637
- console.error("[API] Failed to get SSO token from provider:", error);
638
- }
639
- }
640
- if (!ssoToken && ssoState.auth0AccessToken) {
641
- ssoToken = ssoState.auth0AccessToken;
642
- }
643
- if (!ssoToken && isSsoAccount) {
644
- console.warn("[API] Re-login aborted: SSO account but no token available");
645
- return null;
646
- }
647
- const loginResult = await deps.loginProvider.login({
648
- email: userEmail,
649
- signature,
650
- timestamp,
651
- ssoToken: ssoToken ?? void 0
652
- });
653
- if (!loginResult) {
654
- console.warn("[API] Re-login failed");
655
- return null;
656
- }
657
- await deps.sessionStorage.saveSession({
658
- signingPrivateKey: session.signingPrivateKey,
659
- serverSessionKey: loginResult.sessionKey,
660
- userExtId: loginResult.userExtId,
661
- wrappedIdentityKey: loginResult.wrappedIdentityKey ?? null
662
- });
663
- await deps.onReloginSuccess?.({
664
- email: userEmail,
665
- accessToken: loginResult.accessToken,
666
- userExtId: loginResult.userExtId,
667
- serverSessionKey: loginResult.sessionKey
668
- });
669
- const activeToken = loginResult.accessToken;
670
- deps.authState.setAccessToken(activeToken);
671
- const selectedWorkspaceId = deps.authState.getSelectedWorkspaceId();
672
- if (selectedWorkspaceId) {
673
- try {
674
- const wrappedKey = await deps.workspaceKeyRefreshProvider.getWrappedKey(
675
- loginResult.accessToken,
676
- selectedWorkspaceId
677
- );
678
- if (wrappedKey) {
679
- const workspaceKey = deps.crypto.sealedBoxDecrypt(
680
- wrappedKey,
681
- encryptionKeyPair.secretKey
682
- );
683
- const encryptedWorkspaceKey = await deps.crypto.sealKeyForSession(
684
- workspaceKey,
685
- loginResult.sessionKey
686
- );
687
- if (deps.workspaceOpenProvider) {
688
- await deps.workspaceOpenProvider.openWorkspace(
689
- loginResult.accessToken,
690
- selectedWorkspaceId,
691
- encryptedWorkspaceKey
692
- );
693
- }
694
- }
695
- } catch (error) {
696
- console.error("[API] Failed to re-open workspace after re-login:", error);
697
- }
698
- }
699
- console.info("[API] Re-login successful");
700
- lastSuccessToken = activeToken;
701
- lastSuccessTime = Date.now();
702
- return activeToken;
703
- } catch (error) {
704
- console.error("[API] Re-login error:", error);
705
- lastSuccessToken = null;
706
- lastSuccessTime = 0;
707
- return null;
708
- } finally {
709
- reloginPromise = null;
710
- }
711
- })();
712
- return reloginPromise;
713
- };
714
- }
715
-
716
- // src/relogin/reopen.ts
717
- function createWorkspaceReopener(deps) {
718
- let inFlight = null;
719
- return function reopenWorkspace() {
720
- if (inFlight) return inFlight;
721
- inFlight = (async () => {
722
- try {
723
- const workspaceId = deps.authState.getSelectedWorkspaceId();
724
- const accessToken = deps.getAccessToken();
725
- if (!workspaceId || !accessToken) return false;
726
- const session = await deps.sessionStorage.getSession();
727
- if (!session?.signingPrivateKey || !session.serverSessionKey) return false;
728
- await deps.crypto.ensureReady();
729
- const wrappedKey = await deps.workspaceKeyRefreshProvider.getWrappedKey(
730
- accessToken,
731
- workspaceId
732
- );
733
- if (!wrappedKey) return false;
734
- const encryptionKeyPair = deps.crypto.deriveEncryptionKeypair({
735
- publicKey: session.signingPrivateKey.slice(32, 64),
736
- secretKey: session.signingPrivateKey
737
- });
738
- const workspaceKey = deps.crypto.sealedBoxDecrypt(wrappedKey, encryptionKeyPair.secretKey);
739
- const sealed = await deps.crypto.sealKeyForSession(workspaceKey, session.serverSessionKey);
740
- const opened = await deps.workspaceOpenProvider.openWorkspace(
741
- accessToken,
742
- workspaceId,
743
- sealed
744
- );
745
- return opened === true;
746
- } catch (error) {
747
- console.error("[API] Failed to re-open workspace:", error);
748
- return false;
749
- } finally {
750
- inFlight = null;
751
- }
752
- })();
753
- return inFlight;
754
- };
755
- }
756
-
757
- // src/storage/indexed-db.ts
758
- var DB_NAME = "arbi-crypto";
759
- var DB_VERSION = 2;
760
- var db = null;
761
- var memorySession = null;
762
- function canPersistSession() {
763
- return !!crypto?.subtle;
764
- }
765
- async function initializeDatabase() {
766
- return new Promise((resolve, reject) => {
767
- const request = indexedDB.open(DB_NAME, DB_VERSION);
768
- request.onerror = () => {
769
- console.error("[STORAGE] Failed to open IndexedDB:", request.error);
770
- reject(request.error);
771
- };
772
- request.onsuccess = () => {
773
- db = request.result;
774
- resolve(db);
775
- };
776
- request.onupgradeneeded = (event) => {
777
- const database = event.target.result;
778
- if (!database.objectStoreNames.contains("session")) {
779
- database.createObjectStore("session", { keyPath: "id" });
780
- }
781
- if (!database.objectStoreNames.contains("wrapping-key")) {
782
- database.createObjectStore("wrapping-key", { keyPath: "id" });
783
- }
784
- };
785
- });
786
- }
787
- async function ensureDatabase() {
788
- if (!db) {
789
- await initializeDatabase();
790
- }
791
- }
792
- async function getOrCreateWrappingKey() {
793
- await ensureDatabase();
794
- if (!db) {
795
- throw new Error("Database not initialized");
796
- }
797
- return new Promise((resolve, reject) => {
798
- if (!db) {
799
- reject(new Error("Database not initialized"));
800
- return;
801
- }
802
- const transaction = db.transaction(["wrapping-key"], "readwrite");
803
- const store = transaction.objectStore("wrapping-key");
804
- const request = store.get("master");
805
- request.onsuccess = () => {
806
- const result = request.result;
807
- if (result?.key) {
808
- resolve(result.key);
809
- } else {
810
- if (!crypto?.subtle) {
811
- reject(
812
- new Error(
813
- "Web Crypto API not available. Ensure the page is served over HTTPS or localhost."
814
- )
815
- );
816
- return;
817
- }
818
- crypto.subtle.generateKey(
819
- {
820
- name: "AES-GCM",
821
- length: 256
822
- },
823
- false,
824
- // ❌ NON-EXTRACTABLE - Can't be exported!
825
- ["encrypt", "decrypt"]
826
- ).then((key) => {
827
- if (!db) {
828
- reject(new Error("Database not initialized"));
829
- return;
830
- }
831
- const putTransaction = db.transaction(["wrapping-key"], "readwrite");
832
- const putStore = putTransaction.objectStore("wrapping-key");
833
- const putRequest = putStore.put({
834
- id: "master",
835
- key,
836
- created: Date.now()
837
- });
838
- putRequest.onsuccess = () => {
839
- resolve(key);
840
- };
841
- putRequest.onerror = () => {
842
- reject(new Error("Failed to store wrapping key"));
843
- };
844
- }).catch((error) => {
845
- const errorMsg = error instanceof Error ? error.message : String(error);
846
- reject(new Error(`Failed to generate wrapping key: ${errorMsg}`));
847
- });
848
- }
849
- };
850
- request.onerror = () => {
851
- reject(new Error("Failed to retrieve wrapping key from IndexedDB"));
852
- };
853
- });
854
- }
855
- async function encryptWithWrappingKey(data) {
856
- const wrappingKey = await getOrCreateWrappingKey();
857
- const iv = crypto.getRandomValues(new Uint8Array(12));
858
- const ciphertext = await crypto.subtle.encrypt(
859
- {
860
- name: "AES-GCM",
861
- iv
862
- },
863
- wrappingKey,
864
- data
865
- );
866
- return {
867
- ciphertext: new Uint8Array(ciphertext),
868
- iv
776
+ };
777
+ request.onerror = () => {
778
+ reject(new Error("Failed to retrieve wrapping key from IndexedDB"));
779
+ };
780
+ });
781
+ }
782
+ async function encryptWithWrappingKey(data) {
783
+ const wrappingKey = await getOrCreateWrappingKey();
784
+ const iv = crypto.getRandomValues(new Uint8Array(12));
785
+ const ciphertext = await crypto.subtle.encrypt(
786
+ {
787
+ name: "AES-GCM",
788
+ iv
789
+ },
790
+ wrappingKey,
791
+ data
792
+ );
793
+ return {
794
+ ciphertext: new Uint8Array(ciphertext),
795
+ iv
869
796
  };
870
797
  }
871
798
  async function decryptWithWrappingKey(ciphertext, iv) {
@@ -957,104 +884,427 @@ async function getSession() {
957
884
  result.serverSessionKeyIV
958
885
  );
959
886
  }
960
- resolve({
961
- signingPrivateKey,
962
- serverSessionKey,
963
- userExtId: result.userExtId,
964
- wrappedIdentityKey: result.wrappedIdentityKey ?? null
965
- });
966
- } catch (error) {
967
- console.error("[STORAGE] Failed to decrypt session data:", error);
968
- reject(error);
887
+ resolve({
888
+ signingPrivateKey,
889
+ serverSessionKey,
890
+ userExtId: result.userExtId,
891
+ wrappedIdentityKey: result.wrappedIdentityKey ?? null
892
+ });
893
+ } catch (error) {
894
+ console.error("[STORAGE] Failed to decrypt session data:", error);
895
+ reject(error);
896
+ }
897
+ } else {
898
+ resolve(null);
899
+ }
900
+ };
901
+ request.onerror = () => {
902
+ console.error("[STORAGE] Failed to get session:", request.error);
903
+ reject(request.error);
904
+ };
905
+ });
906
+ }
907
+ async function updateWrappedIdentityKey(wrappedIdentityKey) {
908
+ const existingSession = await getSession();
909
+ if (!existingSession) {
910
+ throw new Error("No existing session to update");
911
+ }
912
+ await saveSession({ ...existingSession, wrappedIdentityKey });
913
+ }
914
+ async function clearSession() {
915
+ memorySession = null;
916
+ if (!canPersistSession()) {
917
+ return;
918
+ }
919
+ await ensureDatabase();
920
+ if (!db) {
921
+ throw new Error("Database not initialized");
922
+ }
923
+ return new Promise((resolve, reject) => {
924
+ if (!db) {
925
+ reject(new Error("Database not initialized"));
926
+ return;
927
+ }
928
+ const transaction = db.transaction(["session"], "readwrite");
929
+ const store = transaction.objectStore("session");
930
+ const request = store.delete("current");
931
+ request.onsuccess = () => {
932
+ resolve();
933
+ };
934
+ request.onerror = () => {
935
+ console.error("[STORAGE] Failed to clear session:", request.error);
936
+ reject(request.error);
937
+ };
938
+ });
939
+ }
940
+ async function clearWrappingKey() {
941
+ await ensureDatabase();
942
+ if (!db) {
943
+ throw new Error("Database not initialized");
944
+ }
945
+ return new Promise((resolve, reject) => {
946
+ if (!db) {
947
+ reject(new Error("Database not initialized"));
948
+ return;
949
+ }
950
+ const transaction = db.transaction(["wrapping-key"], "readwrite");
951
+ const store = transaction.objectStore("wrapping-key");
952
+ const request = store.delete("master");
953
+ request.onsuccess = () => {
954
+ resolve();
955
+ };
956
+ request.onerror = () => {
957
+ console.error("[STORAGE] Failed to clear wrapping key:", request.error);
958
+ reject(request.error);
959
+ };
960
+ });
961
+ }
962
+ async function clearAllData() {
963
+ memorySession = null;
964
+ if (!canPersistSession()) {
965
+ return;
966
+ }
967
+ await clearSession();
968
+ await clearWrappingKey();
969
+ }
970
+ async function hasSession() {
971
+ const session = await getSession();
972
+ return session !== null;
973
+ }
974
+ async function getUserEncryptionKeypair() {
975
+ await initSodium();
976
+ const session = await getSession();
977
+ if (!session?.signingPrivateKey) {
978
+ return null;
979
+ }
980
+ return encryptionKeypairFromSigningKey(session.signingPrivateKey);
981
+ }
982
+
983
+ // src/crypto/offline-sealer.ts
984
+ function bytes(value) {
985
+ return value instanceof Uint8Array ? value : Uint8Array.from(value);
986
+ }
987
+ function createOfflineSealer(namespace, { locked, keypair: getKeypair = getUserEncryptionKeypair }) {
988
+ const ENC_LABEL = `${namespace}:enc`;
989
+ const INDEX_LABEL = `${namespace}:index`;
990
+ let cached = null;
991
+ async function keys() {
992
+ await sodium.ready;
993
+ const keypair = await getKeypair();
994
+ if (!keypair) throw locked();
995
+ const publicKey = sodium.to_hex(keypair.publicKey);
996
+ if (cached?.publicKey === publicKey) {
997
+ keypair.secretKey.fill(0);
998
+ return cached.keys;
999
+ }
1000
+ const derived = {
1001
+ enc: sodium.crypto_generichash(32, ENC_LABEL, bytes(keypair.secretKey)),
1002
+ index: sodium.crypto_generichash(32, INDEX_LABEL, bytes(keypair.secretKey))
1003
+ };
1004
+ keypair.secretKey.fill(0);
1005
+ cached = { publicKey, keys: derived };
1006
+ return derived;
1007
+ }
1008
+ async function seal(plain) {
1009
+ const { enc } = await keys();
1010
+ const nonce = sodium.randombytes_buf(sodium.crypto_secretbox_NONCEBYTES);
1011
+ const box = sodium.crypto_secretbox_easy(
1012
+ typeof plain === "string" ? plain : bytes(plain),
1013
+ nonce,
1014
+ enc
1015
+ );
1016
+ const out = new Uint8Array(nonce.length + box.length);
1017
+ out.set(nonce);
1018
+ out.set(box, nonce.length);
1019
+ return out;
1020
+ }
1021
+ async function openBytes(sealed) {
1022
+ const { enc } = await keys();
1023
+ const nonceBytes = sodium.crypto_secretbox_NONCEBYTES;
1024
+ const data = bytes(sealed);
1025
+ return sodium.crypto_secretbox_open_easy(
1026
+ data.subarray(nonceBytes),
1027
+ data.subarray(0, nonceBytes),
1028
+ enc
1029
+ );
1030
+ }
1031
+ return {
1032
+ sealBytes: (plain) => seal(plain),
1033
+ openBytes,
1034
+ sealJson: (value) => seal(JSON.stringify(value)),
1035
+ async openJson(sealed) {
1036
+ return JSON.parse(sodium.to_string(await openBytes(sealed)));
1037
+ },
1038
+ async blind(...parts) {
1039
+ const { index } = await keys();
1040
+ return sodium.to_hex(sodium.crypto_generichash(16, parts.join("\0"), index));
1041
+ },
1042
+ forget() {
1043
+ cached?.keys.enc.fill(0);
1044
+ cached?.keys.index.fill(0);
1045
+ cached = null;
1046
+ }
1047
+ };
1048
+ }
1049
+
1050
+ // src/middleware/bearer-auth.ts
1051
+ function createBearerAuthMiddleware(config) {
1052
+ return {
1053
+ async onRequest({ request }) {
1054
+ const accessToken = config.tokenProvider.getAccessToken();
1055
+ if (accessToken) {
1056
+ request.headers.set("Authorization", `Bearer ${accessToken}`);
1057
+ }
1058
+ return request;
1059
+ }
1060
+ };
1061
+ }
1062
+
1063
+ // src/middleware/auto-relogin.ts
1064
+ var NO_ACTIVE_WORKSPACE = /no active workspace/i;
1065
+ async function isNoActiveWorkspace(response) {
1066
+ try {
1067
+ return NO_ACTIVE_WORKSPACE.test(await response.clone().text());
1068
+ } catch {
1069
+ return false;
1070
+ }
1071
+ }
1072
+ function safeClone(request) {
1073
+ try {
1074
+ return request.clone();
1075
+ } catch {
1076
+ return null;
1077
+ }
1078
+ }
1079
+ function createAutoReloginMiddleware(config) {
1080
+ const cloneCache = /* @__PURE__ */ new WeakMap();
1081
+ return {
1082
+ async onRequest({ request }) {
1083
+ try {
1084
+ cloneCache.set(request, request.clone());
1085
+ } catch {
1086
+ }
1087
+ },
1088
+ async onResponse({ response, request }) {
1089
+ if (response.status === 400 && await isNoActiveWorkspace(response)) {
1090
+ let token = null;
1091
+ let recovered = await config.reopenWorkspaceHandler?.() ?? false;
1092
+ if (!recovered) {
1093
+ token = await config.reloginHandler();
1094
+ recovered = token !== null;
1095
+ }
1096
+ if (!recovered) {
1097
+ config.onReloginFailed?.();
1098
+ return response;
1099
+ }
1100
+ const retry = cloneCache.get(request) ?? safeClone(request);
1101
+ if (retry) {
1102
+ if (token) retry.headers.set("Authorization", `Bearer ${token}`);
1103
+ return fetch(retry);
1104
+ }
1105
+ config.onRetryCloneFailed?.();
1106
+ return response;
1107
+ }
1108
+ if (response.status === 400) {
1109
+ const isSafeMethod = ["GET", "HEAD", "DELETE"].includes(request.method);
1110
+ if (isSafeMethod) {
1111
+ const newToken = await config.reloginHandler();
1112
+ if (newToken) {
1113
+ const retryRequest = cloneCache.get(request) ?? request.clone();
1114
+ retryRequest.headers.set("Authorization", `Bearer ${newToken}`);
1115
+ return fetch(retryRequest);
1116
+ }
1117
+ }
1118
+ }
1119
+ if (response.status === 401) {
1120
+ const excludePatterns = config.reloginExcludePatterns ?? [];
1121
+ const isExcluded = excludePatterns.some((pattern) => request.url.includes(pattern));
1122
+ if (!isExcluded) {
1123
+ const newToken = await config.reloginHandler();
1124
+ if (newToken) {
1125
+ const cached = cloneCache.get(request);
1126
+ if (cached) {
1127
+ cached.headers.set("Authorization", `Bearer ${newToken}`);
1128
+ return fetch(cached);
1129
+ }
1130
+ try {
1131
+ const retryRequest = request.clone();
1132
+ retryRequest.headers.set("Authorization", `Bearer ${newToken}`);
1133
+ return fetch(retryRequest);
1134
+ } catch {
1135
+ console.warn("[API] Could not retry request after re-login (body already consumed)");
1136
+ config.onRetryCloneFailed?.();
1137
+ }
1138
+ } else {
1139
+ config.onReloginFailed?.();
1140
+ }
969
1141
  }
970
- } else {
971
- resolve(null);
972
1142
  }
973
- };
974
- request.onerror = () => {
975
- console.error("[STORAGE] Failed to get session:", request.error);
976
- reject(request.error);
977
- };
978
- });
979
- }
980
- async function updateWrappedIdentityKey(wrappedIdentityKey) {
981
- const existingSession = await getSession();
982
- if (!existingSession) {
983
- throw new Error("No existing session to update");
984
- }
985
- await saveSession({ ...existingSession, wrappedIdentityKey });
986
- }
987
- async function clearSession() {
988
- memorySession = null;
989
- if (!canPersistSession()) {
990
- return;
991
- }
992
- await ensureDatabase();
993
- if (!db) {
994
- throw new Error("Database not initialized");
995
- }
996
- return new Promise((resolve, reject) => {
997
- if (!db) {
998
- reject(new Error("Database not initialized"));
999
- return;
1143
+ return response;
1000
1144
  }
1001
- const transaction = db.transaction(["session"], "readwrite");
1002
- const store = transaction.objectStore("session");
1003
- const request = store.delete("current");
1004
- request.onsuccess = () => {
1005
- resolve();
1006
- };
1007
- request.onerror = () => {
1008
- console.error("[STORAGE] Failed to clear session:", request.error);
1009
- reject(request.error);
1010
- };
1011
- });
1145
+ };
1012
1146
  }
1013
- async function clearWrappingKey() {
1014
- await ensureDatabase();
1015
- if (!db) {
1016
- throw new Error("Database not initialized");
1017
- }
1018
- return new Promise((resolve, reject) => {
1019
- if (!db) {
1020
- reject(new Error("Database not initialized"));
1021
- return;
1147
+
1148
+ // src/relogin/handler.ts
1149
+ var RELOGIN_COOLDOWN_MS = 5e3;
1150
+ function createReloginHandler(deps) {
1151
+ let reloginPromise = null;
1152
+ let lastSuccessToken = null;
1153
+ let lastSuccessTime = 0;
1154
+ return function instantReLogin() {
1155
+ if (reloginPromise) {
1156
+ return reloginPromise;
1022
1157
  }
1023
- const transaction = db.transaction(["wrapping-key"], "readwrite");
1024
- const store = transaction.objectStore("wrapping-key");
1025
- const request = store.delete("master");
1026
- request.onsuccess = () => {
1027
- resolve();
1028
- };
1029
- request.onerror = () => {
1030
- console.error("[STORAGE] Failed to clear wrapping key:", request.error);
1031
- reject(request.error);
1032
- };
1033
- });
1034
- }
1035
- async function clearAllData() {
1036
- memorySession = null;
1037
- if (!canPersistSession()) {
1038
- return;
1039
- }
1040
- await clearSession();
1041
- await clearWrappingKey();
1042
- }
1043
- async function hasSession() {
1044
- const session = await getSession();
1045
- return session !== null;
1158
+ const elapsed = Date.now() - lastSuccessTime;
1159
+ if (lastSuccessToken && elapsed < RELOGIN_COOLDOWN_MS) {
1160
+ return Promise.resolve(lastSuccessToken);
1161
+ }
1162
+ const userEmail = deps.authState.getUserEmail();
1163
+ if (!userEmail) {
1164
+ console.warn("[API] Re-login aborted: no user email");
1165
+ return Promise.resolve(null);
1166
+ }
1167
+ reloginPromise = (async () => {
1168
+ try {
1169
+ await deps.crypto.ensureReady();
1170
+ const session = await deps.sessionStorage.getSession();
1171
+ if (!session) {
1172
+ console.warn("[API] Re-login aborted: no session");
1173
+ return null;
1174
+ }
1175
+ const ed25519PublicKey = session.signingPrivateKey.slice(32, 64);
1176
+ const encryptionKeyPair = deps.crypto.deriveEncryptionKeypair({
1177
+ publicKey: ed25519PublicKey,
1178
+ secretKey: session.signingPrivateKey
1179
+ });
1180
+ const timestamp = Math.floor(Date.now() / 1e3);
1181
+ const message = `${userEmail}|${timestamp}`;
1182
+ const signature = deps.crypto.signMessage(message, session.signingPrivateKey);
1183
+ const ssoState = deps.authState.getSsoState();
1184
+ const isSsoAccount = ssoState.isSsoMode || ssoState.isAuth0Authenticated || !!ssoState.auth0AccessToken;
1185
+ let ssoToken = null;
1186
+ if (deps.ssoTokenProvider) {
1187
+ try {
1188
+ ssoToken = await deps.ssoTokenProvider.getToken();
1189
+ } catch (error) {
1190
+ console.error("[API] Failed to get SSO token from provider:", error);
1191
+ }
1192
+ }
1193
+ if (!ssoToken && ssoState.auth0AccessToken) {
1194
+ ssoToken = ssoState.auth0AccessToken;
1195
+ }
1196
+ if (!ssoToken && isSsoAccount) {
1197
+ console.warn("[API] Re-login aborted: SSO account but no token available");
1198
+ return null;
1199
+ }
1200
+ const loginResult = await deps.loginProvider.login({
1201
+ email: userEmail,
1202
+ signature,
1203
+ timestamp,
1204
+ ssoToken: ssoToken ?? void 0
1205
+ });
1206
+ if (!loginResult) {
1207
+ console.warn("[API] Re-login failed");
1208
+ return null;
1209
+ }
1210
+ await deps.sessionStorage.saveSession({
1211
+ signingPrivateKey: session.signingPrivateKey,
1212
+ serverSessionKey: loginResult.sessionKey,
1213
+ userExtId: loginResult.userExtId,
1214
+ wrappedIdentityKey: loginResult.wrappedIdentityKey ?? null
1215
+ });
1216
+ await deps.onReloginSuccess?.({
1217
+ email: userEmail,
1218
+ accessToken: loginResult.accessToken,
1219
+ userExtId: loginResult.userExtId,
1220
+ serverSessionKey: loginResult.sessionKey
1221
+ });
1222
+ const activeToken = loginResult.accessToken;
1223
+ deps.authState.setAccessToken(activeToken);
1224
+ const selectedWorkspaceId = deps.authState.getSelectedWorkspaceId();
1225
+ if (selectedWorkspaceId) {
1226
+ try {
1227
+ const wrappedKey = await deps.workspaceKeyRefreshProvider.getWrappedKey(
1228
+ loginResult.accessToken,
1229
+ selectedWorkspaceId
1230
+ );
1231
+ if (wrappedKey) {
1232
+ const workspaceKey = deps.crypto.sealedBoxDecrypt(
1233
+ wrappedKey,
1234
+ encryptionKeyPair.secretKey
1235
+ );
1236
+ const encryptedWorkspaceKey = await deps.crypto.sealKeyForSession(
1237
+ workspaceKey,
1238
+ loginResult.sessionKey
1239
+ );
1240
+ if (deps.workspaceOpenProvider) {
1241
+ await deps.workspaceOpenProvider.openWorkspace(
1242
+ loginResult.accessToken,
1243
+ selectedWorkspaceId,
1244
+ encryptedWorkspaceKey
1245
+ );
1246
+ }
1247
+ }
1248
+ } catch (error) {
1249
+ console.error("[API] Failed to re-open workspace after re-login:", error);
1250
+ }
1251
+ }
1252
+ console.info("[API] Re-login successful");
1253
+ lastSuccessToken = activeToken;
1254
+ lastSuccessTime = Date.now();
1255
+ return activeToken;
1256
+ } catch (error) {
1257
+ console.error("[API] Re-login error:", error);
1258
+ lastSuccessToken = null;
1259
+ lastSuccessTime = 0;
1260
+ return null;
1261
+ } finally {
1262
+ reloginPromise = null;
1263
+ }
1264
+ })();
1265
+ return reloginPromise;
1266
+ };
1046
1267
  }
1047
- async function getUserEncryptionKeypair() {
1048
- await initSodium();
1049
- const session = await getSession();
1050
- if (!session?.signingPrivateKey) {
1051
- return null;
1052
- }
1053
- const publicKey = session.signingPrivateKey.slice(32, 64);
1054
- return deriveEncryptionKeypairFromSigning({
1055
- publicKey,
1056
- secretKey: session.signingPrivateKey
1057
- });
1268
+
1269
+ // src/relogin/reopen.ts
1270
+ function createWorkspaceReopener(deps) {
1271
+ let inFlight = null;
1272
+ return function reopenWorkspace() {
1273
+ if (inFlight) return inFlight;
1274
+ inFlight = (async () => {
1275
+ try {
1276
+ const workspaceId = deps.authState.getSelectedWorkspaceId();
1277
+ const accessToken = deps.getAccessToken();
1278
+ if (!workspaceId || !accessToken) return false;
1279
+ const session = await deps.sessionStorage.getSession();
1280
+ if (!session?.signingPrivateKey || !session.serverSessionKey) return false;
1281
+ await deps.crypto.ensureReady();
1282
+ const wrappedKey = await deps.workspaceKeyRefreshProvider.getWrappedKey(
1283
+ accessToken,
1284
+ workspaceId
1285
+ );
1286
+ if (!wrappedKey) return false;
1287
+ const encryptionKeyPair = deps.crypto.deriveEncryptionKeypair({
1288
+ publicKey: session.signingPrivateKey.slice(32, 64),
1289
+ secretKey: session.signingPrivateKey
1290
+ });
1291
+ const workspaceKey = deps.crypto.sealedBoxDecrypt(wrappedKey, encryptionKeyPair.secretKey);
1292
+ const sealed = await deps.crypto.sealKeyForSession(workspaceKey, session.serverSessionKey);
1293
+ const opened = await deps.workspaceOpenProvider.openWorkspace(
1294
+ accessToken,
1295
+ workspaceId,
1296
+ sealed
1297
+ );
1298
+ return opened === true;
1299
+ } catch (error) {
1300
+ console.error("[API] Failed to re-open workspace:", error);
1301
+ return false;
1302
+ } finally {
1303
+ inFlight = null;
1304
+ }
1305
+ })();
1306
+ return inFlight;
1307
+ };
1058
1308
  }
1059
1309
 
1060
1310
  // src/auth/register.ts
@@ -1086,12 +1336,7 @@ function bytesEqual(a, b) {
1086
1336
  return a.length === b.length && a.every((byte, i) => byte === b[i]);
1087
1337
  }
1088
1338
  function encryptionPublicKeyOf(identitySecretKey) {
1089
- return base64Encode(
1090
- deriveEncryptionKeypairFromSigning({
1091
- publicKey: identitySecretKey.slice(32, 64),
1092
- secretKey: identitySecretKey
1093
- }).publicKey
1094
- );
1339
+ return base64Encode(encryptionKeypairFromSigningKey(identitySecretKey).publicKey);
1095
1340
  }
1096
1341
  function identityFromLoginResponse(passwordPrivateKey, response) {
1097
1342
  const identity = unwrapIdentityKey(response.wrapped_identity_key, passwordPrivateKey);
@@ -1454,6 +1699,6 @@ function isMessageType(msg, type) {
1454
1699
  return msg.type === type;
1455
1700
  }
1456
1701
 
1457
- export { API_PREFIX, CALL_KEY_BYTES, CHAT_KEY_BYTES, base64Decode, base64Encode, base64ToBytes, buildCallFragment, buildWebSocketUrl, bytesToBase64, canPersistSession, changePassword, clearAllData, clearSession, computeSharedSecret, createArbiClient, createAuthMessage, createAutoReloginMiddleware, createBearerAuthMiddleware, createContentHash, createReloginHandler, createSessionManager, createWorkspaceReopener, decodeCallKey, decryptCallTitle, decryptFile, decryptMessage, decryptMessageWithSharedSecret, decryptWithChatKey, deriveDirectChatKey, deriveEncryptionKeypairFromSigning, derivePublicKey, encodeCallKey, encryptCallTitle, encryptFile, encryptMessage, encryptMessageWithSharedSecret, encryptWithChatKey, generateCallKey, generateChatKey, generateIdentityKeypair, generateKeyPairs, generateLoginCredentials, generateLoginCredentialsFromKey, generatePasswordChangeCredentials, generateRandomSigningKeypair, generateRegistrationCredentials, generateUserKeypairs, getSession, getUserEncryptionKeypair, hasSession, identityFromLoginResponse, initSodium, initializeDatabase, isMessageType, openSealedCallKey, openSealedChatKey, parseCallFragment, parseServerMessage, passwordChangeErrorMessage, prewarmKeyPairs, saveSession, sealCallKey, sealChatKey, sealKeyForSession, sealedBoxDecrypt, sealedBoxEncrypt, signMessage, unlockIdentityWithPassword, unwrapIdentityKey, updateWrappedIdentityKey, wrapIdentityKey };
1702
+ export { API_PREFIX, CALL_KEY_BYTES, CHAT_KEY_BYTES, ENCRYPTED_RECOVERY_KEY_SIZE, RECOVERY_KEY_ERRORS, SECURE_MAIL_KEY_BYTES, UNENCRYPTED_RECOVERY_KEY_SIZE, base64Decode, base64Encode, base64ToBytes, base64UrlToBytes, buildCallFragment, buildWebSocketUrl, bytesToBase64, bytesToBase64Url, canPersistSession, changePassword, clearAllData, clearSession, computeSharedSecret, createArbiClient, createAuthMessage, createAutoReloginMiddleware, createBearerAuthMiddleware, createContentHash, createOfflineSealer, createRecoveryFile, createRecoveryUrl, createReloginHandler, createSessionManager, createWorkspaceReopener, decodeCallKey, decodeSecureMailKey, decryptCallTitle, decryptFile, decryptMessage, decryptMessageWithSharedSecret, decryptRecoveryKey, decryptWithChatKey, deriveDirectChatKey, deriveEncryptionKeypairFromSigning, derivePublicKey, encodeCallKey, encryptCallTitle, encryptFile, encryptMessage, encryptMessageWithSharedSecret, encryptRecoveryKey, encryptWithChatKey, encryptionKeypairFromSigningKey, generateCallKey, generateChatKey, generateIdentityKeypair, generateKeyPairs, generateLoginCredentials, generateLoginCredentialsFromKey, generatePasswordChangeCredentials, generateRandomSigningKeypair, generateRegistrationCredentials, generateUserKeypairs, getRecoveryKeyFilename, getSession, getUserEncryptionKeypair, hasSession, identityFromLoginResponse, initSodium, initializeDatabase, isEncryptedRecoveryKey, isMessageType, openSealedCallKey, openSealedChatKey, openSecureMail, parseCallFragment, parseRecoveryUrl, parseServerMessage, passwordChangeErrorMessage, prewarmKeyPairs, saveSession, sealCallKey, sealChatKey, sealKeyForSession, sealSecureMail, sealedBoxDecrypt, sealedBoxEncrypt, secureMailClaim, signMessage, unlockIdentityWithPassword, unwrapIdentityKey, unwrapWorkspaceKey, updateWrappedIdentityKey, validateRecoveryKeyFile, wrapIdentityKey };
1458
1703
  //# sourceMappingURL=index.js.map
1459
1704
  //# sourceMappingURL=index.js.map