@noy-db/on-magic-link 0.2.0-pre.3 → 0.2.0-pre.31
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.d.ts +3 -9
- package/dist/index.js.map +1 -1
- package/package.json +6 -13
- package/dist/index.cjs +0 -424
- package/dist/index.cjs.map +0 -1
- package/dist/index.d.cts +0 -526
package/dist/index.d.cts
DELETED
|
@@ -1,526 +0,0 @@
|
|
|
1
|
-
import { NoydbStore, createNoydb, PassphrasePolicy, Noydb, Role, FactorProof, MagicLinkGrantPayload, UnlockedKeyring, Vault } from '@noy-db/hub';
|
|
2
|
-
export { MAGIC_LINK_GRANTS_COLLECTION, MagicLinkGrantPayload, deriveMagicLinkContentKey } from '@noy-db/hub';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* **Invite + peer-recovery primitives** — issue #32.
|
|
6
|
-
*
|
|
7
|
-
* Layered on top of `db.grant` (for invite, mints a NEW user) and
|
|
8
|
-
* `db.recoverUser` (for peer-recovery, rewraps an EXISTING user under
|
|
9
|
-
* a fresh temp passphrase). These flows are SIBLINGS of the existing
|
|
10
|
-
* delegation-grant primitives in `./index.ts` — different threat
|
|
11
|
-
* model, different on-disk audit shape, no server-held secret.
|
|
12
|
-
*
|
|
13
|
-
* ## Threat model
|
|
14
|
-
*
|
|
15
|
-
* The temp passphrase travels in the **URL fragment** — server-blind
|
|
16
|
-
* transport (fragments don't traverse TLS proxies, don't appear in
|
|
17
|
-
* access logs). Trade-off: any party who sees the URL can claim the
|
|
18
|
-
* invite once. Single-use semantics close the window: `acceptInvite`
|
|
19
|
-
* rotates the passphrase atomically, marking the audit doc accepted —
|
|
20
|
-
* a second `acceptInvite` call rejects.
|
|
21
|
-
*
|
|
22
|
-
* ## What this module does NOT do
|
|
23
|
-
*
|
|
24
|
-
* - Send emails / construct HTTPS URLs (that's the application layer)
|
|
25
|
-
* - Validate the invite under HTTPS (caller's responsibility)
|
|
26
|
-
* - Coordinate with a server-held secret (delegation grants do that;
|
|
27
|
-
* invite is server-blind by design)
|
|
28
|
-
*
|
|
29
|
-
* @see #32 #33 #34
|
|
30
|
-
*
|
|
31
|
-
* @module
|
|
32
|
-
*/
|
|
33
|
-
|
|
34
|
-
/** Whether the payload mints a NEW user (invite) or rewraps an existing one (peer-recovery). */
|
|
35
|
-
type InviteKind = 'invite' | 'peer-recovery';
|
|
36
|
-
/**
|
|
37
|
-
* Serializable payload encoded into the URL fragment. The temp
|
|
38
|
-
* passphrase is the secret; the rest is metadata the recipient
|
|
39
|
-
* needs to open the right vault as the right user.
|
|
40
|
-
*/
|
|
41
|
-
interface InvitePayload {
|
|
42
|
-
/** ULID identifying this invite — used to look up the audit doc. */
|
|
43
|
-
readonly tokenId: string;
|
|
44
|
-
readonly vault: string;
|
|
45
|
-
readonly userId: string;
|
|
46
|
-
readonly displayName?: string;
|
|
47
|
-
readonly role?: Role;
|
|
48
|
-
readonly kind: InviteKind;
|
|
49
|
-
/** Issuer's userId (for forensics; not enforced cryptographically). */
|
|
50
|
-
readonly issuer: string;
|
|
51
|
-
/** Single-use temporary passphrase — replaced on `acceptInvite`. */
|
|
52
|
-
readonly tempPhrase: string;
|
|
53
|
-
readonly expiresAt: string;
|
|
54
|
-
}
|
|
55
|
-
/** Audit doc persisted at `_meta/invite-audit-<tokenId>`. */
|
|
56
|
-
interface InviteAuditDoc {
|
|
57
|
-
readonly _noydb_invite_audit: 1;
|
|
58
|
-
readonly tokenId: string;
|
|
59
|
-
readonly kind: InviteKind;
|
|
60
|
-
readonly issuer: string;
|
|
61
|
-
readonly target: string;
|
|
62
|
-
readonly expiresAt: string;
|
|
63
|
-
readonly issuedAt: string;
|
|
64
|
-
readonly revokedAt?: string;
|
|
65
|
-
readonly acceptedAt?: string;
|
|
66
|
-
}
|
|
67
|
-
interface IssueInviteOptions {
|
|
68
|
-
readonly userId: string;
|
|
69
|
-
readonly displayName: string;
|
|
70
|
-
readonly role: Role;
|
|
71
|
-
readonly ttlMs?: number;
|
|
72
|
-
/** Override the generated temp phrase (rare; deterministic tests). */
|
|
73
|
-
readonly tempPhrase?: string;
|
|
74
|
-
}
|
|
75
|
-
interface IssuePeerRecoveryOptions {
|
|
76
|
-
readonly userId: string;
|
|
77
|
-
readonly displayName?: string;
|
|
78
|
-
readonly role?: Role;
|
|
79
|
-
readonly ttlMs?: number;
|
|
80
|
-
readonly tempPhrase?: string;
|
|
81
|
-
}
|
|
82
|
-
interface IssueInviteResult {
|
|
83
|
-
readonly payload: InvitePayload;
|
|
84
|
-
/**
|
|
85
|
-
* URL-fragment-safe base64url encoding of the JSON payload. Embed
|
|
86
|
-
* after a `#` in the application's invite URL. Decoded back at
|
|
87
|
-
* accept time via `decodeInvitePayload`.
|
|
88
|
-
*/
|
|
89
|
-
readonly encoded: string;
|
|
90
|
-
}
|
|
91
|
-
interface AcceptInviteOptions {
|
|
92
|
-
/**
|
|
93
|
-
* The recipient's NoydbStore. Typically the same shared store the
|
|
94
|
-
* issuer used (e.g. a sync-peer pointing at a common backend) — the
|
|
95
|
-
* recipient must reach the same `_keyring/<userId>` and
|
|
96
|
-
* `_meta/invite-audit-<tokenId>` documents.
|
|
97
|
-
*/
|
|
98
|
-
readonly store: NoydbStore;
|
|
99
|
-
/**
|
|
100
|
-
* The recipient's chosen new passphrase. `acceptInvite` rotates
|
|
101
|
-
* the temp phrase to this value atomically before returning. Must
|
|
102
|
-
* pass the vault's phrase strength policy (or the recipient must
|
|
103
|
-
* pass `validatePassphrase: false` via the underlying createNoydb
|
|
104
|
-
* options — currently exposed via `noydbOptions`).
|
|
105
|
-
*/
|
|
106
|
-
readonly newPhrase: string;
|
|
107
|
-
/**
|
|
108
|
-
* Reference clock for TTL evaluation. Production callers leave
|
|
109
|
-
* this `undefined`; tests pass a fixed date.
|
|
110
|
-
*/
|
|
111
|
-
readonly now?: Date;
|
|
112
|
-
/**
|
|
113
|
-
* Extra options forwarded to `createNoydb` when the recipient's
|
|
114
|
-
* session opens. Useful for `policy`, `validatePassphrase`, or
|
|
115
|
-
* `sessionPolicy` tweaks the application layer wants in scope.
|
|
116
|
-
*/
|
|
117
|
-
readonly noydbOptions?: Omit<Parameters<typeof createNoydb>[0], 'store' | 'user' | 'secret'>;
|
|
118
|
-
/**
|
|
119
|
-
* Forwarded to the inner `keyringRotatePassphrase` call so the
|
|
120
|
-
* recipient's `newPhrase` is validated against the same policy the
|
|
121
|
-
* vault uses elsewhere. Without this, the rotation step applies the
|
|
122
|
-
* default phrase validator (lowercase letters + spaces) regardless
|
|
123
|
-
* of any `customValidator` / `pattern` set on the vault — a
|
|
124
|
-
* consumer using non-default phrase shapes (e.g. hyphen-separated
|
|
125
|
-
* alphanumeric, BIP-39 word-lists, Thai/EN-mixed scripts) hits a
|
|
126
|
-
* spurious `WeakPassphraseError`.
|
|
127
|
-
*
|
|
128
|
-
* `noydbOptions.policy.passphrase` is NOT auto-derived because
|
|
129
|
-
* `noydbOptions` flows to the post-rotation `createNoydb`, not the
|
|
130
|
-
* rotation itself. Passing the same `PassphrasePolicy` here and
|
|
131
|
-
* (via `noydbOptions.policy.passphrase`) keeps both gates aligned.
|
|
132
|
-
*
|
|
133
|
-
* Added in pre.9 (#53).
|
|
134
|
-
*/
|
|
135
|
-
readonly passphrasePolicy?: PassphrasePolicy;
|
|
136
|
-
/**
|
|
137
|
-
* Skip phrase strength validation for the recipient's `newPhrase`.
|
|
138
|
-
* Forwarded to the inner rotation. Use sparingly — bypasses the
|
|
139
|
-
* structural rules that protect against weak phrases.
|
|
140
|
-
*
|
|
141
|
-
* Added in pre.9 (#53).
|
|
142
|
-
*/
|
|
143
|
-
readonly allowWeakPassphrase?: boolean;
|
|
144
|
-
}
|
|
145
|
-
interface AcceptInviteResult {
|
|
146
|
-
readonly db: Noydb;
|
|
147
|
-
readonly payload: InvitePayload;
|
|
148
|
-
}
|
|
149
|
-
declare class InviteExpiredError extends Error {
|
|
150
|
-
readonly expiresAt: string;
|
|
151
|
-
readonly code: "INVITE_EXPIRED";
|
|
152
|
-
constructor(expiresAt: string);
|
|
153
|
-
}
|
|
154
|
-
declare class InviteRevokedError extends Error {
|
|
155
|
-
readonly tokenId: string;
|
|
156
|
-
readonly revokedAt: string;
|
|
157
|
-
readonly code: "INVITE_REVOKED";
|
|
158
|
-
constructor(tokenId: string, revokedAt: string);
|
|
159
|
-
}
|
|
160
|
-
declare class InviteAlreadyAcceptedError extends Error {
|
|
161
|
-
readonly tokenId: string;
|
|
162
|
-
readonly acceptedAt: string;
|
|
163
|
-
readonly code: "INVITE_ALREADY_ACCEPTED";
|
|
164
|
-
constructor(tokenId: string, acceptedAt: string);
|
|
165
|
-
}
|
|
166
|
-
declare class InviteAuditMissingError extends Error {
|
|
167
|
-
readonly tokenId: string;
|
|
168
|
-
readonly code: "INVITE_AUDIT_MISSING";
|
|
169
|
-
constructor(tokenId: string);
|
|
170
|
-
}
|
|
171
|
-
/**
|
|
172
|
-
* Mint an invite for a NEW user. Generates a random temp phrase,
|
|
173
|
-
* calls `db.grant({ userId, passphrase: tempPhrase })`, writes the
|
|
174
|
-
* audit doc, and returns the URL-encodable payload.
|
|
175
|
-
*
|
|
176
|
-
* The recipient claims via `acceptInvite(encoded, { store, newPhrase })`;
|
|
177
|
-
* the rotation inside `acceptInvite` invalidates the temp phrase
|
|
178
|
-
* (single-use by construction).
|
|
179
|
-
*
|
|
180
|
-
* @throws Whatever `db.grant` throws (PrivilegeEscalationError,
|
|
181
|
-
* WeakPassphraseError if the temp phrase fails policy, …)
|
|
182
|
-
*/
|
|
183
|
-
declare function issueInvite(db: Noydb, vault: string, options: IssueInviteOptions): Promise<IssueInviteResult>;
|
|
184
|
-
/**
|
|
185
|
-
* Mint a peer-recovery for an EXISTING user. Generates a random temp
|
|
186
|
-
* phrase, calls `db.recoverUser` (atomic; closes the partial-failure
|
|
187
|
-
* window of compose-from-primitives), writes the audit doc, returns
|
|
188
|
-
* the payload.
|
|
189
|
-
*
|
|
190
|
-
* Owner→owner is allowed (the policy gate `peer-recover-user` carries
|
|
191
|
-
* the freshness factor). The `factors` argument forwards to the gate.
|
|
192
|
-
*/
|
|
193
|
-
declare function issuePeerRecovery(db: Noydb, vault: string, options: IssuePeerRecoveryOptions, factors?: {
|
|
194
|
-
factors?: ReadonlyArray<FactorProof>;
|
|
195
|
-
sharedDevice?: boolean;
|
|
196
|
-
}): Promise<IssueInviteResult>;
|
|
197
|
-
/**
|
|
198
|
-
* Mark an outstanding invite as revoked. After this, `acceptInvite`
|
|
199
|
-
* for the same token rejects with `InviteRevokedError` BEFORE opening
|
|
200
|
-
* any session — closes #32's "revoked-link silent shadow keyring"
|
|
201
|
-
* defense (without the audit doc check, a revoked link could fall
|
|
202
|
-
* through to `createNoydb`'s no-keyring auto-create path and create
|
|
203
|
-
* a fresh empty vault).
|
|
204
|
-
*
|
|
205
|
-
* Idempotent — revoking an already-revoked invite is a no-op.
|
|
206
|
-
*
|
|
207
|
-
* Note: this does NOT delete the granted/recovered keyring; it only
|
|
208
|
-
* marks the invite token as unusable. To fully cancel the invite,
|
|
209
|
-
* call `db.revoke(vault, { userId })` separately. The two are split
|
|
210
|
-
* because peer-recovery doesn't have a "cancel" — the target user
|
|
211
|
-
* already existed.
|
|
212
|
-
*/
|
|
213
|
-
declare function revokeInvite(db: Noydb, vault: string, encodedOrPayload: string | InvitePayload): Promise<void>;
|
|
214
|
-
/**
|
|
215
|
-
* Open the recipient's session under the invite. Validates TTL +
|
|
216
|
-
* revoke + already-accepted, opens `createNoydb` with the temp
|
|
217
|
-
* phrase, immediately rotates to `newPhrase`, marks the audit doc
|
|
218
|
-
* accepted, returns the live `Noydb` instance.
|
|
219
|
-
*
|
|
220
|
-
* Single-use is enforced two ways:
|
|
221
|
-
* 1. The rotation inside this function invalidates the temp phrase.
|
|
222
|
-
* 2. The audit doc's `acceptedAt` field is set on success — a
|
|
223
|
-
* second `acceptInvite` call sees it and throws
|
|
224
|
-
* `InviteAlreadyAcceptedError`.
|
|
225
|
-
*
|
|
226
|
-
* @throws {@link InviteExpiredError} when TTL has passed.
|
|
227
|
-
* @throws {@link InviteRevokedError} when the issuer has revoked.
|
|
228
|
-
* @throws {@link InviteAlreadyAcceptedError} on second call.
|
|
229
|
-
* @throws {@link InviteAuditMissingError} when no audit doc — closes
|
|
230
|
-
* #32's revoked-link-shadow-keyring defense.
|
|
231
|
-
*/
|
|
232
|
-
declare function acceptInvite(encoded: string, options: AcceptInviteOptions): Promise<AcceptInviteResult>;
|
|
233
|
-
/** Encode the payload as a URL-fragment-safe base64url string. */
|
|
234
|
-
declare function encodeInvitePayload(payload: InvitePayload): string;
|
|
235
|
-
/** Decode a base64url string back to an InvitePayload. */
|
|
236
|
-
declare function decodeInvitePayload(encoded: string): InvitePayload;
|
|
237
|
-
|
|
238
|
-
/**
|
|
239
|
-
* **@noy-db/on-magic-link** — one-time-link viewer unlock for noy-db.
|
|
240
|
-
*
|
|
241
|
-
* A magic link is a single-use URL that opens a vault in a read-only,
|
|
242
|
-
* viewer-scoped session WITHOUT entering a passphrase. The link
|
|
243
|
-
* expires after use or after a configurable TTL; the resulting
|
|
244
|
-
* session is strictly limited to the `viewer` role.
|
|
245
|
-
*
|
|
246
|
-
* Part of the `@noy-db/on-*` authentication family. Sibling packages:
|
|
247
|
-
* `@noy-db/on-oidc` (federated login), `@noy-db/on-webauthn` (passkey
|
|
248
|
-
* / biometric). All follow the same shape: enrol once, produce a
|
|
249
|
-
* short-lived token, unwrap a viewer keyring at unlock.
|
|
250
|
-
*
|
|
251
|
-
* ## Security model
|
|
252
|
-
*
|
|
253
|
-
* The viewer KEK is derived via:
|
|
254
|
-
*
|
|
255
|
-
* ```
|
|
256
|
-
* HKDF-SHA256(
|
|
257
|
-
* ikm = serverSecret,
|
|
258
|
-
* salt = sha256(token),
|
|
259
|
-
* info = "noydb-magic-link-v1:" + vaultId,
|
|
260
|
-
* )
|
|
261
|
-
* ```
|
|
262
|
-
*
|
|
263
|
-
* - `serverSecret` is a server-held secret that the SERVER knows but
|
|
264
|
-
* is NOT embedded in the link. If the link is intercepted, the
|
|
265
|
-
* attacker cannot derive the KEK without the server secret.
|
|
266
|
-
* - `token` is a ULID embedded in the URL. It is single-use at the
|
|
267
|
-
* application layer (the server marks it consumed after first use).
|
|
268
|
-
* - `vaultId` binds the derived key to a specific vault — a token for
|
|
269
|
-
* vault A cannot be used to unlock vault B.
|
|
270
|
-
*
|
|
271
|
-
* The resulting keyring is ALWAYS viewer-scoped (`role: 'viewer'`).
|
|
272
|
-
* The DEKs available to the viewer are only the collections in the
|
|
273
|
-
* viewer-specific subset, determined by the admin who created the link.
|
|
274
|
-
*
|
|
275
|
-
* ## What this package is NOT
|
|
276
|
-
*
|
|
277
|
-
* This module provides the CRYPTO layer only — it does not:
|
|
278
|
-
* - Issue HTTP tokens or send emails (that's the application layer)
|
|
279
|
-
* - Mark tokens as consumed (that's the server's responsibility)
|
|
280
|
-
* - Store viewer keyrings in the adapter (callers do this via `grant()`)
|
|
281
|
-
*
|
|
282
|
-
* ## Usage
|
|
283
|
-
*
|
|
284
|
-
* ```ts
|
|
285
|
-
* import {
|
|
286
|
-
* createMagicLinkToken,
|
|
287
|
-
* deriveMagicLinkKEK,
|
|
288
|
-
* isMagicLinkValid,
|
|
289
|
-
* buildMagicLinkKeyring,
|
|
290
|
-
* } from '@noy-db/on-magic-link'
|
|
291
|
-
*
|
|
292
|
-
* // SERVER — mint a token + grant the viewer keyring
|
|
293
|
-
* const token = createMagicLinkToken('company-a', { ttlMs: 24 * 60 * 60 * 1000 })
|
|
294
|
-
* const kek = await deriveMagicLinkKEK(serverSecret, token.token, 'company-a')
|
|
295
|
-
* // ... use kek + db.grant(...) to create a viewer keyring entry ...
|
|
296
|
-
*
|
|
297
|
-
* // Email the link, e.g. https://app.example.com/view?t=<token.token>
|
|
298
|
-
*
|
|
299
|
-
* // CLIENT — derive the same KEK and unlock
|
|
300
|
-
* if (!isMagicLinkValid(token)) throw new Error('expired')
|
|
301
|
-
* const sameKek = await deriveMagicLinkKEK(serverSecret, token.token, token.vault)
|
|
302
|
-
* const keyring = buildMagicLinkKeyring({ ... })
|
|
303
|
-
* ```
|
|
304
|
-
*
|
|
305
|
-
* @packageDocumentation
|
|
306
|
-
*/
|
|
307
|
-
|
|
308
|
-
/** Default magic-link TTL: 24 hours. */
|
|
309
|
-
declare const MAGIC_LINK_DEFAULT_TTL_MS: number;
|
|
310
|
-
/**
|
|
311
|
-
* The serializable metadata describing a magic link.
|
|
312
|
-
* Embed `token` in the link URL as a query parameter or path segment.
|
|
313
|
-
*/
|
|
314
|
-
interface MagicLinkToken {
|
|
315
|
-
/** Unique one-time token (ULID). Embed this in the URL. */
|
|
316
|
-
readonly token: string;
|
|
317
|
-
/** The vault this link unlocks (viewer-only). */
|
|
318
|
-
readonly vault: string;
|
|
319
|
-
/** ISO timestamp after which the link is invalid. */
|
|
320
|
-
readonly expiresAt: string;
|
|
321
|
-
/** Role of the resulting session. Always `'viewer'` for magic links. */
|
|
322
|
-
readonly role: 'viewer';
|
|
323
|
-
}
|
|
324
|
-
/** Options for `createMagicLinkToken()`. */
|
|
325
|
-
interface CreateMagicLinkOptions {
|
|
326
|
-
/** Link lifetime in milliseconds. Default: 24 hours. */
|
|
327
|
-
ttlMs?: number;
|
|
328
|
-
}
|
|
329
|
-
/**
|
|
330
|
-
* Derive a viewer KEK from the server secret and the magic-link token.
|
|
331
|
-
*
|
|
332
|
-
* Both the server (at grant time) and the client (at unlock time) call
|
|
333
|
-
* this with the same inputs to get the same key. The key is used to:
|
|
334
|
-
* - Server: derive the KEK, call `db.grant()` to create a viewer keyring.
|
|
335
|
-
* - Client: derive the KEK, call `db.openVault()` / `loadKeyring()` with
|
|
336
|
-
* this KEK directly (bypassing PBKDF2) to unlock the viewer session.
|
|
337
|
-
*
|
|
338
|
-
* @param serverSecret - Server-held secret (never sent to the client).
|
|
339
|
-
* @param token - The ULID from the magic-link URL.
|
|
340
|
-
* @param vault - The vault ID this link is for.
|
|
341
|
-
*/
|
|
342
|
-
declare function deriveMagicLinkKEK(serverSecret: string | Uint8Array<ArrayBuffer>, token: string, vault: string): Promise<CryptoKey>;
|
|
343
|
-
/**
|
|
344
|
-
* Generate a magic-link token (server-side).
|
|
345
|
-
*
|
|
346
|
-
* Returns a `MagicLinkToken` whose `token` field should be embedded in
|
|
347
|
-
* the URL sent to the viewer. The server must store the token metadata
|
|
348
|
-
* (or reconstruct it from the URL) so it can:
|
|
349
|
-
* 1. Validate that the token has not expired or been used.
|
|
350
|
-
* 2. Call `deriveMagicLinkKEK()` to create the viewer keyring.
|
|
351
|
-
*
|
|
352
|
-
* @param vault - The vault to grant viewer access to.
|
|
353
|
-
* @param options - Optional TTL configuration.
|
|
354
|
-
*/
|
|
355
|
-
declare function createMagicLinkToken(vault: string, options?: CreateMagicLinkOptions): MagicLinkToken;
|
|
356
|
-
/**
|
|
357
|
-
* Validate that a magic-link token is not expired.
|
|
358
|
-
* Returns `true` if valid, `false` if expired.
|
|
359
|
-
*
|
|
360
|
-
* Single-use enforcement (marking a token as consumed after first use)
|
|
361
|
-
* is the server's responsibility — this function only checks `expiresAt`.
|
|
362
|
-
*/
|
|
363
|
-
declare function isMagicLinkValid(linkToken: MagicLinkToken): boolean;
|
|
364
|
-
/**
|
|
365
|
-
* Build a stub `UnlockedKeyring` from the magic-link-derived KEK and
|
|
366
|
-
* the viewer's DEK set.
|
|
367
|
-
*
|
|
368
|
-
* This is a thin wrapper for callers that have already:
|
|
369
|
-
* 1. Called `deriveMagicLinkKEK()` to get the viewer KEK.
|
|
370
|
-
* 2. Loaded the viewer's keyring from the adapter (which holds the DEKs
|
|
371
|
-
* wrapped with the magic-link KEK).
|
|
372
|
-
* 3. Unwrapped the DEKs.
|
|
373
|
-
*
|
|
374
|
-
* The resulting keyring is always viewer-scoped. Callers who want to turn
|
|
375
|
-
* it into a session token should call `createSession()` from
|
|
376
|
-
* `@noy-db/hub/session`.
|
|
377
|
-
*/
|
|
378
|
-
declare function buildMagicLinkKeyring(opts: {
|
|
379
|
-
viewerUserId: string;
|
|
380
|
-
displayName: string;
|
|
381
|
-
deks: Map<string, CryptoKey>;
|
|
382
|
-
kek: CryptoKey;
|
|
383
|
-
salt: Uint8Array;
|
|
384
|
-
}): UnlockedKeyring;
|
|
385
|
-
/**
|
|
386
|
-
* Single grant within a batch magic-link issue. The grantor specifies
|
|
387
|
-
* the tier + scope; the package handles the wrapping. `record` is
|
|
388
|
-
* optional and narrows the grant to a single record id in the
|
|
389
|
-
* collection (leave undefined for a whole-collection grant).
|
|
390
|
-
*/
|
|
391
|
-
interface MagicLinkGrantSpec {
|
|
392
|
-
readonly toUser: string;
|
|
393
|
-
readonly tier: number;
|
|
394
|
-
readonly collection?: string;
|
|
395
|
-
readonly record?: string;
|
|
396
|
-
readonly until: Date | string;
|
|
397
|
-
readonly note?: string;
|
|
398
|
-
}
|
|
399
|
-
interface IssueMagicLinkDelegationOptions {
|
|
400
|
-
/**
|
|
401
|
-
* Server-held secret that gates access to the grant. Same value must
|
|
402
|
-
* be supplied at claim time — the server is the only party that
|
|
403
|
-
* knows it, so a leaked URL alone cannot unlock anything.
|
|
404
|
-
*/
|
|
405
|
-
readonly serverSecret: string | Uint8Array<ArrayBuffer>;
|
|
406
|
-
/**
|
|
407
|
-
* One or more grants to persist under the same magic-link token.
|
|
408
|
-
* Single-element arrays cover the common "one collection to one
|
|
409
|
-
* user" case; multi-element arrays support scoped cross-collection
|
|
410
|
-
* delegations (e.g. client portal: invoices + payments + etax).
|
|
411
|
-
*/
|
|
412
|
-
readonly grants: readonly MagicLinkGrantSpec[];
|
|
413
|
-
/**
|
|
414
|
-
* Magic-link TTL. Controls `link.expiresAt` (the URL freshness).
|
|
415
|
-
* Each grant's own `until` bounds the *delegation* lifetime — the
|
|
416
|
-
* claimant only receives DEKs for grants whose `until` is still
|
|
417
|
-
* future at claim time. Default 24 h.
|
|
418
|
-
*/
|
|
419
|
-
readonly ttlMs?: number;
|
|
420
|
-
/**
|
|
421
|
-
* Optional override for the ULID embedded in the URL. Rarely useful
|
|
422
|
-
* outside deterministic tests.
|
|
423
|
-
*/
|
|
424
|
-
readonly token?: string;
|
|
425
|
-
}
|
|
426
|
-
interface IssueMagicLinkDelegationResult {
|
|
427
|
-
/** URL-embeddable token metadata. Serialize `link.token` into the link. */
|
|
428
|
-
readonly link: MagicLinkToken;
|
|
429
|
-
/** One record per grant — mirrors the input array order. */
|
|
430
|
-
readonly grants: ReadonlyArray<{
|
|
431
|
-
readonly recordId: string;
|
|
432
|
-
readonly payload: MagicLinkGrantPayload;
|
|
433
|
-
}>;
|
|
434
|
-
}
|
|
435
|
-
/**
|
|
436
|
-
* Issue a magic-link-bound delegation.
|
|
437
|
-
*
|
|
438
|
-
* Server-side workflow:
|
|
439
|
-
*
|
|
440
|
-
* ```ts
|
|
441
|
-
* import { issueMagicLinkDelegation } from '@noy-db/on-magic-link'
|
|
442
|
-
*
|
|
443
|
-
* const { link, grants } = await issueMagicLinkDelegation(vault, {
|
|
444
|
-
* serverSecret: process.env.MAGIC_LINK_SECRET!,
|
|
445
|
-
* grants: [
|
|
446
|
-
* { toUser: 'auditor-bob', tier: 1, collection: 'invoices',
|
|
447
|
-
* until: new Date(Date.now() + 48*3600e3) },
|
|
448
|
-
* ],
|
|
449
|
-
* ttlMs: 48 * 3600 * 1000,
|
|
450
|
-
* })
|
|
451
|
-
*
|
|
452
|
-
* // Embed link.token in an email URL. The grantee clicks → loads your
|
|
453
|
-
* // client → calls claimMagicLinkDelegation() with the same serverSecret.
|
|
454
|
-
* ```
|
|
455
|
-
*/
|
|
456
|
-
declare function issueMagicLinkDelegation(vault: Vault, options: IssueMagicLinkDelegationOptions): Promise<IssueMagicLinkDelegationResult>;
|
|
457
|
-
interface ClaimMagicLinkDelegationOptions {
|
|
458
|
-
readonly store: NoydbStore;
|
|
459
|
-
readonly vault: string;
|
|
460
|
-
readonly serverSecret: string | Uint8Array<ArrayBuffer>;
|
|
461
|
-
readonly token: string;
|
|
462
|
-
/**
|
|
463
|
-
* Reference clock used to evaluate grant expiry. Production callers
|
|
464
|
-
* leave this `undefined`; tests pass a fixed date.
|
|
465
|
-
*/
|
|
466
|
-
readonly now?: Date;
|
|
467
|
-
}
|
|
468
|
-
interface ClaimedMagicLinkGrant {
|
|
469
|
-
readonly payload: MagicLinkGrantPayload;
|
|
470
|
-
/** Tier DEK, ready to insert into a keyring map. */
|
|
471
|
-
readonly dek: CryptoKey;
|
|
472
|
-
/** True when the grant's `until` has already passed. */
|
|
473
|
-
readonly expired: boolean;
|
|
474
|
-
}
|
|
475
|
-
interface ClaimMagicLinkDelegationResult {
|
|
476
|
-
/**
|
|
477
|
-
* False when the server secret is wrong, the vault is wrong, or
|
|
478
|
-
* every grant is malformed. A `true` with an empty `grants` array
|
|
479
|
-
* means the record was deleted (revoked) between issue and claim.
|
|
480
|
-
*/
|
|
481
|
-
readonly valid: boolean;
|
|
482
|
-
readonly grants: readonly ClaimedMagicLinkGrant[];
|
|
483
|
-
}
|
|
484
|
-
/**
|
|
485
|
-
* Client-side flow. Derives the same content key + KEK as the grantor,
|
|
486
|
-
* loads every grant persisted under the token (primary + batch
|
|
487
|
-
* entries), and returns the unwrapped tier DEKs.
|
|
488
|
-
*
|
|
489
|
-
* The caller decides what to do with them — typically inserting them
|
|
490
|
-
* into a freshly-built `UnlockedKeyring` (see `buildMagicLinkKeyring`)
|
|
491
|
-
* and opening a viewer session.
|
|
492
|
-
*/
|
|
493
|
-
declare function claimMagicLinkDelegation(options: ClaimMagicLinkDelegationOptions): Promise<ClaimMagicLinkDelegationResult>;
|
|
494
|
-
/**
|
|
495
|
-
* Read (without unwrapping) the grants under a token — useful for an
|
|
496
|
-
* audit UI that shows the grantor what's still live on a link.
|
|
497
|
-
*/
|
|
498
|
-
declare function inspectMagicLinkDelegation(options: {
|
|
499
|
-
readonly store: NoydbStore;
|
|
500
|
-
readonly vault: string;
|
|
501
|
-
readonly serverSecret: string | Uint8Array<ArrayBuffer>;
|
|
502
|
-
readonly token: string;
|
|
503
|
-
}): Promise<readonly MagicLinkGrantPayload[]>;
|
|
504
|
-
/**
|
|
505
|
-
* Delete every grant under a token. Idempotent — safe to call on an
|
|
506
|
-
* already-revoked or never-existed token. Returns the number of
|
|
507
|
-
* records removed.
|
|
508
|
-
*/
|
|
509
|
-
declare function revokeMagicLinkDelegation(options: {
|
|
510
|
-
readonly store: NoydbStore;
|
|
511
|
-
readonly vault: string;
|
|
512
|
-
readonly token: string;
|
|
513
|
-
}): Promise<number>;
|
|
514
|
-
/**
|
|
515
|
-
* Read a single grant by its full record id. Convenience for callers
|
|
516
|
-
* that persisted `recordId` during issue and want to resolve just one.
|
|
517
|
-
*/
|
|
518
|
-
declare function readMagicLinkGrant(options: {
|
|
519
|
-
readonly store: NoydbStore;
|
|
520
|
-
readonly vault: string;
|
|
521
|
-
readonly serverSecret: string | Uint8Array<ArrayBuffer>;
|
|
522
|
-
readonly token: string;
|
|
523
|
-
readonly recordId: string;
|
|
524
|
-
}): Promise<MagicLinkGrantPayload | null>;
|
|
525
|
-
|
|
526
|
-
export { type AcceptInviteOptions, type AcceptInviteResult, type ClaimMagicLinkDelegationOptions, type ClaimMagicLinkDelegationResult, type ClaimedMagicLinkGrant, type CreateMagicLinkOptions, InviteAlreadyAcceptedError, type InviteAuditDoc, InviteAuditMissingError, InviteExpiredError, type InviteKind, type InvitePayload, InviteRevokedError, type IssueInviteOptions, type IssueInviteResult, type IssueMagicLinkDelegationOptions, type IssueMagicLinkDelegationResult, type IssuePeerRecoveryOptions, MAGIC_LINK_DEFAULT_TTL_MS, type MagicLinkGrantSpec, type MagicLinkToken, acceptInvite, buildMagicLinkKeyring, claimMagicLinkDelegation, createMagicLinkToken, decodeInvitePayload, deriveMagicLinkKEK, encodeInvitePayload, inspectMagicLinkDelegation, isMagicLinkValid, issueInvite, issueMagicLinkDelegation, issuePeerRecovery, readMagicLinkGrant, revokeInvite, revokeMagicLinkDelegation };
|