@hadoku/worker-utils 0.0.0-stage → 1.6.19

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.
@@ -0,0 +1,833 @@
1
+ // authPage.ts
2
+ function escapeAttr(value) {
3
+ return value.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
4
+ }
5
+ var SIGN_IN_CLIENT = `
6
+ // Device fingerprint \u2014 see the header comment. Hashed here so only
7
+ // digests are sent; every trait is optional and a missing one is simply
8
+ // left out, which the server scores as "unknown", not as "different".
9
+ async function digest(value) {
10
+ const d = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
11
+ return Array.from(new Uint8Array(d).slice(0, 8), function (b) {
12
+ return b.toString(16).padStart(2, '0');
13
+ }).join('');
14
+ }
15
+ function canvasTrait() {
16
+ try {
17
+ const cv = document.createElement('canvas');
18
+ cv.width = 240;
19
+ cv.height = 60;
20
+ const g = cv.getContext('2d');
21
+ if (!g) return '';
22
+ g.textBaseline = 'top';
23
+ g.font = '16px Arial';
24
+ g.fillStyle = 'rgb(255,102,0)';
25
+ g.fillRect(100, 1, 62, 20);
26
+ g.fillStyle = 'rgba(102,204,0,0.7)';
27
+ g.fillText('Hadoku 0123 \xE9\u4E2D', 2, 15);
28
+ return cv.toDataURL();
29
+ } catch (_) {
30
+ return '';
31
+ }
32
+ }
33
+ function webglTrait() {
34
+ try {
35
+ const gl = document.createElement('canvas').getContext('webgl');
36
+ if (!gl) return '';
37
+ const info = gl.getExtension('WEBGL_debug_renderer_info');
38
+ return info
39
+ ? gl.getParameter(info.UNMASKED_VENDOR_WEBGL) + '|' + gl.getParameter(info.UNMASKED_RENDERER_WEBGL)
40
+ : String(gl.getParameter(gl.RENDERER));
41
+ } catch (_) {
42
+ return '';
43
+ }
44
+ }
45
+ async function collectFingerprint() {
46
+ const n = navigator;
47
+ // Sorted so rotating a phone does not read as a different screen.
48
+ const dims = [screen.width, screen.height].sort(function (a, b) { return b - a; });
49
+ const traits = {
50
+ tz: Intl.DateTimeFormat().resolvedOptions().timeZone || '',
51
+ langs: (n.languages && n.languages.length ? n.languages : [n.language]).join(','),
52
+ screen: dims.concat([screen.colorDepth, window.devicePixelRatio]).join(':'),
53
+ hw: [n.hardwareConcurrency || 0, n.deviceMemory || 0].join('/'),
54
+ touch: String(n.maxTouchPoints || 0),
55
+ platform: n.platform || '',
56
+ canvas: canvasTrait(),
57
+ webgl: webglTrait(),
58
+ };
59
+ const out = {};
60
+ for (const k of Object.keys(traits)) {
61
+ if (traits[k]) out[k] = await digest(traits[k]);
62
+ }
63
+ return out;
64
+ }
65
+ function fingerprintWithin(ms) {
66
+ return Promise.race([
67
+ collectFingerprint().catch(function () { return null; }),
68
+ new Promise(function (resolve) { setTimeout(function () { resolve(null); }, ms); }),
69
+ ]);
70
+ }
71
+ // The model, for the ledger's display label. Real Chrome reduces its UA
72
+ // to "Android 10; K" and only sends the Sec-CH-UA-Model hint on a request
73
+ // the page asked for it on, which an app's fetch is not; so ask the
74
+ // browser directly too. Display only, never scored.
75
+ function modelHint() {
76
+ try {
77
+ const d = navigator.userAgentData;
78
+ if (!d || !d.getHighEntropyValues) return Promise.resolve('');
79
+ return Promise.race([
80
+ d.getHighEntropyValues(['model']).then(function (v) { return v.model || ''; }, function () { return ''; }),
81
+ new Promise(function (resolve) { setTimeout(function () { resolve(''); }, 300); }),
82
+ ]);
83
+ } catch (_) {
84
+ return Promise.resolve('');
85
+ }
86
+ }
87
+
88
+ // The graphics card's name, for a desktop's display label: a desktop has
89
+ // no model, and this is the nearest thing to one. Readable rather than
90
+ // hashed, and display only, never scored; the hashed WebGL trait above is
91
+ // what the ledger matches on.
92
+ function gpuHint() {
93
+ try {
94
+ const gl = document.createElement('canvas').getContext('webgl');
95
+ const info = gl && gl.getExtension('WEBGL_debug_renderer_info');
96
+ return info ? String(gl.getParameter(info.UNMASKED_RENDERER_WEBGL)).slice(0, 200) : '';
97
+ } catch (_) {
98
+ return '';
99
+ }
100
+ }
101
+
102
+ // Create the session: the server sets the cookie. Resolves to the
103
+ // server's answer; throws an Error carrying its message on a refusal.
104
+ // A passkey assertion signs in in place of a key (key is then '').
105
+ // A new member's account (signup) also signs in in place of a key.
106
+ async function createSession(key, ft, fp, passkey, signup) {
107
+ const model = await modelHint();
108
+ const gpu = gpuHint();
109
+ const headers = { 'Content-Type': 'application/json' };
110
+ if (key) headers['X-User-Key'] = key;
111
+ const res = await fetch('/session/create', {
112
+ method: 'POST',
113
+ headers: headers,
114
+ body: JSON.stringify(Object.assign({ ft: ft }, fp ? { fp: fp } : {}, model ? { model: model } : {}, gpu ? { gpu: gpu } : {}, passkey ? { passkey: passkey } : {}, signup ? { signup: signup } : {})),
115
+ credentials: 'same-origin'
116
+ });
117
+ const data = await res.json().catch(function () { return {}; });
118
+ if (!res.ok || !data.valid) {
119
+ const err = new Error(data.error || 'Invalid key');
120
+ if (data.forgetPasskey) err.forget = true;
121
+ throw err;
122
+ }
123
+ // sessionId + tier speed up mf-loader's boot-time identity resolution;
124
+ // the cookie is what authenticates. The raw key is never stored.
125
+ if (data.sessionId) {
126
+ localStorage.setItem('hadoku_session_id', data.sessionId);
127
+ if (data.userType) localStorage.setItem('hadoku_user_type', data.userType);
128
+ }
129
+ return data;
130
+ }
131
+
132
+ // Passkeys (edge-router passkeys.ts): saved from a signed-in browser,
133
+ // used in place of a key. WebAuthn speaks bytes and the
134
+ // server speaks base64url; no backslashes, so split/join, not regexes.
135
+ function b64url(buf) {
136
+ const b = new Uint8Array(buf);
137
+ let s = '';
138
+ for (let i = 0; i < b.length; i++) s += String.fromCharCode(b[i]);
139
+ return btoa(s).split('+').join('-').split('/').join('_').split('=').join('');
140
+ }
141
+ function unb64url(s) {
142
+ const t = s.split('-').join('+').split('_').join('/');
143
+ const bin = atob(t + '==='.slice(0, (4 - (t.length % 4)) % 4));
144
+ const out = new Uint8Array(bin.length);
145
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
146
+ return out;
147
+ }
148
+ async function postJson(url, body) {
149
+ const res = await fetch(url, {
150
+ method: 'POST',
151
+ credentials: 'same-origin',
152
+ headers: { 'Content-Type': 'application/json' },
153
+ body: JSON.stringify(body || {})
154
+ });
155
+ const data = await res.json().catch(function () { return {}; });
156
+ if (!res.ok) throw new Error(data.error || 'Request failed');
157
+ return data;
158
+ }
159
+ // A dismissed or timed-out browser prompt is NotAllowedError, whose
160
+ // message is browser boilerplate.
161
+ function passkeyMessage(err, cancelled) {
162
+ if (err && (err.name === 'NotAllowedError' || err.name === 'AbortError')) return cancelled;
163
+ if (err && err.name === 'InvalidStateError') return 'This device already has your passkey';
164
+ return (err && err.message) || cancelled;
165
+ }
166
+
167
+ // How long a passkey prompt may stay open. A password manager that takes
168
+ // the prompt over (Bitwarden, Vaultwarden) can leave the browser's
169
+ // promise unsettled when it is dismissed, so the page bounds the wait
170
+ // itself rather than trusting the prompt to answer.
171
+ const PASSKEY_WAIT_MS = 90000;
172
+
173
+ function cancelledError() {
174
+ const e = new Error('cancelled');
175
+ e.name = 'NotAllowedError';
176
+ return e;
177
+ }
178
+
179
+ // One browser prompt. Settles when the browser answers, when the caller
180
+ // aborts (a Cancel button) or at PASSKEY_WAIT_MS, whichever comes
181
+ // first, and a late answer from an abandoned prompt is ignored.
182
+ function passkeyPrompt(run, signal) {
183
+ const ctl = new AbortController();
184
+ return new Promise(function (resolve, reject) {
185
+ let done = false;
186
+ let timer = 0;
187
+ function finish(settle, value) {
188
+ if (done) return;
189
+ done = true;
190
+ clearTimeout(timer);
191
+ if (signal) signal.removeEventListener('abort', stop);
192
+ ctl.abort();
193
+ settle(value);
194
+ }
195
+ function stop() { finish(reject, cancelledError()); }
196
+ if (signal && signal.aborted) return stop();
197
+ timer = setTimeout(stop, PASSKEY_WAIT_MS);
198
+ if (signal) signal.addEventListener('abort', stop);
199
+ run(ctl.signal).then(function (cred) {
200
+ if (cred) finish(resolve, cred);
201
+ else stop();
202
+ }, function (err) { finish(reject, err); });
203
+ });
204
+ }
205
+
206
+ // A passkey the server will never accept (unknown to it, or retired by
207
+ // ROTATE): ask the authenticator to forget it, so the browser stops
208
+ // offering it. Best effort; most browsers do not have this yet.
209
+ function forgetPasskey(rpId, id) {
210
+ try {
211
+ if (window.PublicKeyCredential && PublicKeyCredential.signalUnknownCredential) {
212
+ PublicKeyCredential.signalUnknownCredential({ rpId: rpId, credentialId: id })
213
+ .catch(function () { /* nothing more to do */ });
214
+ }
215
+ } catch (_) { /* nothing more to do */ }
216
+ }
217
+
218
+ // Sign in with a passkey: the assertion goes to /session/create in place
219
+ // of a key. Throws like createSession; a dead passkey is forgotten first.
220
+ async function passkeySession(ft, fp, signal) {
221
+ const opt = await postJson('/auth/passkey/challenge');
222
+ const cred = await passkeyPrompt(function (s) {
223
+ return navigator.credentials.get({ signal: s, publicKey: {
224
+ challenge: new TextEncoder().encode(opt.challenge),
225
+ rpId: opt.rpId,
226
+ userVerification: 'preferred',
227
+ timeout: PASSKEY_WAIT_MS
228
+ } });
229
+ }, signal);
230
+ const r = cred.response;
231
+ const id = b64url(cred.rawId);
232
+ try {
233
+ return await createSession('', ft, fp, {
234
+ id: id,
235
+ clientDataJSON: b64url(r.clientDataJSON),
236
+ authenticatorData: b64url(r.authenticatorData),
237
+ signature: b64url(r.signature),
238
+ userHandle: r.userHandle ? b64url(r.userHandle) : null
239
+ });
240
+ } catch (err) {
241
+ if (err && err.forget) forgetPasskey(opt.rpId, id);
242
+ throw err;
243
+ }
244
+ }
245
+
246
+ async function savePasskey(signal) {
247
+ const opt = await postJson('/auth/passkey/register/options');
248
+ const cred = await passkeyPrompt(function (s) {
249
+ return navigator.credentials.create({ signal: s, publicKey: {
250
+ challenge: new TextEncoder().encode(opt.challenge),
251
+ rp: { id: opt.rpId, name: 'hadoku.me' },
252
+ user: { id: unb64url(opt.userHandle), name: opt.userName, displayName: opt.userName },
253
+ pubKeyCredParams: opt.algs.map(function (alg) { return { type: 'public-key', alg: alg }; }),
254
+ excludeCredentials: opt.exclude.map(function (id) { return { type: 'public-key', id: unb64url(id) }; }),
255
+ authenticatorSelection: { residentKey: 'required', requireResidentKey: true, userVerification: 'preferred' },
256
+ attestation: 'none',
257
+ timeout: PASSKEY_WAIT_MS
258
+ } });
259
+ }, signal);
260
+ const r = cred.response;
261
+ const publicKey = r.getPublicKey ? r.getPublicKey() : null;
262
+ if (!publicKey || !r.getAuthenticatorData) throw new Error('This browser cannot save a passkey');
263
+ return postJson('/auth/passkey/register', {
264
+ id: b64url(cred.rawId),
265
+ clientDataJSON: b64url(r.clientDataJSON),
266
+ authenticatorData: b64url(r.getAuthenticatorData()),
267
+ publicKey: b64url(publicKey),
268
+ alg: r.getPublicKeyAlgorithm()
269
+ });
270
+ }
271
+ // Sign up: the name is checked and a challenge minted for a new account,
272
+ // the browser makes its first passkey, and /session/create makes the
273
+ // account and signs it in. One prompt, no key, no password.
274
+ async function signUpSession(name, ft, fp, signal) {
275
+ const opt = await postJson('/auth/signup/options', { name: name });
276
+ const cred = await passkeyPrompt(function (s) {
277
+ return navigator.credentials.create({ signal: s, publicKey: {
278
+ challenge: new TextEncoder().encode(opt.challenge),
279
+ rp: { id: opt.rpId, name: 'hadoku.me' },
280
+ user: { id: unb64url(opt.userHandle), name: opt.name, displayName: opt.name },
281
+ pubKeyCredParams: opt.algs.map(function (alg) { return { type: 'public-key', alg: alg }; }),
282
+ authenticatorSelection: { residentKey: 'required', requireResidentKey: true, userVerification: 'preferred' },
283
+ attestation: 'none',
284
+ timeout: PASSKEY_WAIT_MS
285
+ } });
286
+ }, signal);
287
+ const r = cred.response;
288
+ const publicKey = r.getPublicKey ? r.getPublicKey() : null;
289
+ if (!publicKey || !r.getAuthenticatorData) throw new Error('This browser cannot save a passkey');
290
+ return createSession('', ft, fp, null, {
291
+ userId: opt.userId,
292
+ name: opt.name,
293
+ credential: {
294
+ id: b64url(cred.rawId),
295
+ clientDataJSON: b64url(r.clientDataJSON),
296
+ authenticatorData: b64url(r.getAuthenticatorData()),
297
+ publicKey: b64url(publicKey),
298
+ alg: r.getPublicKeyAlgorithm()
299
+ }
300
+ });
301
+ }
302
+ function passkeysSupported() {
303
+ return !!(window.PublicKeyCredential && navigator.credentials);
304
+ }
305
+ `;
306
+ function renderSignInModule() {
307
+ return `${SIGN_IN_CLIENT}
308
+ const fpPromise = fingerprintWithin(1500);
309
+
310
+ function funnelToken() {
311
+ return fetch('/auth/funnel-token', { credentials: 'same-origin', cache: 'no-store' })
312
+ .then(function (r) { return r.ok ? r.json() : {}; })
313
+ .then(function (t) { return t.ft || ''; }, function () { return ''; });
314
+ }
315
+
316
+ export async function signIn(key) {
317
+ const k = String(key || '').trim();
318
+ if (!k) throw new Error('Please enter a key');
319
+ return createSession(k, await funnelToken(), await fpPromise);
320
+ }
321
+
322
+ export { passkeysSupported };
323
+
324
+ export async function signInWithPasskey(opts) {
325
+ const signal = opts && opts.signal;
326
+ try {
327
+ return await passkeySession(await funnelToken(), await fpPromise, signal);
328
+ } catch (err) {
329
+ throw new Error(passkeyMessage(err, 'Passkey sign-in cancelled'));
330
+ }
331
+ }
332
+
333
+ export async function addPasskey(opts) {
334
+ try {
335
+ return await savePasskey(opts && opts.signal);
336
+ } catch (err) {
337
+ throw new Error(passkeyMessage(err, 'Passkey not saved'));
338
+ }
339
+ }
340
+
341
+ export async function signUp(name, opts) {
342
+ try {
343
+ return await signUpSession(String(name || '').trim(), await funnelToken(), await fpPromise, opts && opts.signal);
344
+ } catch (err) {
345
+ throw new Error(passkeyMessage(err, 'Account not created'));
346
+ }
347
+ }
348
+ `;
349
+ }
350
+ function renderAuthPage(options = {}) {
351
+ const {
352
+ title = "Authentication Required",
353
+ description = "Enter your admin key to access this page.",
354
+ targetPath = "",
355
+ errorMessage = "",
356
+ ogTitle = title,
357
+ ogDescription = description,
358
+ ogImage = "",
359
+ ogImageWidth,
360
+ ogImageHeight,
361
+ ogUrl = "",
362
+ funnelToken = "",
363
+ signup = false
364
+ } = options;
365
+ const targetPathAttr = escapeAttr(targetPath);
366
+ const errorAttr = escapeAttr(errorMessage);
367
+ const errorClass = errorMessage ? "error visible" : "error";
368
+ const ogTitleAttr = escapeAttr(ogTitle);
369
+ const ogDescriptionAttr = escapeAttr(ogDescription);
370
+ const imageMeta = ogImage ? ` <meta property="og:image" content="${escapeAttr(ogImage)}">
371
+ <meta name="twitter:image" content="${escapeAttr(ogImage)}">
372
+ ` + (ogImageWidth && ogImageHeight ? ` <meta property="og:image:width" content="${ogImageWidth}">
373
+ <meta property="og:image:height" content="${ogImageHeight}">
374
+ ` : "") : "";
375
+ const urlMeta = ogUrl ? ` <meta property="og:url" content="${escapeAttr(ogUrl)}">
376
+ ` : "";
377
+ const twitterCard = ogImage ? "summary_large_image" : "summary";
378
+ return `<!DOCTYPE html>
379
+ <html lang="en">
380
+ <head>
381
+ <meta charset="UTF-8">
382
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
383
+ <title>${title}</title>
384
+ <meta name="description" content="${description}">
385
+ <meta property="og:type" content="website">
386
+ <meta property="og:site_name" content="hadoku.me">
387
+ <meta property="og:title" content="${ogTitleAttr}">
388
+ <meta property="og:description" content="${ogDescriptionAttr}">
389
+ ${urlMeta}${imageMeta} <meta name="twitter:card" content="${twitterCard}">
390
+ <meta name="twitter:title" content="${ogTitleAttr}">
391
+ <meta name="twitter:description" content="${ogDescriptionAttr}">
392
+ <meta name="theme-color" content="#2563eb">
393
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
394
+ <script src="/theme-bootstrap.js"></script>
395
+ <link rel="stylesheet" href="/themes.css">
396
+ <style>
397
+ :root {
398
+ --bg-primary: var(--color-bg, #0a0a0a);
399
+ --bg-secondary: var(--color-bg-card, #1a1a1a);
400
+ --text-primary: var(--color-text, #f0f0f0);
401
+ --text-secondary: var(--color-text-secondary, #888);
402
+ --border-color: var(--color-border, #333);
403
+ --accent: var(--color-primary, #2563eb);
404
+ --accent-hover: var(--color-primary-hover, #1d4ed8);
405
+ --on-accent: var(--color-on-primary, #fff);
406
+ --error: var(--color-danger, #ef4444);
407
+ }
408
+
409
+ * {
410
+ box-sizing: border-box;
411
+ margin: 0;
412
+ padding: 0;
413
+ }
414
+
415
+ body {
416
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
417
+ background: var(--bg-primary);
418
+ color: var(--text-primary);
419
+ min-height: 100vh;
420
+ display: flex;
421
+ align-items: center;
422
+ justify-content: center;
423
+ }
424
+
425
+ .auth-container {
426
+ background: var(--bg-secondary);
427
+ border: 1px solid var(--border-color);
428
+ border-radius: 0.75rem;
429
+ padding: 2rem;
430
+ width: 100%;
431
+ max-width: 400px;
432
+ margin: 1rem;
433
+ }
434
+
435
+ h1 {
436
+ font-size: 1.5rem;
437
+ margin-bottom: 0.5rem;
438
+ }
439
+
440
+ .description {
441
+ color: var(--text-secondary);
442
+ font-size: 0.875rem;
443
+ margin-bottom: 1.5rem;
444
+ }
445
+
446
+ .error {
447
+ color: var(--error);
448
+ font-size: 0.875rem;
449
+ margin-bottom: 1rem;
450
+ display: none;
451
+ }
452
+
453
+ .error.visible {
454
+ display: block;
455
+ }
456
+
457
+ input {
458
+ width: 100%;
459
+ background: var(--bg-primary);
460
+ border: 1px solid var(--border-color);
461
+ border-radius: 0.5rem;
462
+ padding: 0.75rem 1rem;
463
+ color: var(--text-primary);
464
+ font-size: 1rem;
465
+ margin-bottom: 1rem;
466
+ }
467
+
468
+ input:focus {
469
+ outline: none;
470
+ border-color: var(--accent);
471
+ }
472
+
473
+ button {
474
+ width: 100%;
475
+ background: var(--accent);
476
+ border: none;
477
+ border-radius: 0.5rem;
478
+ padding: 0.75rem 1rem;
479
+ color: var(--on-accent);
480
+ font-size: 1rem;
481
+ font-weight: 500;
482
+ cursor: pointer;
483
+ transition: background 0.2s;
484
+ }
485
+
486
+ button:hover {
487
+ background: var(--accent-hover);
488
+ }
489
+
490
+ button:disabled {
491
+ opacity: 0.6;
492
+ cursor: not-allowed;
493
+ }
494
+
495
+ button.secondary {
496
+ margin-top: 0.75rem;
497
+ background: transparent;
498
+ border: 1px solid var(--border-color);
499
+ color: var(--text-primary);
500
+ }
501
+
502
+ button.secondary:hover {
503
+ background: var(--bg-primary);
504
+ }
505
+
506
+ #passkey-offer h2,
507
+ #signup h2 {
508
+ font-size: 1.125rem;
509
+ margin: 0 0 0.5rem;
510
+ }
511
+
512
+ #signup {
513
+ margin-top: 1.5rem;
514
+ padding-top: 1.25rem;
515
+ border-top: 1px solid var(--border-color);
516
+ }
517
+
518
+ .visually-hidden {
519
+ position: absolute;
520
+ width: 1px;
521
+ height: 1px;
522
+ overflow: hidden;
523
+ clip: rect(0 0 0 0);
524
+ white-space: nowrap;
525
+ }
526
+
527
+ .home-link {
528
+ display: block;
529
+ text-align: center;
530
+ margin-top: 1rem;
531
+ color: var(--text-secondary);
532
+ font-size: 0.875rem;
533
+ text-decoration: none;
534
+ }
535
+
536
+ .home-link:hover {
537
+ color: var(--text-primary);
538
+ }
539
+ </style>
540
+ </head>
541
+ <body>
542
+ <main class="auth-container">
543
+ <h1>${title}</h1>
544
+ <p class="description">${description}</p>
545
+ <p id="error" class="${errorClass}">${errorAttr}</p>
546
+ <form id="auth-form" action="/session/create" method="POST">
547
+ <input type="hidden" name="return" value="${targetPathAttr}" />
548
+ <input type="hidden" name="fp" id="auth-fp" value="" />
549
+ <input type="hidden" name="ft" id="auth-ft" value="${escapeAttr(funnelToken)}" />
550
+ <input type="text" name="username" value="hadoku" autocomplete="username" hidden />
551
+ <label for="auth-key" class="visually-hidden">Access key</label>
552
+ <input
553
+ type="password"
554
+ id="auth-key"
555
+ name="key"
556
+ placeholder="Enter access key"
557
+ autocomplete="current-password"
558
+ autocapitalize="none"
559
+ spellcheck="false"
560
+ autofocus
561
+ />
562
+ <button type="submit" id="submit-btn">Sign in</button>
563
+ </form>
564
+ <button type="button" id="passkey-btn" class="secondary" hidden>Sign in with a passkey</button>
565
+ <section id="passkey-offer" hidden>
566
+ <h2>Save a passkey?</h2>
567
+ <p class="description">Sign in next time without a code.</p>
568
+ <button type="button" id="passkey-save">Save passkey</button>
569
+ <button type="button" id="passkey-skip" class="secondary">Not now</button>
570
+ </section>
571
+ <section id="signup" hidden>
572
+ <h2>New here?</h2>
573
+ <label for="signup-name" class="visually-hidden">Display name</label>
574
+ <input type="text" id="signup-name" placeholder="Pick a name" maxlength="24" autocomplete="nickname" autocapitalize="none" spellcheck="false" />
575
+ <button type="button" id="signup-btn">Create account</button>
576
+ </section>
577
+ <button type="button" id="passkey-cancel" class="secondary" hidden>Cancel</button>
578
+ <a href="/" class="home-link">Continue without signing in &rarr;</a>
579
+ </main>
580
+
581
+ <script>
582
+ (function() {
583
+ const form = document.getElementById('auth-form');
584
+ const keyInput = document.getElementById('auth-key');
585
+ const errorEl = document.getElementById('error');
586
+ const submitBtn = document.getElementById('submit-btn');
587
+ const passkeyBtn = document.getElementById('passkey-btn');
588
+ const passkeyOffer = document.getElementById('passkey-offer');
589
+ const passkeySave = document.getElementById('passkey-save');
590
+ const passkeySkip = document.getElementById('passkey-skip');
591
+ const passkeyCancel = document.getElementById('passkey-cancel');
592
+ const signupSection = document.getElementById('signup');
593
+ const signupName = document.getElementById('signup-name');
594
+ const signupBtn = document.getElementById('signup-btn');
595
+ const targetPath = '${targetPath}' || window.location.pathname;
596
+ // ?passkey=add: a signed-in visitor saving a passkey on this browser,
597
+ // so the self-heal below must not send them straight on.
598
+ const addingPasskey = new URLSearchParams(window.location.search).get('passkey') === 'add';
599
+
600
+ // A URL that DIFFERS from this one, for the places that must make the browser
601
+ // issue a real request when the natural destination is the page it is on (an
602
+ // identical URL with a fragment is a same-document hop: no request at all).
603
+ //
604
+ // A single-segment mount root WITHOUT its trailing slash 404s the moment it
605
+ // carries a query string (/cardtable?sh=1, /merchant?sh=1 \u2014 measured
606
+ // 2026-10-02; the other apps 301 to the slash form), while the slash form
607
+ // takes a query and is every app's canonical mount. So for those, the slash
608
+ // twin is the distinct URL and no marker is needed; anywhere else, append
609
+ // the marker param every consumer ignores.
610
+ function distinctUrl() {
611
+ const p = window.location.pathname;
612
+ const s = window.location.search;
613
+ const h = window.location.hash;
614
+ if (p.charAt(p.length - 1) !== '/' && p.split('/').length === 2) return p + '/' + s + h;
615
+ return p + s + (s ? '&' : '?') + 'sh=1' + h;
616
+ }
617
+
618
+ // A cross-site navigation (a bookmarklet, a link on another site)
619
+ // withholds our SameSite=Strict session cookie, so a signed-in visitor
620
+ // still lands here looking anonymous. A same-site fetch DOES carry the
621
+ // cookie, so ask who they are: a signed-in answer means one reload \u2014
622
+ // now initiated from this same-site document \u2014 will pass the gate with
623
+ // the URL, fragment included, untouched.
624
+ //
625
+ // Only when the server rendered no error: a non-empty error means the
626
+ // cookie WAS presented and the tier fell short, where reloading would
627
+ // just loop back here. The timestamp guard bounds even a pathological
628
+ // case to one reload per 10 seconds.
629
+ //
630
+ // Never with ?switch=1: that is a signed-in visitor asking to sign in
631
+ // with a DIFFERENT key (an app's "switch key" control links here), and
632
+ // sending them on would make switching impossible.
633
+ const hadServerError = ${errorMessage ? "true" : "false"};
634
+ const switching = new URLSearchParams(window.location.search).has('switch');
635
+ const lastRetry = Number(sessionStorage.getItem('hadoku_auth_retry') || 0);
636
+ if (addingPasskey) {
637
+ fetch('/session/whoami', { credentials: 'same-origin' })
638
+ .then(function (r) { return r.json(); })
639
+ .then(function (d) { if (d && d.valid) offerPasskey(); })
640
+ .catch(function () { /* stay on the sign-in form */ });
641
+ } else if (!hadServerError && !switching && Date.now() - lastRetry > 10000) {
642
+ fetch('/session/whoami', { credentials: 'same-origin' })
643
+ .then(function (r) { return r.json(); })
644
+ .then(function (d) {
645
+ if (d && d.valid) {
646
+ sessionStorage.setItem('hadoku_auth_retry', String(Date.now()));
647
+ // NOT location.reload(): a reload re-issues the navigation with
648
+ // its ORIGINAL initiator, so a tab opened from another site
649
+ // stays cross-site forever and the Strict cookie stays withheld
650
+ // \u2014 F5 on this page demonstrates it. A replace() to a distinct
651
+ // URL is a fresh navigation initiated by THIS same-site
652
+ // document, so the cookie flows. The marker param makes the URL
653
+ // differ (an identical URL with a fragment would be a
654
+ // same-document hop and no request at all); every consumer
655
+ // ignores unknown query params.
656
+ // On /auth itself there is no gate to re-pass, and its slash twin
657
+ // is a static-site 404: go where the sign-in was headed.
658
+ const here = window.location.pathname;
659
+ const onAuth = here === '/auth' || here === '/auth/';
660
+ window.location.replace(onAuth ? targetPath + window.location.hash : distinctUrl());
661
+ }
662
+ })
663
+ .catch(function () { /* stay on the sign-in form */ });
664
+ }
665
+
666
+ ${SIGN_IN_CLIENT}
667
+ // The native-form fallback carries it too, when the script got this far.
668
+ const fpPromise = fingerprintWithin(1500);
669
+ fpPromise.then(function (fp) {
670
+ if (fp) document.getElementById('auth-fp').value = JSON.stringify(fp);
671
+ });
672
+
673
+ function showError(msg) {
674
+ errorEl.textContent = msg;
675
+ errorEl.classList.add('visible');
676
+ }
677
+
678
+ // Leave for the destination, once signed in.
679
+ //
680
+ // Carry the fragment: it never reaches the server, so targetPath cannot
681
+ // contain it, and dropping it breaks fragment-carried state like the
682
+ // merchant console's #import payload.
683
+ //
684
+ // But when the destination IS the URL we are already on and it has a
685
+ // fragment (a hash-routed app: /cardtable#/), replace() is a
686
+ // same-document hop: NO request, no reload, and the button sits on
687
+ // "Signing in..." forever with the cookie already set. Seen live on
688
+ // 2026-10-02 (the telemetry row shows the server answered in 1.1s);
689
+ // reproduced in a browser. Same cure as the cross-site self-heal above:
690
+ // make the URL differ with a marker param every consumer ignores.
691
+ function leave() {
692
+ const here = window.location.pathname + window.location.search + window.location.hash;
693
+ let dest = targetPath + window.location.hash;
694
+ if (dest === here && window.location.hash) dest = distinctUrl();
695
+ window.location.replace(dest);
696
+
697
+ // Tripwire, not a fix: if we are STILL here a moment after asking to
698
+ // leave, the redirect did not take. Say so to the edge (a request
699
+ // logged as POST /auth/stall in the telemetry table) so the next
700
+ // variant of this is visible instead of reported as "it's just slow".
701
+ setTimeout(function () {
702
+ try { navigator.sendBeacon('/auth/stall'); } catch (_) { /* best effort */ }
703
+ }, 3000);
704
+ }
705
+
706
+ // Signed in already: offer to save a passkey instead of leaving.
707
+ function offerPasskey() {
708
+ if (!passkeysSupported()) {
709
+ if (addingPasskey) showError('This browser cannot save a passkey');
710
+ else leave();
711
+ return;
712
+ }
713
+ form.hidden = true;
714
+ passkeyBtn.hidden = true;
715
+ passkeyOffer.hidden = false;
716
+ }
717
+
718
+ // One passkey prompt at a time, cancellable: a prompt that never answers
719
+ // (a password manager dismissed without saying so) must not strand the
720
+ // button. Cancel shows only while one is open.
721
+ let pending = null;
722
+ async function withPasskeyPrompt(btn, run, cancelled, done) {
723
+ btn.disabled = true;
724
+ errorEl.classList.remove('visible');
725
+ pending = new AbortController();
726
+ passkeyCancel.hidden = false;
727
+ try {
728
+ await run(pending.signal);
729
+ done();
730
+ } catch (err) {
731
+ showError(passkeyMessage(err, cancelled));
732
+ btn.disabled = false;
733
+ }
734
+ pending = null;
735
+ passkeyCancel.hidden = true;
736
+ }
737
+ passkeyCancel.addEventListener('click', function () {
738
+ if (pending) pending.abort();
739
+ });
740
+
741
+ passkeySave.addEventListener('click', function () {
742
+ return withPasskeyPrompt(passkeySave, savePasskey, 'Passkey not saved', leave);
743
+ });
744
+ passkeySkip.addEventListener('click', function () {
745
+ if (pending) pending.abort();
746
+ leave();
747
+ });
748
+
749
+ if (passkeysSupported() && !addingPasskey) passkeyBtn.hidden = false;
750
+ if (${signup ? "true" : "false"} && passkeysSupported() && !addingPasskey) signupSection.hidden = false;
751
+ signupBtn.addEventListener('click', function () {
752
+ return withPasskeyPrompt(signupBtn, function (signal) {
753
+ return fpPromise.then(function (fp) {
754
+ return signUpSession(signupName.value.trim(), document.getElementById('auth-ft').value, fp, signal);
755
+ });
756
+ }, 'Account not created', leave);
757
+ });
758
+ passkeyBtn.addEventListener('click', function () {
759
+ return withPasskeyPrompt(passkeyBtn, function (signal) {
760
+ return fpPromise.then(function (fp) {
761
+ return passkeySession(document.getElementById('auth-ft').value, fp, signal);
762
+ });
763
+ }, 'Passkey sign-in cancelled', leave);
764
+ });
765
+
766
+ // Check for key in URL - auto-fill and auto-validate if present
767
+ const urlParams = new URLSearchParams(window.location.search);
768
+ const keyFromUrl = urlParams.get('key');
769
+ if (keyFromUrl) {
770
+ // Clean URL immediately for security (remove key from browser history)
771
+ urlParams.delete('key');
772
+ const cleanUrl = window.location.pathname +
773
+ (urlParams.toString() ? '?' + urlParams.toString() : '');
774
+ window.history.replaceState({}, '', cleanUrl);
775
+
776
+ // Auto-fill the input and trigger validation
777
+ keyInput.value = keyFromUrl;
778
+ submitBtn.disabled = true;
779
+ submitBtn.textContent = 'Validating...';
780
+
781
+ // Auto-submit after brief delay for UX
782
+ setTimeout(function() {
783
+ form.dispatchEvent(new Event('submit'));
784
+ }, 100);
785
+ }
786
+
787
+ form.addEventListener('submit', async function(e) {
788
+ e.preventDefault();
789
+ const key = keyInput.value.trim();
790
+
791
+ if (!key) {
792
+ showError('Please enter a key');
793
+ return;
794
+ }
795
+
796
+ submitBtn.disabled = true;
797
+ submitBtn.textContent = 'Signing in...';
798
+ errorEl.classList.remove('visible');
799
+
800
+ try {
801
+ // Display names are assigned out-of-band via registry-set-name
802
+ // when a key is handed out, not self-served at login.
803
+ const data = await createSession(key, document.getElementById('auth-ft').value, await fpPromise);
804
+
805
+ // A one-time code vouched for this device (the server says so), or
806
+ // the visitor came to save one: offer a passkey before leaving.
807
+ if (data.offerPasskey || addingPasskey) offerPasskey();
808
+ else leave();
809
+ } catch (err) {
810
+ showError(err.message || 'Authentication failed');
811
+ submitBtn.disabled = false;
812
+ submitBtn.textContent = 'Sign in';
813
+ }
814
+ });
815
+ })();
816
+ </script>
817
+ </body>
818
+ </html>`;
819
+ }
820
+ function authPageResponse(options = {}) {
821
+ return new Response(renderAuthPage(options), {
822
+ status: 200,
823
+ headers: {
824
+ "Content-Type": "text/html; charset=utf-8",
825
+ "Cache-Control": "no-store"
826
+ }
827
+ });
828
+ }
829
+ export {
830
+ authPageResponse,
831
+ renderAuthPage,
832
+ renderSignInModule
833
+ };