@atlastodo/client-core 0.1.13 → 0.1.14-rc.1

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
@@ -496,6 +496,9 @@ function safetyNumberGroups(number) {
496
496
  return number.match(/\d{5}/g) ?? [];
497
497
  }
498
498
  // src/crypto/field.ts
499
+ import { hkdf as hkdf4 } from "@noble/hashes/hkdf.js";
500
+ import { hmac } from "@noble/hashes/hmac.js";
501
+ import { sha256 as sha2564 } from "@noble/hashes/sha2.js";
499
502
  var FIELD_ENVELOPE_VERSION = 2;
500
503
  function sameKeyScope(a, b) {
501
504
  if (a.kind !== b.kind)
@@ -516,16 +519,16 @@ function fieldAad(at, scope, kid) {
516
519
  kid
517
520
  ]));
518
521
  }
519
- function paddedLength(n) {
522
+ function paddedLength(n, min = 32) {
520
523
  const needed = n + 1;
521
- if (needed <= 32)
522
- return 32;
524
+ if (needed <= min)
525
+ return min;
523
526
  if (needed <= 1024)
524
527
  return 2 ** Math.ceil(Math.log2(needed));
525
528
  return Math.ceil(needed / 1024) * 1024;
526
529
  }
527
- function padPlaintext(data) {
528
- const out = new Uint8Array(paddedLength(data.length));
530
+ function padPlaintext(data, min = 32) {
531
+ const out = new Uint8Array(paddedLength(data.length, min));
529
532
  out.set(data, 0);
530
533
  out[data.length] = 128;
531
534
  return out;
@@ -547,6 +550,49 @@ function decryptField(key, scope, at, envelope) {
547
550
  const plain = decryptAesGcm(key, envelope, fieldAad(at, scope, envelope.kid));
548
551
  return JSON.parse(bytesToUtf8(unpadPlaintext(plain)));
549
552
  }
553
+ var FIELD_ENVELOPE_V3 = 3;
554
+ var FIELD_V3_MIN_PADDING = 64;
555
+ function fieldAadV3(at, scope, kid) {
556
+ return utf8ToBytes(JSON.stringify([
557
+ "atlas-field",
558
+ FIELD_ENVELOPE_V3,
559
+ at.entity,
560
+ at.entityId,
561
+ at.field,
562
+ keyScopeLabel(scope),
563
+ kid
564
+ ]));
565
+ }
566
+ function sealField(key, kid, scope, at, sealed) {
567
+ const plain = padPlaintext(utf8ToBytes(JSON.stringify([sealed.kind, sealed.field, sealed.value ?? null])), FIELD_V3_MIN_PADDING);
568
+ const { iv, ct } = encryptAesGcm(key, plain, fieldAadV3(at, scope, kid));
569
+ return { __enc: 3, kid, iv, ct };
570
+ }
571
+ function openSealedField(key, scope, at, envelope) {
572
+ const plain = decryptAesGcm(key, envelope, fieldAadV3(at, scope, envelope.kid));
573
+ const parsed = JSON.parse(bytesToUtf8(unpadPlaintext(plain)));
574
+ if (!Array.isArray(parsed) || parsed.length !== 3 || typeof parsed[0] !== "string" || typeof parsed[1] !== "string") {
575
+ throw new Error("malformed sealed field");
576
+ }
577
+ return { kind: parsed[0], field: parsed[1], value: parsed[2] };
578
+ }
579
+ function isFieldEnvelopeV3(v) {
580
+ if (typeof v !== "object" || v === null)
581
+ return false;
582
+ const o = v;
583
+ return o.__enc === 3 && typeof o.kid === "string" && typeof o.iv === "string" && typeof o.ct === "string";
584
+ }
585
+ function fieldTagKey(key) {
586
+ return hkdf4(sha2564, key, undefined, utf8ToBytes("atlas-field-tag-v1"), 32);
587
+ }
588
+ var TAG_RE = /^[0-9a-f]{32}$/;
589
+ function fieldTag(tagKey, entityId, kind, field) {
590
+ const mac = hmac(sha2564, tagKey, utf8ToBytes(JSON.stringify(["atlas-field-tag", 1, entityId, kind, field])));
591
+ return bytesToHex(mac.subarray(0, 16));
592
+ }
593
+ function isFieldTag(name) {
594
+ return TAG_RE.test(name);
595
+ }
550
596
  function isFieldEnvelopeV2(v) {
551
597
  if (typeof v !== "object" || v === null)
552
598
  return false;
@@ -595,17 +641,17 @@ function isEncryptedEnvelope(v) {
595
641
  const o = v;
596
642
  if (typeof o.iv !== "string" || typeof o.ct !== "string")
597
643
  return false;
598
- return o.__enc === 1 || o.__enc === 2 && typeof o.kid === "string";
644
+ return o.__enc === 1 || (o.__enc === 2 || o.__enc === 3) && typeof o.kid === "string";
599
645
  }
600
646
  // src/crypto/keyring.ts
601
- import { sha256 as sha2564 } from "@noble/hashes/sha2.js";
647
+ import { sha256 as sha2565 } from "@noble/hashes/sha2.js";
602
648
  var DEK_KEY_ID = "dek";
603
649
  var KEY_ID_DOMAIN = utf8ToBytes("atlas-pek-id-v1");
604
650
  function projectKeyId(pek) {
605
651
  const input = new Uint8Array(KEY_ID_DOMAIN.length + pek.length);
606
652
  input.set(KEY_ID_DOMAIN, 0);
607
653
  input.set(pek, KEY_ID_DOMAIN.length);
608
- return bytesToHex(sha2564(input).subarray(0, 16));
654
+ return bytesToHex(sha2565(input).subarray(0, 16));
609
655
  }
610
656
 
611
657
  class MissingScopeKey extends Error {
@@ -994,8 +1040,8 @@ class BlobOpener {
994
1040
  }
995
1041
  }
996
1042
  // src/crypto/apiToken.ts
997
- import { sha256 as sha2565 } from "@noble/hashes/sha2.js";
998
- import { hkdf as hkdf4 } from "@noble/hashes/hkdf.js";
1043
+ import { sha256 as sha2566 } from "@noble/hashes/sha2.js";
1044
+ import { hkdf as hkdf5 } from "@noble/hashes/hkdf.js";
999
1045
  var API_TOKEN_PREFIX = "atlas_pat_";
1000
1046
  var ID_BYTES = 16;
1001
1047
  var SECRET_BYTES = 32;
@@ -1036,8 +1082,8 @@ function parseApiToken(token) {
1036
1082
  function deriveApiTokenKeys(parts) {
1037
1083
  const salt = hexToBytes(parts.id.replace(/-/g, ""));
1038
1084
  return {
1039
- auth: bytesToHex(hkdf4(sha2565, parts.secret, salt, utf8ToBytes("atlas-pat-auth-v1"), 32)),
1040
- wrapKey: hkdf4(sha2565, parts.secret, salt, utf8ToBytes("atlas-pat-wrap-v1"), 32)
1085
+ auth: bytesToHex(hkdf5(sha2566, parts.secret, salt, utf8ToBytes("atlas-pat-auth-v1"), 32)),
1086
+ wrapKey: hkdf5(sha2566, parts.secret, salt, utf8ToBytes("atlas-pat-wrap-v1"), 32)
1041
1087
  };
1042
1088
  }
1043
1089
  var wrapAad = (userId, tokenId, slot) => utf8ToBytes(JSON.stringify(["atlas-pat-wrap", 1, userId.toLowerCase(), tokenId.toLowerCase(), slot]));
@@ -1096,8 +1142,8 @@ function unwrapApiTokenAppKeys(parts, userId, app, wrapped) {
1096
1142
  }
1097
1143
  // src/crypto/rootKey.ts
1098
1144
  import { x25519 as x255193 } from "@noble/curves/ed25519.js";
1099
- import { sha256 as sha2566 } from "@noble/hashes/sha2.js";
1100
- import { hkdf as hkdf5 } from "@noble/hashes/hkdf.js";
1145
+ import { sha256 as sha2567 } from "@noble/hashes/sha2.js";
1146
+ import { hkdf as hkdf6 } from "@noble/hashes/hkdf.js";
1101
1147
  var TODO_APP = "todo";
1102
1148
  var JOURNAL_APP = "journal";
1103
1149
  var KEY_BYTES = 32;
@@ -1135,7 +1181,7 @@ function wrapRootKey(root, wrappingKey, userId, slot) {
1135
1181
  function unwrapRootKey(wrap, wrappingKey, userId, slot) {
1136
1182
  return unwrapWith(wrappingKey, wrap, rootAad(userId, slot), "root key");
1137
1183
  }
1138
- var sealKeyFrom = (shared) => hkdf5(sha2566, shared, undefined, utf8ToBytes("atlas-root-seal-v1"), 32);
1184
+ var sealKeyFrom = (shared) => hkdf6(sha2567, shared, undefined, utf8ToBytes("atlas-root-seal-v1"), 32);
1139
1185
  function sealRootKey(root, recoveryPublicKeyHex, userId) {
1140
1186
  const ephemeral = x255193.keygen();
1141
1187
  const shared = x255193.getSharedSecret(ephemeral.secretKey, hexToBytes(recoveryPublicKeyHex));
@@ -1170,8 +1216,8 @@ function unwrapAppPrivateKey(wrap, appKey, userId, app) {
1170
1216
  return unwrapWith(appKey, wrap, appIdentityAad(userId, app), "private key");
1171
1217
  }
1172
1218
  // src/crypto/passkey.ts
1173
- import { sha256 as sha2567 } from "@noble/hashes/sha2.js";
1174
- import { hkdf as hkdf6 } from "@noble/hashes/hkdf.js";
1219
+ import { sha256 as sha2568 } from "@noble/hashes/sha2.js";
1220
+ import { hkdf as hkdf7 } from "@noble/hashes/hkdf.js";
1175
1221
 
1176
1222
  // src/text.ts
1177
1223
  function trimTrailing(text, char) {
@@ -1187,7 +1233,7 @@ function trimTrailing(text, char) {
1187
1233
  var PASSKEY_PRF_LABEL = "atlas-passkey-prf-v1";
1188
1234
  var SLOT_INFO = "atlas-passkey-slot-v1";
1189
1235
  function passkeyPrfInput() {
1190
- return sha2567(utf8ToBytes(PASSKEY_PRF_LABEL));
1236
+ return sha2568(utf8ToBytes(PASSKEY_PRF_LABEL));
1191
1237
  }
1192
1238
  function passkeyPrfInputBase64Url() {
1193
1239
  return bytesToBase64Url(passkeyPrfInput());
@@ -1207,7 +1253,32 @@ function passkeySlot(credentialId) {
1207
1253
  function derivePasskeySlotKey(prfOutput, credentialId) {
1208
1254
  if (prfOutput.byteLength !== 32)
1209
1255
  throw new Error("a PRF output is 32 bytes");
1210
- return hkdf6(sha2567, prfOutput, decodeBase64Url(credentialId), utf8ToBytes(SLOT_INFO), 32);
1256
+ return hkdf7(sha2568, prfOutput, decodeBase64Url(credentialId), utf8ToBytes(SLOT_INFO), 32);
1257
+ }
1258
+ // src/crypto/invitePreview.ts
1259
+ function previewAad(projectId, kid) {
1260
+ return utf8ToBytes(JSON.stringify(["atlas-invite-preview", 1, projectId, kid]));
1261
+ }
1262
+ function sealInvitePreview(pek, kid, projectId, preview) {
1263
+ const { name, icon, color, kind } = preview;
1264
+ const plain = padPlaintext(utf8ToBytes(JSON.stringify({ name, icon, color, kind })), 64);
1265
+ const { iv, ct } = encryptAesGcm(pek, plain, previewAad(projectId, kid));
1266
+ return { v: 1, kid, iv, ct };
1267
+ }
1268
+ function openInvitePreview(pek, projectId, sealed) {
1269
+ const plain = decryptAesGcm(pek, sealed, previewAad(projectId, sealed.kid));
1270
+ const parsed = JSON.parse(bytesToUtf8(unpadPlaintext(plain)));
1271
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1272
+ throw new Error("malformed invite preview");
1273
+ }
1274
+ const { name, icon, color, kind } = parsed;
1275
+ return { name, icon, color, kind };
1276
+ }
1277
+ function isSealedInvitePreview(v) {
1278
+ if (typeof v !== "object" || v === null)
1279
+ return false;
1280
+ const o = v;
1281
+ return o.v === 1 && typeof o.kid === "string" && typeof o.iv === "string" && typeof o.ct === "string";
1211
1282
  }
1212
1283
  // src/realtime.ts
1213
1284
  var REALTIME_BACKOFF_BASE_MS = 1000;
@@ -1227,7 +1298,7 @@ function retryAfterOf(err) {
1227
1298
  const ms = err?.retryAfterMs;
1228
1299
  return typeof ms === "number" && ms > 0 ? ms : undefined;
1229
1300
  }
1230
- var SYNC_PROTOCOL = 6;
1301
+ var SYNC_PROTOCOL = 7;
1231
1302
  function buildSyncWsUrl(baseUrl, ticket, since) {
1232
1303
  const base = baseUrl.replace(/\/$/, "").replace(/^http/, "ws");
1233
1304
  return `${base}/sync/ws?ticket=${encodeURIComponent(ticket)}&since=${since}&protocol=${SYNC_PROTOCOL}`;
@@ -1454,50 +1525,59 @@ function assertServerCompatible(discovery, required) {
1454
1525
  }
1455
1526
  // ../../manifests/journal.json
1456
1527
  var journal_default = {
1457
- _doc: "The Atlas Journal app manifest: daily journal entries, end-to-end encrypted. Its kinds are namespaced `journal.<kind>`. Journal v1 is personal: the server never fans its ops out to another account and reads none of their fields, so every field value is encrypted (no plaintext_fields). `journal.entry` is one entry (date, title, body, mood, tags, …), `journal.preference` one of the app's settings. Bump `version` whenever the kinds or their plaintext fields change.",
1528
+ _doc: "The Atlas Journal app manifest: daily journal entries, end-to-end encrypted. Its kinds are namespaced `journal.<kind>`; on the wire (sync protocol 7, docs/decisions/0006) every one is `journal.sealed`, with opaque field tags and the real kind inside the encrypted value. Journal v1 is personal: the server never fans its ops out to another account and reads none of their fields, so nothing is public or plaintext. `journal.entry` is one entry (date, title, body, mood, tags, …), `journal.preference` one of the app's settings. Rows written before protocol 7 carry the real kind as their wire kind and stay readable until clients migrate them. Bump `version` whenever the kinds or their fields change.",
1458
1529
  app: "journal",
1459
1530
  name: "Atlas Journal",
1460
- version: 1,
1531
+ version: 2,
1461
1532
  kinds: {
1462
- "journal.entry": { plaintext_fields: [] },
1463
- "journal.preference": { plaintext_fields: [] }
1533
+ "journal.entry": { wire: "journal.sealed" },
1534
+ "journal.preference": { wire: "journal.sealed" }
1464
1535
  }
1465
1536
  };
1466
1537
  // ../../manifests/todo.json
1467
1538
  var todo_default = {
1468
- _doc: 'The Atlas Todo app manifest, compiled into the server (atlas-core) and bundled by the clients (packages/client-core); operators never load manifests from disk. `kinds` lists the entity kinds the app syncs: todo\'s kinds keep their original bare names on the wire, every later app namespaces its kinds as `<app>.<kind>`. `plaintext_fields` lists, per kind, the fields a client sends WITHOUT end-to-end encryption; every other field value is encrypted. Only values the server must read stay plaintext: fan-out and membership checks (project_id, task_id, assignee_id, actor_id) and attachment blob addressing (blob_sha, thumb_sha); wrapped_key and meta are already ciphertext. project_member rows are server-authored ("all"). The client encrypts by this table and the server asserts that every field value it reads is listed here. Bump `version` whenever the kinds or their plaintext fields change.',
1539
+ _doc: "The Atlas Todo app manifest, compiled into the server (atlas-core) and bundled by the clients (packages/client-core); operators never load manifests from disk. `kinds` lists the entity kinds the app syncs, by their real names, which only clients see (sync protocol 7, docs/decisions/0006). `wire` is the kind the server sees instead: `todo.project` for a project, `todo.sealed` for everything else, `project_member` for the server-authored membership rows. Every field travels under an opaque per-entity tag inside an encrypted value, except `public_fields`, which keep their names because the server acts on them: a project's `deleted_at`/`archived_at` are owner-only (values still encrypted), an attachment's `blob_sha`/`thumb_sha` address its blobs. `plaintext_fields` (a subset of `public_fields`, or \"all\" for server-authored kinds) travel unencrypted. `legacy_plaintext_fields` are the fields protocol 6 sent in plaintext, under the real kind name as the wire kind; such rows stay readable until clients migrate them (POST /sync/legacy/drop). Bump `version` whenever the kinds or their fields change.",
1469
1540
  app: "todo",
1470
1541
  name: "Atlas Todo",
1471
- version: 1,
1542
+ version: 2,
1472
1543
  kinds: {
1473
- task: { plaintext_fields: ["project_id", "assignee_id"] },
1474
- project: { plaintext_fields: [] },
1475
- section: { plaintext_fields: ["project_id"] },
1476
- label: { plaintext_fields: [] },
1477
- comment: { plaintext_fields: ["task_id"] },
1478
- preference: { plaintext_fields: [] },
1479
- saved_filter: { plaintext_fields: [] },
1480
- reminder: { plaintext_fields: [] },
1481
- project_member: { plaintext_fields: "all" },
1482
- activity: { plaintext_fields: ["task_id", "actor_id"] },
1483
- focus_session: { plaintext_fields: [] },
1484
- habit: { plaintext_fields: [] },
1485
- habit_checkin: { plaintext_fields: [] },
1544
+ task: { wire: "todo.sealed", legacy_plaintext_fields: ["project_id", "assignee_id"] },
1545
+ project: { wire: "todo.project", public_fields: ["deleted_at", "archived_at"] },
1546
+ section: { wire: "todo.sealed", legacy_plaintext_fields: ["project_id"] },
1547
+ label: { wire: "todo.sealed" },
1548
+ comment: { wire: "todo.sealed", legacy_plaintext_fields: ["task_id"] },
1549
+ preference: { wire: "todo.sealed" },
1550
+ saved_filter: { wire: "todo.sealed" },
1551
+ reminder: { wire: "todo.sealed" },
1552
+ project_member: { wire: "project_member", plaintext_fields: "all" },
1553
+ activity: { wire: "todo.sealed", legacy_plaintext_fields: ["task_id", "actor_id"] },
1554
+ focus_session: { wire: "todo.sealed" },
1555
+ habit: { wire: "todo.sealed" },
1556
+ habit_checkin: { wire: "todo.sealed" },
1486
1557
  attachment: {
1487
- plaintext_fields: ["task_id", "blob_sha", "thumb_sha", "wrapped_key", "meta"]
1558
+ wire: "todo.sealed",
1559
+ public_fields: ["blob_sha", "thumb_sha"],
1560
+ plaintext_fields: ["blob_sha", "thumb_sha"],
1561
+ legacy_plaintext_fields: ["task_id", "blob_sha", "thumb_sha", "wrapped_key", "meta"]
1488
1562
  }
1489
1563
  }
1490
1564
  };
1491
1565
  // ../../manifests/vault.json
1492
1566
  var vault_default = {
1493
- _doc: "The Atlas Vault app manifest: a password manager, end-to-end encrypted, held to a stricter threat model than todo. Its kinds are namespaced `vault.<kind>`. Vault v1 is personal: the server never fans its ops out to another account and reads none of their fields, so every field value is encrypted (no plaintext_fields), including the item type, its folder and its timestamps. `vault.item` is one item (login, note, card, identity, ssh_key, api_key: name, typed fields, urls, notes, tags, folder, favorite, secret field history), `vault.folder` one folder, `vault.preference` one of the app's settings. `api_tokens` is the policy for API tokens that include the app, which the server enforces: an expiry is required, at most `max_lifetime_days` after creation, a token is read-only unless its creator opts in to writes, and it is for the vault alone (`exclusive`: no other app's key travels with a vault secret). Bump `version` whenever the kinds, their plaintext fields or the token policy change.",
1567
+ _doc: "The Atlas Vault app manifest: a password manager, end-to-end encrypted, held to a stricter threat model than todo. Its kinds are namespaced `vault.<kind>`; on the wire (sync protocol 7, docs/decisions/0006) every one is `vault.sealed`, with opaque field tags and the real kind inside the encrypted value, and rows written before protocol 7 (under the real kind) stay readable until clients migrate them. Vault v1 is personal: the server never fans its ops out to another account and reads none of their fields, so every field value is encrypted (nothing public or plaintext), including the item type, its folder and its timestamps. `vault.item` is one item (login, note, card, identity, ssh_key, api_key: name, typed fields, urls, notes, tags, folder, favorite, secret field history), `vault.folder` one folder, `vault.preference` one of the app's settings. `api_tokens` is the policy for API tokens that include the app, which the server enforces: an expiry is required, at most `max_lifetime_days` after creation, a token is read-only unless its creator opts in to writes, and it is for the vault alone (`exclusive`: no other app's key travels with a vault secret). Bump `version` whenever the kinds, their plaintext fields or the token policy change.",
1494
1568
  app: "vault",
1495
1569
  name: "Atlas Vault",
1496
- version: 2,
1570
+ version: 3,
1497
1571
  kinds: {
1498
- "vault.item": { plaintext_fields: [] },
1499
- "vault.folder": { plaintext_fields: [] },
1500
- "vault.preference": { plaintext_fields: [] }
1572
+ "vault.item": {
1573
+ wire: "vault.sealed"
1574
+ },
1575
+ "vault.folder": {
1576
+ wire: "vault.sealed"
1577
+ },
1578
+ "vault.preference": {
1579
+ wire: "vault.sealed"
1580
+ }
1501
1581
  },
1502
1582
  api_tokens: {
1503
1583
  expiry_required: true,
@@ -1560,11 +1640,18 @@ var ENTITY_KINDS = {
1560
1640
  "vault.folder": true,
1561
1641
  "vault.preference": true
1562
1642
  };
1643
+ function kindSpec(kind) {
1644
+ return appOfKind(kind)?.kinds[kind];
1645
+ }
1563
1646
  function loadTable(manifests) {
1564
1647
  const table = {};
1565
1648
  for (const kind of Object.keys(ENTITY_KINDS)) {
1566
1649
  const owner = manifests.find((m) => Object.prototype.hasOwnProperty.call(m.kinds, kind));
1567
- const entry = owner?.kinds[kind]?.plaintext_fields;
1650
+ const spec = owner?.kinds[kind];
1651
+ if (typeof spec?.wire !== "string") {
1652
+ throw new Error(`app manifests: no wire kind for "${kind}"`);
1653
+ }
1654
+ const entry = spec.plaintext_fields ?? [];
1568
1655
  if (entry === "all")
1569
1656
  table[kind] = "all";
1570
1657
  else if (Array.isArray(entry) && entry.every((f) => typeof f === "string"))
@@ -1575,8 +1662,20 @@ function loadTable(manifests) {
1575
1662
  return Object.freeze(table);
1576
1663
  }
1577
1664
  var PLAINTEXT_FIELDS = loadTable(BUNDLED_MANIFESTS);
1665
+ function appOfWire(name) {
1666
+ return appOfKind(name) ?? BUNDLED_MANIFESTS.find((m) => Object.values(m.kinds).some((k) => k.wire === name));
1667
+ }
1668
+ function wireKindOf(kind) {
1669
+ return kindSpec(kind)?.wire ?? null;
1670
+ }
1671
+ function isPublicField(kind, field) {
1672
+ const spec = kindSpec(kind);
1673
+ if (!spec)
1674
+ return false;
1675
+ return spec.plaintext_fields === "all" || (spec.public_fields ?? []).includes(field);
1676
+ }
1578
1677
  function shouldEncryptKindField(entity, field) {
1579
- const plain = appOfKind(entity)?.kinds[entity]?.plaintext_fields;
1678
+ const plain = kindSpec(entity)?.plaintext_fields;
1580
1679
  if (plain === "all")
1581
1680
  return false;
1582
1681
  return !plain?.includes(field);
@@ -1587,6 +1686,28 @@ function shouldEncryptField(entity, field) {
1587
1686
  return false;
1588
1687
  return !plain?.includes(field);
1589
1688
  }
1689
+ function legacyEncrypted(kind, field) {
1690
+ const spec = kindSpec(kind);
1691
+ if (spec?.plaintext_fields === "all")
1692
+ return false;
1693
+ return !(spec?.legacy_plaintext_fields ?? []).includes(field);
1694
+ }
1695
+ function isLegacyWireKind(name) {
1696
+ const spec = kindSpec(name);
1697
+ return spec !== undefined && spec.wire !== name;
1698
+ }
1699
+ function kindsOfWire(wire) {
1700
+ return Object.keys(ENTITY_KINDS).filter((k) => kindSpec(k)?.wire === wire);
1701
+ }
1702
+ function realKindOf(wire, publicField) {
1703
+ const kinds = kindsOfWire(wire);
1704
+ if (kinds.length === 1)
1705
+ return kinds[0];
1706
+ if (publicField === undefined)
1707
+ return null;
1708
+ const naming = kinds.filter((k) => isPublicField(k, publicField));
1709
+ return naming.length === 1 ? naming[0] : null;
1710
+ }
1590
1711
 
1591
1712
  // src/scope.ts
1592
1713
  import { sha1 } from "@noble/hashes/legacy.js";
@@ -1787,6 +1908,178 @@ function recordKeyTrustBaseline(store, keyring, userId) {
1787
1908
  write(store, "keys_initialized", true);
1788
1909
  }
1789
1910
 
1911
+ // src/wire.ts
1912
+ function isSealedPlaceholder(v) {
1913
+ return isFieldEnvelopeV3(v) && "scope" in v;
1914
+ }
1915
+ function isSealedWireKind(name) {
1916
+ return kindsOfWire(name).length > 0 && !isLegacyWireKind(name) && name !== "project_member";
1917
+ }
1918
+ var tagKeys = new WeakMap;
1919
+ function tagKeyOf(key) {
1920
+ let tagKey = tagKeys.get(key);
1921
+ if (!tagKey)
1922
+ tagKeys.set(key, tagKey = fieldTagKey(key));
1923
+ return tagKey;
1924
+ }
1925
+ function wireFieldOf(key, kind, entityId, field) {
1926
+ return isPublicField(kind, field) ? field : fieldTag(tagKeyOf(key), entityId, kind, field);
1927
+ }
1928
+ function scopeOfKey(scope) {
1929
+ return scope.kind === "project" ? scope.projectId : null;
1930
+ }
1931
+ function encodeOp(op, k) {
1932
+ const wire = wireKindOf(op.entity);
1933
+ if (!wire || wire === "project_member")
1934
+ throw new Error(`cannot push ${op.entity} ops`);
1935
+ const ts = { wall_ms: op.ts.wallMs, counter: op.ts.counter, node: op.ts.node };
1936
+ const base = { id: op.id, entity: wire, entity_id: op.entityId, scope: scopeOfKey(k.scope), ts };
1937
+ if (op.op === "delete")
1938
+ return { ...base, op: "delete" };
1939
+ const field = wireFieldOf(k.key, op.entity, op.entityId, op.field);
1940
+ const value = shouldEncryptField(op.entity, op.field) ? sealField(k.key, k.keyId, k.scope, { entity: wire, entityId: op.entityId, field }, { kind: op.entity, field: op.field, value: op.value }) : op.value;
1941
+ return { ...base, op: "set", field, value };
1942
+ }
1943
+ function keysFor(keyring, scope, kid) {
1944
+ if (scope === null) {
1945
+ if (kid !== DEK_KEY_ID)
1946
+ return [];
1947
+ return [{ key: keyring.getDek(), keyId: DEK_KEY_ID, scope: { kind: "personal" } }];
1948
+ }
1949
+ const key = keyring.projectKey(scope, kid);
1950
+ return key ? [{ key, keyId: kid, scope: { kind: "project", projectId: scope } }] : [];
1951
+ }
1952
+ function openSealed(keyring, wire, entityId, wireField, scope, envelope) {
1953
+ for (const k of keysFor(keyring, scope, envelope.kid)) {
1954
+ let sealed;
1955
+ try {
1956
+ sealed = openSealedField(k.key, k.scope, { entity: wire, entityId, field: wireField }, envelope);
1957
+ } catch {
1958
+ continue;
1959
+ }
1960
+ const kind = sealed.kind;
1961
+ if (wireKindOf(kind) !== wire)
1962
+ return null;
1963
+ if (wireFieldOf(k.key, kind, entityId, sealed.field) !== wireField)
1964
+ return null;
1965
+ return { kind, field: sealed.field, value: sealed.value, keyId: k.keyId, scope: k.scope };
1966
+ }
1967
+ return null;
1968
+ }
1969
+ function hlcOf(w) {
1970
+ return { wallMs: w.ts.wall_ms, counter: w.ts.counter, node: w.ts.node };
1971
+ }
1972
+ function decodeOps(wireOps, ctx) {
1973
+ const out = [];
1974
+ const pageKinds = new Map;
1975
+ const pendingDeletes = [];
1976
+ for (const w of wireOps) {
1977
+ const ts = hlcOf(w);
1978
+ const base = { id: w.id, entityId: w.entity_id, ts };
1979
+ if (isLegacyWireKind(w.entity) || w.entity === "project_member") {
1980
+ const kind = w.entity;
1981
+ pageKinds.set(w.entity_id, kind);
1982
+ if (w.op === "delete") {
1983
+ out.push({
1984
+ op: { ...base, entity: kind, op: "delete" },
1985
+ legacy: w.entity !== "project_member"
1986
+ });
1987
+ continue;
1988
+ }
1989
+ const op = { ...base, entity: kind, op: "set", field: w.field, value: w.value };
1990
+ if (w.entity === "project_member" || !legacyEncrypted(kind, w.field)) {
1991
+ out.push({ op, legacy: w.entity !== "project_member" });
1992
+ continue;
1993
+ }
1994
+ const opened = ctx.openLegacy(op);
1995
+ out.push(opened ? { op: opened, legacy: true } : null);
1996
+ continue;
1997
+ }
1998
+ if (w.op === "delete") {
1999
+ const kind = realKindOf(w.entity) ?? ctx.store.kindOf(w.entity_id);
2000
+ out.push({
2001
+ op: { ...base, entity: kind ?? w.entity, op: "delete" },
2002
+ legacy: false
2003
+ });
2004
+ if (!kind)
2005
+ pendingDeletes.push(out.length - 1);
2006
+ continue;
2007
+ }
2008
+ const scope = w.scope ?? null;
2009
+ const publicKind = isPublicFieldOfWire(w.entity, w.field) ? realKindOf(w.entity, w.field) : null;
2010
+ if (publicKind && !shouldEncryptField(publicKind, w.field)) {
2011
+ pageKinds.set(w.entity_id, publicKind);
2012
+ out.push({
2013
+ op: { ...base, entity: publicKind, op: "set", field: w.field, value: w.value },
2014
+ legacy: false
2015
+ });
2016
+ continue;
2017
+ }
2018
+ if (!isFieldEnvelopeV3(w.value)) {
2019
+ console.warn(`[atlas-e2ee] dropped a value that is no sealed envelope (${w.entity_id})`);
2020
+ out.push(null);
2021
+ continue;
2022
+ }
2023
+ const opened = ctx.keyring ? openSealed(ctx.keyring, w.entity, w.entity_id, w.field, scope, w.value) : null;
2024
+ if (!opened) {
2025
+ console.warn(`[atlas-e2ee] no key opens ${w.entity} ${w.field} (${w.entity_id})`);
2026
+ const placeholder = { ...w.value, scope };
2027
+ out.push({
2028
+ op: {
2029
+ ...base,
2030
+ entity: w.entity,
2031
+ op: "set",
2032
+ field: w.field,
2033
+ value: placeholder
2034
+ },
2035
+ legacy: false
2036
+ });
2037
+ continue;
2038
+ }
2039
+ if (opened.kind === "activity" && opened.field === "actor_id") {
2040
+ if (!w.pushed_by || opened.value !== w.pushed_by) {
2041
+ console.warn(`[atlas-e2ee] dropped an activity whose actor did not push it (${w.entity_id})`);
2042
+ out.push(null);
2043
+ continue;
2044
+ }
2045
+ }
2046
+ pageKinds.set(w.entity_id, opened.kind);
2047
+ out.push({
2048
+ op: { ...base, entity: opened.kind, op: "set", field: opened.field, value: opened.value },
2049
+ opened: { keyId: opened.keyId, scope: opened.scope },
2050
+ legacy: false
2051
+ });
2052
+ }
2053
+ for (const i of pendingDeletes) {
2054
+ const d = out[i];
2055
+ const kind = pageKinds.get(d.op.entityId);
2056
+ if (kind)
2057
+ d.op = { ...d.op, entity: kind };
2058
+ }
2059
+ return out.filter((d) => d !== null);
2060
+ }
2061
+ function isPublicFieldOfWire(wire, field) {
2062
+ return kindsOfWire(wire).some((k) => isPublicField(k, field));
2063
+ }
2064
+ function sealedRepair(d, expected, isCanonical) {
2065
+ if (d.op.op !== "set" || !d.opened)
2066
+ return null;
2067
+ const { keyId, scope } = d.opened;
2068
+ if (expected && sameKeyScope(expected, scope) && isCanonical(keyId, expected))
2069
+ return null;
2070
+ return {
2071
+ entity: d.op.entity,
2072
+ entityId: d.op.entityId,
2073
+ field: d.op.field,
2074
+ ts: d.op.ts,
2075
+ value: d.op.value,
2076
+ reason: "key",
2077
+ keyId,
2078
+ scope,
2079
+ version: 3
2080
+ };
2081
+ }
2082
+
1790
2083
  // src/scope.ts
1791
2084
  var EMPTY_SCOPE_STORE = {
1792
2085
  list: () => [],
@@ -2049,9 +2342,9 @@ function confirmRepairs(store, keyring, userId, repairs) {
2049
2342
  const desired = preferredKey(keyring, resolveScope(store, r.entity, r.entityId), shared);
2050
2343
  if (!desired || !r.scope || !sameKeyScope(desired.scope, r.scope))
2051
2344
  return false;
2052
- if (desired.keyId === r.keyId && r.version === 2)
2345
+ if (desired.keyId === r.keyId && (r.version === 2 || r.version === 3))
2053
2346
  return false;
2054
- if (r.version === 2 && r.keyId !== undefined && r.scope.kind === "project" && keyring.isRetired(r.scope.projectId, r.keyId)) {
2347
+ if ((r.version === 2 || r.version === 3) && r.keyId !== undefined && r.scope.kind === "project" && keyring.isRetired(r.scope.projectId, r.keyId)) {
2055
2348
  return false;
2056
2349
  }
2057
2350
  }
@@ -2088,8 +2381,22 @@ function reviveLockedValues(store, keyring, userId, personalV1 = true) {
2088
2381
  adoptPersonalV1: expected?.kind === "project" && sole.has(expected.projectId)
2089
2382
  });
2090
2383
  });
2091
- const repairs = confirmRepairs(store, keyring, userId, revived.map((r) => ({ ...r, reason: "key" })));
2092
- return { revived: revived.length, repaired: applyRepairs(store, repairs) };
2384
+ const sealed = store.reviveSealed((wire, entityId, wireField, placeholder) => openSealed(keyring, wire, entityId, wireField, placeholder.scope, placeholder));
2385
+ const repairs = confirmRepairs(store, keyring, userId, [
2386
+ ...revived.map((r) => ({ ...r, reason: "key" })),
2387
+ ...sealed.map((r) => ({
2388
+ entity: r.entity,
2389
+ entityId: r.entityId,
2390
+ field: r.field,
2391
+ ts: r.ts,
2392
+ value: r.value,
2393
+ reason: "key",
2394
+ keyId: r.keyId,
2395
+ scope: r.scope,
2396
+ version: 3
2397
+ }))
2398
+ ]);
2399
+ return { revived: revived.length + sealed.length, repaired: applyRepairs(store, repairs) };
2093
2400
  }
2094
2401
  function isPayload(v) {
2095
2402
  return typeof v === "object" && v !== null && typeof v.iv === "string" && typeof v.ct === "string";
@@ -2187,20 +2494,9 @@ class E2eeLockedError extends Error {
2187
2494
  }
2188
2495
  }
2189
2496
  function encodeWireOp(op, keyring, key) {
2190
- const ts = { wall_ms: op.ts.wallMs, counter: op.ts.counter, node: op.ts.node };
2191
- const base = { id: op.id, entity: op.entity, entity_id: op.entityId, ts };
2192
- if (op.op === "delete")
2193
- return { ...base, op: "delete" };
2194
- let value = op.value;
2195
- if (shouldEncryptField(op.entity, op.field)) {
2196
- if (!keyring?.hasKeys())
2197
- throw new E2eeLockedError;
2198
- const k = key();
2199
- const at = { entity: op.entity, entityId: op.entityId, field: op.field };
2200
- const encryptedValue = encryptField(k.key, k.keyId, k.scope, at, op.value);
2201
- value = encryptedValue;
2202
- }
2203
- return { ...base, op: "set", field: op.field, value };
2497
+ if (!keyring?.hasKeys())
2498
+ throw new E2eeLockedError;
2499
+ return encodeOp(op, key());
2204
2500
  }
2205
2501
  function toEncryptedWire(op, keyring, projectId) {
2206
2502
  return encodeWireOp(op, keyring, () => {
@@ -2214,15 +2510,25 @@ function toEncryptedWire(op, keyring, projectId) {
2214
2510
  });
2215
2511
  }
2216
2512
  function fromEncryptedWire(w, keyring, projectId) {
2513
+ if (!isLegacyWireKind(w.entity) && w.entity !== "project_member") {
2514
+ const [d] = decodeOps([w], {
2515
+ keyring: keyring?.hasKeys() ? keyring : null,
2516
+ store: { kindOf: () => null },
2517
+ openLegacy: () => null
2518
+ });
2519
+ if (!d)
2520
+ throw new Error(`dropped ${w.entity} op ${w.id}`);
2521
+ return d.op;
2522
+ }
2217
2523
  const ts = { wallMs: w.ts.wall_ms, counter: w.ts.counter, node: w.ts.node };
2218
2524
  const base = { id: w.id, entity: w.entity, entityId: w.entity_id, ts };
2219
2525
  if (w.op === "delete")
2220
2526
  return { ...base, op: "delete" };
2221
2527
  let value = w.value;
2222
- if (keyring?.hasKeys() && isEncryptedEnvelope(value)) {
2528
+ if (keyring?.hasKeys() && isEncryptedEnvelope(value) && legacyEncrypted(w.entity, w.field)) {
2223
2529
  const expected = projectId === undefined ? null : projectId === null ? { kind: "personal" } : { kind: "project", projectId };
2224
2530
  const at = { entity: w.entity, entityId: w.entity_id, field: w.field };
2225
- const opened = openFieldValue(keyring, value, at, expected, openOptionsFor(w.entity, w.entity_id, true));
2531
+ const opened = openFieldValue(keyring, value, at, expected, openOptionsFor(base.entity, w.entity_id, true));
2226
2532
  if (opened)
2227
2533
  value = opened.value;
2228
2534
  else
@@ -2431,6 +2737,10 @@ class ApiClient {
2431
2737
  isLegacyMigrated() {
2432
2738
  return this.legacyDone || readKeyTrust(this.scopeStore ?? EMPTY_SCOPE_STORE).legacyMigrated;
2433
2739
  }
2740
+ kindOfEntity(id) {
2741
+ const store = this.scopeStore;
2742
+ return store?.kindOf?.(id) ?? null;
2743
+ }
2434
2744
  isCanonical(keyId, scope) {
2435
2745
  if (scope.kind === "personal")
2436
2746
  return keyId === DEK_KEY_ID;
@@ -2975,43 +3285,76 @@ class ApiClient {
2975
3285
  throw new Error("malformed sync payload");
2976
3286
  const wireOps = operations;
2977
3287
  const store = this.scopeStore ?? EMPTY_SCOPE_STORE;
2978
- const decoded = wireOps.map((w) => fromEncryptedWire(w, null));
2979
- const batch = batchLinksOf(decoded);
2980
- const shared = sharedProjectIds(store);
2981
3288
  const legacy = !this.isLegacyMigrated();
2982
- const repairs = [];
2983
- const out = [];
2984
- for (const op of decoded) {
2985
- if (op.op !== "set" || !shouldEncryptField(op.entity, op.field)) {
2986
- out.push(op);
2987
- continue;
2988
- }
2989
- const at = { entity: op.entity, entityId: op.entityId, field: op.field, ts: op.ts };
2990
- const trustState = op.entity === "preference" && op.entityId === KEY_TRUST_ID;
2991
- if (!isEncryptedEnvelope(op.value)) {
2992
- if (!legacy || trustState) {
2993
- console.warn(`[atlas-e2ee] dropped plaintext in ${op.entity}.${op.field} (${op.entityId})`);
2994
- continue;
3289
+ const legacyLinks = batchLinksOf(wireOps.filter((w) => isLegacyWireKind(w.entity)).map((w) => fromEncryptedWire(w, null)));
3290
+ const shared = sharedProjectIds(store);
3291
+ const ctx = {
3292
+ keyring,
3293
+ store: { kindOf: (id) => this.kindOfEntity(id) },
3294
+ openLegacy: (op) => {
3295
+ const at = { entity: op.entity, entityId: op.entityId, field: op.field, ts: op.ts };
3296
+ const trustState = op.entity === "preference" && op.entityId === KEY_TRUST_ID;
3297
+ if (!isEncryptedEnvelope(op.value)) {
3298
+ if (!legacy || trustState) {
3299
+ console.warn(`[atlas-e2ee] dropped plaintext in ${op.entity}.${op.field} (${op.entityId})`);
3300
+ return null;
3301
+ }
3302
+ return op;
2995
3303
  }
2996
- repairs.push({ ...at, value: op.value, reason: "plaintext" });
2997
- out.push(op);
2998
- continue;
3304
+ const expected = expectedKeyScope(keyring, resolveScope(store, op.entity, op.entityId, legacyLinks), shared);
3305
+ const opened = openFieldValue(keyring, op.value, at, expected, openOptionsFor(op.entity, op.entityId, legacy));
3306
+ if (!opened) {
3307
+ console.warn(`[atlas-e2ee] no key opens ${op.entity}.${op.field} (${op.entityId})`);
3308
+ return op;
3309
+ }
3310
+ return { ...op, value: opened.value };
2999
3311
  }
3000
- const expected = expectedKeyScope(keyring, resolveScope(store, op.entity, op.entityId, batch), shared);
3001
- const opened = openFieldValue(keyring, op.value, at, expected, openOptionsFor(op.entity, op.entityId, legacy));
3002
- if (!opened) {
3003
- console.warn(`[atlas-e2ee] no key opens ${op.entity}.${op.field} (${op.entityId})`);
3004
- out.push(op);
3312
+ };
3313
+ const decoded = decodeOps(wireOps, ctx);
3314
+ const batch = batchLinksOf(decoded.map((d) => d.op));
3315
+ const repairs = [];
3316
+ for (const d of decoded) {
3317
+ if (d.legacy || !d.opened)
3005
3318
  continue;
3006
- }
3007
- if (!expected || opened.version === 1 || !this.isCanonical(opened.keyId, expected)) {
3008
- const { keyId, scope, version } = opened;
3009
- repairs.push({ ...at, value: opened.value, reason: "key", keyId, scope, version });
3010
- }
3011
- out.push({ ...op, value: opened.value });
3319
+ const expected = expectedKeyScope(keyring, resolveScope(store, d.op.entity, d.op.entityId, batch), shared);
3320
+ const repair = sealedRepair(d, expected, (keyId, scope) => this.isCanonical(keyId, scope));
3321
+ if (repair)
3322
+ repairs.push(repair);
3012
3323
  }
3324
+ const out = decoded.map((d) => d.op);
3013
3325
  return { operations: out, cursor, from: typeof from === "number" ? from : undefined, repairs };
3014
3326
  }
3327
+ legacyKeys(next) {
3328
+ const q = next === undefined ? "" : `?next=${encodeURIComponent(next)}`;
3329
+ return this.request("GET", `/sync/legacy/keys${q}`);
3330
+ }
3331
+ async dropLegacy(keys) {
3332
+ const res = await this.request("POST", "/sync/legacy/drop", {
3333
+ entities: keys
3334
+ });
3335
+ return res.dropped;
3336
+ }
3337
+ mayReencode(kind, entityId) {
3338
+ const store = this.scopeStore;
3339
+ const user = this.scopeUserId;
3340
+ if (!store || !user)
3341
+ return false;
3342
+ if (kind === "activity" && store.rawField("activity", entityId, "actor_id") !== user) {
3343
+ return false;
3344
+ }
3345
+ const scope = resolveScope(store, kind, entityId);
3346
+ if (scope.kind === "unknown")
3347
+ return false;
3348
+ if (scope.kind === "personal")
3349
+ return true;
3350
+ const rows = store.list("project_member").filter((e) => e.fields.project_id === scope.projectId && e.fields.state === "active");
3351
+ if (rows.length === 0)
3352
+ return true;
3353
+ const mine = rows.find((e) => e.fields.user_id === user);
3354
+ if (!mine)
3355
+ return false;
3356
+ return kind !== "project" || mine.fields.role === "owner";
3357
+ }
3015
3358
  async syncWsUrl(since) {
3016
3359
  if (!this.token)
3017
3360
  return null;
@@ -3021,8 +3364,12 @@ class ApiClient {
3021
3364
  listMembers(projectId) {
3022
3365
  return this.request("GET", `/projects/${projectId}/members`);
3023
3366
  }
3024
- inviteMember(projectId, email, role) {
3025
- return this.request("POST", `/projects/${projectId}/members`, { email, role });
3367
+ inviteMember(projectId, email, role, preview) {
3368
+ return this.request("POST", `/projects/${projectId}/members`, {
3369
+ email,
3370
+ role,
3371
+ ...preview ? { preview } : {}
3372
+ });
3026
3373
  }
3027
3374
  acceptInvite(projectId) {
3028
3375
  return this.request("POST", `/projects/${projectId}/accept`);
@@ -3816,6 +4163,8 @@ class LocalStore {
3816
4163
  let state = byId.get(op.entityId);
3817
4164
  if (!state) {
3818
4165
  state = { fields: new Map, deleted: null, deletedOp: null };
4166
+ if (!isSealedWireKind(op.entity))
4167
+ this.inheritTombstone(op.entityId, state);
3819
4168
  byId.set(op.entityId, state);
3820
4169
  }
3821
4170
  let changed;
@@ -3836,6 +4185,26 @@ class LocalStore {
3836
4185
  this.kindChanged.set(op.entity, ++this.changeSeq);
3837
4186
  return changed;
3838
4187
  }
4188
+ inheritTombstone(entityId, state) {
4189
+ for (const [kind, byId] of this.entities) {
4190
+ if (!isSealedWireKind(kind))
4191
+ continue;
4192
+ const pending = byId.get(entityId);
4193
+ if (!pending?.deleted)
4194
+ continue;
4195
+ if (state.deleted === null || compareHlc(pending.deleted, state.deleted) > 0) {
4196
+ state.deleted = pending.deleted;
4197
+ state.deletedOp = pending.deletedOp;
4198
+ }
4199
+ }
4200
+ }
4201
+ kindOf(entityId) {
4202
+ for (const [kind, byId] of this.entities) {
4203
+ if (!isSealedWireKind(kind) && byId.has(entityId))
4204
+ return kind;
4205
+ }
4206
+ return null;
4207
+ }
3839
4208
  state(entity, entityId) {
3840
4209
  return this.entities.get(entity)?.get(entityId);
3841
4210
  }
@@ -3886,6 +4255,10 @@ class LocalStore {
3886
4255
  return null;
3887
4256
  return { ...fs.ts };
3888
4257
  }
4258
+ tombstoneTs(entity, entityId) {
4259
+ const deleted = this.state(entity, entityId)?.deleted;
4260
+ return deleted ? { ...deleted } : null;
4261
+ }
3889
4262
  visibleFieldStates(entity, entityId) {
3890
4263
  const state = this.state(entity, entityId);
3891
4264
  if (!state)
@@ -3913,6 +4286,8 @@ class LocalStore {
3913
4286
  const revived = [];
3914
4287
  const ops = [];
3915
4288
  for (const [entity, byId] of this.entities) {
4289
+ if (isSealedWireKind(entity))
4290
+ continue;
3916
4291
  for (const [entityId, state] of byId) {
3917
4292
  for (const [field, fs] of state.fields) {
3918
4293
  if (!isEncryptedEnvelope(fs.value))
@@ -3944,6 +4319,56 @@ class LocalStore {
3944
4319
  }
3945
4320
  return revived;
3946
4321
  }
4322
+ reviveSealed(open) {
4323
+ const revived = [];
4324
+ const ops = [];
4325
+ const gone = [];
4326
+ for (const [wire, byId] of this.entities) {
4327
+ if (!isSealedWireKind(wire))
4328
+ continue;
4329
+ for (const [entityId, state] of byId) {
4330
+ for (const [wireField, fs] of state.fields) {
4331
+ if (!isSealedPlaceholder(fs.value))
4332
+ continue;
4333
+ const opened = open(wire, entityId, wireField, fs.value);
4334
+ if (!opened)
4335
+ continue;
4336
+ state.fields.delete(wireField);
4337
+ gone.push(fs.op);
4338
+ const op = {
4339
+ id: this.newId(),
4340
+ entity: opened.kind,
4341
+ entityId,
4342
+ ts: { ...fs.ts },
4343
+ op: "set",
4344
+ field: opened.field,
4345
+ value: opened.value
4346
+ };
4347
+ this.applied.add(op.id);
4348
+ if (this.mergeInto(op))
4349
+ ops.push(op);
4350
+ revived.push({
4351
+ ...opened,
4352
+ entity: opened.kind,
4353
+ entityId,
4354
+ field: opened.field,
4355
+ ts: { ...fs.ts }
4356
+ });
4357
+ }
4358
+ if (state.fields.size === 0 && state.deleted === null)
4359
+ byId.delete(entityId);
4360
+ }
4361
+ }
4362
+ if (ops.length > 0)
4363
+ this.persistBatch(ops, true);
4364
+ const p = this.persistence;
4365
+ if (p?.deleteOps && gone.length > 0) {
4366
+ this.writeChain = this.writeChain.then(() => p.deleteOps(gone)).catch((err) => this.reportPersistError(err));
4367
+ }
4368
+ if (revived.length > 0)
4369
+ this.emit();
4370
+ return revived;
4371
+ }
3947
4372
  visibleFields(state) {
3948
4373
  const visible = {};
3949
4374
  for (const [name, fs] of state.fields) {
@@ -4716,14 +5141,14 @@ function rowToOp(r) {
4716
5141
  };
4717
5142
  }
4718
5143
  // src/sealedPersistence.ts
4719
- import { hkdf as hkdf7 } from "@noble/hashes/hkdf.js";
4720
- import { sha256 as sha2568 } from "@noble/hashes/sha2.js";
5144
+ import { hkdf as hkdf8 } from "@noble/hashes/hkdf.js";
5145
+ import { sha256 as sha2569 } from "@noble/hashes/sha2.js";
4721
5146
  var LOCAL_STORE_KEY_ID = "local-v1";
4722
5147
  var SCOPE = { kind: "personal" };
4723
5148
  function deriveLocalStoreKey(appKey, userId) {
4724
5149
  if (appKey.length !== 32)
4725
5150
  throw new Error("the app key must be 32 bytes");
4726
- return hkdf7(sha2568, appKey, utf8ToBytes(userId), utf8ToBytes("atlas-local-store-v1"), 32);
5151
+ return hkdf8(sha2569, appKey, utf8ToBytes(userId), utf8ToBytes("atlas-local-store-v1"), 32);
4727
5152
  }
4728
5153
 
4729
5154
  class SealedStoreError extends Error {
@@ -4818,7 +5243,7 @@ function sealPersistence(inner, key) {
4818
5243
  return sealed;
4819
5244
  }
4820
5245
  // src/attachments.ts
4821
- import { sha256 as sha2569 } from "@noble/hashes/sha2.js";
5246
+ import { sha256 as sha25610 } from "@noble/hashes/sha2.js";
4822
5247
  var DEFAULT_MAX_BLOB_BYTES = 25 * 1024 * 1024;
4823
5248
  function maxAttachmentBytes(maxBlobBytes = DEFAULT_MAX_BLOB_BYTES) {
4824
5249
  return maxPlainSize(maxBlobBytes);
@@ -4836,7 +5261,7 @@ function generateAek() {
4836
5261
  return randomBytes(32);
4837
5262
  }
4838
5263
  function sha256Hex(bytes) {
4839
- return bytesToHex(sha2569(bytes));
5264
+ return bytesToHex(sha25610(bytes));
4840
5265
  }
4841
5266
  function bytesSource(bytes) {
4842
5267
  return {
@@ -4895,8 +5320,8 @@ async function sealSource(aek, source, opts = {}) {
4895
5320
  const expected = sealedBlobSize(total);
4896
5321
  const sink = opts.asBlob ? new BlobSink : new BufferSink(expected);
4897
5322
  const sealer = new BlobSealer(aek);
4898
- const blobHash = sha2569.create();
4899
- const plainHash = sha2569.create();
5323
+ const blobHash = sha25610.create();
5324
+ const plainHash = sha25610.create();
4900
5325
  let size = 0;
4901
5326
  const emit = (bytes) => {
4902
5327
  blobHash.update(bytes);
@@ -4959,7 +5384,7 @@ class PlainCollector {
4959
5384
  out;
4960
5385
  parts = [];
4961
5386
  length = 0;
4962
- hash = sha2569.create();
5387
+ hash = sha25610.create();
4963
5388
  constructor(blobSize) {
4964
5389
  this.out = blobSize !== null && blobSize <= MAX_PREALLOCATION ? new Uint8Array(blobSize) : null;
4965
5390
  }
@@ -5037,7 +5462,7 @@ async function fetchBlob(transport, sha, aek, plainSha) {
5037
5462
  const download = body instanceof Uint8Array ? { size: body.length, chunks: [body] } : body;
5038
5463
  const opener = new BlobOpener(aek);
5039
5464
  const plain = new PlainCollector(download.size);
5040
- const received = sha2569.create();
5465
+ const received = sha25610.create();
5041
5466
  for await (const piece of download.chunks) {
5042
5467
  received.update(piece);
5043
5468
  for (const chunk of opener.push(piece))
@@ -5662,6 +6087,70 @@ class ExpoSqlDatabase {
5662
6087
  await this.db.closeAsync?.();
5663
6088
  }
5664
6089
  }
6090
+ // src/legacy.ts
6091
+ var ENTITIES_PER_PUSH = 100;
6092
+ function reencode(store, kind, id) {
6093
+ if (!store.exists(kind, id))
6094
+ return null;
6095
+ const fields = store.visibleFieldStates(kind, id);
6096
+ if (fields.some((f) => isEncryptedEnvelope(f.value)))
6097
+ return null;
6098
+ const ops = fields.map((f) => ({
6099
+ id: store.newEntityId(),
6100
+ entity: kind,
6101
+ entityId: id,
6102
+ ts: f.ts,
6103
+ op: "set",
6104
+ field: f.field,
6105
+ value: f.value
6106
+ }));
6107
+ const deleted = store.tombstoneTs(kind, id);
6108
+ if (deleted)
6109
+ ops.push({ id: store.newEntityId(), entity: kind, entityId: id, ts: deleted, op: "delete" });
6110
+ return ops;
6111
+ }
6112
+ async function migrateLegacy(store, transport) {
6113
+ const result = { reencoded: 0, covered: 0, left: 0 };
6114
+ let next;
6115
+ do {
6116
+ const page = await transport.legacyKeys(next);
6117
+ next = page.next;
6118
+ const drop = [];
6119
+ const pending = [];
6120
+ for (const key of page.entities) {
6121
+ if (key.covered) {
6122
+ drop.push(key);
6123
+ result.covered++;
6124
+ continue;
6125
+ }
6126
+ const kind = key.entity;
6127
+ const ops = transport.mayReencode?.(kind, key.entity_id) === false ? null : reencode(store, kind, key.entity_id);
6128
+ if (!ops || ops.length === 0) {
6129
+ result.left++;
6130
+ continue;
6131
+ }
6132
+ pending.push({ key, ops });
6133
+ }
6134
+ for (let i = 0;i < pending.length; i += ENTITIES_PER_PUSH) {
6135
+ const chunk = pending.slice(i, i + ENTITIES_PER_PUSH);
6136
+ const { deferred = [] } = await transport.syncPush(chunk.flatMap((c) => c.ops));
6137
+ const waiting = new Set(deferred);
6138
+ for (const { key, ops } of chunk) {
6139
+ if (ops.some((op) => waiting.has(op.id))) {
6140
+ result.left++;
6141
+ continue;
6142
+ }
6143
+ drop.push(key);
6144
+ result.reencoded++;
6145
+ }
6146
+ }
6147
+ if (drop.length > 0) {
6148
+ await transport.dropLegacy(drop.map(({ entity, entity_id }) => ({ entity, entity_id })));
6149
+ }
6150
+ } while (next);
6151
+ return result;
6152
+ }
6153
+
5665
6154
  // src/sync-client.ts
5666
6155
  var MAX_PUSH_OPS = 500;
5667
6156
  var MAX_PUSH_BYTES = 1.5 * 1024 * 1024;
@@ -5716,6 +6205,7 @@ function isBadSnapshotToken(err) {
5716
6205
  function isSnapshotUnsupported(err) {
5717
6206
  return err instanceof ApiError && err.status === 404;
5718
6207
  }
6208
+ var LEGACY_RETRY_MS = 10 * 60000;
5719
6209
  var THROTTLE_BASE_MS = 5000;
5720
6210
  var THROTTLE_MAX_MS = 5 * 60000;
5721
6211
  function isRateLimited(err) {
@@ -5752,6 +6242,8 @@ class SyncClient {
5752
6242
  clockSkew = null;
5753
6243
  resumeTimer = null;
5754
6244
  epoch = 0;
6245
+ legacyDone = false;
6246
+ legacyNextAt = 0;
5755
6247
  constructor(store, transport, opts = {}) {
5756
6248
  this.store = store;
5757
6249
  this.transport = transport;
@@ -5878,6 +6370,7 @@ class SyncClient {
5878
6370
  }
5879
6371
  if (repaired > 0)
5880
6372
  await this.pushOutbox(this.store.unsyncedOps());
6373
+ await this.migrateLegacyRows();
5881
6374
  this.initialSyncDone = true;
5882
6375
  this.firstCycleDone = true;
5883
6376
  this.throttleStreak = 0;
@@ -5907,6 +6400,28 @@ class SyncClient {
5907
6400
  throw err;
5908
6401
  }
5909
6402
  }
6403
+ async migrateLegacyRows() {
6404
+ const t = this.transport;
6405
+ if (this.legacyDone || !t.legacyKeys || !t.dropLegacy || Date.now() < this.legacyNextAt)
6406
+ return;
6407
+ try {
6408
+ const result = await migrateLegacy(this.store, {
6409
+ legacyKeys: (next) => t.legacyKeys(next),
6410
+ dropLegacy: (keys) => t.dropLegacy(keys),
6411
+ syncPush: (ops) => t.syncPush(ops),
6412
+ mayReencode: t.mayReencode?.bind(t)
6413
+ });
6414
+ this.legacyDone = result.left === 0;
6415
+ if (result.reencoded + result.covered > 0)
6416
+ this.opts.onLegacyMigrated?.(result);
6417
+ } catch (err) {
6418
+ if (err instanceof ApiError && err.status === 404)
6419
+ this.legacyDone = true;
6420
+ else
6421
+ console.warn("[atlas SyncClient] protocol 6 migration pass failed:", err);
6422
+ }
6423
+ this.legacyNextAt = Date.now() + LEGACY_RETRY_MS;
6424
+ }
5910
6425
  async pullPhase(firstPage) {
5911
6426
  if (this.needsBootstrap && await this.bootstrapFromSnapshot(firstPage) === null) {
5912
6427
  return this.replayFromZero();
@@ -6799,7 +7314,9 @@ export {
6799
7314
  EMPTY_KEY_TRUST,
6800
7315
  EMPTY_SCOPE_STORE,
6801
7316
  ExpoSqlDatabase,
7317
+ FIELD_ENVELOPE_V3,
6802
7318
  FIELD_ENVELOPE_VERSION,
7319
+ FIELD_V3_MIN_PADDING,
6803
7320
  HlcClock,
6804
7321
  IndexedDbPersistence,
6805
7322
  InvalidApiTokenError,
@@ -6808,6 +7325,7 @@ export {
6808
7325
  Keyring,
6809
7326
  LEGACY_DISCOVERY,
6810
7327
  LEGACY_KDF,
7328
+ LEGACY_RETRY_MS,
6811
7329
  LOCAL_STORE_KEY_ID,
6812
7330
  LocalStore,
6813
7331
  MAX_CLOCK_DRIFT_MS,
@@ -6848,6 +7366,7 @@ export {
6848
7366
  addPasskey,
6849
7367
  apiErrorCode,
6850
7368
  appOfKind,
7369
+ appOfWire,
6851
7370
  applyRepairs,
6852
7371
  argon2idPortable,
6853
7372
  assertServerCompatible,
@@ -6876,6 +7395,7 @@ export {
6876
7395
  createPasskeySlot,
6877
7396
  decodeBase64Url,
6878
7397
  decodeOpLogFile,
7398
+ decodeOps,
6879
7399
  decryptAesGcm,
6880
7400
  decryptBlob,
6881
7401
  decryptField,
@@ -6891,6 +7411,7 @@ export {
6891
7411
  deriveRecoveryKey,
6892
7412
  deriveRecoveryKeys,
6893
7413
  enableApp,
7414
+ encodeOp,
6894
7415
  encodeOpLogFile,
6895
7416
  encryptAesGcm,
6896
7417
  encryptBlob,
@@ -6901,6 +7422,8 @@ export {
6901
7422
  ensureSigningKey,
6902
7423
  expectedKeyScope,
6903
7424
  fetchBlob,
7425
+ fieldTag,
7426
+ fieldTagKey,
6904
7427
  forgetIdentity,
6905
7428
  formatApiToken,
6906
7429
  fromEncryptedWire,
@@ -6921,9 +7444,16 @@ export {
6921
7444
  isAdmin,
6922
7445
  isEncryptedEnvelope,
6923
7446
  isFieldEnvelopeV2,
7447
+ isFieldEnvelopeV3,
7448
+ isFieldTag,
7449
+ isLegacyWireKind,
6924
7450
  isProjectCreator,
6925
7451
  isProjectShared,
7452
+ isPublicField,
7453
+ isSealedInvitePreview,
6926
7454
  isSealedKeyRow,
7455
+ isSealedPlaceholder,
7456
+ isSealedWireKind,
6927
7457
  isVerified,
6928
7458
  isWellFormedOp,
6929
7459
  isWrappedAttachmentKey,
@@ -6932,21 +7462,27 @@ export {
6932
7462
  kdfToWire,
6933
7463
  keyForScope,
6934
7464
  keyScopeLabel,
7465
+ kindsOfWire,
7466
+ legacyEncrypted,
6935
7467
  maxAttachmentBytes,
6936
7468
  maxPlainSize,
6937
7469
  memberEntityId,
6938
7470
  mergeOpLogs,
6939
7471
  mergeRotatedTokens,
7472
+ migrateLegacy,
6940
7473
  normalizeCredentialId,
6941
7474
  observeIdentity,
6942
7475
  openAccountKeys,
6943
7476
  openAppKey,
6944
7477
  openFieldValue,
7478
+ openInvitePreview,
6945
7479
  openOptionsFor,
6946
7480
  openPasskeyAccountKeys,
6947
7481
  openPasskeySlot,
6948
7482
  openProjectKeyRow,
6949
7483
  openRecoverySlot,
7484
+ openSealed,
7485
+ openSealedField,
6950
7486
  openTodoAppKey,
6951
7487
  padPlaintext,
6952
7488
  paddedLength,
@@ -6963,6 +7499,7 @@ export {
6963
7499
  projectKeyId,
6964
7500
  randomBytes,
6965
7501
  readKeyTrust,
7502
+ realKindOf,
6966
7503
  reauthenticateWithPasskey,
6967
7504
  reconcileStoredSession,
6968
7505
  recordAcceptedProject,
@@ -6986,11 +7523,14 @@ export {
6986
7523
  sameKdf,
6987
7524
  sameKeyScope,
6988
7525
  sealDelivery,
7526
+ sealField,
7527
+ sealInvitePreview,
6989
7528
  sealKey,
6990
7529
  sealPersistence,
6991
7530
  sealRootKey,
6992
7531
  sealSource,
6993
7532
  sealedBlobSize,
7533
+ sealedRepair,
6994
7534
  serverIncompatibility,
6995
7535
  sessionFromAuth,
6996
7536
  setArgon2idProvider,
@@ -7029,6 +7569,8 @@ export {
7029
7569
  validateRecoveryPhrase,
7030
7570
  verifyAccountPassword,
7031
7571
  verifyDelivery,
7572
+ wireFieldOf,
7573
+ wireKindOf,
7032
7574
  withoutCiphertext,
7033
7575
  withoutPrfOutput,
7034
7576
  wrapAek,