@noy-db/on-magic-link 0.1.0-pre.7 → 0.1.0-pre.9

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.cjs CHANGED
@@ -20,21 +20,261 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ InviteAlreadyAcceptedError: () => InviteAlreadyAcceptedError,
24
+ InviteAuditMissingError: () => InviteAuditMissingError,
25
+ InviteExpiredError: () => InviteExpiredError,
26
+ InviteRevokedError: () => InviteRevokedError,
23
27
  MAGIC_LINK_DEFAULT_TTL_MS: () => MAGIC_LINK_DEFAULT_TTL_MS,
24
- MAGIC_LINK_GRANTS_COLLECTION: () => import_hub.MAGIC_LINK_GRANTS_COLLECTION,
28
+ MAGIC_LINK_GRANTS_COLLECTION: () => import_hub2.MAGIC_LINK_GRANTS_COLLECTION,
29
+ acceptInvite: () => acceptInvite,
25
30
  buildMagicLinkKeyring: () => buildMagicLinkKeyring,
26
31
  claimMagicLinkDelegation: () => claimMagicLinkDelegation,
27
32
  createMagicLinkToken: () => createMagicLinkToken,
28
- deriveMagicLinkContentKey: () => import_hub.deriveMagicLinkContentKey,
33
+ decodeInvitePayload: () => decodeInvitePayload,
34
+ deriveMagicLinkContentKey: () => import_hub2.deriveMagicLinkContentKey,
29
35
  deriveMagicLinkKEK: () => deriveMagicLinkKEK,
36
+ encodeInvitePayload: () => encodeInvitePayload,
30
37
  inspectMagicLinkDelegation: () => inspectMagicLinkDelegation,
31
38
  isMagicLinkValid: () => isMagicLinkValid,
39
+ issueInvite: () => issueInvite,
32
40
  issueMagicLinkDelegation: () => issueMagicLinkDelegation,
41
+ issuePeerRecovery: () => issuePeerRecovery,
33
42
  readMagicLinkGrant: () => readMagicLinkGrant,
43
+ revokeInvite: () => revokeInvite,
34
44
  revokeMagicLinkDelegation: () => revokeMagicLinkDelegation
35
45
  });
36
46
  module.exports = __toCommonJS(index_exports);
47
+ var import_hub2 = require("@noy-db/hub");
48
+
49
+ // src/invite.ts
37
50
  var import_hub = require("@noy-db/hub");
51
+ var INVITE_AUDIT_DOC_PREFIX = "invite-audit-";
52
+ var INVITE_DEFAULT_TTL_MS = 24 * 60 * 60 * 1e3;
53
+ var InviteExpiredError = class extends Error {
54
+ constructor(expiresAt) {
55
+ super(`Invite expired at ${expiresAt}.`);
56
+ this.expiresAt = expiresAt;
57
+ this.name = "InviteExpiredError";
58
+ }
59
+ expiresAt;
60
+ code = "INVITE_EXPIRED";
61
+ };
62
+ var InviteRevokedError = class extends Error {
63
+ constructor(tokenId, revokedAt) {
64
+ super(`Invite ${tokenId} was revoked at ${revokedAt}.`);
65
+ this.tokenId = tokenId;
66
+ this.revokedAt = revokedAt;
67
+ this.name = "InviteRevokedError";
68
+ }
69
+ tokenId;
70
+ revokedAt;
71
+ code = "INVITE_REVOKED";
72
+ };
73
+ var InviteAlreadyAcceptedError = class extends Error {
74
+ constructor(tokenId, acceptedAt) {
75
+ super(`Invite ${tokenId} was already accepted at ${acceptedAt}. Single-use semantics enforced.`);
76
+ this.tokenId = tokenId;
77
+ this.acceptedAt = acceptedAt;
78
+ this.name = "InviteAlreadyAcceptedError";
79
+ }
80
+ tokenId;
81
+ acceptedAt;
82
+ code = "INVITE_ALREADY_ACCEPTED";
83
+ };
84
+ var InviteAuditMissingError = class extends Error {
85
+ constructor(tokenId) {
86
+ super(
87
+ `Invite audit doc for ${tokenId} not found. The issuer may have used a different store, or the audit doc was deleted. Refusing to open a session \u2014 this is the revoked-link-shadow-keyring defense from #32.`
88
+ );
89
+ this.tokenId = tokenId;
90
+ this.name = "InviteAuditMissingError";
91
+ }
92
+ tokenId;
93
+ code = "INVITE_AUDIT_MISSING";
94
+ };
95
+ async function issueInvite(db, vault, options) {
96
+ const tokenId = (0, import_hub.generateULID)();
97
+ const ttlMs = options.ttlMs ?? INVITE_DEFAULT_TTL_MS;
98
+ const tempPhrase = options.tempPhrase ?? generateTempPhrase();
99
+ const expiresAt = new Date(Date.now() + ttlMs).toISOString();
100
+ const issuer = db.options.user;
101
+ await db.grant(vault, {
102
+ userId: options.userId,
103
+ displayName: options.displayName,
104
+ role: options.role,
105
+ passphrase: tempPhrase,
106
+ // Allow weak temp phrase — random-generated phrases are
107
+ // high-entropy but may not satisfy the human-typeable rules
108
+ // (lowercase + spaces + min words). The recipient's chosen
109
+ // newPhrase will be validated normally.
110
+ allowWeakPassphrase: true
111
+ });
112
+ const payload = {
113
+ tokenId,
114
+ vault,
115
+ userId: options.userId,
116
+ displayName: options.displayName,
117
+ role: options.role,
118
+ kind: "invite",
119
+ issuer,
120
+ tempPhrase,
121
+ expiresAt
122
+ };
123
+ await writeAuditDoc(getStore(db), vault, {
124
+ _noydb_invite_audit: 1,
125
+ tokenId,
126
+ kind: "invite",
127
+ issuer,
128
+ target: options.userId,
129
+ expiresAt,
130
+ issuedAt: (/* @__PURE__ */ new Date()).toISOString()
131
+ });
132
+ return { payload, encoded: encodeInvitePayload(payload) };
133
+ }
134
+ async function issuePeerRecovery(db, vault, options, factors) {
135
+ const tokenId = (0, import_hub.generateULID)();
136
+ const ttlMs = options.ttlMs ?? INVITE_DEFAULT_TTL_MS;
137
+ const tempPhrase = options.tempPhrase ?? generateTempPhrase();
138
+ const expiresAt = new Date(Date.now() + ttlMs).toISOString();
139
+ const issuer = db.options.user;
140
+ await db.recoverUser(
141
+ vault,
142
+ {
143
+ userId: options.userId,
144
+ passphrase: tempPhrase,
145
+ ...options.role !== void 0 && { role: options.role },
146
+ ...options.displayName !== void 0 && { displayName: options.displayName },
147
+ // Same allow-weak rationale as issueInvite.
148
+ allowWeakPassphrase: true
149
+ },
150
+ factors
151
+ );
152
+ const payload = {
153
+ tokenId,
154
+ vault,
155
+ userId: options.userId,
156
+ ...options.displayName !== void 0 && { displayName: options.displayName },
157
+ ...options.role !== void 0 && { role: options.role },
158
+ kind: "peer-recovery",
159
+ issuer,
160
+ tempPhrase,
161
+ expiresAt
162
+ };
163
+ await writeAuditDoc(getStore(db), vault, {
164
+ _noydb_invite_audit: 1,
165
+ tokenId,
166
+ kind: "peer-recovery",
167
+ issuer,
168
+ target: options.userId,
169
+ expiresAt,
170
+ issuedAt: (/* @__PURE__ */ new Date()).toISOString()
171
+ });
172
+ return { payload, encoded: encodeInvitePayload(payload) };
173
+ }
174
+ async function revokeInvite(db, vault, encodedOrPayload) {
175
+ const payload = typeof encodedOrPayload === "string" ? decodeInvitePayload(encodedOrPayload) : encodedOrPayload;
176
+ const store = getStore(db);
177
+ const audit = await readAuditDoc(store, vault, payload.tokenId);
178
+ if (!audit) {
179
+ return;
180
+ }
181
+ if (audit.revokedAt !== void 0) {
182
+ return;
183
+ }
184
+ await writeAuditDoc(store, vault, {
185
+ ...audit,
186
+ revokedAt: (/* @__PURE__ */ new Date()).toISOString()
187
+ });
188
+ }
189
+ async function acceptInvite(encoded, options) {
190
+ const payload = decodeInvitePayload(encoded);
191
+ const now = options.now ?? /* @__PURE__ */ new Date();
192
+ if (now.getTime() > Date.parse(payload.expiresAt)) {
193
+ throw new InviteExpiredError(payload.expiresAt);
194
+ }
195
+ const audit = await readAuditDoc(options.store, payload.vault, payload.tokenId);
196
+ if (!audit) {
197
+ throw new InviteAuditMissingError(payload.tokenId);
198
+ }
199
+ if (audit.revokedAt !== void 0) {
200
+ throw new InviteRevokedError(payload.tokenId, audit.revokedAt);
201
+ }
202
+ if (audit.acceptedAt !== void 0) {
203
+ throw new InviteAlreadyAcceptedError(payload.tokenId, audit.acceptedAt);
204
+ }
205
+ await (0, import_hub.keyringRotatePassphrase)(options.store, payload.vault, payload.userId, {
206
+ oldPassphrase: payload.tempPhrase,
207
+ newPassphrase: options.newPhrase,
208
+ ...options.passphrasePolicy !== void 0 && { passphrasePolicy: options.passphrasePolicy },
209
+ ...options.allowWeakPassphrase !== void 0 && { allowWeakPassphrase: options.allowWeakPassphrase }
210
+ });
211
+ await writeAuditDoc(options.store, payload.vault, {
212
+ ...audit,
213
+ acceptedAt: (/* @__PURE__ */ new Date()).toISOString()
214
+ });
215
+ const db = await (0, import_hub.createNoydb)({
216
+ store: options.store,
217
+ user: payload.userId,
218
+ secret: options.newPhrase,
219
+ ...options.noydbOptions
220
+ });
221
+ await db.openVault(payload.vault);
222
+ return { db, payload };
223
+ }
224
+ function encodeInvitePayload(payload) {
225
+ const json = JSON.stringify(payload);
226
+ const bytes = new TextEncoder().encode(json);
227
+ return base64UrlEncode(bytes);
228
+ }
229
+ function decodeInvitePayload(encoded) {
230
+ const bytes = base64UrlDecode(encoded);
231
+ const json = new TextDecoder().decode(bytes);
232
+ return JSON.parse(json);
233
+ }
234
+ function getStore(db) {
235
+ return db.options.store;
236
+ }
237
+ async function readAuditDoc(store, vault, tokenId) {
238
+ const env = await store.get(vault, "_meta", INVITE_AUDIT_DOC_PREFIX + tokenId);
239
+ if (!env) return void 0;
240
+ try {
241
+ return JSON.parse(env._data);
242
+ } catch {
243
+ return void 0;
244
+ }
245
+ }
246
+ async function writeAuditDoc(store, vault, doc) {
247
+ const envelope = {
248
+ _noydb: 1,
249
+ _v: 1,
250
+ _ts: (/* @__PURE__ */ new Date()).toISOString(),
251
+ _iv: "",
252
+ _data: JSON.stringify(doc)
253
+ };
254
+ await store.put(vault, "_meta", INVITE_AUDIT_DOC_PREFIX + doc.tokenId, envelope);
255
+ }
256
+ function generateTempPhrase() {
257
+ const bytes = crypto.getRandomValues(new Uint8Array(32));
258
+ let hex = "";
259
+ for (const b of bytes) hex += b.toString(16).padStart(2, "0");
260
+ return hex;
261
+ }
262
+ function base64UrlEncode(bytes) {
263
+ let s = "";
264
+ for (const b of bytes) s += String.fromCharCode(b);
265
+ return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
266
+ }
267
+ function base64UrlDecode(s) {
268
+ let padded = s.replace(/-/g, "+").replace(/_/g, "/");
269
+ const padLen = (4 - padded.length % 4) % 4;
270
+ padded += "=".repeat(padLen);
271
+ const decoded = atob(padded);
272
+ const out = new Uint8Array(decoded.length);
273
+ for (let i = 0; i < decoded.length; i++) out[i] = decoded.charCodeAt(i);
274
+ return out;
275
+ }
276
+
277
+ // src/index.ts
38
278
  var MAGIC_LINK_INFO_PREFIX = "noydb-magic-link-v1:";
39
279
  var MAGIC_LINK_DEFAULT_TTL_MS = 24 * 60 * 60 * 1e3;
40
280
  async function deriveMagicLinkKEK(serverSecret, token, vault) {
@@ -60,7 +300,7 @@ async function deriveMagicLinkKEK(serverSecret, token, vault) {
60
300
  function createMagicLinkToken(vault, options = {}) {
61
301
  const ttlMs = options.ttlMs ?? MAGIC_LINK_DEFAULT_TTL_MS;
62
302
  return {
63
- token: (0, import_hub.generateULID)(),
303
+ token: (0, import_hub2.generateULID)(),
64
304
  vault,
65
305
  expiresAt: new Date(Date.now() + ttlMs).toISOString(),
66
306
  role: "viewer"
@@ -85,7 +325,7 @@ async function issueMagicLinkDelegation(vault, options) {
85
325
  if (options.grants.length === 0) {
86
326
  throw new Error("@noy-db/on-magic-link: grants[] must be non-empty");
87
327
  }
88
- const token = options.token ?? (0, import_hub.generateULID)();
328
+ const token = options.token ?? (0, import_hub2.generateULID)();
89
329
  const ttlMs = options.ttlMs ?? MAGIC_LINK_DEFAULT_TTL_MS;
90
330
  const link = {
91
331
  token,
@@ -93,12 +333,12 @@ async function issueMagicLinkDelegation(vault, options) {
93
333
  expiresAt: new Date(Date.now() + ttlMs).toISOString(),
94
334
  role: "viewer"
95
335
  };
96
- const contentKey = await (0, import_hub.deriveMagicLinkContentKey)(options.serverSecret, token, vault.name);
336
+ const contentKey = await (0, import_hub2.deriveMagicLinkContentKey)(options.serverSecret, token, vault.name);
97
337
  const grantKek = await deriveMagicLinkKEK(options.serverSecret, token, vault.name);
98
338
  const grants = [];
99
339
  for (let i = 0; i < options.grants.length; i += 1) {
100
340
  const spec = options.grants[i];
101
- const recordId = (0, import_hub.magicLinkGrantRecordId)(token, i);
341
+ const recordId = (0, import_hub2.magicLinkGrantRecordId)(token, i);
102
342
  const issueOpts = {
103
343
  toUser: spec.toUser,
104
344
  tier: spec.tier,
@@ -114,9 +354,9 @@ async function issueMagicLinkDelegation(vault, options) {
114
354
  }
115
355
  async function claimMagicLinkDelegation(options) {
116
356
  const { store, vault, token, serverSecret } = options;
117
- const contentKey = await (0, import_hub.deriveMagicLinkContentKey)(serverSecret, token, vault);
357
+ const contentKey = await (0, import_hub2.deriveMagicLinkContentKey)(serverSecret, token, vault);
118
358
  const grantKek = await deriveMagicLinkKEK(serverSecret, token, vault);
119
- const payloads = await (0, import_hub.listMagicLinkGrants)(store, vault, contentKey, token);
359
+ const payloads = await (0, import_hub2.listMagicLinkGrants)(store, vault, contentKey, token);
120
360
  if (payloads.length === 0) {
121
361
  return { valid: false, grants: [] };
122
362
  }
@@ -125,50 +365,60 @@ async function claimMagicLinkDelegation(options) {
125
365
  for (const payload of payloads) {
126
366
  let dek;
127
367
  try {
128
- dek = await (0, import_hub.unwrapMagicLinkGrant)(payload, grantKek);
368
+ dek = await (0, import_hub2.unwrapMagicLinkGrant)(payload, grantKek);
129
369
  } catch {
130
370
  continue;
131
371
  }
132
372
  claimed.push({
133
373
  payload,
134
374
  dek,
135
- expired: (0, import_hub.isMagicLinkGrantExpired)(payload, now)
375
+ expired: (0, import_hub2.isMagicLinkGrantExpired)(payload, now)
136
376
  });
137
377
  }
138
378
  return { valid: true, grants: claimed };
139
379
  }
140
380
  async function inspectMagicLinkDelegation(options) {
141
- const contentKey = await (0, import_hub.deriveMagicLinkContentKey)(
381
+ const contentKey = await (0, import_hub2.deriveMagicLinkContentKey)(
142
382
  options.serverSecret,
143
383
  options.token,
144
384
  options.vault
145
385
  );
146
- return (0, import_hub.listMagicLinkGrants)(options.store, options.vault, contentKey, options.token);
386
+ return (0, import_hub2.listMagicLinkGrants)(options.store, options.vault, contentKey, options.token);
147
387
  }
148
388
  async function revokeMagicLinkDelegation(options) {
149
- return (0, import_hub.revokeMagicLinkGrant)(options.store, options.vault, options.token);
389
+ return (0, import_hub2.revokeMagicLinkGrant)(options.store, options.vault, options.token);
150
390
  }
151
391
  async function readMagicLinkGrant(options) {
152
- const contentKey = await (0, import_hub.deriveMagicLinkContentKey)(
392
+ const contentKey = await (0, import_hub2.deriveMagicLinkContentKey)(
153
393
  options.serverSecret,
154
394
  options.token,
155
395
  options.vault
156
396
  );
157
- return (0, import_hub.readMagicLinkGrantRecord)(options.store, options.vault, contentKey, options.recordId);
397
+ return (0, import_hub2.readMagicLinkGrantRecord)(options.store, options.vault, contentKey, options.recordId);
158
398
  }
159
399
  // Annotate the CommonJS export names for ESM import in node:
160
400
  0 && (module.exports = {
401
+ InviteAlreadyAcceptedError,
402
+ InviteAuditMissingError,
403
+ InviteExpiredError,
404
+ InviteRevokedError,
161
405
  MAGIC_LINK_DEFAULT_TTL_MS,
162
406
  MAGIC_LINK_GRANTS_COLLECTION,
407
+ acceptInvite,
163
408
  buildMagicLinkKeyring,
164
409
  claimMagicLinkDelegation,
165
410
  createMagicLinkToken,
411
+ decodeInvitePayload,
166
412
  deriveMagicLinkContentKey,
167
413
  deriveMagicLinkKEK,
414
+ encodeInvitePayload,
168
415
  inspectMagicLinkDelegation,
169
416
  isMagicLinkValid,
417
+ issueInvite,
170
418
  issueMagicLinkDelegation,
419
+ issuePeerRecovery,
171
420
  readMagicLinkGrant,
421
+ revokeInvite,
172
422
  revokeMagicLinkDelegation
173
423
  });
174
424
  //# sourceMappingURL=index.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/index.ts"],"sourcesContent":["/**\n * **@noy-db/on-magic-link** — one-time-link viewer unlock for noy-db.\n *\n * A magic link is a single-use URL that opens a vault in a read-only,\n * viewer-scoped session WITHOUT entering a passphrase. The link\n * expires after use or after a configurable TTL; the resulting\n * session is strictly limited to the `viewer` role.\n *\n * Part of the `@noy-db/on-*` authentication family. Sibling packages:\n * `@noy-db/on-oidc` (federated login), `@noy-db/on-webauthn` (passkey\n * / biometric). All follow the same shape: enrol once, produce a\n * short-lived token, unwrap a viewer keyring at unlock.\n *\n * ## Security model\n *\n * The viewer KEK is derived via:\n *\n * ```\n * HKDF-SHA256(\n * ikm = serverSecret,\n * salt = sha256(token),\n * info = \"noydb-magic-link-v1:\" + vaultId,\n * )\n * ```\n *\n * - `serverSecret` is a server-held secret that the SERVER knows but\n * is NOT embedded in the link. If the link is intercepted, the\n * attacker cannot derive the KEK without the server secret.\n * - `token` is a ULID embedded in the URL. It is single-use at the\n * application layer (the server marks it consumed after first use).\n * - `vaultId` binds the derived key to a specific vault — a token for\n * vault A cannot be used to unlock vault B.\n *\n * The resulting keyring is ALWAYS viewer-scoped (`role: 'viewer'`).\n * The DEKs available to the viewer are only the collections in the\n * viewer-specific subset, determined by the admin who created the link.\n *\n * ## What this package is NOT\n *\n * This module provides the CRYPTO layer only — it does not:\n * - Issue HTTP tokens or send emails (that's the application layer)\n * - Mark tokens as consumed (that's the server's responsibility)\n * - Store viewer keyrings in the adapter (callers do this via `grant()`)\n *\n * ## Usage\n *\n * ```ts\n * import {\n * createMagicLinkToken,\n * deriveMagicLinkKEK,\n * isMagicLinkValid,\n * buildMagicLinkKeyring,\n * } from '@noy-db/on-magic-link'\n *\n * // SERVER — mint a token + grant the viewer keyring\n * const token = createMagicLinkToken('company-a', { ttlMs: 24 * 60 * 60 * 1000 })\n * const kek = await deriveMagicLinkKEK(serverSecret, token.token, 'company-a')\n * // ... use kek + db.grant(...) to create a viewer keyring entry ...\n *\n * // Email the link, e.g. https://app.example.com/view?t=<token.token>\n *\n * // CLIENT — derive the same KEK and unlock\n * if (!isMagicLinkValid(token)) throw new Error('expired')\n * const sameKek = await deriveMagicLinkKEK(serverSecret, token.token, token.vault)\n * const keyring = buildMagicLinkKeyring({ ... })\n * ```\n *\n * @packageDocumentation\n */\n\nimport {\n generateULID,\n deriveMagicLinkContentKey,\n readMagicLinkGrantRecord,\n listMagicLinkGrants,\n unwrapMagicLinkGrant,\n revokeMagicLinkGrant,\n magicLinkGrantRecordId,\n isMagicLinkGrantExpired,\n MAGIC_LINK_GRANTS_COLLECTION,\n} from '@noy-db/hub'\nimport type {\n Role,\n UnlockedKeyring,\n Vault,\n NoydbStore,\n MagicLinkGrantPayload,\n IssueMagicLinkGrantOptions,\n} from '@noy-db/hub'\n\n// HKDF info string — version-namespaced so future schemes are distinguishable.\nconst MAGIC_LINK_INFO_PREFIX = 'noydb-magic-link-v1:'\n\n/** Default magic-link TTL: 24 hours. */\nexport const MAGIC_LINK_DEFAULT_TTL_MS = 24 * 60 * 60 * 1000\n\n// ─── Types ──────────────────────────────────────────────────────────────\n\n/**\n * The serializable metadata describing a magic link.\n * Embed `token` in the link URL as a query parameter or path segment.\n */\nexport interface MagicLinkToken {\n /** Unique one-time token (ULID). Embed this in the URL. */\n readonly token: string\n /** The vault this link unlocks (viewer-only). */\n readonly vault: string\n /** ISO timestamp after which the link is invalid. */\n readonly expiresAt: string\n /** Role of the resulting session. Always `'viewer'` for magic links. */\n readonly role: 'viewer'\n}\n\n/** Options for `createMagicLinkToken()`. */\nexport interface CreateMagicLinkOptions {\n /** Link lifetime in milliseconds. Default: 24 hours. */\n ttlMs?: number\n}\n\n// ─── KEK derivation ─────────────────────────────────────────────────────\n\n/**\n * Derive a viewer KEK from the server secret and the magic-link token.\n *\n * Both the server (at grant time) and the client (at unlock time) call\n * this with the same inputs to get the same key. The key is used to:\n * - Server: derive the KEK, call `db.grant()` to create a viewer keyring.\n * - Client: derive the KEK, call `db.openVault()` / `loadKeyring()` with\n * this KEK directly (bypassing PBKDF2) to unlock the viewer session.\n *\n * @param serverSecret - Server-held secret (never sent to the client).\n * @param token - The ULID from the magic-link URL.\n * @param vault - The vault ID this link is for.\n */\nexport async function deriveMagicLinkKEK(\n serverSecret: string | Uint8Array<ArrayBuffer>,\n token: string,\n vault: string,\n): Promise<CryptoKey> {\n const subtle = globalThis.crypto.subtle\n\n // IKM: the server secret\n const ikmBytes =\n serverSecret instanceof Uint8Array\n ? serverSecret\n : new TextEncoder().encode(serverSecret)\n\n // Salt: SHA-256(token). Hashing the token prevents the salt from being\n // trivially guessable if the token format is known (ULID has predictable\n // structure — hashing removes that structure from the HKDF salt).\n const tokenBytes = new TextEncoder().encode(token)\n const saltBuffer = await subtle.digest('SHA-256', tokenBytes)\n\n // Info: \"noydb-magic-link-v1:\" + vaultId — binds the key to a specific\n // vault so a token for vault A cannot unlock vault B.\n const info = new TextEncoder().encode(MAGIC_LINK_INFO_PREFIX + vault)\n\n const ikm = await subtle.importKey('raw', ikmBytes, 'HKDF', false, ['deriveKey'])\n\n return subtle.deriveKey(\n {\n name: 'HKDF',\n hash: 'SHA-256',\n salt: saltBuffer,\n info,\n },\n ikm,\n { name: 'AES-KW', length: 256 },\n false,\n ['wrapKey', 'unwrapKey'],\n )\n}\n\n// ─── Link creation (server-side) ────────────────────────────────────────\n\n/**\n * Generate a magic-link token (server-side).\n *\n * Returns a `MagicLinkToken` whose `token` field should be embedded in\n * the URL sent to the viewer. The server must store the token metadata\n * (or reconstruct it from the URL) so it can:\n * 1. Validate that the token has not expired or been used.\n * 2. Call `deriveMagicLinkKEK()` to create the viewer keyring.\n *\n * @param vault - The vault to grant viewer access to.\n * @param options - Optional TTL configuration.\n */\nexport function createMagicLinkToken(\n vault: string,\n options: CreateMagicLinkOptions = {},\n): MagicLinkToken {\n const ttlMs = options.ttlMs ?? MAGIC_LINK_DEFAULT_TTL_MS\n return {\n token: generateULID(),\n vault,\n expiresAt: new Date(Date.now() + ttlMs).toISOString(),\n role: 'viewer',\n }\n}\n\n/**\n * Validate that a magic-link token is not expired.\n * Returns `true` if valid, `false` if expired.\n *\n * Single-use enforcement (marking a token as consumed after first use)\n * is the server's responsibility — this function only checks `expiresAt`.\n */\nexport function isMagicLinkValid(linkToken: MagicLinkToken): boolean {\n return Date.now() <= new Date(linkToken.expiresAt).getTime()\n}\n\n/**\n * Build a stub `UnlockedKeyring` from the magic-link-derived KEK and\n * the viewer's DEK set.\n *\n * This is a thin wrapper for callers that have already:\n * 1. Called `deriveMagicLinkKEK()` to get the viewer KEK.\n * 2. Loaded the viewer's keyring from the adapter (which holds the DEKs\n * wrapped with the magic-link KEK).\n * 3. Unwrapped the DEKs.\n *\n * The resulting keyring is always viewer-scoped. Callers who want to turn\n * it into a session token should call `createSession()` from\n * `@noy-db/hub/session`.\n */\nexport function buildMagicLinkKeyring(opts: {\n viewerUserId: string\n displayName: string\n deks: Map<string, CryptoKey>\n kek: CryptoKey\n salt: Uint8Array\n}): UnlockedKeyring {\n return {\n userId: opts.viewerUserId,\n displayName: opts.displayName,\n role: 'viewer' as Role,\n permissions: {},\n deks: opts.deks,\n kek: opts.kek,\n salt: opts.salt,\n authenticators: [],\n }\n}\n\n// ─── Delegation bridge ─────────────────────────────────────\n\n/**\n * Single grant within a batch magic-link issue. The grantor specifies\n * the tier + scope; the package handles the wrapping. `record` is\n * optional and narrows the grant to a single record id in the\n * collection (leave undefined for a whole-collection grant).\n */\nexport interface MagicLinkGrantSpec {\n readonly toUser: string\n readonly tier: number\n readonly collection?: string\n readonly record?: string\n readonly until: Date | string\n readonly note?: string\n}\n\nexport interface IssueMagicLinkDelegationOptions {\n /**\n * Server-held secret that gates access to the grant. Same value must\n * be supplied at claim time — the server is the only party that\n * knows it, so a leaked URL alone cannot unlock anything.\n */\n readonly serverSecret: string | Uint8Array<ArrayBuffer>\n /**\n * One or more grants to persist under the same magic-link token.\n * Single-element arrays cover the common \"one collection to one\n * user\" case; multi-element arrays support scoped cross-collection\n * delegations (e.g. client portal: invoices + payments + etax).\n */\n readonly grants: readonly MagicLinkGrantSpec[]\n /**\n * Magic-link TTL. Controls `link.expiresAt` (the URL freshness).\n * Each grant's own `until` bounds the *delegation* lifetime — the\n * claimant only receives DEKs for grants whose `until` is still\n * future at claim time. Default 24 h.\n */\n readonly ttlMs?: number\n /**\n * Optional override for the ULID embedded in the URL. Rarely useful\n * outside deterministic tests.\n */\n readonly token?: string\n}\n\nexport interface IssueMagicLinkDelegationResult {\n /** URL-embeddable token metadata. Serialize `link.token` into the link. */\n readonly link: MagicLinkToken\n /** One record per grant — mirrors the input array order. */\n readonly grants: ReadonlyArray<{\n readonly recordId: string\n readonly payload: MagicLinkGrantPayload\n }>\n}\n\n/**\n * Issue a magic-link-bound delegation.\n *\n * Server-side workflow:\n *\n * ```ts\n * import { issueMagicLinkDelegation } from '@noy-db/on-magic-link'\n *\n * const { link, grants } = await issueMagicLinkDelegation(vault, {\n * serverSecret: process.env.MAGIC_LINK_SECRET!,\n * grants: [\n * { toUser: 'auditor-bob', tier: 1, collection: 'invoices',\n * until: new Date(Date.now() + 48*3600e3) },\n * ],\n * ttlMs: 48 * 3600 * 1000,\n * })\n *\n * // Embed link.token in an email URL. The grantee clicks → loads your\n * // client → calls claimMagicLinkDelegation() with the same serverSecret.\n * ```\n */\nexport async function issueMagicLinkDelegation(\n vault: Vault,\n options: IssueMagicLinkDelegationOptions,\n): Promise<IssueMagicLinkDelegationResult> {\n if (options.grants.length === 0) {\n throw new Error('@noy-db/on-magic-link: grants[] must be non-empty')\n }\n const token = options.token ?? generateULID()\n const ttlMs = options.ttlMs ?? MAGIC_LINK_DEFAULT_TTL_MS\n const link: MagicLinkToken = {\n token,\n vault: vault.name,\n expiresAt: new Date(Date.now() + ttlMs).toISOString(),\n role: 'viewer',\n }\n const contentKey = await deriveMagicLinkContentKey(options.serverSecret, token, vault.name)\n const grantKek = await deriveMagicLinkKEK(options.serverSecret, token, vault.name)\n\n const grants: Array<{ recordId: string; payload: MagicLinkGrantPayload }> = []\n for (let i = 0; i < options.grants.length; i += 1) {\n const spec = options.grants[i]!\n const recordId = magicLinkGrantRecordId(token, i)\n const issueOpts: IssueMagicLinkGrantOptions = {\n toUser: spec.toUser,\n tier: spec.tier,\n ...(spec.collection !== undefined && { collection: spec.collection }),\n ...(spec.record !== undefined && { record: spec.record }),\n until: spec.until,\n ...(spec.note !== undefined && { note: spec.note }),\n }\n const record = await vault.writeMagicLinkGrant(contentKey, grantKek, recordId, issueOpts)\n grants.push({ recordId: record.recordId, payload: record.payload })\n }\n return { link, grants }\n}\n\nexport interface ClaimMagicLinkDelegationOptions {\n readonly store: NoydbStore\n readonly vault: string\n readonly serverSecret: string | Uint8Array<ArrayBuffer>\n readonly token: string\n /**\n * Reference clock used to evaluate grant expiry. Production callers\n * leave this `undefined`; tests pass a fixed date.\n */\n readonly now?: Date\n}\n\nexport interface ClaimedMagicLinkGrant {\n readonly payload: MagicLinkGrantPayload\n /** Tier DEK, ready to insert into a keyring map. */\n readonly dek: CryptoKey\n /** True when the grant's `until` has already passed. */\n readonly expired: boolean\n}\n\nexport interface ClaimMagicLinkDelegationResult {\n /**\n * False when the server secret is wrong, the vault is wrong, or\n * every grant is malformed. A `true` with an empty `grants` array\n * means the record was deleted (revoked) between issue and claim.\n */\n readonly valid: boolean\n readonly grants: readonly ClaimedMagicLinkGrant[]\n}\n\n/**\n * Client-side flow. Derives the same content key + KEK as the grantor,\n * loads every grant persisted under the token (primary + batch\n * entries), and returns the unwrapped tier DEKs.\n *\n * The caller decides what to do with them — typically inserting them\n * into a freshly-built `UnlockedKeyring` (see `buildMagicLinkKeyring`)\n * and opening a viewer session.\n */\nexport async function claimMagicLinkDelegation(\n options: ClaimMagicLinkDelegationOptions,\n): Promise<ClaimMagicLinkDelegationResult> {\n const { store, vault, token, serverSecret } = options\n const contentKey = await deriveMagicLinkContentKey(serverSecret, token, vault)\n const grantKek = await deriveMagicLinkKEK(serverSecret, token, vault)\n\n const payloads = await listMagicLinkGrants(store, vault, contentKey, token)\n if (payloads.length === 0) {\n // Could be wrong secret / wrong vault / revoked — all indistinguishable.\n return { valid: false, grants: [] }\n }\n const now = options.now ?? new Date()\n const claimed: ClaimedMagicLinkGrant[] = []\n for (const payload of payloads) {\n let dek: CryptoKey\n try {\n dek = await unwrapMagicLinkGrant(payload, grantKek)\n } catch {\n // Malformed wrappedDek — skip this record but keep the others.\n continue\n }\n claimed.push({\n payload,\n dek,\n expired: isMagicLinkGrantExpired(payload, now),\n })\n }\n return { valid: true, grants: claimed }\n}\n\n/**\n * Read (without unwrapping) the grants under a token — useful for an\n * audit UI that shows the grantor what's still live on a link.\n */\nexport async function inspectMagicLinkDelegation(options: {\n readonly store: NoydbStore\n readonly vault: string\n readonly serverSecret: string | Uint8Array<ArrayBuffer>\n readonly token: string\n}): Promise<readonly MagicLinkGrantPayload[]> {\n const contentKey = await deriveMagicLinkContentKey(\n options.serverSecret,\n options.token,\n options.vault,\n )\n return listMagicLinkGrants(options.store, options.vault, contentKey, options.token)\n}\n\n/**\n * Delete every grant under a token. Idempotent — safe to call on an\n * already-revoked or never-existed token. Returns the number of\n * records removed.\n */\nexport async function revokeMagicLinkDelegation(options: {\n readonly store: NoydbStore\n readonly vault: string\n readonly token: string\n}): Promise<number> {\n return revokeMagicLinkGrant(options.store, options.vault, options.token)\n}\n\n/**\n * Read a single grant by its full record id. Convenience for callers\n * that persisted `recordId` during issue and want to resolve just one.\n */\nexport async function readMagicLinkGrant(options: {\n readonly store: NoydbStore\n readonly vault: string\n readonly serverSecret: string | Uint8Array<ArrayBuffer>\n readonly token: string\n readonly recordId: string\n}): Promise<MagicLinkGrantPayload | null> {\n const contentKey = await deriveMagicLinkContentKey(\n options.serverSecret,\n options.token,\n options.vault,\n )\n return readMagicLinkGrantRecord(options.store, options.vault, contentKey, options.recordId)\n}\n\n// Re-exports so callers don't need a separate @noy-db/hub import for\n// these helpers.\nexport { MAGIC_LINK_GRANTS_COLLECTION, deriveMagicLinkContentKey }\nexport type { MagicLinkGrantPayload }\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/index.ts","../src/invite.ts"],"sourcesContent":["/**\n * **@noy-db/on-magic-link** — one-time-link viewer unlock for noy-db.\n *\n * A magic link is a single-use URL that opens a vault in a read-only,\n * viewer-scoped session WITHOUT entering a passphrase. The link\n * expires after use or after a configurable TTL; the resulting\n * session is strictly limited to the `viewer` role.\n *\n * Part of the `@noy-db/on-*` authentication family. Sibling packages:\n * `@noy-db/on-oidc` (federated login), `@noy-db/on-webauthn` (passkey\n * / biometric). All follow the same shape: enrol once, produce a\n * short-lived token, unwrap a viewer keyring at unlock.\n *\n * ## Security model\n *\n * The viewer KEK is derived via:\n *\n * ```\n * HKDF-SHA256(\n * ikm = serverSecret,\n * salt = sha256(token),\n * info = \"noydb-magic-link-v1:\" + vaultId,\n * )\n * ```\n *\n * - `serverSecret` is a server-held secret that the SERVER knows but\n * is NOT embedded in the link. If the link is intercepted, the\n * attacker cannot derive the KEK without the server secret.\n * - `token` is a ULID embedded in the URL. It is single-use at the\n * application layer (the server marks it consumed after first use).\n * - `vaultId` binds the derived key to a specific vault — a token for\n * vault A cannot be used to unlock vault B.\n *\n * The resulting keyring is ALWAYS viewer-scoped (`role: 'viewer'`).\n * The DEKs available to the viewer are only the collections in the\n * viewer-specific subset, determined by the admin who created the link.\n *\n * ## What this package is NOT\n *\n * This module provides the CRYPTO layer only — it does not:\n * - Issue HTTP tokens or send emails (that's the application layer)\n * - Mark tokens as consumed (that's the server's responsibility)\n * - Store viewer keyrings in the adapter (callers do this via `grant()`)\n *\n * ## Usage\n *\n * ```ts\n * import {\n * createMagicLinkToken,\n * deriveMagicLinkKEK,\n * isMagicLinkValid,\n * buildMagicLinkKeyring,\n * } from '@noy-db/on-magic-link'\n *\n * // SERVER — mint a token + grant the viewer keyring\n * const token = createMagicLinkToken('company-a', { ttlMs: 24 * 60 * 60 * 1000 })\n * const kek = await deriveMagicLinkKEK(serverSecret, token.token, 'company-a')\n * // ... use kek + db.grant(...) to create a viewer keyring entry ...\n *\n * // Email the link, e.g. https://app.example.com/view?t=<token.token>\n *\n * // CLIENT — derive the same KEK and unlock\n * if (!isMagicLinkValid(token)) throw new Error('expired')\n * const sameKek = await deriveMagicLinkKEK(serverSecret, token.token, token.vault)\n * const keyring = buildMagicLinkKeyring({ ... })\n * ```\n *\n * @packageDocumentation\n */\n\nimport {\n generateULID,\n deriveMagicLinkContentKey,\n readMagicLinkGrantRecord,\n listMagicLinkGrants,\n unwrapMagicLinkGrant,\n revokeMagicLinkGrant,\n magicLinkGrantRecordId,\n isMagicLinkGrantExpired,\n MAGIC_LINK_GRANTS_COLLECTION,\n} from '@noy-db/hub'\nimport type {\n Role,\n UnlockedKeyring,\n Vault,\n NoydbStore,\n MagicLinkGrantPayload,\n IssueMagicLinkGrantOptions,\n} from '@noy-db/hub'\n\n// HKDF info string — version-namespaced so future schemes are distinguishable.\nconst MAGIC_LINK_INFO_PREFIX = 'noydb-magic-link-v1:'\n\n/** Default magic-link TTL: 24 hours. */\nexport const MAGIC_LINK_DEFAULT_TTL_MS = 24 * 60 * 60 * 1000\n\n// ─── Types ──────────────────────────────────────────────────────────────\n\n/**\n * The serializable metadata describing a magic link.\n * Embed `token` in the link URL as a query parameter or path segment.\n */\nexport interface MagicLinkToken {\n /** Unique one-time token (ULID). Embed this in the URL. */\n readonly token: string\n /** The vault this link unlocks (viewer-only). */\n readonly vault: string\n /** ISO timestamp after which the link is invalid. */\n readonly expiresAt: string\n /** Role of the resulting session. Always `'viewer'` for magic links. */\n readonly role: 'viewer'\n}\n\n/** Options for `createMagicLinkToken()`. */\nexport interface CreateMagicLinkOptions {\n /** Link lifetime in milliseconds. Default: 24 hours. */\n ttlMs?: number\n}\n\n// ─── KEK derivation ─────────────────────────────────────────────────────\n\n/**\n * Derive a viewer KEK from the server secret and the magic-link token.\n *\n * Both the server (at grant time) and the client (at unlock time) call\n * this with the same inputs to get the same key. The key is used to:\n * - Server: derive the KEK, call `db.grant()` to create a viewer keyring.\n * - Client: derive the KEK, call `db.openVault()` / `loadKeyring()` with\n * this KEK directly (bypassing PBKDF2) to unlock the viewer session.\n *\n * @param serverSecret - Server-held secret (never sent to the client).\n * @param token - The ULID from the magic-link URL.\n * @param vault - The vault ID this link is for.\n */\nexport async function deriveMagicLinkKEK(\n serverSecret: string | Uint8Array<ArrayBuffer>,\n token: string,\n vault: string,\n): Promise<CryptoKey> {\n const subtle = globalThis.crypto.subtle\n\n // IKM: the server secret\n const ikmBytes =\n serverSecret instanceof Uint8Array\n ? serverSecret\n : new TextEncoder().encode(serverSecret)\n\n // Salt: SHA-256(token). Hashing the token prevents the salt from being\n // trivially guessable if the token format is known (ULID has predictable\n // structure — hashing removes that structure from the HKDF salt).\n const tokenBytes = new TextEncoder().encode(token)\n const saltBuffer = await subtle.digest('SHA-256', tokenBytes)\n\n // Info: \"noydb-magic-link-v1:\" + vaultId — binds the key to a specific\n // vault so a token for vault A cannot unlock vault B.\n const info = new TextEncoder().encode(MAGIC_LINK_INFO_PREFIX + vault)\n\n const ikm = await subtle.importKey('raw', ikmBytes, 'HKDF', false, ['deriveKey'])\n\n return subtle.deriveKey(\n {\n name: 'HKDF',\n hash: 'SHA-256',\n salt: saltBuffer,\n info,\n },\n ikm,\n { name: 'AES-KW', length: 256 },\n false,\n ['wrapKey', 'unwrapKey'],\n )\n}\n\n// ─── Link creation (server-side) ────────────────────────────────────────\n\n/**\n * Generate a magic-link token (server-side).\n *\n * Returns a `MagicLinkToken` whose `token` field should be embedded in\n * the URL sent to the viewer. The server must store the token metadata\n * (or reconstruct it from the URL) so it can:\n * 1. Validate that the token has not expired or been used.\n * 2. Call `deriveMagicLinkKEK()` to create the viewer keyring.\n *\n * @param vault - The vault to grant viewer access to.\n * @param options - Optional TTL configuration.\n */\nexport function createMagicLinkToken(\n vault: string,\n options: CreateMagicLinkOptions = {},\n): MagicLinkToken {\n const ttlMs = options.ttlMs ?? MAGIC_LINK_DEFAULT_TTL_MS\n return {\n token: generateULID(),\n vault,\n expiresAt: new Date(Date.now() + ttlMs).toISOString(),\n role: 'viewer',\n }\n}\n\n/**\n * Validate that a magic-link token is not expired.\n * Returns `true` if valid, `false` if expired.\n *\n * Single-use enforcement (marking a token as consumed after first use)\n * is the server's responsibility — this function only checks `expiresAt`.\n */\nexport function isMagicLinkValid(linkToken: MagicLinkToken): boolean {\n return Date.now() <= new Date(linkToken.expiresAt).getTime()\n}\n\n/**\n * Build a stub `UnlockedKeyring` from the magic-link-derived KEK and\n * the viewer's DEK set.\n *\n * This is a thin wrapper for callers that have already:\n * 1. Called `deriveMagicLinkKEK()` to get the viewer KEK.\n * 2. Loaded the viewer's keyring from the adapter (which holds the DEKs\n * wrapped with the magic-link KEK).\n * 3. Unwrapped the DEKs.\n *\n * The resulting keyring is always viewer-scoped. Callers who want to turn\n * it into a session token should call `createSession()` from\n * `@noy-db/hub/session`.\n */\nexport function buildMagicLinkKeyring(opts: {\n viewerUserId: string\n displayName: string\n deks: Map<string, CryptoKey>\n kek: CryptoKey\n salt: Uint8Array\n}): UnlockedKeyring {\n return {\n userId: opts.viewerUserId,\n displayName: opts.displayName,\n role: 'viewer' as Role,\n permissions: {},\n deks: opts.deks,\n kek: opts.kek,\n salt: opts.salt,\n authenticators: [],\n }\n}\n\n// ─── Delegation bridge ─────────────────────────────────────\n\n/**\n * Single grant within a batch magic-link issue. The grantor specifies\n * the tier + scope; the package handles the wrapping. `record` is\n * optional and narrows the grant to a single record id in the\n * collection (leave undefined for a whole-collection grant).\n */\nexport interface MagicLinkGrantSpec {\n readonly toUser: string\n readonly tier: number\n readonly collection?: string\n readonly record?: string\n readonly until: Date | string\n readonly note?: string\n}\n\nexport interface IssueMagicLinkDelegationOptions {\n /**\n * Server-held secret that gates access to the grant. Same value must\n * be supplied at claim time — the server is the only party that\n * knows it, so a leaked URL alone cannot unlock anything.\n */\n readonly serverSecret: string | Uint8Array<ArrayBuffer>\n /**\n * One or more grants to persist under the same magic-link token.\n * Single-element arrays cover the common \"one collection to one\n * user\" case; multi-element arrays support scoped cross-collection\n * delegations (e.g. client portal: invoices + payments + etax).\n */\n readonly grants: readonly MagicLinkGrantSpec[]\n /**\n * Magic-link TTL. Controls `link.expiresAt` (the URL freshness).\n * Each grant's own `until` bounds the *delegation* lifetime — the\n * claimant only receives DEKs for grants whose `until` is still\n * future at claim time. Default 24 h.\n */\n readonly ttlMs?: number\n /**\n * Optional override for the ULID embedded in the URL. Rarely useful\n * outside deterministic tests.\n */\n readonly token?: string\n}\n\nexport interface IssueMagicLinkDelegationResult {\n /** URL-embeddable token metadata. Serialize `link.token` into the link. */\n readonly link: MagicLinkToken\n /** One record per grant — mirrors the input array order. */\n readonly grants: ReadonlyArray<{\n readonly recordId: string\n readonly payload: MagicLinkGrantPayload\n }>\n}\n\n/**\n * Issue a magic-link-bound delegation.\n *\n * Server-side workflow:\n *\n * ```ts\n * import { issueMagicLinkDelegation } from '@noy-db/on-magic-link'\n *\n * const { link, grants } = await issueMagicLinkDelegation(vault, {\n * serverSecret: process.env.MAGIC_LINK_SECRET!,\n * grants: [\n * { toUser: 'auditor-bob', tier: 1, collection: 'invoices',\n * until: new Date(Date.now() + 48*3600e3) },\n * ],\n * ttlMs: 48 * 3600 * 1000,\n * })\n *\n * // Embed link.token in an email URL. The grantee clicks → loads your\n * // client → calls claimMagicLinkDelegation() with the same serverSecret.\n * ```\n */\nexport async function issueMagicLinkDelegation(\n vault: Vault,\n options: IssueMagicLinkDelegationOptions,\n): Promise<IssueMagicLinkDelegationResult> {\n if (options.grants.length === 0) {\n throw new Error('@noy-db/on-magic-link: grants[] must be non-empty')\n }\n const token = options.token ?? generateULID()\n const ttlMs = options.ttlMs ?? MAGIC_LINK_DEFAULT_TTL_MS\n const link: MagicLinkToken = {\n token,\n vault: vault.name,\n expiresAt: new Date(Date.now() + ttlMs).toISOString(),\n role: 'viewer',\n }\n const contentKey = await deriveMagicLinkContentKey(options.serverSecret, token, vault.name)\n const grantKek = await deriveMagicLinkKEK(options.serverSecret, token, vault.name)\n\n const grants: Array<{ recordId: string; payload: MagicLinkGrantPayload }> = []\n for (let i = 0; i < options.grants.length; i += 1) {\n const spec = options.grants[i]!\n const recordId = magicLinkGrantRecordId(token, i)\n const issueOpts: IssueMagicLinkGrantOptions = {\n toUser: spec.toUser,\n tier: spec.tier,\n ...(spec.collection !== undefined && { collection: spec.collection }),\n ...(spec.record !== undefined && { record: spec.record }),\n until: spec.until,\n ...(spec.note !== undefined && { note: spec.note }),\n }\n const record = await vault.writeMagicLinkGrant(contentKey, grantKek, recordId, issueOpts)\n grants.push({ recordId: record.recordId, payload: record.payload })\n }\n return { link, grants }\n}\n\nexport interface ClaimMagicLinkDelegationOptions {\n readonly store: NoydbStore\n readonly vault: string\n readonly serverSecret: string | Uint8Array<ArrayBuffer>\n readonly token: string\n /**\n * Reference clock used to evaluate grant expiry. Production callers\n * leave this `undefined`; tests pass a fixed date.\n */\n readonly now?: Date\n}\n\nexport interface ClaimedMagicLinkGrant {\n readonly payload: MagicLinkGrantPayload\n /** Tier DEK, ready to insert into a keyring map. */\n readonly dek: CryptoKey\n /** True when the grant's `until` has already passed. */\n readonly expired: boolean\n}\n\nexport interface ClaimMagicLinkDelegationResult {\n /**\n * False when the server secret is wrong, the vault is wrong, or\n * every grant is malformed. A `true` with an empty `grants` array\n * means the record was deleted (revoked) between issue and claim.\n */\n readonly valid: boolean\n readonly grants: readonly ClaimedMagicLinkGrant[]\n}\n\n/**\n * Client-side flow. Derives the same content key + KEK as the grantor,\n * loads every grant persisted under the token (primary + batch\n * entries), and returns the unwrapped tier DEKs.\n *\n * The caller decides what to do with them — typically inserting them\n * into a freshly-built `UnlockedKeyring` (see `buildMagicLinkKeyring`)\n * and opening a viewer session.\n */\nexport async function claimMagicLinkDelegation(\n options: ClaimMagicLinkDelegationOptions,\n): Promise<ClaimMagicLinkDelegationResult> {\n const { store, vault, token, serverSecret } = options\n const contentKey = await deriveMagicLinkContentKey(serverSecret, token, vault)\n const grantKek = await deriveMagicLinkKEK(serverSecret, token, vault)\n\n const payloads = await listMagicLinkGrants(store, vault, contentKey, token)\n if (payloads.length === 0) {\n // Could be wrong secret / wrong vault / revoked — all indistinguishable.\n return { valid: false, grants: [] }\n }\n const now = options.now ?? new Date()\n const claimed: ClaimedMagicLinkGrant[] = []\n for (const payload of payloads) {\n let dek: CryptoKey\n try {\n dek = await unwrapMagicLinkGrant(payload, grantKek)\n } catch {\n // Malformed wrappedDek — skip this record but keep the others.\n continue\n }\n claimed.push({\n payload,\n dek,\n expired: isMagicLinkGrantExpired(payload, now),\n })\n }\n return { valid: true, grants: claimed }\n}\n\n/**\n * Read (without unwrapping) the grants under a token — useful for an\n * audit UI that shows the grantor what's still live on a link.\n */\nexport async function inspectMagicLinkDelegation(options: {\n readonly store: NoydbStore\n readonly vault: string\n readonly serverSecret: string | Uint8Array<ArrayBuffer>\n readonly token: string\n}): Promise<readonly MagicLinkGrantPayload[]> {\n const contentKey = await deriveMagicLinkContentKey(\n options.serverSecret,\n options.token,\n options.vault,\n )\n return listMagicLinkGrants(options.store, options.vault, contentKey, options.token)\n}\n\n/**\n * Delete every grant under a token. Idempotent — safe to call on an\n * already-revoked or never-existed token. Returns the number of\n * records removed.\n */\nexport async function revokeMagicLinkDelegation(options: {\n readonly store: NoydbStore\n readonly vault: string\n readonly token: string\n}): Promise<number> {\n return revokeMagicLinkGrant(options.store, options.vault, options.token)\n}\n\n/**\n * Read a single grant by its full record id. Convenience for callers\n * that persisted `recordId` during issue and want to resolve just one.\n */\nexport async function readMagicLinkGrant(options: {\n readonly store: NoydbStore\n readonly vault: string\n readonly serverSecret: string | Uint8Array<ArrayBuffer>\n readonly token: string\n readonly recordId: string\n}): Promise<MagicLinkGrantPayload | null> {\n const contentKey = await deriveMagicLinkContentKey(\n options.serverSecret,\n options.token,\n options.vault,\n )\n return readMagicLinkGrantRecord(options.store, options.vault, contentKey, options.recordId)\n}\n\n// Re-exports so callers don't need a separate @noy-db/hub import for\n// these helpers.\nexport { MAGIC_LINK_GRANTS_COLLECTION, deriveMagicLinkContentKey }\nexport type { MagicLinkGrantPayload }\n\n// ─── Invite + peer-recovery (#32) ─────────────────────────────────\n// Parallel primitives in the same package, layered on top of db.grant\n// (invite mints a NEW user) and db.recoverUser (peer-recovery rewraps\n// an EXISTING user). Different threat model than delegation grants —\n// the temp passphrase travels in the URL fragment, single-use enforced\n// by atomic rotation inside acceptInvite. See ./invite.ts.\nexport {\n issueInvite,\n issuePeerRecovery,\n acceptInvite,\n revokeInvite,\n encodeInvitePayload,\n decodeInvitePayload,\n InviteExpiredError,\n InviteRevokedError,\n InviteAlreadyAcceptedError,\n InviteAuditMissingError,\n} from './invite.js'\nexport type {\n InviteKind,\n InvitePayload,\n InviteAuditDoc,\n IssueInviteOptions,\n IssuePeerRecoveryOptions,\n IssueInviteResult,\n AcceptInviteOptions,\n AcceptInviteResult,\n} from './invite.js'\n","/**\n * **Invite + peer-recovery primitives** — issue #32.\n *\n * Layered on top of `db.grant` (for invite, mints a NEW user) and\n * `db.recoverUser` (for peer-recovery, rewraps an EXISTING user under\n * a fresh temp passphrase). These flows are SIBLINGS of the existing\n * delegation-grant primitives in `./index.ts` — different threat\n * model, different on-disk audit shape, no server-held secret.\n *\n * ## Threat model\n *\n * The temp passphrase travels in the **URL fragment** — server-blind\n * transport (fragments don't traverse TLS proxies, don't appear in\n * access logs). Trade-off: any party who sees the URL can claim the\n * invite once. Single-use semantics close the window: `acceptInvite`\n * rotates the passphrase atomically, marking the audit doc accepted —\n * a second `acceptInvite` call rejects.\n *\n * ## What this module does NOT do\n *\n * - Send emails / construct HTTPS URLs (that's the application layer)\n * - Validate the invite under HTTPS (caller's responsibility)\n * - Coordinate with a server-held secret (delegation grants do that;\n * invite is server-blind by design)\n *\n * @see #32 #33 #34\n *\n * @module\n */\nimport {\n generateULID,\n createNoydb,\n keyringRotatePassphrase,\n type Noydb,\n type NoydbStore,\n type EncryptedEnvelope,\n type Role,\n type FactorProof,\n type PassphrasePolicy,\n} from '@noy-db/hub'\n\nconst INVITE_AUDIT_DOC_PREFIX = 'invite-audit-'\nconst INVITE_DEFAULT_TTL_MS = 24 * 60 * 60 * 1000\n\n// ─── Types ─────────────────────────────────────────────────────────────\n\n/** Whether the payload mints a NEW user (invite) or rewraps an existing one (peer-recovery). */\nexport type InviteKind = 'invite' | 'peer-recovery'\n\n/**\n * Serializable payload encoded into the URL fragment. The temp\n * passphrase is the secret; the rest is metadata the recipient\n * needs to open the right vault as the right user.\n */\nexport interface InvitePayload {\n /** ULID identifying this invite — used to look up the audit doc. */\n readonly tokenId: string\n readonly vault: string\n readonly userId: string\n readonly displayName?: string\n readonly role?: Role\n readonly kind: InviteKind\n /** Issuer's userId (for forensics; not enforced cryptographically). */\n readonly issuer: string\n /** Single-use temporary passphrase — replaced on `acceptInvite`. */\n readonly tempPhrase: string\n readonly expiresAt: string\n}\n\n/** Audit doc persisted at `_meta/invite-audit-<tokenId>`. */\nexport interface InviteAuditDoc {\n readonly _noydb_invite_audit: 1\n readonly tokenId: string\n readonly kind: InviteKind\n readonly issuer: string\n readonly target: string\n readonly expiresAt: string\n readonly issuedAt: string\n readonly revokedAt?: string\n readonly acceptedAt?: string\n}\n\nexport interface IssueInviteOptions {\n readonly userId: string\n readonly displayName: string\n readonly role: Role\n readonly ttlMs?: number\n /** Override the generated temp phrase (rare; deterministic tests). */\n readonly tempPhrase?: string\n}\n\nexport interface IssuePeerRecoveryOptions {\n readonly userId: string\n readonly displayName?: string\n readonly role?: Role\n readonly ttlMs?: number\n readonly tempPhrase?: string\n}\n\nexport interface IssueInviteResult {\n readonly payload: InvitePayload\n /**\n * URL-fragment-safe base64url encoding of the JSON payload. Embed\n * after a `#` in the application's invite URL. Decoded back at\n * accept time via `decodeInvitePayload`.\n */\n readonly encoded: string\n}\n\nexport interface AcceptInviteOptions {\n /**\n * The recipient's NoydbStore. Typically the same shared store the\n * issuer used (e.g. a sync-peer pointing at a common backend) — the\n * recipient must reach the same `_keyring/<userId>` and\n * `_meta/invite-audit-<tokenId>` documents.\n */\n readonly store: NoydbStore\n /**\n * The recipient's chosen new passphrase. `acceptInvite` rotates\n * the temp phrase to this value atomically before returning. Must\n * pass the vault's phrase strength policy (or the recipient must\n * pass `validatePassphrase: false` via the underlying createNoydb\n * options — currently exposed via `noydbOptions`).\n */\n readonly newPhrase: string\n /**\n * Reference clock for TTL evaluation. Production callers leave\n * this `undefined`; tests pass a fixed date.\n */\n readonly now?: Date\n /**\n * Extra options forwarded to `createNoydb` when the recipient's\n * session opens. Useful for `policy`, `validatePassphrase`, or\n * `sessionPolicy` tweaks the application layer wants in scope.\n */\n readonly noydbOptions?: Omit<Parameters<typeof createNoydb>[0], 'store' | 'user' | 'secret'>\n /**\n * Forwarded to the inner `keyringRotatePassphrase` call so the\n * recipient's `newPhrase` is validated against the same policy the\n * vault uses elsewhere. Without this, the rotation step applies the\n * default phrase validator (lowercase letters + spaces) regardless\n * of any `customValidator` / `pattern` set on the vault — a\n * consumer using non-default phrase shapes (e.g. hyphen-separated\n * alphanumeric, BIP-39 word-lists, Thai/EN-mixed scripts) hits a\n * spurious `WeakPassphraseError`.\n *\n * `noydbOptions.policy.passphrase` is NOT auto-derived because\n * `noydbOptions` flows to the post-rotation `createNoydb`, not the\n * rotation itself. Passing the same `PassphrasePolicy` here and\n * (via `noydbOptions.policy.passphrase`) keeps both gates aligned.\n *\n * Added in pre.9 (#53).\n */\n readonly passphrasePolicy?: PassphrasePolicy\n /**\n * Skip phrase strength validation for the recipient's `newPhrase`.\n * Forwarded to the inner rotation. Use sparingly — bypasses the\n * structural rules that protect against weak phrases.\n *\n * Added in pre.9 (#53).\n */\n readonly allowWeakPassphrase?: boolean\n}\n\nexport interface AcceptInviteResult {\n readonly db: Noydb\n readonly payload: InvitePayload\n}\n\n// ─── Errors ────────────────────────────────────────────────────────────\n\nexport class InviteExpiredError extends Error {\n readonly code = 'INVITE_EXPIRED' as const\n constructor(public readonly expiresAt: string) {\n super(`Invite expired at ${expiresAt}.`)\n this.name = 'InviteExpiredError'\n }\n}\n\nexport class InviteRevokedError extends Error {\n readonly code = 'INVITE_REVOKED' as const\n constructor(public readonly tokenId: string, public readonly revokedAt: string) {\n super(`Invite ${tokenId} was revoked at ${revokedAt}.`)\n this.name = 'InviteRevokedError'\n }\n}\n\nexport class InviteAlreadyAcceptedError extends Error {\n readonly code = 'INVITE_ALREADY_ACCEPTED' as const\n constructor(public readonly tokenId: string, public readonly acceptedAt: string) {\n super(`Invite ${tokenId} was already accepted at ${acceptedAt}. Single-use semantics enforced.`)\n this.name = 'InviteAlreadyAcceptedError'\n }\n}\n\nexport class InviteAuditMissingError extends Error {\n readonly code = 'INVITE_AUDIT_MISSING' as const\n constructor(public readonly tokenId: string) {\n super(\n `Invite audit doc for ${tokenId} not found. The issuer may have used a different ` +\n 'store, or the audit doc was deleted. Refusing to open a session — this is the ' +\n 'revoked-link-shadow-keyring defense from #32.',\n )\n this.name = 'InviteAuditMissingError'\n }\n}\n\n// ─── Issue (server / issuer side) ──────────────────────────────────────\n\n/**\n * Mint an invite for a NEW user. Generates a random temp phrase,\n * calls `db.grant({ userId, passphrase: tempPhrase })`, writes the\n * audit doc, and returns the URL-encodable payload.\n *\n * The recipient claims via `acceptInvite(encoded, { store, newPhrase })`;\n * the rotation inside `acceptInvite` invalidates the temp phrase\n * (single-use by construction).\n *\n * @throws Whatever `db.grant` throws (PrivilegeEscalationError,\n * WeakPassphraseError if the temp phrase fails policy, …)\n */\nexport async function issueInvite(\n db: Noydb,\n vault: string,\n options: IssueInviteOptions,\n): Promise<IssueInviteResult> {\n const tokenId = generateULID()\n const ttlMs = options.ttlMs ?? INVITE_DEFAULT_TTL_MS\n const tempPhrase = options.tempPhrase ?? generateTempPhrase()\n const expiresAt = new Date(Date.now() + ttlMs).toISOString()\n const issuer = (db as unknown as { options: { user: string } }).options.user\n\n // Mint the new user under the temp phrase. The recipient will\n // overwrite the wrapping at acceptInvite time via rotatePassphrase.\n await db.grant(vault, {\n userId: options.userId,\n displayName: options.displayName,\n role: options.role,\n passphrase: tempPhrase,\n // Allow weak temp phrase — random-generated phrases are\n // high-entropy but may not satisfy the human-typeable rules\n // (lowercase + spaces + min words). The recipient's chosen\n // newPhrase will be validated normally.\n allowWeakPassphrase: true,\n })\n\n const payload: InvitePayload = {\n tokenId,\n vault,\n userId: options.userId,\n displayName: options.displayName,\n role: options.role,\n kind: 'invite',\n issuer,\n tempPhrase,\n expiresAt,\n }\n\n await writeAuditDoc(getStore(db), vault, {\n _noydb_invite_audit: 1,\n tokenId,\n kind: 'invite',\n issuer,\n target: options.userId,\n expiresAt,\n issuedAt: new Date().toISOString(),\n })\n\n return { payload, encoded: encodeInvitePayload(payload) }\n}\n\n/**\n * Mint a peer-recovery for an EXISTING user. Generates a random temp\n * phrase, calls `db.recoverUser` (atomic; closes the partial-failure\n * window of compose-from-primitives), writes the audit doc, returns\n * the payload.\n *\n * Owner→owner is allowed (the policy gate `peer-recover-user` carries\n * the freshness factor). The `factors` argument forwards to the gate.\n */\nexport async function issuePeerRecovery(\n db: Noydb,\n vault: string,\n options: IssuePeerRecoveryOptions,\n factors?: { factors?: ReadonlyArray<FactorProof>; sharedDevice?: boolean },\n): Promise<IssueInviteResult> {\n const tokenId = generateULID()\n const ttlMs = options.ttlMs ?? INVITE_DEFAULT_TTL_MS\n const tempPhrase = options.tempPhrase ?? generateTempPhrase()\n const expiresAt = new Date(Date.now() + ttlMs).toISOString()\n const issuer = (db as unknown as { options: { user: string } }).options.user\n\n await db.recoverUser(\n vault,\n {\n userId: options.userId,\n passphrase: tempPhrase,\n ...(options.role !== undefined && { role: options.role }),\n ...(options.displayName !== undefined && { displayName: options.displayName }),\n // Same allow-weak rationale as issueInvite.\n allowWeakPassphrase: true,\n },\n factors,\n )\n\n const payload: InvitePayload = {\n tokenId,\n vault,\n userId: options.userId,\n ...(options.displayName !== undefined && { displayName: options.displayName }),\n ...(options.role !== undefined && { role: options.role }),\n kind: 'peer-recovery',\n issuer,\n tempPhrase,\n expiresAt,\n }\n\n await writeAuditDoc(getStore(db), vault, {\n _noydb_invite_audit: 1,\n tokenId,\n kind: 'peer-recovery',\n issuer,\n target: options.userId,\n expiresAt,\n issuedAt: new Date().toISOString(),\n })\n\n return { payload, encoded: encodeInvitePayload(payload) }\n}\n\n/**\n * Mark an outstanding invite as revoked. After this, `acceptInvite`\n * for the same token rejects with `InviteRevokedError` BEFORE opening\n * any session — closes #32's \"revoked-link silent shadow keyring\"\n * defense (without the audit doc check, a revoked link could fall\n * through to `createNoydb`'s no-keyring auto-create path and create\n * a fresh empty vault).\n *\n * Idempotent — revoking an already-revoked invite is a no-op.\n *\n * Note: this does NOT delete the granted/recovered keyring; it only\n * marks the invite token as unusable. To fully cancel the invite,\n * call `db.revoke(vault, { userId })` separately. The two are split\n * because peer-recovery doesn't have a \"cancel\" — the target user\n * already existed.\n */\nexport async function revokeInvite(\n db: Noydb,\n vault: string,\n encodedOrPayload: string | InvitePayload,\n): Promise<void> {\n const payload = typeof encodedOrPayload === 'string'\n ? decodeInvitePayload(encodedOrPayload)\n : encodedOrPayload\n const store = getStore(db)\n const audit = await readAuditDoc(store, vault, payload.tokenId)\n if (!audit) {\n // Nothing to revoke — token was never issued or audit was deleted.\n // Don't throw; revokeInvite is meant to be best-effort.\n return\n }\n if (audit.revokedAt !== undefined) {\n // Idempotent.\n return\n }\n await writeAuditDoc(store, vault, {\n ...audit,\n revokedAt: new Date().toISOString(),\n })\n}\n\n// ─── Accept (recipient side) ───────────────────────────────────────────\n\n/**\n * Open the recipient's session under the invite. Validates TTL +\n * revoke + already-accepted, opens `createNoydb` with the temp\n * phrase, immediately rotates to `newPhrase`, marks the audit doc\n * accepted, returns the live `Noydb` instance.\n *\n * Single-use is enforced two ways:\n * 1. The rotation inside this function invalidates the temp phrase.\n * 2. The audit doc's `acceptedAt` field is set on success — a\n * second `acceptInvite` call sees it and throws\n * `InviteAlreadyAcceptedError`.\n *\n * @throws {@link InviteExpiredError} when TTL has passed.\n * @throws {@link InviteRevokedError} when the issuer has revoked.\n * @throws {@link InviteAlreadyAcceptedError} on second call.\n * @throws {@link InviteAuditMissingError} when no audit doc — closes\n * #32's revoked-link-shadow-keyring defense.\n */\nexport async function acceptInvite(\n encoded: string,\n options: AcceptInviteOptions,\n): Promise<AcceptInviteResult> {\n const payload = decodeInvitePayload(encoded)\n const now = options.now ?? new Date()\n\n // Pre-flight: TTL + audit + revoke + already-accepted checks all\n // run before opening any noydb session. This is the\n // \"revoked-link-shadow-keyring defense\" — a missing audit doc must\n // NOT silently fall through to createNoydb's auto-create path.\n if (now.getTime() > Date.parse(payload.expiresAt)) {\n throw new InviteExpiredError(payload.expiresAt)\n }\n const audit = await readAuditDoc(options.store, payload.vault, payload.tokenId)\n if (!audit) {\n throw new InviteAuditMissingError(payload.tokenId)\n }\n if (audit.revokedAt !== undefined) {\n throw new InviteRevokedError(payload.tokenId, audit.revokedAt)\n }\n if (audit.acceptedAt !== undefined) {\n throw new InviteAlreadyAcceptedError(payload.tokenId, audit.acceptedAt)\n }\n\n // Atomic rotate inside acceptInvite (per #32 spec). We call the\n // team-level `keyringRotatePassphrase` directly rather than going\n // through `db.rotatePassphrase`, which is gated by the\n // `rotate-passphrase` policy gate (PERSONAL_POLICY requires a\n // factor proof there). In the invite flow, the temp phrase reaching\n // the recipient through a trusted issuer-side audit-trailed channel\n // IS the freshness proof — the policy gate's factor requirement\n // doesn't apply to \"rotate FROM a temp phrase delivered via the\n // invite mechanism.\" The audit-doc-presence check above + this\n // single rotation closes the single-use semantics by construction.\n await keyringRotatePassphrase(options.store, payload.vault, payload.userId, {\n oldPassphrase: payload.tempPhrase,\n newPassphrase: options.newPhrase,\n ...(options.passphrasePolicy !== undefined && { passphrasePolicy: options.passphrasePolicy }),\n ...(options.allowWeakPassphrase !== undefined && { allowWeakPassphrase: options.allowWeakPassphrase }),\n })\n\n // Mark accepted — second acceptInvite for this token throws.\n await writeAuditDoc(options.store, payload.vault, {\n ...audit,\n acceptedAt: new Date().toISOString(),\n })\n\n // Open the recipient's session under the now-rotated phrase. The\n // returned `db` handle is what they use going forward.\n const db = await createNoydb({\n store: options.store,\n user: payload.userId,\n secret: options.newPhrase,\n ...options.noydbOptions,\n })\n await db.openVault(payload.vault)\n\n return { db, payload }\n}\n\n// ─── Encoding ──────────────────────────────────────────────────────────\n\n/** Encode the payload as a URL-fragment-safe base64url string. */\nexport function encodeInvitePayload(payload: InvitePayload): string {\n const json = JSON.stringify(payload)\n const bytes = new TextEncoder().encode(json)\n return base64UrlEncode(bytes)\n}\n\n/** Decode a base64url string back to an InvitePayload. */\nexport function decodeInvitePayload(encoded: string): InvitePayload {\n const bytes = base64UrlDecode(encoded)\n const json = new TextDecoder().decode(bytes)\n return JSON.parse(json) as InvitePayload\n}\n\n// ─── Internals ─────────────────────────────────────────────────────────\n\n/** Best-effort getter for the `NoydbStore` from a `Noydb` instance. */\nfunction getStore(db: Noydb): NoydbStore {\n // The store is configured at createNoydb time but not exposed via a\n // public getter; reaching into options is the documented escape\n // hatch (the same pattern niwat-app uses for direct store access).\n return (db as unknown as { options: { store: NoydbStore } }).options.store\n}\n\nasync function readAuditDoc(\n store: NoydbStore,\n vault: string,\n tokenId: string,\n): Promise<InviteAuditDoc | undefined> {\n const env = await store.get(vault, '_meta', INVITE_AUDIT_DOC_PREFIX + tokenId)\n if (!env) return undefined\n try {\n return JSON.parse(env._data) as InviteAuditDoc\n } catch {\n return undefined\n }\n}\n\nasync function writeAuditDoc(\n store: NoydbStore,\n vault: string,\n doc: InviteAuditDoc,\n): Promise<void> {\n const envelope: EncryptedEnvelope = {\n _noydb: 1 as const,\n _v: 1,\n _ts: new Date().toISOString(),\n _iv: '',\n _data: JSON.stringify(doc),\n }\n await store.put(vault, '_meta', INVITE_AUDIT_DOC_PREFIX + doc.tokenId, envelope)\n}\n\n/**\n * Generate a high-entropy random temp passphrase. Uses 256 bits of\n * entropy, base32-encoded — readable enough to log for forensics\n * without being trivially copy-paste typeable.\n */\nfunction generateTempPhrase(): string {\n const bytes = crypto.getRandomValues(new Uint8Array(32))\n // Use the existing magic-link ULID format pattern: hex bytes joined\n // with hyphens every 8 chars. Functionally equivalent to base32 for\n // a temp string the user never types.\n let hex = ''\n for (const b of bytes) hex += b.toString(16).padStart(2, '0')\n return hex\n}\n\nfunction base64UrlEncode(bytes: Uint8Array): string {\n let s = ''\n for (const b of bytes) s += String.fromCharCode(b)\n return btoa(s).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=+$/, '')\n}\n\nfunction base64UrlDecode(s: string): Uint8Array {\n // Pad back to a multiple of 4 if needed.\n let padded = s.replace(/-/g, '+').replace(/_/g, '/')\n const padLen = (4 - (padded.length % 4)) % 4\n padded += '='.repeat(padLen)\n const decoded = atob(padded)\n const out = new Uint8Array(decoded.length)\n for (let i = 0; i < decoded.length; i++) out[i] = decoded.charCodeAt(i)\n return out\n}\n"],"mappings":"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","names":["import_hub"]}