@noy-db/on-magic-link 0.1.0-pre.3

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 vLannaAi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,33 @@
1
+ # @noy-db/on-magic-link
2
+
3
+ [![npm](https://img.shields.io/npm/v/%40noy-db/on-magic-link.svg)](https://www.npmjs.com/package/@noy-db/on-magic-link)
4
+
5
+ > Magic-link unlock for noy-db
6
+
7
+ Part of [**`@noy-db/hub`**](https://www.npmjs.com/package/@noy-db/hub) — the zero-knowledge, offline-first, encrypted document store.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pnpm add @noy-db/hub @noy-db/on-magic-link
13
+ ```
14
+
15
+ ## What it is
16
+
17
+ Magic-link unlock for noy-db — one-time URL that opens a vault in a viewer-scoped session without a passphrase. Part of the @noy-db/on-* authentication family.
18
+
19
+ ## Status
20
+
21
+ **Pre-release** (`0.1.0-pre.1`). API may change before `1.0`.
22
+
23
+ ## Documentation
24
+
25
+ See the [main repository](https://github.com/vLannaAi/noy-db#readme) for setup, examples, and the full subsystem catalog.
26
+
27
+ - Source — [`packages/on-magic-link`](https://github.com/vLannaAi/noy-db/tree/main/packages/on-magic-link)
28
+ - Issues — [github.com/vLannaAi/noy-db/issues](https://github.com/vLannaAi/noy-db/issues)
29
+ - Spec — [`SPEC.md`](https://github.com/vLannaAi/noy-db/blob/main/SPEC.md)
30
+
31
+ ## License
32
+
33
+ [MIT](./LICENSE) © vLannaAi
package/dist/index.cjs ADDED
@@ -0,0 +1,173 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/index.ts
21
+ var index_exports = {};
22
+ __export(index_exports, {
23
+ MAGIC_LINK_DEFAULT_TTL_MS: () => MAGIC_LINK_DEFAULT_TTL_MS,
24
+ MAGIC_LINK_GRANTS_COLLECTION: () => import_hub.MAGIC_LINK_GRANTS_COLLECTION,
25
+ buildMagicLinkKeyring: () => buildMagicLinkKeyring,
26
+ claimMagicLinkDelegation: () => claimMagicLinkDelegation,
27
+ createMagicLinkToken: () => createMagicLinkToken,
28
+ deriveMagicLinkContentKey: () => import_hub.deriveMagicLinkContentKey,
29
+ deriveMagicLinkKEK: () => deriveMagicLinkKEK,
30
+ inspectMagicLinkDelegation: () => inspectMagicLinkDelegation,
31
+ isMagicLinkValid: () => isMagicLinkValid,
32
+ issueMagicLinkDelegation: () => issueMagicLinkDelegation,
33
+ readMagicLinkGrant: () => readMagicLinkGrant,
34
+ revokeMagicLinkDelegation: () => revokeMagicLinkDelegation
35
+ });
36
+ module.exports = __toCommonJS(index_exports);
37
+ var import_hub = require("@noy-db/hub");
38
+ var MAGIC_LINK_INFO_PREFIX = "noydb-magic-link-v1:";
39
+ var MAGIC_LINK_DEFAULT_TTL_MS = 24 * 60 * 60 * 1e3;
40
+ async function deriveMagicLinkKEK(serverSecret, token, vault) {
41
+ const subtle = globalThis.crypto.subtle;
42
+ const ikmBytes = serverSecret instanceof Uint8Array ? serverSecret : new TextEncoder().encode(serverSecret);
43
+ const tokenBytes = new TextEncoder().encode(token);
44
+ const saltBuffer = await subtle.digest("SHA-256", tokenBytes);
45
+ const info = new TextEncoder().encode(MAGIC_LINK_INFO_PREFIX + vault);
46
+ const ikm = await subtle.importKey("raw", ikmBytes, "HKDF", false, ["deriveKey"]);
47
+ return subtle.deriveKey(
48
+ {
49
+ name: "HKDF",
50
+ hash: "SHA-256",
51
+ salt: saltBuffer,
52
+ info
53
+ },
54
+ ikm,
55
+ { name: "AES-KW", length: 256 },
56
+ false,
57
+ ["wrapKey", "unwrapKey"]
58
+ );
59
+ }
60
+ function createMagicLinkToken(vault, options = {}) {
61
+ const ttlMs = options.ttlMs ?? MAGIC_LINK_DEFAULT_TTL_MS;
62
+ return {
63
+ token: (0, import_hub.generateULID)(),
64
+ vault,
65
+ expiresAt: new Date(Date.now() + ttlMs).toISOString(),
66
+ role: "viewer"
67
+ };
68
+ }
69
+ function isMagicLinkValid(linkToken) {
70
+ return Date.now() <= new Date(linkToken.expiresAt).getTime();
71
+ }
72
+ function buildMagicLinkKeyring(opts) {
73
+ return {
74
+ userId: opts.viewerUserId,
75
+ displayName: opts.displayName,
76
+ role: "viewer",
77
+ permissions: {},
78
+ deks: opts.deks,
79
+ kek: opts.kek,
80
+ salt: opts.salt
81
+ };
82
+ }
83
+ async function issueMagicLinkDelegation(vault, options) {
84
+ if (options.grants.length === 0) {
85
+ throw new Error("@noy-db/on-magic-link: grants[] must be non-empty");
86
+ }
87
+ const token = options.token ?? (0, import_hub.generateULID)();
88
+ const ttlMs = options.ttlMs ?? MAGIC_LINK_DEFAULT_TTL_MS;
89
+ const link = {
90
+ token,
91
+ vault: vault.name,
92
+ expiresAt: new Date(Date.now() + ttlMs).toISOString(),
93
+ role: "viewer"
94
+ };
95
+ const contentKey = await (0, import_hub.deriveMagicLinkContentKey)(options.serverSecret, token, vault.name);
96
+ const grantKek = await deriveMagicLinkKEK(options.serverSecret, token, vault.name);
97
+ const grants = [];
98
+ for (let i = 0; i < options.grants.length; i += 1) {
99
+ const spec = options.grants[i];
100
+ const recordId = (0, import_hub.magicLinkGrantRecordId)(token, i);
101
+ const issueOpts = {
102
+ toUser: spec.toUser,
103
+ tier: spec.tier,
104
+ ...spec.collection !== void 0 && { collection: spec.collection },
105
+ ...spec.record !== void 0 && { record: spec.record },
106
+ until: spec.until,
107
+ ...spec.note !== void 0 && { note: spec.note }
108
+ };
109
+ const record = await vault.writeMagicLinkGrant(contentKey, grantKek, recordId, issueOpts);
110
+ grants.push({ recordId: record.recordId, payload: record.payload });
111
+ }
112
+ return { link, grants };
113
+ }
114
+ async function claimMagicLinkDelegation(options) {
115
+ const { store, vault, token, serverSecret } = options;
116
+ const contentKey = await (0, import_hub.deriveMagicLinkContentKey)(serverSecret, token, vault);
117
+ const grantKek = await deriveMagicLinkKEK(serverSecret, token, vault);
118
+ const payloads = await (0, import_hub.listMagicLinkGrants)(store, vault, contentKey, token);
119
+ if (payloads.length === 0) {
120
+ return { valid: false, grants: [] };
121
+ }
122
+ const now = options.now ?? /* @__PURE__ */ new Date();
123
+ const claimed = [];
124
+ for (const payload of payloads) {
125
+ let dek;
126
+ try {
127
+ dek = await (0, import_hub.unwrapMagicLinkGrant)(payload, grantKek);
128
+ } catch {
129
+ continue;
130
+ }
131
+ claimed.push({
132
+ payload,
133
+ dek,
134
+ expired: (0, import_hub.isMagicLinkGrantExpired)(payload, now)
135
+ });
136
+ }
137
+ return { valid: true, grants: claimed };
138
+ }
139
+ async function inspectMagicLinkDelegation(options) {
140
+ const contentKey = await (0, import_hub.deriveMagicLinkContentKey)(
141
+ options.serverSecret,
142
+ options.token,
143
+ options.vault
144
+ );
145
+ return (0, import_hub.listMagicLinkGrants)(options.store, options.vault, contentKey, options.token);
146
+ }
147
+ async function revokeMagicLinkDelegation(options) {
148
+ return (0, import_hub.revokeMagicLinkGrant)(options.store, options.vault, options.token);
149
+ }
150
+ async function readMagicLinkGrant(options) {
151
+ const contentKey = await (0, import_hub.deriveMagicLinkContentKey)(
152
+ options.serverSecret,
153
+ options.token,
154
+ options.vault
155
+ );
156
+ return (0, import_hub.readMagicLinkGrantRecord)(options.store, options.vault, contentKey, options.recordId);
157
+ }
158
+ // Annotate the CommonJS export names for ESM import in node:
159
+ 0 && (module.exports = {
160
+ MAGIC_LINK_DEFAULT_TTL_MS,
161
+ MAGIC_LINK_GRANTS_COLLECTION,
162
+ buildMagicLinkKeyring,
163
+ claimMagicLinkDelegation,
164
+ createMagicLinkToken,
165
+ deriveMagicLinkContentKey,
166
+ deriveMagicLinkKEK,
167
+ inspectMagicLinkDelegation,
168
+ isMagicLinkValid,
169
+ issueMagicLinkDelegation,
170
+ readMagicLinkGrant,
171
+ revokeMagicLinkDelegation
172
+ });
173
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +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 }\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":[]}
@@ -0,0 +1,292 @@
1
+ import { NoydbStore, MagicLinkGrantPayload, UnlockedKeyring, Vault } from '@noy-db/hub';
2
+ export { MAGIC_LINK_GRANTS_COLLECTION, MagicLinkGrantPayload, deriveMagicLinkContentKey } from '@noy-db/hub';
3
+
4
+ /**
5
+ * **@noy-db/on-magic-link** — one-time-link viewer unlock for noy-db.
6
+ *
7
+ * A magic link is a single-use URL that opens a vault in a read-only,
8
+ * viewer-scoped session WITHOUT entering a passphrase. The link
9
+ * expires after use or after a configurable TTL; the resulting
10
+ * session is strictly limited to the `viewer` role.
11
+ *
12
+ * Part of the `@noy-db/on-*` authentication family. Sibling packages:
13
+ * `@noy-db/on-oidc` (federated login), `@noy-db/on-webauthn` (passkey
14
+ * / biometric). All follow the same shape: enrol once, produce a
15
+ * short-lived token, unwrap a viewer keyring at unlock.
16
+ *
17
+ * ## Security model
18
+ *
19
+ * The viewer KEK is derived via:
20
+ *
21
+ * ```
22
+ * HKDF-SHA256(
23
+ * ikm = serverSecret,
24
+ * salt = sha256(token),
25
+ * info = "noydb-magic-link-v1:" + vaultId,
26
+ * )
27
+ * ```
28
+ *
29
+ * - `serverSecret` is a server-held secret that the SERVER knows but
30
+ * is NOT embedded in the link. If the link is intercepted, the
31
+ * attacker cannot derive the KEK without the server secret.
32
+ * - `token` is a ULID embedded in the URL. It is single-use at the
33
+ * application layer (the server marks it consumed after first use).
34
+ * - `vaultId` binds the derived key to a specific vault — a token for
35
+ * vault A cannot be used to unlock vault B.
36
+ *
37
+ * The resulting keyring is ALWAYS viewer-scoped (`role: 'viewer'`).
38
+ * The DEKs available to the viewer are only the collections in the
39
+ * viewer-specific subset, determined by the admin who created the link.
40
+ *
41
+ * ## What this package is NOT
42
+ *
43
+ * This module provides the CRYPTO layer only — it does not:
44
+ * - Issue HTTP tokens or send emails (that's the application layer)
45
+ * - Mark tokens as consumed (that's the server's responsibility)
46
+ * - Store viewer keyrings in the adapter (callers do this via `grant()`)
47
+ *
48
+ * ## Usage
49
+ *
50
+ * ```ts
51
+ * import {
52
+ * createMagicLinkToken,
53
+ * deriveMagicLinkKEK,
54
+ * isMagicLinkValid,
55
+ * buildMagicLinkKeyring,
56
+ * } from '@noy-db/on-magic-link'
57
+ *
58
+ * // SERVER — mint a token + grant the viewer keyring
59
+ * const token = createMagicLinkToken('company-a', { ttlMs: 24 * 60 * 60 * 1000 })
60
+ * const kek = await deriveMagicLinkKEK(serverSecret, token.token, 'company-a')
61
+ * // ... use kek + db.grant(...) to create a viewer keyring entry ...
62
+ *
63
+ * // Email the link, e.g. https://app.example.com/view?t=<token.token>
64
+ *
65
+ * // CLIENT — derive the same KEK and unlock
66
+ * if (!isMagicLinkValid(token)) throw new Error('expired')
67
+ * const sameKek = await deriveMagicLinkKEK(serverSecret, token.token, token.vault)
68
+ * const keyring = buildMagicLinkKeyring({ ... })
69
+ * ```
70
+ *
71
+ * @packageDocumentation
72
+ */
73
+
74
+ /** Default magic-link TTL: 24 hours. */
75
+ declare const MAGIC_LINK_DEFAULT_TTL_MS: number;
76
+ /**
77
+ * The serializable metadata describing a magic link.
78
+ * Embed `token` in the link URL as a query parameter or path segment.
79
+ */
80
+ interface MagicLinkToken {
81
+ /** Unique one-time token (ULID). Embed this in the URL. */
82
+ readonly token: string;
83
+ /** The vault this link unlocks (viewer-only). */
84
+ readonly vault: string;
85
+ /** ISO timestamp after which the link is invalid. */
86
+ readonly expiresAt: string;
87
+ /** Role of the resulting session. Always `'viewer'` for magic links. */
88
+ readonly role: 'viewer';
89
+ }
90
+ /** Options for `createMagicLinkToken()`. */
91
+ interface CreateMagicLinkOptions {
92
+ /** Link lifetime in milliseconds. Default: 24 hours. */
93
+ ttlMs?: number;
94
+ }
95
+ /**
96
+ * Derive a viewer KEK from the server secret and the magic-link token.
97
+ *
98
+ * Both the server (at grant time) and the client (at unlock time) call
99
+ * this with the same inputs to get the same key. The key is used to:
100
+ * - Server: derive the KEK, call `db.grant()` to create a viewer keyring.
101
+ * - Client: derive the KEK, call `db.openVault()` / `loadKeyring()` with
102
+ * this KEK directly (bypassing PBKDF2) to unlock the viewer session.
103
+ *
104
+ * @param serverSecret - Server-held secret (never sent to the client).
105
+ * @param token - The ULID from the magic-link URL.
106
+ * @param vault - The vault ID this link is for.
107
+ */
108
+ declare function deriveMagicLinkKEK(serverSecret: string | Uint8Array<ArrayBuffer>, token: string, vault: string): Promise<CryptoKey>;
109
+ /**
110
+ * Generate a magic-link token (server-side).
111
+ *
112
+ * Returns a `MagicLinkToken` whose `token` field should be embedded in
113
+ * the URL sent to the viewer. The server must store the token metadata
114
+ * (or reconstruct it from the URL) so it can:
115
+ * 1. Validate that the token has not expired or been used.
116
+ * 2. Call `deriveMagicLinkKEK()` to create the viewer keyring.
117
+ *
118
+ * @param vault - The vault to grant viewer access to.
119
+ * @param options - Optional TTL configuration.
120
+ */
121
+ declare function createMagicLinkToken(vault: string, options?: CreateMagicLinkOptions): MagicLinkToken;
122
+ /**
123
+ * Validate that a magic-link token is not expired.
124
+ * Returns `true` if valid, `false` if expired.
125
+ *
126
+ * Single-use enforcement (marking a token as consumed after first use)
127
+ * is the server's responsibility — this function only checks `expiresAt`.
128
+ */
129
+ declare function isMagicLinkValid(linkToken: MagicLinkToken): boolean;
130
+ /**
131
+ * Build a stub `UnlockedKeyring` from the magic-link-derived KEK and
132
+ * the viewer's DEK set.
133
+ *
134
+ * This is a thin wrapper for callers that have already:
135
+ * 1. Called `deriveMagicLinkKEK()` to get the viewer KEK.
136
+ * 2. Loaded the viewer's keyring from the adapter (which holds the DEKs
137
+ * wrapped with the magic-link KEK).
138
+ * 3. Unwrapped the DEKs.
139
+ *
140
+ * The resulting keyring is always viewer-scoped. Callers who want to turn
141
+ * it into a session token should call `createSession()` from
142
+ * `@noy-db/hub/session`.
143
+ */
144
+ declare function buildMagicLinkKeyring(opts: {
145
+ viewerUserId: string;
146
+ displayName: string;
147
+ deks: Map<string, CryptoKey>;
148
+ kek: CryptoKey;
149
+ salt: Uint8Array;
150
+ }): UnlockedKeyring;
151
+ /**
152
+ * Single grant within a batch magic-link issue. The grantor specifies
153
+ * the tier + scope; the package handles the wrapping. `record` is
154
+ * optional and narrows the grant to a single record id in the
155
+ * collection (leave undefined for a whole-collection grant).
156
+ */
157
+ interface MagicLinkGrantSpec {
158
+ readonly toUser: string;
159
+ readonly tier: number;
160
+ readonly collection?: string;
161
+ readonly record?: string;
162
+ readonly until: Date | string;
163
+ readonly note?: string;
164
+ }
165
+ interface IssueMagicLinkDelegationOptions {
166
+ /**
167
+ * Server-held secret that gates access to the grant. Same value must
168
+ * be supplied at claim time — the server is the only party that
169
+ * knows it, so a leaked URL alone cannot unlock anything.
170
+ */
171
+ readonly serverSecret: string | Uint8Array<ArrayBuffer>;
172
+ /**
173
+ * One or more grants to persist under the same magic-link token.
174
+ * Single-element arrays cover the common "one collection to one
175
+ * user" case; multi-element arrays support scoped cross-collection
176
+ * delegations (e.g. client portal: invoices + payments + etax).
177
+ */
178
+ readonly grants: readonly MagicLinkGrantSpec[];
179
+ /**
180
+ * Magic-link TTL. Controls `link.expiresAt` (the URL freshness).
181
+ * Each grant's own `until` bounds the *delegation* lifetime — the
182
+ * claimant only receives DEKs for grants whose `until` is still
183
+ * future at claim time. Default 24 h.
184
+ */
185
+ readonly ttlMs?: number;
186
+ /**
187
+ * Optional override for the ULID embedded in the URL. Rarely useful
188
+ * outside deterministic tests.
189
+ */
190
+ readonly token?: string;
191
+ }
192
+ interface IssueMagicLinkDelegationResult {
193
+ /** URL-embeddable token metadata. Serialize `link.token` into the link. */
194
+ readonly link: MagicLinkToken;
195
+ /** One record per grant — mirrors the input array order. */
196
+ readonly grants: ReadonlyArray<{
197
+ readonly recordId: string;
198
+ readonly payload: MagicLinkGrantPayload;
199
+ }>;
200
+ }
201
+ /**
202
+ * Issue a magic-link-bound delegation.
203
+ *
204
+ * Server-side workflow:
205
+ *
206
+ * ```ts
207
+ * import { issueMagicLinkDelegation } from '@noy-db/on-magic-link'
208
+ *
209
+ * const { link, grants } = await issueMagicLinkDelegation(vault, {
210
+ * serverSecret: process.env.MAGIC_LINK_SECRET!,
211
+ * grants: [
212
+ * { toUser: 'auditor-bob', tier: 1, collection: 'invoices',
213
+ * until: new Date(Date.now() + 48*3600e3) },
214
+ * ],
215
+ * ttlMs: 48 * 3600 * 1000,
216
+ * })
217
+ *
218
+ * // Embed link.token in an email URL. The grantee clicks → loads your
219
+ * // client → calls claimMagicLinkDelegation() with the same serverSecret.
220
+ * ```
221
+ */
222
+ declare function issueMagicLinkDelegation(vault: Vault, options: IssueMagicLinkDelegationOptions): Promise<IssueMagicLinkDelegationResult>;
223
+ interface ClaimMagicLinkDelegationOptions {
224
+ readonly store: NoydbStore;
225
+ readonly vault: string;
226
+ readonly serverSecret: string | Uint8Array<ArrayBuffer>;
227
+ readonly token: string;
228
+ /**
229
+ * Reference clock used to evaluate grant expiry. Production callers
230
+ * leave this `undefined`; tests pass a fixed date.
231
+ */
232
+ readonly now?: Date;
233
+ }
234
+ interface ClaimedMagicLinkGrant {
235
+ readonly payload: MagicLinkGrantPayload;
236
+ /** Tier DEK, ready to insert into a keyring map. */
237
+ readonly dek: CryptoKey;
238
+ /** True when the grant's `until` has already passed. */
239
+ readonly expired: boolean;
240
+ }
241
+ interface ClaimMagicLinkDelegationResult {
242
+ /**
243
+ * False when the server secret is wrong, the vault is wrong, or
244
+ * every grant is malformed. A `true` with an empty `grants` array
245
+ * means the record was deleted (revoked) between issue and claim.
246
+ */
247
+ readonly valid: boolean;
248
+ readonly grants: readonly ClaimedMagicLinkGrant[];
249
+ }
250
+ /**
251
+ * Client-side flow. Derives the same content key + KEK as the grantor,
252
+ * loads every grant persisted under the token (primary + batch
253
+ * entries), and returns the unwrapped tier DEKs.
254
+ *
255
+ * The caller decides what to do with them — typically inserting them
256
+ * into a freshly-built `UnlockedKeyring` (see `buildMagicLinkKeyring`)
257
+ * and opening a viewer session.
258
+ */
259
+ declare function claimMagicLinkDelegation(options: ClaimMagicLinkDelegationOptions): Promise<ClaimMagicLinkDelegationResult>;
260
+ /**
261
+ * Read (without unwrapping) the grants under a token — useful for an
262
+ * audit UI that shows the grantor what's still live on a link.
263
+ */
264
+ declare function inspectMagicLinkDelegation(options: {
265
+ readonly store: NoydbStore;
266
+ readonly vault: string;
267
+ readonly serverSecret: string | Uint8Array<ArrayBuffer>;
268
+ readonly token: string;
269
+ }): Promise<readonly MagicLinkGrantPayload[]>;
270
+ /**
271
+ * Delete every grant under a token. Idempotent — safe to call on an
272
+ * already-revoked or never-existed token. Returns the number of
273
+ * records removed.
274
+ */
275
+ declare function revokeMagicLinkDelegation(options: {
276
+ readonly store: NoydbStore;
277
+ readonly vault: string;
278
+ readonly token: string;
279
+ }): Promise<number>;
280
+ /**
281
+ * Read a single grant by its full record id. Convenience for callers
282
+ * that persisted `recordId` during issue and want to resolve just one.
283
+ */
284
+ declare function readMagicLinkGrant(options: {
285
+ readonly store: NoydbStore;
286
+ readonly vault: string;
287
+ readonly serverSecret: string | Uint8Array<ArrayBuffer>;
288
+ readonly token: string;
289
+ readonly recordId: string;
290
+ }): Promise<MagicLinkGrantPayload | null>;
291
+
292
+ export { type ClaimMagicLinkDelegationOptions, type ClaimMagicLinkDelegationResult, type ClaimedMagicLinkGrant, type CreateMagicLinkOptions, type IssueMagicLinkDelegationOptions, type IssueMagicLinkDelegationResult, MAGIC_LINK_DEFAULT_TTL_MS, type MagicLinkGrantSpec, type MagicLinkToken, buildMagicLinkKeyring, claimMagicLinkDelegation, createMagicLinkToken, deriveMagicLinkKEK, inspectMagicLinkDelegation, isMagicLinkValid, issueMagicLinkDelegation, readMagicLinkGrant, revokeMagicLinkDelegation };
@@ -0,0 +1,292 @@
1
+ import { NoydbStore, MagicLinkGrantPayload, UnlockedKeyring, Vault } from '@noy-db/hub';
2
+ export { MAGIC_LINK_GRANTS_COLLECTION, MagicLinkGrantPayload, deriveMagicLinkContentKey } from '@noy-db/hub';
3
+
4
+ /**
5
+ * **@noy-db/on-magic-link** — one-time-link viewer unlock for noy-db.
6
+ *
7
+ * A magic link is a single-use URL that opens a vault in a read-only,
8
+ * viewer-scoped session WITHOUT entering a passphrase. The link
9
+ * expires after use or after a configurable TTL; the resulting
10
+ * session is strictly limited to the `viewer` role.
11
+ *
12
+ * Part of the `@noy-db/on-*` authentication family. Sibling packages:
13
+ * `@noy-db/on-oidc` (federated login), `@noy-db/on-webauthn` (passkey
14
+ * / biometric). All follow the same shape: enrol once, produce a
15
+ * short-lived token, unwrap a viewer keyring at unlock.
16
+ *
17
+ * ## Security model
18
+ *
19
+ * The viewer KEK is derived via:
20
+ *
21
+ * ```
22
+ * HKDF-SHA256(
23
+ * ikm = serverSecret,
24
+ * salt = sha256(token),
25
+ * info = "noydb-magic-link-v1:" + vaultId,
26
+ * )
27
+ * ```
28
+ *
29
+ * - `serverSecret` is a server-held secret that the SERVER knows but
30
+ * is NOT embedded in the link. If the link is intercepted, the
31
+ * attacker cannot derive the KEK without the server secret.
32
+ * - `token` is a ULID embedded in the URL. It is single-use at the
33
+ * application layer (the server marks it consumed after first use).
34
+ * - `vaultId` binds the derived key to a specific vault — a token for
35
+ * vault A cannot be used to unlock vault B.
36
+ *
37
+ * The resulting keyring is ALWAYS viewer-scoped (`role: 'viewer'`).
38
+ * The DEKs available to the viewer are only the collections in the
39
+ * viewer-specific subset, determined by the admin who created the link.
40
+ *
41
+ * ## What this package is NOT
42
+ *
43
+ * This module provides the CRYPTO layer only — it does not:
44
+ * - Issue HTTP tokens or send emails (that's the application layer)
45
+ * - Mark tokens as consumed (that's the server's responsibility)
46
+ * - Store viewer keyrings in the adapter (callers do this via `grant()`)
47
+ *
48
+ * ## Usage
49
+ *
50
+ * ```ts
51
+ * import {
52
+ * createMagicLinkToken,
53
+ * deriveMagicLinkKEK,
54
+ * isMagicLinkValid,
55
+ * buildMagicLinkKeyring,
56
+ * } from '@noy-db/on-magic-link'
57
+ *
58
+ * // SERVER — mint a token + grant the viewer keyring
59
+ * const token = createMagicLinkToken('company-a', { ttlMs: 24 * 60 * 60 * 1000 })
60
+ * const kek = await deriveMagicLinkKEK(serverSecret, token.token, 'company-a')
61
+ * // ... use kek + db.grant(...) to create a viewer keyring entry ...
62
+ *
63
+ * // Email the link, e.g. https://app.example.com/view?t=<token.token>
64
+ *
65
+ * // CLIENT — derive the same KEK and unlock
66
+ * if (!isMagicLinkValid(token)) throw new Error('expired')
67
+ * const sameKek = await deriveMagicLinkKEK(serverSecret, token.token, token.vault)
68
+ * const keyring = buildMagicLinkKeyring({ ... })
69
+ * ```
70
+ *
71
+ * @packageDocumentation
72
+ */
73
+
74
+ /** Default magic-link TTL: 24 hours. */
75
+ declare const MAGIC_LINK_DEFAULT_TTL_MS: number;
76
+ /**
77
+ * The serializable metadata describing a magic link.
78
+ * Embed `token` in the link URL as a query parameter or path segment.
79
+ */
80
+ interface MagicLinkToken {
81
+ /** Unique one-time token (ULID). Embed this in the URL. */
82
+ readonly token: string;
83
+ /** The vault this link unlocks (viewer-only). */
84
+ readonly vault: string;
85
+ /** ISO timestamp after which the link is invalid. */
86
+ readonly expiresAt: string;
87
+ /** Role of the resulting session. Always `'viewer'` for magic links. */
88
+ readonly role: 'viewer';
89
+ }
90
+ /** Options for `createMagicLinkToken()`. */
91
+ interface CreateMagicLinkOptions {
92
+ /** Link lifetime in milliseconds. Default: 24 hours. */
93
+ ttlMs?: number;
94
+ }
95
+ /**
96
+ * Derive a viewer KEK from the server secret and the magic-link token.
97
+ *
98
+ * Both the server (at grant time) and the client (at unlock time) call
99
+ * this with the same inputs to get the same key. The key is used to:
100
+ * - Server: derive the KEK, call `db.grant()` to create a viewer keyring.
101
+ * - Client: derive the KEK, call `db.openVault()` / `loadKeyring()` with
102
+ * this KEK directly (bypassing PBKDF2) to unlock the viewer session.
103
+ *
104
+ * @param serverSecret - Server-held secret (never sent to the client).
105
+ * @param token - The ULID from the magic-link URL.
106
+ * @param vault - The vault ID this link is for.
107
+ */
108
+ declare function deriveMagicLinkKEK(serverSecret: string | Uint8Array<ArrayBuffer>, token: string, vault: string): Promise<CryptoKey>;
109
+ /**
110
+ * Generate a magic-link token (server-side).
111
+ *
112
+ * Returns a `MagicLinkToken` whose `token` field should be embedded in
113
+ * the URL sent to the viewer. The server must store the token metadata
114
+ * (or reconstruct it from the URL) so it can:
115
+ * 1. Validate that the token has not expired or been used.
116
+ * 2. Call `deriveMagicLinkKEK()` to create the viewer keyring.
117
+ *
118
+ * @param vault - The vault to grant viewer access to.
119
+ * @param options - Optional TTL configuration.
120
+ */
121
+ declare function createMagicLinkToken(vault: string, options?: CreateMagicLinkOptions): MagicLinkToken;
122
+ /**
123
+ * Validate that a magic-link token is not expired.
124
+ * Returns `true` if valid, `false` if expired.
125
+ *
126
+ * Single-use enforcement (marking a token as consumed after first use)
127
+ * is the server's responsibility — this function only checks `expiresAt`.
128
+ */
129
+ declare function isMagicLinkValid(linkToken: MagicLinkToken): boolean;
130
+ /**
131
+ * Build a stub `UnlockedKeyring` from the magic-link-derived KEK and
132
+ * the viewer's DEK set.
133
+ *
134
+ * This is a thin wrapper for callers that have already:
135
+ * 1. Called `deriveMagicLinkKEK()` to get the viewer KEK.
136
+ * 2. Loaded the viewer's keyring from the adapter (which holds the DEKs
137
+ * wrapped with the magic-link KEK).
138
+ * 3. Unwrapped the DEKs.
139
+ *
140
+ * The resulting keyring is always viewer-scoped. Callers who want to turn
141
+ * it into a session token should call `createSession()` from
142
+ * `@noy-db/hub/session`.
143
+ */
144
+ declare function buildMagicLinkKeyring(opts: {
145
+ viewerUserId: string;
146
+ displayName: string;
147
+ deks: Map<string, CryptoKey>;
148
+ kek: CryptoKey;
149
+ salt: Uint8Array;
150
+ }): UnlockedKeyring;
151
+ /**
152
+ * Single grant within a batch magic-link issue. The grantor specifies
153
+ * the tier + scope; the package handles the wrapping. `record` is
154
+ * optional and narrows the grant to a single record id in the
155
+ * collection (leave undefined for a whole-collection grant).
156
+ */
157
+ interface MagicLinkGrantSpec {
158
+ readonly toUser: string;
159
+ readonly tier: number;
160
+ readonly collection?: string;
161
+ readonly record?: string;
162
+ readonly until: Date | string;
163
+ readonly note?: string;
164
+ }
165
+ interface IssueMagicLinkDelegationOptions {
166
+ /**
167
+ * Server-held secret that gates access to the grant. Same value must
168
+ * be supplied at claim time — the server is the only party that
169
+ * knows it, so a leaked URL alone cannot unlock anything.
170
+ */
171
+ readonly serverSecret: string | Uint8Array<ArrayBuffer>;
172
+ /**
173
+ * One or more grants to persist under the same magic-link token.
174
+ * Single-element arrays cover the common "one collection to one
175
+ * user" case; multi-element arrays support scoped cross-collection
176
+ * delegations (e.g. client portal: invoices + payments + etax).
177
+ */
178
+ readonly grants: readonly MagicLinkGrantSpec[];
179
+ /**
180
+ * Magic-link TTL. Controls `link.expiresAt` (the URL freshness).
181
+ * Each grant's own `until` bounds the *delegation* lifetime — the
182
+ * claimant only receives DEKs for grants whose `until` is still
183
+ * future at claim time. Default 24 h.
184
+ */
185
+ readonly ttlMs?: number;
186
+ /**
187
+ * Optional override for the ULID embedded in the URL. Rarely useful
188
+ * outside deterministic tests.
189
+ */
190
+ readonly token?: string;
191
+ }
192
+ interface IssueMagicLinkDelegationResult {
193
+ /** URL-embeddable token metadata. Serialize `link.token` into the link. */
194
+ readonly link: MagicLinkToken;
195
+ /** One record per grant — mirrors the input array order. */
196
+ readonly grants: ReadonlyArray<{
197
+ readonly recordId: string;
198
+ readonly payload: MagicLinkGrantPayload;
199
+ }>;
200
+ }
201
+ /**
202
+ * Issue a magic-link-bound delegation.
203
+ *
204
+ * Server-side workflow:
205
+ *
206
+ * ```ts
207
+ * import { issueMagicLinkDelegation } from '@noy-db/on-magic-link'
208
+ *
209
+ * const { link, grants } = await issueMagicLinkDelegation(vault, {
210
+ * serverSecret: process.env.MAGIC_LINK_SECRET!,
211
+ * grants: [
212
+ * { toUser: 'auditor-bob', tier: 1, collection: 'invoices',
213
+ * until: new Date(Date.now() + 48*3600e3) },
214
+ * ],
215
+ * ttlMs: 48 * 3600 * 1000,
216
+ * })
217
+ *
218
+ * // Embed link.token in an email URL. The grantee clicks → loads your
219
+ * // client → calls claimMagicLinkDelegation() with the same serverSecret.
220
+ * ```
221
+ */
222
+ declare function issueMagicLinkDelegation(vault: Vault, options: IssueMagicLinkDelegationOptions): Promise<IssueMagicLinkDelegationResult>;
223
+ interface ClaimMagicLinkDelegationOptions {
224
+ readonly store: NoydbStore;
225
+ readonly vault: string;
226
+ readonly serverSecret: string | Uint8Array<ArrayBuffer>;
227
+ readonly token: string;
228
+ /**
229
+ * Reference clock used to evaluate grant expiry. Production callers
230
+ * leave this `undefined`; tests pass a fixed date.
231
+ */
232
+ readonly now?: Date;
233
+ }
234
+ interface ClaimedMagicLinkGrant {
235
+ readonly payload: MagicLinkGrantPayload;
236
+ /** Tier DEK, ready to insert into a keyring map. */
237
+ readonly dek: CryptoKey;
238
+ /** True when the grant's `until` has already passed. */
239
+ readonly expired: boolean;
240
+ }
241
+ interface ClaimMagicLinkDelegationResult {
242
+ /**
243
+ * False when the server secret is wrong, the vault is wrong, or
244
+ * every grant is malformed. A `true` with an empty `grants` array
245
+ * means the record was deleted (revoked) between issue and claim.
246
+ */
247
+ readonly valid: boolean;
248
+ readonly grants: readonly ClaimedMagicLinkGrant[];
249
+ }
250
+ /**
251
+ * Client-side flow. Derives the same content key + KEK as the grantor,
252
+ * loads every grant persisted under the token (primary + batch
253
+ * entries), and returns the unwrapped tier DEKs.
254
+ *
255
+ * The caller decides what to do with them — typically inserting them
256
+ * into a freshly-built `UnlockedKeyring` (see `buildMagicLinkKeyring`)
257
+ * and opening a viewer session.
258
+ */
259
+ declare function claimMagicLinkDelegation(options: ClaimMagicLinkDelegationOptions): Promise<ClaimMagicLinkDelegationResult>;
260
+ /**
261
+ * Read (without unwrapping) the grants under a token — useful for an
262
+ * audit UI that shows the grantor what's still live on a link.
263
+ */
264
+ declare function inspectMagicLinkDelegation(options: {
265
+ readonly store: NoydbStore;
266
+ readonly vault: string;
267
+ readonly serverSecret: string | Uint8Array<ArrayBuffer>;
268
+ readonly token: string;
269
+ }): Promise<readonly MagicLinkGrantPayload[]>;
270
+ /**
271
+ * Delete every grant under a token. Idempotent — safe to call on an
272
+ * already-revoked or never-existed token. Returns the number of
273
+ * records removed.
274
+ */
275
+ declare function revokeMagicLinkDelegation(options: {
276
+ readonly store: NoydbStore;
277
+ readonly vault: string;
278
+ readonly token: string;
279
+ }): Promise<number>;
280
+ /**
281
+ * Read a single grant by its full record id. Convenience for callers
282
+ * that persisted `recordId` during issue and want to resolve just one.
283
+ */
284
+ declare function readMagicLinkGrant(options: {
285
+ readonly store: NoydbStore;
286
+ readonly vault: string;
287
+ readonly serverSecret: string | Uint8Array<ArrayBuffer>;
288
+ readonly token: string;
289
+ readonly recordId: string;
290
+ }): Promise<MagicLinkGrantPayload | null>;
291
+
292
+ export { type ClaimMagicLinkDelegationOptions, type ClaimMagicLinkDelegationResult, type ClaimedMagicLinkGrant, type CreateMagicLinkOptions, type IssueMagicLinkDelegationOptions, type IssueMagicLinkDelegationResult, MAGIC_LINK_DEFAULT_TTL_MS, type MagicLinkGrantSpec, type MagicLinkToken, buildMagicLinkKeyring, claimMagicLinkDelegation, createMagicLinkToken, deriveMagicLinkKEK, inspectMagicLinkDelegation, isMagicLinkValid, issueMagicLinkDelegation, readMagicLinkGrant, revokeMagicLinkDelegation };
package/dist/index.js ADDED
@@ -0,0 +1,147 @@
1
+ // src/index.ts
2
+ import {
3
+ generateULID,
4
+ deriveMagicLinkContentKey,
5
+ readMagicLinkGrantRecord,
6
+ listMagicLinkGrants,
7
+ unwrapMagicLinkGrant,
8
+ revokeMagicLinkGrant,
9
+ magicLinkGrantRecordId,
10
+ isMagicLinkGrantExpired,
11
+ MAGIC_LINK_GRANTS_COLLECTION
12
+ } from "@noy-db/hub";
13
+ var MAGIC_LINK_INFO_PREFIX = "noydb-magic-link-v1:";
14
+ var MAGIC_LINK_DEFAULT_TTL_MS = 24 * 60 * 60 * 1e3;
15
+ async function deriveMagicLinkKEK(serverSecret, token, vault) {
16
+ const subtle = globalThis.crypto.subtle;
17
+ const ikmBytes = serverSecret instanceof Uint8Array ? serverSecret : new TextEncoder().encode(serverSecret);
18
+ const tokenBytes = new TextEncoder().encode(token);
19
+ const saltBuffer = await subtle.digest("SHA-256", tokenBytes);
20
+ const info = new TextEncoder().encode(MAGIC_LINK_INFO_PREFIX + vault);
21
+ const ikm = await subtle.importKey("raw", ikmBytes, "HKDF", false, ["deriveKey"]);
22
+ return subtle.deriveKey(
23
+ {
24
+ name: "HKDF",
25
+ hash: "SHA-256",
26
+ salt: saltBuffer,
27
+ info
28
+ },
29
+ ikm,
30
+ { name: "AES-KW", length: 256 },
31
+ false,
32
+ ["wrapKey", "unwrapKey"]
33
+ );
34
+ }
35
+ function createMagicLinkToken(vault, options = {}) {
36
+ const ttlMs = options.ttlMs ?? MAGIC_LINK_DEFAULT_TTL_MS;
37
+ return {
38
+ token: generateULID(),
39
+ vault,
40
+ expiresAt: new Date(Date.now() + ttlMs).toISOString(),
41
+ role: "viewer"
42
+ };
43
+ }
44
+ function isMagicLinkValid(linkToken) {
45
+ return Date.now() <= new Date(linkToken.expiresAt).getTime();
46
+ }
47
+ function buildMagicLinkKeyring(opts) {
48
+ return {
49
+ userId: opts.viewerUserId,
50
+ displayName: opts.displayName,
51
+ role: "viewer",
52
+ permissions: {},
53
+ deks: opts.deks,
54
+ kek: opts.kek,
55
+ salt: opts.salt
56
+ };
57
+ }
58
+ async function issueMagicLinkDelegation(vault, options) {
59
+ if (options.grants.length === 0) {
60
+ throw new Error("@noy-db/on-magic-link: grants[] must be non-empty");
61
+ }
62
+ const token = options.token ?? generateULID();
63
+ const ttlMs = options.ttlMs ?? MAGIC_LINK_DEFAULT_TTL_MS;
64
+ const link = {
65
+ token,
66
+ vault: vault.name,
67
+ expiresAt: new Date(Date.now() + ttlMs).toISOString(),
68
+ role: "viewer"
69
+ };
70
+ const contentKey = await deriveMagicLinkContentKey(options.serverSecret, token, vault.name);
71
+ const grantKek = await deriveMagicLinkKEK(options.serverSecret, token, vault.name);
72
+ const grants = [];
73
+ for (let i = 0; i < options.grants.length; i += 1) {
74
+ const spec = options.grants[i];
75
+ const recordId = magicLinkGrantRecordId(token, i);
76
+ const issueOpts = {
77
+ toUser: spec.toUser,
78
+ tier: spec.tier,
79
+ ...spec.collection !== void 0 && { collection: spec.collection },
80
+ ...spec.record !== void 0 && { record: spec.record },
81
+ until: spec.until,
82
+ ...spec.note !== void 0 && { note: spec.note }
83
+ };
84
+ const record = await vault.writeMagicLinkGrant(contentKey, grantKek, recordId, issueOpts);
85
+ grants.push({ recordId: record.recordId, payload: record.payload });
86
+ }
87
+ return { link, grants };
88
+ }
89
+ async function claimMagicLinkDelegation(options) {
90
+ const { store, vault, token, serverSecret } = options;
91
+ const contentKey = await deriveMagicLinkContentKey(serverSecret, token, vault);
92
+ const grantKek = await deriveMagicLinkKEK(serverSecret, token, vault);
93
+ const payloads = await listMagicLinkGrants(store, vault, contentKey, token);
94
+ if (payloads.length === 0) {
95
+ return { valid: false, grants: [] };
96
+ }
97
+ const now = options.now ?? /* @__PURE__ */ new Date();
98
+ const claimed = [];
99
+ for (const payload of payloads) {
100
+ let dek;
101
+ try {
102
+ dek = await unwrapMagicLinkGrant(payload, grantKek);
103
+ } catch {
104
+ continue;
105
+ }
106
+ claimed.push({
107
+ payload,
108
+ dek,
109
+ expired: isMagicLinkGrantExpired(payload, now)
110
+ });
111
+ }
112
+ return { valid: true, grants: claimed };
113
+ }
114
+ async function inspectMagicLinkDelegation(options) {
115
+ const contentKey = await deriveMagicLinkContentKey(
116
+ options.serverSecret,
117
+ options.token,
118
+ options.vault
119
+ );
120
+ return listMagicLinkGrants(options.store, options.vault, contentKey, options.token);
121
+ }
122
+ async function revokeMagicLinkDelegation(options) {
123
+ return revokeMagicLinkGrant(options.store, options.vault, options.token);
124
+ }
125
+ async function readMagicLinkGrant(options) {
126
+ const contentKey = await deriveMagicLinkContentKey(
127
+ options.serverSecret,
128
+ options.token,
129
+ options.vault
130
+ );
131
+ return readMagicLinkGrantRecord(options.store, options.vault, contentKey, options.recordId);
132
+ }
133
+ export {
134
+ MAGIC_LINK_DEFAULT_TTL_MS,
135
+ MAGIC_LINK_GRANTS_COLLECTION,
136
+ buildMagicLinkKeyring,
137
+ claimMagicLinkDelegation,
138
+ createMagicLinkToken,
139
+ deriveMagicLinkContentKey,
140
+ deriveMagicLinkKEK,
141
+ inspectMagicLinkDelegation,
142
+ isMagicLinkValid,
143
+ issueMagicLinkDelegation,
144
+ readMagicLinkGrant,
145
+ revokeMagicLinkDelegation
146
+ };
147
+ //# sourceMappingURL=index.js.map
@@ -0,0 +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 }\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":[]}
package/package.json ADDED
@@ -0,0 +1,68 @@
1
+ {
2
+ "name": "@noy-db/on-magic-link",
3
+ "version": "0.1.0-pre.3",
4
+ "description": "Magic-link unlock for noy-db — one-time URL that opens a vault in a viewer-scoped session without a passphrase. Part of the @noy-db/on-* authentication family.",
5
+ "license": "MIT",
6
+ "author": "vLannaAi <vicio@lanna.ai>",
7
+ "homepage": "https://github.com/vLannaAi/noy-db/tree/main/packages/on-magic-link#readme",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/vLannaAi/noy-db.git",
11
+ "directory": "packages/on-magic-link"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/vLannaAi/noy-db/issues"
15
+ },
16
+ "type": "module",
17
+ "sideEffects": false,
18
+ "exports": {
19
+ ".": {
20
+ "import": {
21
+ "types": "./dist/index.d.ts",
22
+ "default": "./dist/index.js"
23
+ },
24
+ "require": {
25
+ "types": "./dist/index.d.cts",
26
+ "default": "./dist/index.cjs"
27
+ }
28
+ }
29
+ },
30
+ "main": "./dist/index.cjs",
31
+ "module": "./dist/index.js",
32
+ "types": "./dist/index.d.ts",
33
+ "files": [
34
+ "dist",
35
+ "README.md",
36
+ "LICENSE"
37
+ ],
38
+ "engines": {
39
+ "node": ">=18.0.0"
40
+ },
41
+ "peerDependencies": {
42
+ "@noy-db/hub": "0.1.0-pre.3"
43
+ },
44
+ "devDependencies": {
45
+ "@noy-db/hub": "0.1.0-pre.3"
46
+ },
47
+ "keywords": [
48
+ "noy-db",
49
+ "auth",
50
+ "on-magic-link",
51
+ "magic-link",
52
+ "passwordless",
53
+ "viewer-portal",
54
+ "one-time-token",
55
+ "encryption",
56
+ "zero-knowledge"
57
+ ],
58
+ "publishConfig": {
59
+ "access": "public",
60
+ "tag": "latest"
61
+ },
62
+ "scripts": {
63
+ "build": "tsup",
64
+ "test": "vitest run",
65
+ "lint": "eslint src/",
66
+ "typecheck": "tsc --noEmit"
67
+ }
68
+ }