signet-login 0.1.0

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/modal.js ADDED
@@ -0,0 +1,401 @@
1
+ /**
2
+ * The login modal — picker → method-specific UI → resolved session.
3
+ *
4
+ * Mirrors signet-verify's <dialog>-based pattern: native focus trap,
5
+ * top-layer placement, theme-aware colours, no third-party UI deps.
6
+ */
7
+ import { DEFAULTS } from './types.js';
8
+ import { hasNip07, createNip07Signer, createBunkerSigner, EphemeralSigner } from './signers.js';
9
+ import { waitForAuthResponse } from 'signet-verify';
10
+ import { schnorr } from '@noble/curves/secp256k1';
11
+ import { bytesToHex } from '@noble/hashes/utils';
12
+ function escapeHtml(str) {
13
+ return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
14
+ }
15
+ function generateChallenge() {
16
+ const bytes = new Uint8Array(32);
17
+ crypto.getRandomValues(bytes);
18
+ return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
19
+ }
20
+ function isDarkMode(theme) {
21
+ if (theme === 'dark')
22
+ return true;
23
+ if (theme === 'light')
24
+ return false;
25
+ return typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches;
26
+ }
27
+ function buildModalShell(theme) {
28
+ const style = document.createElement('style');
29
+ style.textContent = '#signet-login-dialog::backdrop{background:rgba(0,0,0,0.7)}';
30
+ document.head.appendChild(style);
31
+ const dark = isDarkMode(theme);
32
+ const bg = dark ? '#1a1a2e' : '#ffffff';
33
+ const fg = dark ? '#e0e0e0' : '#1a1a2e';
34
+ const dialog = document.createElement('dialog');
35
+ dialog.id = 'signet-login-dialog';
36
+ dialog.style.cssText = `border:none;border-radius:16px;padding:32px;max-width:380px;width:90%;text-align:center;box-shadow:0 20px 60px rgba(0,0,0,0.3);background:${bg};color:${fg};font-family:system-ui,-apple-system,sans-serif;`;
37
+ document.body.appendChild(dialog);
38
+ dialog.showModal();
39
+ return { dialog, style };
40
+ }
41
+ function tearDown(refs) {
42
+ try {
43
+ refs.dialog.close();
44
+ }
45
+ catch { /* ignore */ }
46
+ refs.dialog.remove();
47
+ refs.style.remove();
48
+ }
49
+ function buttonStyle(dark, primary = false) {
50
+ if (primary) {
51
+ return 'background:#2c3e8f;color:white;border:0;padding:12px 16px;border-radius:8px;cursor:pointer;font-size:0.95rem;width:100%;margin-bottom:8px;text-align:left;display:flex;align-items:center;gap:12px;';
52
+ }
53
+ const border = dark ? '#3a3a4e' : '#d0d0d0';
54
+ const fg = dark ? '#e0e0e0' : '#1a1a2e';
55
+ return `background:transparent;color:${fg};border:1px solid ${border};padding:12px 16px;border-radius:8px;cursor:pointer;font-size:0.95rem;width:100%;margin-bottom:8px;text-align:left;display:flex;align-items:center;gap:12px;`;
56
+ }
57
+ // ── Picker ────────────────────────────────────────────────────────────────────
58
+ function renderPicker(refs, appName, theme) {
59
+ const dark = isDarkMode(theme);
60
+ const muted = dark ? '#888' : '#666';
61
+ const showNip07 = hasNip07();
62
+ refs.dialog.innerHTML = `
63
+ <h2 style="margin:0 0 8px;font-size:1.3rem;">Sign in to ${escapeHtml(appName)}</h2>
64
+ <p style="margin:0 0 24px;color:${muted};font-size:0.9rem;">Choose how you want to sign in. Your keys never leave your control.</p>
65
+ <div style="display:flex;flex-direction:column;">
66
+ ${showNip07 ? `<button data-choice="nip07" style="${buttonStyle(dark, true)}"><span style="font-size:1.2rem;">🌐</span><span><strong>Browser extension</strong><br><span style="font-size:0.8rem;opacity:0.8;">bark, Alby, nos2x</span></span></button>` : ''}
67
+ <button data-choice="redirect" style="${buttonStyle(dark)}"><span style="font-size:1.2rem;">🪪</span><span><strong>Sign in with Signet</strong><br><span style="font-size:0.8rem;color:${muted};">Scan QR with your phone</span></span></button>
68
+ <button data-choice="bunker" style="${buttonStyle(dark)}"><span style="font-size:1.2rem;">🔑</span><span><strong>Paste bunker URI</strong><br><span style="font-size:0.8rem;color:${muted};">For NIP-46 power users</span></span></button>
69
+ </div>
70
+ <button data-choice="cancel" style="background:none;border:0;color:${muted};padding:12px;cursor:pointer;font-size:0.85rem;margin-top:8px;">Cancel</button>
71
+ `;
72
+ return new Promise(resolve => {
73
+ refs.dialog.querySelectorAll('button[data-choice]').forEach(btn => {
74
+ btn.addEventListener('click', () => {
75
+ const choice = btn.dataset.choice;
76
+ resolve(choice);
77
+ });
78
+ });
79
+ });
80
+ }
81
+ /**
82
+ * Render a "waiting for browser extension" UI with a working cancel button
83
+ * and an elapsed-time ticker. NIP-07 calls (`getPublicKey`, `signEvent`) have
84
+ * no native cancellation — bark / Alby / nsec.app etc. can take 4-30s to
85
+ * respond on cold start (service worker spawn + relay handshake). Without
86
+ * this UI the user sees the picker frozen and the picker's Cancel button is
87
+ * already-resolved, so they appear stuck. Replacing the picker DOM with a
88
+ * dedicated wait screen restores a real Cancel.
89
+ */
90
+ async function runNip07Flow(refs, opts) {
91
+ const dark = isDarkMode(opts.theme);
92
+ const muted = dark ? '#888' : '#666';
93
+ const fg = dark ? '#e0e0e0' : '#1a1a2e';
94
+ refs.dialog.innerHTML = `
95
+ <h2 style="margin:0 0 8px;font-size:1.2rem;">Waiting for your extension</h2>
96
+ <p style="margin:0 0 20px;color:${muted};font-size:0.85rem;">Approve the sign-in prompt in bark, Alby, nos2x, or whichever NIP-07 extension you use. Cold-start can take a few seconds.</p>
97
+ <div style="display:flex;align-items:center;justify-content:center;gap:14px;margin:0 0 24px;color:${fg};">
98
+ <div style="width:28px;height:28px;border:3px solid ${dark ? '#3a3a4e' : '#d0d0d0'};border-top-color:#5b6dff;border-radius:50%;animation:signet-login-spin 0.9s linear infinite;"></div>
99
+ <span id="signet-login-nip07-elapsed" style="font-variant-numeric:tabular-nums;font-size:0.95rem;">Connecting…</span>
100
+ </div>
101
+ <div style="display:flex;gap:8px;justify-content:space-between;">
102
+ <button data-action="back" style="${buttonStyle(dark)}width:auto;flex:0 0 auto;padding:8px 16px;">← Back</button>
103
+ <button data-action="cancel" style="${buttonStyle(dark)}width:auto;flex:0 0 auto;padding:8px 16px;">Cancel</button>
104
+ </div>
105
+ <style>@keyframes signet-login-spin{to{transform:rotate(360deg)}}</style>
106
+ `;
107
+ const elapsedEl = refs.dialog.querySelector('#signet-login-nip07-elapsed');
108
+ let elapsed = 0;
109
+ const ticker = window.setInterval(() => {
110
+ elapsed += 1;
111
+ if (elapsedEl)
112
+ elapsedEl.textContent = `Waiting for your signer (${elapsed}s)…`;
113
+ }, 1000);
114
+ // The cancel signal — resolves when the user clicks Cancel/Back. Used to
115
+ // race the NIP-07 calls so the modal can dismiss promptly instead of
116
+ // hanging on the unresolvable extension promise. (We can't truly abort
117
+ // the NIP-07 promise since it has no abort signal, but we stop waiting
118
+ // for it and let it resolve into the void.)
119
+ const cancelled = new Promise(resolve => {
120
+ refs.dialog.querySelector('[data-action="back"]')?.addEventListener('click', () => resolve(null));
121
+ refs.dialog.querySelector('[data-action="cancel"]')?.addEventListener('click', () => resolve(null));
122
+ });
123
+ try {
124
+ // Race: either the extension comes through, or the user cancels.
125
+ const signer = await Promise.race([createNip07Signer(), cancelled]);
126
+ if (!signer)
127
+ return null;
128
+ const authEvent = await Promise.race([
129
+ signer.signEvent({
130
+ kind: 21236,
131
+ content: '',
132
+ tags: [
133
+ ['challenge', opts.challenge],
134
+ ['origin', opts.origin],
135
+ ['app', opts.appName],
136
+ ],
137
+ }),
138
+ cancelled,
139
+ ]);
140
+ if (!authEvent) {
141
+ try {
142
+ await signer.close();
143
+ }
144
+ catch { /* ignore */ }
145
+ return null;
146
+ }
147
+ return { pubkey: signer.pubkey, authEvent };
148
+ }
149
+ catch (err) {
150
+ if (elapsedEl) {
151
+ elapsedEl.textContent = `✗ ${err instanceof Error ? err.message : String(err)}`;
152
+ elapsedEl.style.color = '#d04848';
153
+ }
154
+ // Keep the modal up so user sees the error; resolve null after a beat
155
+ // so the cancel button can take them back.
156
+ await Promise.race([new Promise(r => setTimeout(r, 2500)), cancelled]);
157
+ return null;
158
+ }
159
+ finally {
160
+ window.clearInterval(ticker);
161
+ }
162
+ }
163
+ async function runRedirectFlow(refs, opts) {
164
+ const dark = isDarkMode(opts.theme);
165
+ const muted = dark ? '#888' : '#666';
166
+ // Generate session keypair for cross-device gift-wrap
167
+ const sessionPrivKey = schnorr.utils.randomPrivateKey();
168
+ const sessionPubkey = bytesToHex(schnorr.getPublicKey(sessionPrivKey));
169
+ const params = new URLSearchParams({
170
+ auth: '1',
171
+ challenge: opts.challenge,
172
+ origin: opts.origin,
173
+ name: opts.appName,
174
+ callback: opts.redirectCallback ?? `${opts.origin}/`,
175
+ t: String(Math.floor(Date.now() / 1000)),
176
+ relay: opts.relayUrl,
177
+ sessionPubkey,
178
+ });
179
+ const authUrl = `${opts.signetAppOrigin}/?${params.toString()}`;
180
+ refs.dialog.innerHTML = `
181
+ <h2 style="margin:0 0 8px;font-size:1.2rem;">Sign in with Signet</h2>
182
+ <p style="margin:0 0 16px;color:${muted};font-size:0.85rem;">Open the link on your phone, or scan the QR if rendered.</p>
183
+ <div style="background:${dark ? '#0f0f1f' : '#f5f5f8'};border-radius:8px;padding:16px;margin-bottom:16px;">
184
+ <div id="signet-login-qr" style="width:200px;height:200px;margin:0 auto 12px;background:${dark ? '#1a1a2e' : '#ffffff'};border-radius:6px;display:flex;align-items:center;justify-content:center;color:${muted};font-size:0.8rem;text-align:center;padding:12px;box-sizing:border-box;">
185
+ QR placeholder<br><span style="font-size:0.7rem;">(bundle qr lib for production)</span>
186
+ </div>
187
+ <a href="${escapeHtml(authUrl)}" target="_blank" rel="noopener" style="display:block;color:#5b6dff;font-size:0.75rem;word-break:break-all;text-decoration:none;">${escapeHtml(authUrl.slice(0, 80))}…</a>
188
+ </div>
189
+ <p id="signet-login-status" style="margin:0 0 12px;color:${muted};font-size:0.85rem;">Waiting for approval…</p>
190
+ <div style="display:flex;gap:8px;justify-content:space-between;">
191
+ <button data-action="back" style="${buttonStyle(dark)}width:auto;flex:0 0 auto;padding:8px 16px;">← Back</button>
192
+ <button data-action="cancel" style="${buttonStyle(dark)}width:auto;flex:0 0 auto;padding:8px 16px;">Cancel</button>
193
+ </div>
194
+ `;
195
+ return new Promise(resolve => {
196
+ let settled = false;
197
+ const settle = (v) => {
198
+ if (settled)
199
+ return;
200
+ settled = true;
201
+ resolve(v);
202
+ };
203
+ refs.dialog.querySelector('[data-action="back"]')?.addEventListener('click', () => {
204
+ settle(null);
205
+ });
206
+ refs.dialog.querySelector('[data-action="cancel"]')?.addEventListener('click', () => {
207
+ settle(null);
208
+ });
209
+ waitForAuthResponse({
210
+ requestId: opts.challenge,
211
+ relayUrl: opts.relayUrl,
212
+ sessionPrivKey,
213
+ expectedOrigin: opts.origin,
214
+ timeout: opts.timeout,
215
+ }).then(result => {
216
+ const authEvent = {
217
+ id: result.authEvent.id,
218
+ pubkey: result.authEvent.pubkey,
219
+ kind: 21236,
220
+ created_at: result.authEvent.created_at,
221
+ tags: result.authEvent.tags,
222
+ content: result.authEvent.content,
223
+ sig: result.authEvent.sig,
224
+ };
225
+ const out = { pubkey: result.pubkey, authEvent };
226
+ if (result.displayName)
227
+ out.displayName = result.displayName;
228
+ settle(out);
229
+ }).catch(err => {
230
+ const status = refs.dialog.querySelector('#signet-login-status');
231
+ if (status) {
232
+ status.textContent = `✗ ${err instanceof Error ? err.message : String(err)}`;
233
+ status.style.color = '#d04848';
234
+ }
235
+ // Don't auto-settle on error — let the user choose to go back/cancel.
236
+ });
237
+ });
238
+ }
239
+ // ── Paste bunker URI ──────────────────────────────────────────────────────────
240
+ async function runBunkerFlow(refs, opts) {
241
+ const dark = isDarkMode(opts.theme);
242
+ const muted = dark ? '#888' : '#666';
243
+ const inputBg = dark ? '#0f0f1f' : '#f5f5f8';
244
+ const inputFg = dark ? '#e0e0e0' : '#1a1a2e';
245
+ refs.dialog.innerHTML = `
246
+ <h2 style="margin:0 0 8px;font-size:1.2rem;">Paste bunker URI</h2>
247
+ <p style="margin:0 0 16px;color:${muted};font-size:0.85rem;">Connect to your NIP-46 bunker (Heartwood, nsecBunker, or any compatible signer).</p>
248
+ <textarea id="signet-login-bunker-input" placeholder="bunker://..." rows="3" style="width:100%;background:${inputBg};color:${inputFg};border:1px solid ${dark ? '#3a3a4e' : '#d0d0d0'};border-radius:8px;padding:10px;font-size:0.85rem;font-family:ui-monospace,monospace;box-sizing:border-box;resize:vertical;margin-bottom:12px;"></textarea>
249
+ <p id="signet-login-bunker-status" style="margin:0 0 12px;color:${muted};font-size:0.85rem;min-height:1.2em;"></p>
250
+ <div style="display:flex;gap:8px;justify-content:space-between;">
251
+ <button data-action="back" style="${buttonStyle(dark)}width:auto;flex:0 0 auto;padding:8px 16px;">← Back</button>
252
+ <button data-action="connect" style="${buttonStyle(dark, true)}width:auto;flex:1;padding:8px 16px;text-align:center;">Connect</button>
253
+ </div>
254
+ `;
255
+ return new Promise(resolve => {
256
+ let settled = false;
257
+ const settle = (v) => {
258
+ if (settled)
259
+ return;
260
+ settled = true;
261
+ resolve(v);
262
+ };
263
+ const input = refs.dialog.querySelector('#signet-login-bunker-input');
264
+ const status = refs.dialog.querySelector('#signet-login-bunker-status');
265
+ const connectBtn = refs.dialog.querySelector('[data-action="connect"]');
266
+ refs.dialog.querySelector('[data-action="back"]')?.addEventListener('click', () => {
267
+ settle(null);
268
+ });
269
+ connectBtn?.addEventListener('click', async () => {
270
+ const uri = input?.value.trim() ?? '';
271
+ if (!uri) {
272
+ if (status)
273
+ status.textContent = 'Please paste a bunker URI.';
274
+ return;
275
+ }
276
+ if (status) {
277
+ status.textContent = 'Connecting…';
278
+ status.style.color = '';
279
+ }
280
+ connectBtn.disabled = true;
281
+ try {
282
+ const signer = await createBunkerSigner({ uri });
283
+ settle(signer);
284
+ }
285
+ catch (err) {
286
+ if (status) {
287
+ status.textContent = `✗ ${err instanceof Error ? err.message : String(err)}`;
288
+ status.style.color = '#d04848';
289
+ }
290
+ connectBtn.disabled = false;
291
+ }
292
+ });
293
+ });
294
+ }
295
+ function resolveOptions(opts) {
296
+ const challenge = opts.challenge ?? generateChallenge();
297
+ if (!/^[0-9a-f]{64}$/i.test(challenge))
298
+ throw new Error('challenge-must-be-64-hex');
299
+ const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost';
300
+ const timeout = Math.max(5000, Math.min(opts.timeout ?? DEFAULTS.timeout, 600000));
301
+ const result = {
302
+ appName: opts.appName,
303
+ challenge: challenge.toLowerCase(),
304
+ origin,
305
+ relayUrl: opts.relayUrl ?? DEFAULTS.relayUrl,
306
+ theme: opts.theme ?? DEFAULTS.theme,
307
+ timeout,
308
+ signetAppOrigin: opts.signetAppOrigin ?? DEFAULTS.signetAppOrigin,
309
+ };
310
+ if (opts.preferredMethod !== undefined)
311
+ result.preferredMethod = opts.preferredMethod;
312
+ if (opts.redirectCallback !== undefined)
313
+ result.redirectCallback = opts.redirectCallback;
314
+ return result;
315
+ }
316
+ /**
317
+ * Entry point — show the modal, route to the chosen method, return a session.
318
+ *
319
+ * Returns null when the user cancels or the flow times out.
320
+ */
321
+ export async function showLoginModal(opts) {
322
+ if (!opts.appName || opts.appName.length === 0)
323
+ throw new Error('appName-required');
324
+ if (opts.appName.length > 64)
325
+ throw new Error('appName-too-long');
326
+ const resolved = resolveOptions(opts);
327
+ const refs = buildModalShell(resolved.theme);
328
+ try {
329
+ while (true) {
330
+ const choice = resolved.preferredMethod
331
+ ? resolved.preferredMethod
332
+ : await renderPicker(refs, resolved.appName, resolved.theme);
333
+ if (choice === 'cancel')
334
+ return null;
335
+ if (choice === 'nip07') {
336
+ const result = await runNip07Flow(refs, resolved);
337
+ if (!result) {
338
+ if (resolved.preferredMethod)
339
+ return null;
340
+ continue; // back to picker
341
+ }
342
+ // Re-create the signer object — runNip07Flow created one internally
343
+ // but we need a usable handle to return. The extension is now warm
344
+ // so this call resolves immediately.
345
+ const signer = await createNip07Signer();
346
+ return {
347
+ pubkey: result.pubkey,
348
+ method: 'nip07',
349
+ signer,
350
+ authEvent: result.authEvent,
351
+ };
352
+ }
353
+ if (choice === 'redirect') {
354
+ const result = await runRedirectFlow(refs, resolved);
355
+ if (!result) {
356
+ if (resolved.preferredMethod)
357
+ return null;
358
+ continue;
359
+ }
360
+ const ephemeral = new EphemeralSigner(result.pubkey, result.authEvent);
361
+ const session = {
362
+ pubkey: result.pubkey,
363
+ method: 'redirect',
364
+ signer: ephemeral,
365
+ authEvent: result.authEvent,
366
+ };
367
+ if (result.displayName)
368
+ session.displayName = result.displayName;
369
+ return session;
370
+ }
371
+ if (choice === 'bunker') {
372
+ const signer = await runBunkerFlow(refs, resolved);
373
+ if (!signer) {
374
+ if (resolved.preferredMethod)
375
+ return null;
376
+ continue;
377
+ }
378
+ // Sign a kind-21236 auth event so we have a uniform proof shape
379
+ const authEvent = await signer.signEvent({
380
+ kind: 21236,
381
+ content: '',
382
+ tags: [
383
+ ['challenge', resolved.challenge],
384
+ ['origin', resolved.origin],
385
+ ['app', resolved.appName],
386
+ ],
387
+ });
388
+ return {
389
+ pubkey: signer.pubkey,
390
+ method: 'bunker',
391
+ signer,
392
+ authEvent,
393
+ };
394
+ }
395
+ // Unknown choice — restart picker
396
+ }
397
+ }
398
+ finally {
399
+ tearDown(refs);
400
+ }
401
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Same-tab redirect flow for "Sign in with Signet".
3
+ *
4
+ * Two halves:
5
+ *
6
+ * 1. `startRedirect()` — called from `Signet.login({ mode: 'redirect' })`.
7
+ * Persists pending state to localStorage, builds the signet-app auth URL
8
+ * WITHOUT relay/sessionPubkey (so signet-app falls into its
9
+ * `window.location.href = callbackUrl` path), and navigates the current
10
+ * tab. The caller's promise never resolves in this tab — the page is
11
+ * gone.
12
+ *
13
+ * 2. `consumeCallback()` — called from `Signet.handleCallback()` on boot.
14
+ * Detects auth params in `window.location.search`, validates them
15
+ * against the persisted pending state, reconstructs the kind-21236 auth
16
+ * event, persists the session via the existing storage layer, strips
17
+ * the auth params from the URL, and returns a `SignetSession`.
18
+ *
19
+ * Verification note: the reconstructed auth event has a signature that was
20
+ * produced over the original `created_at` chosen by signet-app at sign time.
21
+ * To rebuild the event hash exactly, signet-app must emit `t` (unix seconds)
22
+ * alongside pubkey/signature/eventId in the redirect URL — see the
23
+ * coordinated change in signet-protocol's `buildAuthCallbackUrl`. When `t`
24
+ * is present, the reconstructed event passes signature verification. When
25
+ * absent (older signet-app deployments), the SDK falls back to "now" and
26
+ * logs a warning — server-side strict verification will fail until the
27
+ * issuer is upgraded.
28
+ */
29
+ import type { SignetSession } from './types.js';
30
+ import { DEFAULTS } from './types.js';
31
+ /** Subset of resolved options used by the redirect path. */
32
+ export interface RedirectStartOptions {
33
+ appName: string;
34
+ challenge: string;
35
+ origin: string;
36
+ signetAppOrigin: string;
37
+ redirectCallback?: string;
38
+ }
39
+ /**
40
+ * Build the signet-app auth URL for redirect mode. Deliberately omits `relay`
41
+ * and `sessionPubkey` so signet-app's `isRelayMode` check (App.tsx) returns
42
+ * false and the redirect path runs.
43
+ */
44
+ export declare function buildRedirectAuthUrl(opts: RedirectStartOptions): string;
45
+ /**
46
+ * Persist pending state and navigate. Resolves to a never-settling promise on
47
+ * success (the page navigates before it can resolve) so callers using
48
+ * `await Signet.login()` see consistent behaviour with the relay path.
49
+ *
50
+ * Throws synchronously if the environment lacks `window` — calling redirect
51
+ * mode in non-browser code is a programming error, not something to silently
52
+ * swallow.
53
+ */
54
+ export declare function startRedirect(opts: RedirectStartOptions): Promise<never>;
55
+ /** Outcome of consuming a redirect callback. */
56
+ export type ConsumeCallbackResult = {
57
+ kind: 'session';
58
+ session: SignetSession;
59
+ } | {
60
+ kind: 'denied';
61
+ } | {
62
+ kind: 'no-callback';
63
+ } | {
64
+ kind: 'invalid';
65
+ reason: string;
66
+ };
67
+ /**
68
+ * Detect and consume a redirect-back callback. Returns:
69
+ *
70
+ * - { kind: 'session', session } — round-trip valid; clears pending state
71
+ * and strips auth params from the URL
72
+ * - { kind: 'denied' } — signet-app sent `error=denied`
73
+ * - { kind: 'no-callback' } — no auth params in the URL; do nothing
74
+ * - { kind: 'invalid', reason } — params present but failed validation
75
+ * (pending state mismatch, stale, hex
76
+ * malformed, …). Pending state is cleared
77
+ * in this case too — a stale or attacker-
78
+ * supplied URL shouldn't poison the next
79
+ * login attempt.
80
+ *
81
+ * Idempotent: calling it twice on the same loaded page returns 'no-callback'
82
+ * the second time because the URL params have been stripped.
83
+ */
84
+ export declare function consumeCallback(): ConsumeCallbackResult;
85
+ export { DEFAULTS };