@noy-db/hub 0.1.0-pre.5 → 0.1.0-pre.7

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.
Files changed (77) hide show
  1. package/dist/blobs/index.cjs.map +1 -1
  2. package/dist/blobs/index.d.cts +2 -2
  3. package/dist/blobs/index.d.ts +2 -2
  4. package/dist/blobs/index.js +2 -2
  5. package/dist/bundle/index.d.cts +2 -2
  6. package/dist/bundle/index.d.ts +2 -2
  7. package/dist/bundle/index.js +3 -3
  8. package/dist/{chunk-QUDXYI4W.js → chunk-3WCRU7TI.js} +2 -2
  9. package/dist/{chunk-6NPQTBZN.js → chunk-6IJQ27XN.js} +112 -4
  10. package/dist/chunk-6IJQ27XN.js.map +1 -0
  11. package/dist/{chunk-NXUVITPB.js → chunk-B6HF6NTZ.js} +1 -1
  12. package/dist/{chunk-NXUVITPB.js.map → chunk-B6HF6NTZ.js.map} +1 -1
  13. package/dist/{chunk-H3DV46AQ.js → chunk-CL37QSND.js} +2 -2
  14. package/dist/{chunk-LRN3PNI6.js → chunk-FAAWLVTF.js} +2 -2
  15. package/dist/{chunk-UQQ2XFXI.js → chunk-GILMPJXB.js} +2 -2
  16. package/dist/{chunk-MIRZMUSQ.js → chunk-INSJBB5W.js} +3 -3
  17. package/dist/{chunk-E4OOAPBZ.js → chunk-KPF2HHPI.js} +2 -2
  18. package/dist/{chunk-LMKOSLJY.js → chunk-N2LMZKLR.js} +2 -2
  19. package/dist/{chunk-QV4WLLKB.js → chunk-NZ4XCIKS.js} +3 -3
  20. package/dist/consent/index.d.cts +2 -2
  21. package/dist/consent/index.d.ts +2 -2
  22. package/dist/{dev-unlock-BgFqShBi.d.ts → dev-unlock-CcJ1qIi7.d.ts} +1 -1
  23. package/dist/{dev-unlock-qVMxG2Je.d.cts → dev-unlock-Dk14V6lX.d.cts} +1 -1
  24. package/dist/{hash-BhoL7iUE.d.ts → hash-1Xsqx1jl.d.ts} +1 -1
  25. package/dist/{hash-Bpvl2eSe.d.cts → hash-h_2U3TFb.d.cts} +1 -1
  26. package/dist/history/index.cjs.map +1 -1
  27. package/dist/history/index.d.cts +3 -3
  28. package/dist/history/index.d.ts +3 -3
  29. package/dist/history/index.js +2 -2
  30. package/dist/i18n/index.cjs.map +1 -1
  31. package/dist/i18n/index.d.cts +2 -2
  32. package/dist/i18n/index.d.ts +2 -2
  33. package/dist/i18n/index.js +3 -3
  34. package/dist/{index-DyRt_5vM.d.cts → index-Cvb0efA_.d.cts} +1 -1
  35. package/dist/{index-DhK_zqOO.d.ts → index-DZn6Yick.d.ts} +1 -1
  36. package/dist/index.cjs +505 -4
  37. package/dist/index.cjs.map +1 -1
  38. package/dist/index.d.cts +60 -6
  39. package/dist/index.d.ts +60 -6
  40. package/dist/index.js +409 -13
  41. package/dist/index.js.map +1 -1
  42. package/dist/{ledger-GA4DMJS6.js → ledger-5V67MAIL.js} +3 -3
  43. package/dist/periods/index.cjs.map +1 -1
  44. package/dist/periods/index.d.cts +2 -2
  45. package/dist/periods/index.d.ts +2 -2
  46. package/dist/periods/index.js +3 -3
  47. package/dist/{public-envelope-R4EIEQE6.js → public-envelope-DFJZHXVH.js} +3 -3
  48. package/dist/session/index.d.cts +3 -3
  49. package/dist/session/index.d.ts +3 -3
  50. package/dist/shadow/index.d.cts +2 -2
  51. package/dist/shadow/index.d.ts +2 -2
  52. package/dist/store/index.d.cts +2 -2
  53. package/dist/store/index.d.ts +2 -2
  54. package/dist/sync/index.cjs.map +1 -1
  55. package/dist/sync/index.d.cts +1 -1
  56. package/dist/sync/index.d.ts +1 -1
  57. package/dist/sync/index.js +2 -2
  58. package/dist/team/index.cjs.map +1 -1
  59. package/dist/team/index.d.cts +2 -2
  60. package/dist/team/index.d.ts +2 -2
  61. package/dist/team/index.js +4 -4
  62. package/dist/tx/index.d.cts +2 -2
  63. package/dist/tx/index.d.ts +2 -2
  64. package/dist/{types-Df72wWCC.d.ts → types-D-6bmD2c.d.ts} +378 -2
  65. package/dist/{types-BpyE4o_n.d.cts → types-D3QLmhlk.d.cts} +378 -2
  66. package/package.json +1 -1
  67. package/dist/chunk-6NPQTBZN.js.map +0 -1
  68. /package/dist/{chunk-QUDXYI4W.js.map → chunk-3WCRU7TI.js.map} +0 -0
  69. /package/dist/{chunk-H3DV46AQ.js.map → chunk-CL37QSND.js.map} +0 -0
  70. /package/dist/{chunk-LRN3PNI6.js.map → chunk-FAAWLVTF.js.map} +0 -0
  71. /package/dist/{chunk-UQQ2XFXI.js.map → chunk-GILMPJXB.js.map} +0 -0
  72. /package/dist/{chunk-MIRZMUSQ.js.map → chunk-INSJBB5W.js.map} +0 -0
  73. /package/dist/{chunk-E4OOAPBZ.js.map → chunk-KPF2HHPI.js.map} +0 -0
  74. /package/dist/{chunk-LMKOSLJY.js.map → chunk-N2LMZKLR.js.map} +0 -0
  75. /package/dist/{chunk-QV4WLLKB.js.map → chunk-NZ4XCIKS.js.map} +0 -0
  76. /package/dist/{ledger-GA4DMJS6.js.map → ledger-5V67MAIL.js.map} +0 -0
  77. /package/dist/{public-envelope-R4EIEQE6.js.map → public-envelope-DFJZHXVH.js.map} +0 -0
package/dist/index.cjs CHANGED
@@ -2061,6 +2061,10 @@ __export(src_exports, {
2061
2061
  TxCollection: () => TxCollection,
2062
2062
  TxContext: () => TxContext,
2063
2063
  TxVault: () => TxVault,
2064
+ USER_ENVELOPE_COLLECTION: () => USER_ENVELOPE_COLLECTION,
2065
+ USER_ENVELOPE_MAX_BYTES: () => USER_ENVELOPE_MAX_BYTES,
2066
+ UserApi: () => UserApi,
2067
+ UserEnvelopeOversizedError: () => UserEnvelopeOversizedError,
2064
2068
  ValidationError: () => ValidationError,
2065
2069
  Vault: () => Vault,
2066
2070
  VaultFrame: () => VaultFrame,
@@ -2093,6 +2097,7 @@ __export(src_exports, {
2093
2097
  decryptDeterministic: () => decryptDeterministic,
2094
2098
  dekKey: () => dekKey,
2095
2099
  deleteCredential: () => deleteCredential,
2100
+ deleteUserEnvelope: () => deleteUserEnvelope,
2096
2101
  deriveMagicLinkContentKey: () => deriveMagicLinkContentKey,
2097
2102
  derivePresenceKey: () => derivePresenceKey,
2098
2103
  describeAllUsersAuth: () => describeAllUsersAuth,
@@ -2144,10 +2149,14 @@ __export(src_exports, {
2144
2149
  keyringRotatePassphrase: () => rotatePassphrase,
2145
2150
  listCredentials: () => listCredentials,
2146
2151
  listMagicLinkGrants: () => listMagicLinkGrants,
2152
+ listUserEnvelopeIds: () => listUserEnvelopeIds,
2153
+ listUsers: () => listUsers,
2154
+ listUsersWithEnvelopes: () => listUsersWithEnvelopes,
2147
2155
  loadActiveDelegations: () => loadActiveDelegations,
2148
2156
  loadDevUnlock: () => loadDevUnlock,
2149
2157
  loadPaperRecoveryEntries: () => loadPaperRecoveryEntries,
2150
2158
  loadPublicEnvelope: () => loadPublicEnvelope,
2159
+ loadUserEnvelope: () => loadUserEnvelope,
2151
2160
  loadVaultPolicy: () => loadVaultPolicy,
2152
2161
  magicLinkGrantRecordId: () => magicLinkGrantRecordId,
2153
2162
  max: () => max,
@@ -2181,6 +2190,7 @@ __export(src_exports, {
2181
2190
  runTransaction: () => runTransaction,
2182
2191
  savePaperRecoveryEntries: () => savePaperRecoveryEntries,
2183
2192
  savePublicEnvelope: () => savePublicEnvelope,
2193
+ saveUserEnvelope: () => saveUserEnvelope,
2184
2194
  saveVaultPolicy: () => saveVaultPolicy,
2185
2195
  sha256Hex: () => sha256Hex3,
2186
2196
  sum: () => sum,
@@ -4904,6 +4914,81 @@ function estimateEntropy(passphrase) {
4904
4914
  return Math.round(result.words * Math.log2(7776));
4905
4915
  }
4906
4916
 
4917
+ // src/meta/user-envelope/types.ts
4918
+ init_errors();
4919
+ var USER_ENVELOPE_MAX_BYTES = 64 * 1024;
4920
+ var USER_ENVELOPE_COLLECTION = "_users";
4921
+ var UserEnvelopeOversizedError = class extends NoydbError {
4922
+ bytes;
4923
+ limit;
4924
+ constructor(bytes, limit = USER_ENVELOPE_MAX_BYTES) {
4925
+ super(
4926
+ "USER_ENVELOPE_OVERSIZED",
4927
+ `User envelope payload is ${bytes} bytes; soft cap is ${limit} bytes. Move large data into the vault's regular collections.`
4928
+ );
4929
+ this.name = "UserEnvelopeOversizedError";
4930
+ this.bytes = bytes;
4931
+ this.limit = limit;
4932
+ }
4933
+ };
4934
+
4935
+ // src/meta/user-envelope/storage.ts
4936
+ init_types();
4937
+ init_crypto();
4938
+ init_errors();
4939
+ async function loadUserEnvelope(store, vault, keyringId, dek) {
4940
+ const envelope = await store.get(vault, USER_ENVELOPE_COLLECTION, keyringId);
4941
+ if (!envelope) return null;
4942
+ const plaintext = await decrypt(envelope._iv, envelope._data, dek);
4943
+ const data = JSON.parse(plaintext);
4944
+ return {
4945
+ keyringId,
4946
+ data,
4947
+ _v: envelope._v,
4948
+ _ts: envelope._ts
4949
+ };
4950
+ }
4951
+ async function saveUserEnvelope(store, vault, keyringId, payload, dek, expectedVersion) {
4952
+ const json = JSON.stringify(payload);
4953
+ const bytes = new TextEncoder().encode(json).byteLength;
4954
+ if (bytes > USER_ENVELOPE_MAX_BYTES) {
4955
+ throw new UserEnvelopeOversizedError(bytes);
4956
+ }
4957
+ const prior = await store.get(vault, USER_ENVELOPE_COLLECTION, keyringId);
4958
+ if (expectedVersion !== void 0) {
4959
+ const priorVersion = prior?._v ?? 0;
4960
+ if (priorVersion !== expectedVersion) {
4961
+ throw new ConflictError(
4962
+ priorVersion,
4963
+ `User envelope for "${keyringId}" expected version ${expectedVersion}, actual ${priorVersion}`
4964
+ );
4965
+ }
4966
+ }
4967
+ const nextVersion = (prior?._v ?? 0) + 1;
4968
+ const ts = (/* @__PURE__ */ new Date()).toISOString();
4969
+ const { iv, data } = await encrypt(json, dek);
4970
+ const envelope = {
4971
+ _noydb: NOYDB_FORMAT_VERSION,
4972
+ _v: nextVersion,
4973
+ _ts: ts,
4974
+ _iv: iv,
4975
+ _data: data
4976
+ };
4977
+ await store.put(vault, USER_ENVELOPE_COLLECTION, keyringId, envelope);
4978
+ return {
4979
+ keyringId,
4980
+ data: payload,
4981
+ _v: nextVersion,
4982
+ _ts: ts
4983
+ };
4984
+ }
4985
+ async function deleteUserEnvelope(store, vault, keyringId) {
4986
+ await store.delete(vault, USER_ENVELOPE_COLLECTION, keyringId);
4987
+ }
4988
+ async function listUserEnvelopeIds(store, vault) {
4989
+ return store.list(vault, USER_ENVELOPE_COLLECTION);
4990
+ }
4991
+
4907
4992
  // src/team/keyring.ts
4908
4993
  var ADMIN_GRANTABLE_TARGETS = ["operator", "viewer", "client", "admin"];
4909
4994
  function canGrant(callerRole, targetRole) {
@@ -4956,13 +5041,15 @@ async function createOwnerKeyring(adapter, vault, userId, passphrase, passphrase
4956
5041
  }
4957
5042
  const salt = generateSalt();
4958
5043
  const kek = await deriveKey(passphrase, salt);
5044
+ const userEnvelopeDek = await generateDEK();
5045
+ const wrappedUserEnvelopeDek = await wrapKey(userEnvelopeDek, kek);
4959
5046
  const keyringFile = {
4960
5047
  _noydb_keyring: NOYDB_KEYRING_VERSION,
4961
5048
  user_id: userId,
4962
5049
  display_name: userId,
4963
5050
  role: "owner",
4964
5051
  permissions: {},
4965
- deks: {},
5052
+ deks: { [USER_ENVELOPE_COLLECTION]: wrappedUserEnvelopeDek },
4966
5053
  salt: bufferToBase64(salt),
4967
5054
  created_at: (/* @__PURE__ */ new Date()).toISOString(),
4968
5055
  granted_by: userId
@@ -4973,7 +5060,7 @@ async function createOwnerKeyring(adapter, vault, userId, passphrase, passphrase
4973
5060
  displayName: userId,
4974
5061
  role: "owner",
4975
5062
  permissions: {},
4976
- deks: /* @__PURE__ */ new Map(),
5063
+ deks: /* @__PURE__ */ new Map([[USER_ENVELOPE_COLLECTION, userEnvelopeDek]]),
4977
5064
  kek,
4978
5065
  salt,
4979
5066
  authenticators: []
@@ -5029,6 +5116,17 @@ async function grant(adapter, vault, callerKeyring, options) {
5029
5116
  ...options.importCapability !== void 0 && { import_capability: options.importCapability }
5030
5117
  };
5031
5118
  await writeKeyringFile(adapter, vault, options.userId, keyringFile);
5119
+ const userEnvelopeDek = callerKeyring.deks.get(USER_ENVELOPE_COLLECTION);
5120
+ if (userEnvelopeDek) {
5121
+ const initialPayload = options.initialProfile ?? {};
5122
+ await saveUserEnvelope(
5123
+ adapter,
5124
+ vault,
5125
+ options.userId,
5126
+ initialPayload,
5127
+ userEnvelopeDek
5128
+ );
5129
+ }
5032
5130
  }
5033
5131
  async function findAdminDescendants(adapter, vault, rootUserId) {
5034
5132
  const allUserIds = await adapter.list(vault, "_keyring");
@@ -5091,6 +5189,7 @@ async function revoke(adapter, vault, callerKeyring, options) {
5091
5189
  }
5092
5190
  for (const userId of usersToRevoke) {
5093
5191
  await adapter.delete(vault, "_keyring", userId);
5192
+ await deleteUserEnvelope(adapter, vault, userId);
5094
5193
  }
5095
5194
  if (options.rotateKeys !== false && affectedCollections.size > 0) {
5096
5195
  await rotateKeys(adapter, vault, callerKeyring, [...affectedCollections]);
@@ -5249,6 +5348,20 @@ async function listUsers(adapter, vault) {
5249
5348
  }
5250
5349
  return users;
5251
5350
  }
5351
+ async function listUsersWithEnvelopes(adapter, vault, userEnvelopeDek) {
5352
+ const users = await listUsers(adapter, vault);
5353
+ const out = [];
5354
+ for (const user of users) {
5355
+ const envelope = await loadUserEnvelope(
5356
+ adapter,
5357
+ vault,
5358
+ user.userId,
5359
+ userEnvelopeDek
5360
+ );
5361
+ out.push({ user, envelope });
5362
+ }
5363
+ return out;
5364
+ }
5252
5365
  async function ensureCollectionDEK(adapter, vault, keyring) {
5253
5366
  const inFlight = /* @__PURE__ */ new Map();
5254
5367
  return async (collectionName) => {
@@ -5713,6 +5826,220 @@ async function writeKeyringFile2(store, vault, userId, file) {
5713
5826
  // src/index.ts
5714
5827
  init_public_envelope();
5715
5828
 
5829
+ // src/meta/user-envelope/api.ts
5830
+ var UserApi = class {
5831
+ constructor(adapter, vaultName, writerKeyringId, getDek, checkGate2) {
5832
+ this.adapter = adapter;
5833
+ this.vaultName = vaultName;
5834
+ this.writerKeyringId = writerKeyringId;
5835
+ this.getDek = getDek;
5836
+ this.checkGate = checkGate2;
5837
+ }
5838
+ adapter;
5839
+ vaultName;
5840
+ writerKeyringId;
5841
+ getDek;
5842
+ checkGate;
5843
+ /** keyringId → set of listeners. Wildcard '*' fires on every change. */
5844
+ listeners = /* @__PURE__ */ new Map();
5845
+ // ─── Write-self ──────────────────────────────────────────────────────
5846
+ /** Read the writer's own envelope. Returns null if never written. */
5847
+ async me() {
5848
+ const dek = await this.getDek();
5849
+ return loadUserEnvelope(this.adapter, this.vaultName, this.writerKeyringId, dek);
5850
+ }
5851
+ /**
5852
+ * Deep-merge a partial patch into the writer's own envelope. Creates
5853
+ * the envelope on first call. Optimistic-concurrency safe — a stale
5854
+ * `_v` (parallel writer on another device) throws `ConflictError`.
5855
+ *
5856
+ * Gated by the `edit-own-profile` policy gate (default `minTier: 3`).
5857
+ * Pass `presented` to satisfy tightened policies that require a
5858
+ * factor proof (e.g. STRICT_POLICY's TOTP requirement).
5859
+ */
5860
+ async updateMe(patch, presented) {
5861
+ if (this.checkGate) await this.checkGate("edit-own-profile", presented);
5862
+ const dek = await this.getDek();
5863
+ const current = await loadUserEnvelope(
5864
+ this.adapter,
5865
+ this.vaultName,
5866
+ this.writerKeyringId,
5867
+ dek
5868
+ );
5869
+ const merged = current ? deepMerge(current.data, patch) : patch;
5870
+ const written = await saveUserEnvelope(
5871
+ this.adapter,
5872
+ this.vaultName,
5873
+ this.writerKeyringId,
5874
+ merged,
5875
+ dek,
5876
+ current?._v ?? 0
5877
+ );
5878
+ this.fireChange(this.writerKeyringId, written);
5879
+ return written;
5880
+ }
5881
+ /**
5882
+ * Replace the writer's own envelope with `payload`. Use sparingly —
5883
+ * `updateMe` is the canonical mutation. No `expectedVersion` check;
5884
+ * callers explicitly take last-write-wins semantics.
5885
+ *
5886
+ * Gated by `edit-own-profile`. See `updateMe` for `presented` usage.
5887
+ */
5888
+ async setMe(payload, presented) {
5889
+ if (this.checkGate) await this.checkGate("edit-own-profile", presented);
5890
+ const dek = await this.getDek();
5891
+ const written = await saveUserEnvelope(
5892
+ this.adapter,
5893
+ this.vaultName,
5894
+ this.writerKeyringId,
5895
+ payload,
5896
+ dek
5897
+ );
5898
+ this.fireChange(this.writerKeyringId, written);
5899
+ return written;
5900
+ }
5901
+ // ─── Read-anyone ─────────────────────────────────────────────────────
5902
+ /**
5903
+ * Read another principal's envelope by their keyringId. Returns null
5904
+ * if the principal exists but has no envelope yet, or if the
5905
+ * keyringId does not exist at all.
5906
+ *
5907
+ * Gated by `view-team-profiles` (default `minTier: 2`) — but ONLY for
5908
+ * cross-principal reads. Reading your own envelope (`keyringId ===
5909
+ * self`) is never gated; that's just `me()` written long-form.
5910
+ */
5911
+ async get(keyringId, presented) {
5912
+ if (this.checkGate && keyringId !== this.writerKeyringId) {
5913
+ await this.checkGate("view-team-profiles", presented);
5914
+ }
5915
+ const dek = await this.getDek();
5916
+ return loadUserEnvelope(this.adapter, this.vaultName, keyringId, dek);
5917
+ }
5918
+ /**
5919
+ * Read every persisted envelope in the vault. Order is store-defined.
5920
+ *
5921
+ * Gated by `view-team-profiles`. Default policy (`minTier: 2`) lets
5922
+ * any authenticated session read all envelopes. Two privacy-strict
5923
+ * opt-outs:
5924
+ *
5925
+ * - `view-team-profiles.enabled: false` → list() returns only the
5926
+ * caller's own envelope (silent self-fallback, no thrown error).
5927
+ * - `view-team-profiles.minTier: 1` + insufficient tier → throws
5928
+ * `PolicyDeniedError` with `reason: 'insufficient-tier'`. The
5929
+ * caller is expected to elevate, not silently degrade.
5930
+ *
5931
+ * The asymmetry is deliberate: `enabled: false` is a deliberate
5932
+ * design choice ("nobody sees teammate profiles in this app");
5933
+ * `insufficient-tier` is "you need to authenticate further". Different
5934
+ * UX prompts for different intents.
5935
+ */
5936
+ async list(presented) {
5937
+ if (this.checkGate) {
5938
+ try {
5939
+ await this.checkGate("view-team-profiles", presented);
5940
+ } catch (err) {
5941
+ if (err instanceof PolicyDeniedError && err.reason === "disabled") {
5942
+ const me = await this.me();
5943
+ return me ? [me] : [];
5944
+ }
5945
+ throw err;
5946
+ }
5947
+ }
5948
+ const dek = await this.getDek();
5949
+ const ids = await listUserEnvelopeIds(this.adapter, this.vaultName);
5950
+ const envelopes = await Promise.all(
5951
+ ids.map((id) => loadUserEnvelope(this.adapter, this.vaultName, id, dek))
5952
+ );
5953
+ return envelopes.filter((e) => e !== null);
5954
+ }
5955
+ // ─── Reactive ────────────────────────────────────────────────────────
5956
+ /**
5957
+ * Listen for changes to a specific keyringId's envelope. The callback
5958
+ * fires synchronously after every successful local `updateMe` /
5959
+ * `setMe` for that principal.
5960
+ *
5961
+ * Cross-instance changes (a teammate edits their profile on their
5962
+ * device, the sync engine pulls the diff onto this device) will fire
5963
+ * subscribers when the sync layer replays the write through this API.
5964
+ * In v1, subscribers do NOT fire on raw store changes — wire your sync
5965
+ * layer to call back through `vault.user.setMe` / `updateMe` if you
5966
+ * need that.
5967
+ *
5968
+ * Pass keyringId `'*'` to fire on every change in the vault.
5969
+ */
5970
+ subscribe(keyringId, cb) {
5971
+ let listeners = this.listeners.get(keyringId);
5972
+ if (!listeners) {
5973
+ listeners = /* @__PURE__ */ new Set();
5974
+ this.listeners.set(keyringId, listeners);
5975
+ }
5976
+ const wrapped = cb;
5977
+ listeners.add(wrapped);
5978
+ return () => {
5979
+ listeners?.delete(wrapped);
5980
+ if (listeners && listeners.size === 0) {
5981
+ this.listeners.delete(keyringId);
5982
+ }
5983
+ };
5984
+ }
5985
+ /**
5986
+ * Reactive handle that caches the current value and re-reads on every
5987
+ * change for the given keyringId. Convenient for framework bindings:
5988
+ *
5989
+ * const live = vault.user.live<UserShape>(vault.userId)
5990
+ * live.subscribe(env => render(env?.data))
5991
+ *
5992
+ * Initial value is `null` until the first `current()` call materializes
5993
+ * it via `vault.user.get()`. Call `stop()` when done to release the
5994
+ * subscription.
5995
+ */
5996
+ live(keyringId) {
5997
+ let value = null;
5998
+ let primed = false;
5999
+ const unsubscribe = this.subscribe(keyringId, (env) => {
6000
+ value = env;
6001
+ });
6002
+ return {
6003
+ current() {
6004
+ if (!primed) {
6005
+ primed = true;
6006
+ }
6007
+ return value;
6008
+ },
6009
+ subscribe: (cb) => this.subscribe(keyringId, cb),
6010
+ stop: unsubscribe
6011
+ };
6012
+ }
6013
+ // ─── Internal: change emission ───────────────────────────────────────
6014
+ fireChange(keyringId, env) {
6015
+ const targeted = this.listeners.get(keyringId);
6016
+ if (targeted) for (const l of targeted) l(env);
6017
+ const wildcard = this.listeners.get("*");
6018
+ if (wildcard) for (const l of wildcard) l(env);
6019
+ }
6020
+ };
6021
+ function deepMerge(source, patch) {
6022
+ if (!isPlainObject(source) || !isPlainObject(patch)) {
6023
+ return patch;
6024
+ }
6025
+ const out = { ...source };
6026
+ for (const [key, patchVal] of Object.entries(patch)) {
6027
+ const sourceVal = source[key];
6028
+ if (isPlainObject(sourceVal) && isPlainObject(patchVal)) {
6029
+ out[key] = deepMerge(sourceVal, patchVal);
6030
+ } else {
6031
+ out[key] = patchVal;
6032
+ }
6033
+ }
6034
+ return out;
6035
+ }
6036
+ function isPlainObject(x) {
6037
+ if (x === null || typeof x !== "object") return false;
6038
+ if (Array.isArray(x)) return false;
6039
+ const proto = Object.getPrototypeOf(x);
6040
+ return proto === Object.prototype || proto === null;
6041
+ }
6042
+
5716
6043
  // src/policy/storage.ts
5717
6044
  init_types();
5718
6045
  var META_COLLECTION = "_meta";
@@ -11114,6 +11441,19 @@ var Vault = class {
11114
11441
  i18nStrategy;
11115
11442
  syncStrategy;
11116
11443
  getDEK;
11444
+ /**
11445
+ * Per-principal user envelope API.
11446
+ *
11447
+ * - Write-self: `me()`, `updateMe(patch)`, `setMe(payload)` — always
11448
+ * target this vault session's keyringId. There is no method to write
11449
+ * another principal's envelope (own-only write rule, structural).
11450
+ * - Read-anyone: `get(keyringId)`, `list()` — read other principals'
11451
+ * envelopes, subject to the `view-team-profiles` policy gate (#22).
11452
+ * - Reactive: `subscribe(id, cb)`, `live(id)` — fire on local writes.
11453
+ *
11454
+ * @see docs/superpowers/specs/2026-05-05-user-envelope-design.md
11455
+ */
11456
+ user;
11117
11457
  /**
11118
11458
  * Optional callback that re-derives an UnlockedKeyring from the
11119
11459
  * adapter using the active user's passphrase. Called by `load()`
@@ -11246,6 +11586,13 @@ var Vault = class {
11246
11586
  this.locale = opts.locale;
11247
11587
  this.translateText = opts.plaintextTranslator;
11248
11588
  this.getDEK = this.makeGetDEK();
11589
+ this.user = new UserApi(
11590
+ this.adapter,
11591
+ this.name,
11592
+ this.keyring.userId,
11593
+ () => this.getDEK(USER_ENVELOPE_COLLECTION),
11594
+ (gate, presented) => this.noydb.checkGate(this.name, gate, presented)
11595
+ );
11249
11596
  }
11250
11597
  /**
11251
11598
  * Construct (or reconstruct) the lazy DEK resolver. Captures the
@@ -13125,7 +13472,17 @@ var PERSONAL_POLICY = Object.freeze({
13125
13472
  "view-user-auth": {
13126
13473
  minTier: 1,
13127
13474
  enabled: false
13128
- }
13475
+ },
13476
+ // ─── User envelope gates (#22) ────────────────────────────────────
13477
+ // edit-own-profile: tier 3 floor — any active session can edit their
13478
+ // own profile/preferences. Tightening to require a TOTP for
13479
+ // profile changes is a one-line override.
13480
+ // view-team-profiles: tier 2 floor — an authenticated session can
13481
+ // read teammates' profiles (display names, avatars, locales).
13482
+ // Setting `enabled: false` makes vault.user.list() return only
13483
+ // self (privacy-strict opt-out).
13484
+ "edit-own-profile": { minTier: 3 },
13485
+ "view-team-profiles": { minTier: 2 }
13129
13486
  }
13130
13487
  });
13131
13488
  var STRICT_POLICY = Object.freeze({
@@ -13173,7 +13530,16 @@ var STRICT_POLICY = Object.freeze({
13173
13530
  "view-user-auth": {
13174
13531
  minTier: 1,
13175
13532
  enabled: false
13176
- }
13533
+ },
13534
+ // ─── User envelope gates (#22) ────────────────────────────────────
13535
+ // STRICT: profile edits require a TOTP/email-OTP factor (typical
13536
+ // shared-workstation hardening — your name/avatar shouldn't change
13537
+ // without a fresh second-factor proof).
13538
+ "edit-own-profile": {
13539
+ minTier: 2,
13540
+ factors: [{ anyOf: ["totp", "email-otp"] }]
13541
+ },
13542
+ "view-team-profiles": { minTier: 2 }
13177
13543
  }
13178
13544
  });
13179
13545
  function mergePolicy(base, override) {
@@ -13864,6 +14230,36 @@ var Noydb = class {
13864
14230
  off(event, handler) {
13865
14231
  this.emitter.off(event, handler);
13866
14232
  }
14233
+ /**
14234
+ * Soft-lock a single vault: clear its in-memory keyring, DEKs, vault
14235
+ * instance, sync engine, policy enforcer, and active-tier entry —
14236
+ * WITHOUT destroying the `Noydb` instance.
14237
+ *
14238
+ * Designed for "lock screen" UX: the user taps **Lock** and DEKs are
14239
+ * scrubbed from memory immediately, but the same `Noydb` instance can
14240
+ * be re-unlocked via {@link unlockViaAuthenticator} (tier 2) or
14241
+ * {@link unlockViaPin} (tier 3) without re-running `createNoydb`.
14242
+ *
14243
+ * **QuickUnlock state is preserved.** That's the whole point — the
14244
+ * user can still resume via PIN without a full credential re-prompt.
14245
+ * The on-disk `_meta/policy` document is also kept in cache (it
14246
+ * survives lock; nothing about it changes when DEKs are scrubbed).
14247
+ *
14248
+ * No-op when `vault` is not currently in cache (idempotent).
14249
+ *
14250
+ * Unblocks vLannaAi/niwat#33.
14251
+ *
14252
+ * @see #17
14253
+ */
14254
+ lockVault(vault) {
14255
+ this.syncEngines.get(vault)?.stopAutoSync();
14256
+ this.syncEngines.delete(vault);
14257
+ this.policyEnforcers.get(vault)?.destroy();
14258
+ this.policyEnforcers.delete(vault);
14259
+ this.keyringCache.delete(vault);
14260
+ this.vaultCache.delete(vault);
14261
+ this.activeTier.delete(vault);
14262
+ }
13867
14263
  close() {
13868
14264
  this.closed = true;
13869
14265
  if (this.sessionTimer) {
@@ -14058,6 +14454,92 @@ var Noydb = class {
14058
14454
  const keyring = await this.getKeyring(vault);
14059
14455
  return keyring.authenticators;
14060
14456
  }
14457
+ /**
14458
+ * Native WebAuthn enrollment using the **real** internal keyring (#16).
14459
+ *
14460
+ * Why this exists: when a consumer is using `createNoydb({ secret })`,
14461
+ * they cannot reach the live `UnlockedKeyring` to feed it to
14462
+ * `enrollWebAuthn(keyring, vault, opts)` from `@noy-db/on-webauthn`.
14463
+ * Constructing a synthetic keyring (the previous workaround) produces
14464
+ * a slot whose `wrapped_kek` references the synthetic payload, not
14465
+ * the live session — so `unlockViaAuthenticator()` later replaces the
14466
+ * live DEK map with stale wrapped DEKs and every decrypt fails.
14467
+ *
14468
+ * This method runs `ceremony` with the REAL keyring (still in
14469
+ * `keyringCache`). The ceremony performs the WebAuthn enrollment and
14470
+ * returns the slot options that hub then persists via the standard
14471
+ * tier-2 enrollAuthenticator path.
14472
+ *
14473
+ * Layering note: hub does not import `@noy-db/on-webauthn` (that
14474
+ * would invert the dep graph). The consumer wires it in:
14475
+ *
14476
+ * ```ts
14477
+ * import { enrollWebAuthn } from '@noy-db/on-webauthn'
14478
+ *
14479
+ * await db.enrollWebAuthn('demo', async (keyring) => {
14480
+ * const e = await enrollWebAuthn(keyring, 'demo', { rp: {...} })
14481
+ * return {
14482
+ * id: `webauthn-${e.credentialId.slice(0, 8)}`,
14483
+ * method: 'webauthn',
14484
+ * wrapped_kek: e.wrappedPayload,
14485
+ * meta: {
14486
+ * credentialId: e.credentialId,
14487
+ * wrapIv: e.wrapIv,
14488
+ * prfUsed: e.prfUsed,
14489
+ * beFlag: e.beFlag,
14490
+ * requireSingleDevice: e.requireSingleDevice,
14491
+ * },
14492
+ * }
14493
+ * })
14494
+ * ```
14495
+ *
14496
+ * Returns the WebAuthn `credentialId` (extracted from `meta.credentialId`)
14497
+ * for the caller's lookup index (a bootstrap vault, a PublicEnvelope,
14498
+ * a server-side allowlist).
14499
+ *
14500
+ * Gated by `enroll-authenticator` like `enrollAuthenticator()` itself.
14501
+ *
14502
+ * @see #16
14503
+ */
14504
+ async enrollWebAuthn(vault, ceremony, presented) {
14505
+ await this.checkGate(vault, "enroll-authenticator", presented);
14506
+ const keyring = await this.getKeyring(vault);
14507
+ const slotOptions = await ceremony(keyring);
14508
+ if (slotOptions.method !== "webauthn") {
14509
+ throw new ValidationError(
14510
+ `enrollWebAuthn: ceremony returned method "${slotOptions.method}"; expected "webauthn". Use db.enrollAuthenticator() for non-webauthn methods.`
14511
+ );
14512
+ }
14513
+ const credentialId = slotOptions.meta.credentialId;
14514
+ if (typeof credentialId !== "string" || credentialId.length === 0) {
14515
+ throw new ValidationError(
14516
+ "enrollWebAuthn: ceremony result must include `meta.credentialId` (base64 string). See @noy-db/on-webauthn enrollWebAuthn() return shape."
14517
+ );
14518
+ }
14519
+ const next = await enrollAuthenticator(this.options.store, vault, keyring, slotOptions);
14520
+ this.keyringCache.set(vault, next);
14521
+ return { credentialId };
14522
+ }
14523
+ /**
14524
+ * Filter the slot list to webauthn-method slots only. Useful for
14525
+ * "you have N WebAuthn credentials enrolled" UI surfaces and for
14526
+ * deciding when a new device prompt should appear. Identity is
14527
+ * `id` + `enrolled_at`; the `meta.credentialId` (base64) is used by
14528
+ * `allowCredentials` at unlock time.
14529
+ *
14530
+ * @see #16
14531
+ */
14532
+ async listWebAuthnSlots(vault) {
14533
+ const keyring = await this.getKeyring(vault);
14534
+ return keyring.authenticators.filter((a) => a.method === "webauthn").map((a) => {
14535
+ const credentialId = a.meta.credentialId;
14536
+ return {
14537
+ id: a.id,
14538
+ enrolledAt: a.enrolled_at,
14539
+ credentialId: typeof credentialId === "string" ? credentialId : ""
14540
+ };
14541
+ });
14542
+ }
14061
14543
  /**
14062
14544
  * Resolve a slot by id, then hand the wrapped-KEK ciphertext + meta
14063
14545
  * to the caller-supplied verifier. The verifier is the
@@ -14277,6 +14759,15 @@ var Noydb = class {
14277
14759
  this.options.secret,
14278
14760
  { validate: this.options.validatePassphrase === true }
14279
14761
  );
14762
+ } else if (err instanceof InvalidKeyError && this.options.onInvalidKey === "reset") {
14763
+ await this.options.store.delete(vault, "_keyring", this.options.user);
14764
+ keyring = await createOwnerKeyring(
14765
+ this.options.store,
14766
+ vault,
14767
+ this.options.user,
14768
+ this.options.secret,
14769
+ { validate: this.options.validatePassphrase === true }
14770
+ );
14280
14771
  } else {
14281
14772
  throw err;
14282
14773
  }
@@ -16062,6 +16553,10 @@ function shortJSON(value) {
16062
16553
  TxCollection,
16063
16554
  TxContext,
16064
16555
  TxVault,
16556
+ USER_ENVELOPE_COLLECTION,
16557
+ USER_ENVELOPE_MAX_BYTES,
16558
+ UserApi,
16559
+ UserEnvelopeOversizedError,
16065
16560
  ValidationError,
16066
16561
  Vault,
16067
16562
  VaultFrame,
@@ -16094,6 +16589,7 @@ function shortJSON(value) {
16094
16589
  decryptDeterministic,
16095
16590
  dekKey,
16096
16591
  deleteCredential,
16592
+ deleteUserEnvelope,
16097
16593
  deriveMagicLinkContentKey,
16098
16594
  derivePresenceKey,
16099
16595
  describeAllUsersAuth,
@@ -16145,10 +16641,14 @@ function shortJSON(value) {
16145
16641
  keyringRotatePassphrase,
16146
16642
  listCredentials,
16147
16643
  listMagicLinkGrants,
16644
+ listUserEnvelopeIds,
16645
+ listUsers,
16646
+ listUsersWithEnvelopes,
16148
16647
  loadActiveDelegations,
16149
16648
  loadDevUnlock,
16150
16649
  loadPaperRecoveryEntries,
16151
16650
  loadPublicEnvelope,
16651
+ loadUserEnvelope,
16152
16652
  loadVaultPolicy,
16153
16653
  magicLinkGrantRecordId,
16154
16654
  max,
@@ -16182,6 +16682,7 @@ function shortJSON(value) {
16182
16682
  runTransaction,
16183
16683
  savePaperRecoveryEntries,
16184
16684
  savePublicEnvelope,
16685
+ saveUserEnvelope,
16185
16686
  saveVaultPolicy,
16186
16687
  sha256Hex,
16187
16688
  sum,