@vielzeug/archive 26.11.15 → 26.11.16

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.
@@ -1,4 +1,4 @@
1
- const e=require("../_server/http.cjs"),t=require("../contract.cjs"),n=require("./store.cjs");var escapeHtml=e=>e.replace(/[&<>"']/g,e=>`&#${e.charCodeAt(0)};`),scriptValue=e=>JSON.stringify(e).replace(/[<>&\u2028\u2029]/g,e=>`\\u${e.charCodeAt(0).toString(16).padStart(4,`0`)}`);function confirmationPage({appName:r,nonce:i,publicOrigin:a,token:o}){let s=o!==null,c=s?`Confirm sign-in`:`This sign-in link is not valid`,l=s?`
1
+ const e=require("../_server/http.cjs"),t=require("../contract.cjs"),n=require("./store.cjs");var escapeHtml=e=>e.replace(/[&<>"']/g,e=>`&#${e.charCodeAt(0)};`),scriptValue=e=>JSON.stringify(e).replace(/[<>&\u2028\u2029]/g,e=>`\\u${e.charCodeAt(0).toString(16).padStart(4,`0`)}`);function confirmationPage({appName:r,device:i=!1,deviceCode:a,errorStatus:o=400,nonce:s,publicOrigin:c,token:l}){let u=l!==null,d=u?`Confirm sign-in`:o===410?`This sign-in link has expired or was already used`:`This sign-in link is not valid`,f=u?`
2
2
  const button = document.getElementById('confirm');
3
3
  const status = document.getElementById('status');
4
4
  const recovery = document.getElementById('recovery');
@@ -70,11 +70,11 @@ button.addEventListener('click', async () => {
70
70
  }, 15000);
71
71
  timeout = requestTimeout;
72
72
  try {
73
- const response = await fetch('/api/v1/auth/verify', {
73
+ const response = await fetch(${scriptValue(i?`/api/v1/auth/device/approve`:`/api/v1/auth/verify`)}, {
74
74
  method: 'POST',
75
75
  credentials: 'same-origin',
76
76
  headers: { 'content-type': 'application/json', '${t.REQUEST_MARKER_HEADER}': '1' },
77
- body: JSON.stringify({ token: ${scriptValue(o??``)} }),
77
+ body: JSON.stringify({ token: ${scriptValue(l??``)} }),
78
78
  signal: active.signal,
79
79
  redirect: 'error',
80
80
  referrerPolicy: 'no-referrer'
@@ -83,9 +83,9 @@ button.addEventListener('click', async () => {
83
83
  if (disposed || controller !== active) return;
84
84
  if (response.status === 204) {
85
85
  terminal = true;
86
- button.textContent = 'Signed in';
87
- announce('You are signed in. Continuing to the app.');
88
- location.replace(${scriptValue(`${a}/signed-in`)});
86
+ button.textContent = ${scriptValue(i?`Sign-in approved`:`Signed in`)};
87
+ announce(${scriptValue(i?`Sign-in approved. Return to the app to finish signing in.`:`You are signed in. Continuing to the app.`)});
88
+ ${i?``:`location.replace(${scriptValue(`${c}/signed-in`)});`}
89
89
  } else if (response.status === 410 || response.status === 400) {
90
90
  terminal = true;
91
91
  button.hidden = true;
@@ -130,16 +130,16 @@ button.addEventListener('click', async () => {
130
130
  document.getElementById('actions').removeAttribute('aria-busy');
131
131
  }
132
132
  }
133
- });`:``,u=`<!doctype html>
133
+ });`:``,p=`<!doctype html>
134
134
  <html lang="en">
135
135
  <head>
136
136
  <meta charset="utf-8">
137
137
  <meta name="viewport" content="width=device-width, initial-scale=1">
138
138
  <meta name="color-scheme" content="light dark">
139
139
  <meta name="referrer" content="no-referrer">
140
- <title>${escapeHtml(c)} | ${escapeHtml(r)}</title>
141
- <script nonce="${i}">history.replaceState(null, '', location.pathname);<\/script>
142
- <style nonce="${i}">
140
+ <title>${escapeHtml(d)} | ${escapeHtml(r)}</title>
141
+ <script nonce="${s}">history.replaceState(null, '', location.pathname);<\/script>
142
+ <style nonce="${s}">
143
143
  :root { color-scheme: light dark; --paper: #fcfbf9; --ink: #302d2b; --muted: #68615c; --accent: #934b2b; --hover: #783a20; --rule: #d6d0ca; --focus: #934b2b; }
144
144
  * { box-sizing: border-box; }
145
145
  body { margin: 0; background: var(--paper); color: var(--ink); font: 1rem/1.6 'Avenir Next', 'Segoe UI', sans-serif; letter-spacing: 0; }
@@ -166,19 +166,20 @@ footer { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--rule);
166
166
  </head>
167
167
  <body>
168
168
  <main id="main" aria-labelledby="title">
169
- <h1 id="title" tabindex="-1">${s?`Sign in to ${escapeHtml(r)}`:escapeHtml(c)}</h1>
170
- <p>${s?`Continue only if you requested this sign-in link.`:`Request a new sign-in link for ${escapeHtml(r)}.`}</p>
171
- ${s?`<p class="detail">This link works once and expires ${n.LINK_TTL_MS/6e4} minutes after it was requested.</p>`:``}
169
+ <h1 id="title" tabindex="-1">${u?`Sign in to ${escapeHtml(r)}`:escapeHtml(d)}</h1>
170
+ <p>${u?i?`Continue only if you requested sign-in from your app. This will sign in that app, not this browser.`:`Continue only if you requested this sign-in link.`:`Request a new sign-in link for ${escapeHtml(r)}.`}</p>
171
+ ${u&&i&&a?`<p>Confirm that this code matches the one shown in your app: <strong>${escapeHtml(a)}</strong>. If it does not match, do not continue.</p>`:``}
172
+ ${u?`<p class="detail">This link works once and expires ${n.LINK_TTL_MS/6e4} minutes after it was requested.</p>`:``}
172
173
  <div class="actions" id="actions">
173
- ${s?`<button type="button" id="confirm" hidden>Confirm sign-in</button>`:``}
174
- <a${s?``:` class="primary"`} id="recovery" href="${escapeHtml(`${a}/account`)}"${s?` hidden`:``}>Request a new sign-in link</a>
174
+ ${u?`<button type="button" id="confirm" hidden>Confirm sign-in</button>`:``}
175
+ <a${u?``:` class="primary"`} id="recovery" href="${escapeHtml(`${c}/account`)}"${u?` hidden`:``}>Request a new sign-in link</a>
175
176
  </div>
176
177
  <p role="status" aria-live="polite" aria-atomic="true" id="status"></p>
177
- ${s?`<noscript><p>JavaScript is required to confirm this sign-in. No sign-in has been completed. <a href="${escapeHtml(`${a}/account`)}">Request a new sign-in link</a>.</p></noscript>`:``}
178
- ${s?`<footer id="exit"><a href="${escapeHtml(`${a}/`)}">Back to app</a></footer>`:``}
178
+ ${u?`<noscript><p>JavaScript is required to confirm this sign-in. No sign-in has been completed. <a href="${escapeHtml(`${c}/account`)}">Request a new sign-in link</a>.</p></noscript>`:``}
179
+ ${u?`<footer id="exit"><a href="${escapeHtml(`${c}/`)}">Back to app</a></footer>`:``}
179
180
  </main>
180
- ${s?`<script nonce="${i}">${l}
181
+ ${u?`<script nonce="${s}">${f}
181
182
  <\/script>`:``}
182
183
  </body>
183
- </html>`;return new Response(u,{headers:{...e.noStore,"content-security-policy":`default-src 'none'; script-src 'nonce-${i}'; style-src 'nonce-${i}'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,"content-type":`text/html; charset=utf-8`,"referrer-policy":`no-referrer`,"x-content-type-options":`nosniff`},status:s?200:400})}exports.confirmationPage=confirmationPage,exports.escapeHtml=escapeHtml;
184
+ </html>`;return new Response(p,{headers:{...e.noStore,"content-security-policy":`default-src 'none'; script-src 'nonce-${s}'; style-src 'nonce-${s}'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,"content-type":`text/html; charset=utf-8`,"referrer-policy":`no-referrer`,"x-content-type-options":`nosniff`},status:u?200:o})}exports.confirmationPage=confirmationPage,exports.escapeHtml=escapeHtml;
184
185
  //# sourceMappingURL=_confirmation.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"_confirmation.cjs","names":["REQUEST_MARKER_HEADER","LINK_TTL_MS","noStore","require_http","require_contract","require_store","value","char","valid","token","title","script","publicOrigin","html","appName","nonce"],"sources":["../../src/auth/_confirmation.ts"],"sourcesContent":["import { noStore } from '../_server/http';\nimport { REQUEST_MARKER_HEADER } from '../contract';\nimport { LINK_TTL_MS } from './store';\n\nexport const escapeHtml = (value: string): string => value.replace(/[&<>\"']/g, (char) => `&#${char.charCodeAt(0)};`);\n\nconst scriptValue = (value: string): string =>\n JSON.stringify(value).replace(\n /[<>&\\u2028\\u2029]/g,\n (char) => `\\\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`,\n );\n\nexport function confirmationPage({\n appName,\n nonce,\n publicOrigin,\n token,\n}: {\n appName: string;\n nonce: string;\n publicOrigin: string;\n token: string | null;\n}): Response {\n const valid = token !== null;\n const title = valid ? 'Confirm sign-in' : 'This sign-in link is not valid';\n const script = valid\n ? `\nconst button = document.getElementById('confirm');\nconst status = document.getElementById('status');\nconst recovery = document.getElementById('recovery');\nlet pending = false;\nlet terminal = false;\nlet disposed = false;\nlet cooldown;\nlet timeout;\nlet controller;\nlet retryAt = 0;\nconst announce = (message) => { status.textContent = message; };\nconst ready = () => {\n button.disabled = false;\n button.textContent = 'Try again';\n};\nconst stop = () => {\n clearTimeout(cooldown);\n clearTimeout(timeout);\n controller?.abort();\n controller = undefined;\n};\nconst tick = () => {\n if (disposed) return;\n const seconds = Math.max(0, Math.ceil((retryAt - Date.now()) / 1000));\n if (seconds === 0) {\n ready();\n announce('You can try signing in again now.');\n return;\n }\n button.textContent = 'Try again in ' + seconds + 's';\n cooldown = setTimeout(tick, 1000);\n};\nconst recover = (message) => {\n announce(message);\n recovery.hidden = false;\n document.getElementById('exit').hidden = true;\n};\nbutton.hidden = false;\nbutton.focus();\naddEventListener('pagehide', () => {\n disposed = true;\n stop();\n});\naddEventListener('pageshow', (event) => {\n if (!event.persisted || !disposed) return;\n disposed = false;\n pending = false;\n document.getElementById('actions').removeAttribute('aria-busy');\n if (terminal) return;\n if (retryAt > Date.now()) tick();\n else {\n ready();\n announce('Confirm sign-in when you are ready.');\n }\n});\nbutton.addEventListener('click', async () => {\n if (disposed || pending || terminal || retryAt > Date.now()) return;\n pending = true;\n button.disabled = true;\n button.textContent = 'Signing you in';\n document.getElementById('actions').setAttribute('aria-busy', 'true');\n announce('Signing you in. Please wait.');\n const active = new AbortController();\n controller = active;\n let timedOut = false;\n const requestTimeout = setTimeout(() => {\n timedOut = true;\n active.abort();\n }, 15000);\n timeout = requestTimeout;\n try {\n const response = await fetch('/api/v1/auth/verify', {\n method: 'POST',\n credentials: 'same-origin',\n headers: { 'content-type': 'application/json', '${REQUEST_MARKER_HEADER}': '1' },\n body: JSON.stringify({ token: ${scriptValue(token ?? '')} }),\n signal: active.signal,\n redirect: 'error',\n referrerPolicy: 'no-referrer'\n });\n void response.body?.cancel().catch(() => {});\n if (disposed || controller !== active) return;\n if (response.status === 204) {\n terminal = true;\n button.textContent = 'Signed in';\n announce('You are signed in. Continuing to the app.');\n location.replace(${scriptValue(`${publicOrigin}/signed-in`)});\n } else if (response.status === 410 || response.status === 400) {\n terminal = true;\n button.hidden = true;\n recovery.setAttribute('class', 'primary');\n recover(response.status === 410\n ? 'This link has expired or was already used. Request a new sign-in link.'\n : 'This sign-in link is not valid. Request a new sign-in link.');\n recovery.focus();\n } else if (response.status === 429) {\n const value = response.headers.get('retry-after');\n const httpDate = /^(?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (?:0[1-9]|[12]\\\\d|3[01]) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \\\\d{4} (?:[01]\\\\d|2[0-3]):[0-5]\\\\d:[0-5]\\\\d GMT|(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (?:0[1-9]|[12]\\\\d|3[01])-(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\\\\d{2} (?:[01]\\\\d|2[0-3]):[0-5]\\\\d:[0-5]\\\\d GMT|(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (?: [1-9]|[12]\\\\d|3[01]) (?:[01]\\\\d|2[0-3]):[0-5]\\\\d:[0-5]\\\\d \\\\d{4})$/;\n // HTTP dates are always GMT; bare asctime forms need the suffix or Date.parse reads them as local time.\n const seconds = value && /^\\\\d+$/.test(value)\n ? Number(value)\n : (value && httpDate.test(value)\n ? (Date.parse(value.endsWith('GMT') ? value : value + ' GMT') - Date.now()) / 1000\n : NaN);\n const wait = Number.isFinite(seconds) ? Math.min(300, Math.max(1, Math.ceil(seconds))) : 30;\n retryAt = Date.now() + wait * 1000;\n recover('Too many attempts. Wait ' + wait + ' seconds before trying again.');\n tick();\n } else if (response.status === 503) {\n recover('Sign-in is temporarily unavailable. Try again shortly, or request a new sign-in link.');\n ready();\n } else {\n recover('Sign-in could not be completed. Try again, or request a new sign-in link.');\n ready();\n }\n } catch {\n if (!disposed && controller === active) {\n recover(timedOut\n ? 'Sign-in took too long. Try again, or request a new sign-in link.'\n : 'Unable to connect. Check your connection and try again, or request a new sign-in link.');\n ready();\n }\n } finally {\n active.abort();\n clearTimeout(requestTimeout);\n if (!disposed && controller === active) {\n controller = undefined;\n pending = false;\n document.getElementById('actions').removeAttribute('aria-busy');\n }\n }\n});`\n : '';\n const html = `<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"color-scheme\" content=\"light dark\">\n<meta name=\"referrer\" content=\"no-referrer\">\n<title>${escapeHtml(title)} | ${escapeHtml(appName)}</title>\n<script nonce=\"${nonce}\">history.replaceState(null, '', location.pathname);</script>\n<style nonce=\"${nonce}\">\n:root { color-scheme: light dark; --paper: #fcfbf9; --ink: #302d2b; --muted: #68615c; --accent: #934b2b; --hover: #783a20; --rule: #d6d0ca; --focus: #934b2b; }\n* { box-sizing: border-box; }\nbody { margin: 0; background: var(--paper); color: var(--ink); font: 1rem/1.6 'Avenir Next', 'Segoe UI', sans-serif; letter-spacing: 0; }\nmain { width: min(100%, 38rem); margin: 0 auto; padding: 5rem 1.5rem 3rem; }\nh1 { margin: 0 0 1.5rem; font-family: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Palatino, serif; font-size: 2.25rem; line-height: 1.2; font-weight: 500; overflow-wrap: anywhere; text-wrap: balance; }\np { margin: 0 0 1rem; }\n.detail { color: var(--muted); }\n.actions { margin-top: 2rem; display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; }\nbutton, .primary { min-height: 44px; max-width: 100%; padding: .75rem 1.25rem; border: 1px solid var(--accent); border-radius: 4px; background: var(--accent); color: #fff; font: inherit; font-weight: 600; text-align: center; text-decoration: none; overflow-wrap: anywhere; cursor: pointer; transition: background-color 120ms ease-out; }\nbutton { min-width: 12rem; }\nbutton:hover:not(:disabled), .primary:hover { background: var(--hover); }\nbutton:disabled { cursor: wait; opacity: .75; }\na { color: var(--accent); text-underline-offset: .2em; overflow-wrap: anywhere; }\na:not(.primary) { display: inline-flex; align-items: center; min-height: 44px; }\n:focus-visible { outline: 3px solid var(--focus); outline-offset: 4px; }\n::selection { background: var(--accent); color: #fff; }\n#status { min-height: 3.2em; margin-top: 1.5rem; }\nfooter { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--rule); }\n[hidden] { display: none !important; }\n@media (prefers-color-scheme: dark) { :root { --paper: #242220; --ink: #f2efeb; --muted: #bfb7b0; --accent: #e0a07b; --hover: #efb998; --rule: #504942; --focus: #e0a07b; } button, .primary { color: #242220; } ::selection { color: #242220; } }\n@media (max-width: 480px) { main { padding: 3rem 1.25rem 2rem; } h1 { font-size: 2rem; } .actions { align-items: stretch; flex-direction: column; } }\n@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; } }\n</style>\n</head>\n<body>\n<main id=\"main\" aria-labelledby=\"title\">\n<h1 id=\"title\" tabindex=\"-1\">${valid ? `Sign in to ${escapeHtml(appName)}` : escapeHtml(title)}</h1>\n<p>${valid ? 'Continue only if you requested this sign-in link.' : `Request a new sign-in link for ${escapeHtml(appName)}.`}</p>\n${valid ? `<p class=\"detail\">This link works once and expires ${LINK_TTL_MS / 60_000} minutes after it was requested.</p>` : ''}\n<div class=\"actions\" id=\"actions\">\n${valid ? '<button type=\"button\" id=\"confirm\" hidden>Confirm sign-in</button>' : ''}\n<a${valid ? '' : ' class=\"primary\"'} id=\"recovery\" href=\"${escapeHtml(`${publicOrigin}/account`)}\"${valid ? ' hidden' : ''}>Request a new sign-in link</a>\n</div>\n<p role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" id=\"status\"></p>\n${valid ? `<noscript><p>JavaScript is required to confirm this sign-in. No sign-in has been completed. <a href=\"${escapeHtml(`${publicOrigin}/account`)}\">Request a new sign-in link</a>.</p></noscript>` : ''}\n${valid ? `<footer id=\"exit\"><a href=\"${escapeHtml(`${publicOrigin}/`)}\">Back to app</a></footer>` : ''}\n</main>\n${\n valid\n ? `<script nonce=\"${nonce}\">${script}\n</script>`\n : ''\n}\n</body>\n</html>`;\n return new Response(html, {\n headers: {\n ...noStore,\n 'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,\n 'content-type': 'text/html; charset=utf-8',\n 'referrer-policy': 'no-referrer',\n 'x-content-type-options': 'nosniff',\n },\n status: valid ? 200 : 400,\n });\n}\n"],"mappings":"A,6FAIA,IAAa,WAAc,GAA0BM,EAAM,QAAQ,WAAa,GAAS,KAAKC,EAAK,WAAW,CAAC,EAAE,EAAE,EAE7G,YAAe,GACnB,KAAK,UAAUD,CAAK,CAAC,CAAC,QACpB,qBACC,GAAS,MAAMC,EAAK,WAAW,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,SAAS,EAAG,GAAG,GACjE,EAEF,SAAgB,iBAAiB,CAC/B,UACA,QACA,eACA,SAMW,CACX,IAAMC,EAAQC,IAAU,KAClBC,EAAQF,EAAQ,kBAAoB,iCACpCG,EAASH,EACX;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wDA2EkDR,EAAAA,sBAAsB;sCACxC,YAAYS,GAAS,EAAE,EAAE;;;;;;;;;;;yBAWtC,YAAY,GAAGG,EAAa,WAAW,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KA8C5D,GACEC,EAAO;;;;;;;SAON,WAAWH,CAAK,EAAE,KAAK,WAAWI,CAAO,EAAE;iBACnCC,EAAM;gBACPA,EAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;+BA2BSP,EAAQ,cAAc,WAAWM,CAAO,IAAM,WAAWJ,CAAK,EAAE;KAC1FF,EAAQ,oDAAsD,kCAAkC,WAAWM,CAAO,EAAE,GAAG;EAC1HN,EAAQ,sDAAsDP,EAAAA,YAAc,IAAO,sCAAwC,GAAG;;EAE9HO,EAAQ,qEAAuE,GAAG;IAChFA,EAAQ,GAAK,mBAAmB,uBAAuB,WAAW,GAAGI,EAAa,SAAS,EAAE,GAAGJ,EAAQ,UAAY,GAAG;;;EAGzHA,EAAQ,wGAAwG,WAAW,GAAGI,EAAa,SAAS,EAAE,kDAAoD,GAAG;EAC7MJ,EAAQ,8BAA8B,WAAW,GAAGI,EAAa,EAAE,EAAE,4BAA8B,GAAG;;EAGtGJ,EACI,kBAAkBO,EAAM,IAAIJ,EAAO;YAEnC,GACL;;SAGC,OAAO,IAAI,SAASE,EAAM,CACxB,QAAS,CACP,GAAGX,EAAAA,QACH,0BAA2B,yCAAyCa,EAAM,sBAAsBA,EAAM,oFACtG,eAAgB,2BAChB,kBAAmB,cACnB,yBAA0B,SAC5B,EACA,OAAQP,EAAQ,IAAM,GACxB,CAAC,CACH,C"}
1
+ {"version":3,"file":"_confirmation.cjs","names":["REQUEST_MARKER_HEADER","LINK_TTL_MS","noStore","require_http","require_contract","require_store","value","char","valid","token","title","errorStatus","script","device","publicOrigin","html","appName","nonce","deviceCode"],"sources":["../../src/auth/_confirmation.ts"],"sourcesContent":["import { noStore } from '../_server/http';\nimport { REQUEST_MARKER_HEADER } from '../contract';\nimport { LINK_TTL_MS } from './store';\n\nexport const escapeHtml = (value: string): string => value.replace(/[&<>\"']/g, (char) => `&#${char.charCodeAt(0)};`);\n\nconst scriptValue = (value: string): string =>\n JSON.stringify(value).replace(\n /[<>&\\u2028\\u2029]/g,\n (char) => `\\\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`,\n );\n\nexport function confirmationPage({\n appName,\n device = false,\n deviceCode,\n errorStatus = 400,\n nonce,\n publicOrigin,\n token,\n}: {\n appName: string;\n device?: boolean;\n deviceCode?: string;\n errorStatus?: 400 | 410;\n nonce: string;\n publicOrigin: string;\n token: string | null;\n}): Response {\n const valid = token !== null;\n const title = valid\n ? 'Confirm sign-in'\n : errorStatus === 410\n ? 'This sign-in link has expired or was already used'\n : 'This sign-in link is not valid';\n const script = valid\n ? `\nconst button = document.getElementById('confirm');\nconst status = document.getElementById('status');\nconst recovery = document.getElementById('recovery');\nlet pending = false;\nlet terminal = false;\nlet disposed = false;\nlet cooldown;\nlet timeout;\nlet controller;\nlet retryAt = 0;\nconst announce = (message) => { status.textContent = message; };\nconst ready = () => {\n button.disabled = false;\n button.textContent = 'Try again';\n};\nconst stop = () => {\n clearTimeout(cooldown);\n clearTimeout(timeout);\n controller?.abort();\n controller = undefined;\n};\nconst tick = () => {\n if (disposed) return;\n const seconds = Math.max(0, Math.ceil((retryAt - Date.now()) / 1000));\n if (seconds === 0) {\n ready();\n announce('You can try signing in again now.');\n return;\n }\n button.textContent = 'Try again in ' + seconds + 's';\n cooldown = setTimeout(tick, 1000);\n};\nconst recover = (message) => {\n announce(message);\n recovery.hidden = false;\n document.getElementById('exit').hidden = true;\n};\nbutton.hidden = false;\nbutton.focus();\naddEventListener('pagehide', () => {\n disposed = true;\n stop();\n});\naddEventListener('pageshow', (event) => {\n if (!event.persisted || !disposed) return;\n disposed = false;\n pending = false;\n document.getElementById('actions').removeAttribute('aria-busy');\n if (terminal) return;\n if (retryAt > Date.now()) tick();\n else {\n ready();\n announce('Confirm sign-in when you are ready.');\n }\n});\nbutton.addEventListener('click', async () => {\n if (disposed || pending || terminal || retryAt > Date.now()) return;\n pending = true;\n button.disabled = true;\n button.textContent = 'Signing you in';\n document.getElementById('actions').setAttribute('aria-busy', 'true');\n announce('Signing you in. Please wait.');\n const active = new AbortController();\n controller = active;\n let timedOut = false;\n const requestTimeout = setTimeout(() => {\n timedOut = true;\n active.abort();\n }, 15000);\n timeout = requestTimeout;\n try {\n const response = await fetch(${scriptValue(device ? '/api/v1/auth/device/approve' : '/api/v1/auth/verify')}, {\n method: 'POST',\n credentials: 'same-origin',\n headers: { 'content-type': 'application/json', '${REQUEST_MARKER_HEADER}': '1' },\n body: JSON.stringify({ token: ${scriptValue(token ?? '')} }),\n signal: active.signal,\n redirect: 'error',\n referrerPolicy: 'no-referrer'\n });\n void response.body?.cancel().catch(() => {});\n if (disposed || controller !== active) return;\n if (response.status === 204) {\n terminal = true;\n button.textContent = ${scriptValue(device ? 'Sign-in approved' : 'Signed in')};\n announce(${scriptValue(device ? 'Sign-in approved. Return to the app to finish signing in.' : 'You are signed in. Continuing to the app.')});\n ${device ? '' : `location.replace(${scriptValue(`${publicOrigin}/signed-in`)});`}\n } else if (response.status === 410 || response.status === 400) {\n terminal = true;\n button.hidden = true;\n recovery.setAttribute('class', 'primary');\n recover(response.status === 410\n ? 'This link has expired or was already used. Request a new sign-in link.'\n : 'This sign-in link is not valid. Request a new sign-in link.');\n recovery.focus();\n } else if (response.status === 429) {\n const value = response.headers.get('retry-after');\n const httpDate = /^(?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (?:0[1-9]|[12]\\\\d|3[01]) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \\\\d{4} (?:[01]\\\\d|2[0-3]):[0-5]\\\\d:[0-5]\\\\d GMT|(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (?:0[1-9]|[12]\\\\d|3[01])-(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\\\\d{2} (?:[01]\\\\d|2[0-3]):[0-5]\\\\d:[0-5]\\\\d GMT|(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (?: [1-9]|[12]\\\\d|3[01]) (?:[01]\\\\d|2[0-3]):[0-5]\\\\d:[0-5]\\\\d \\\\d{4})$/;\n // HTTP dates are always GMT; bare asctime forms need the suffix or Date.parse reads them as local time.\n const seconds = value && /^\\\\d+$/.test(value)\n ? Number(value)\n : (value && httpDate.test(value)\n ? (Date.parse(value.endsWith('GMT') ? value : value + ' GMT') - Date.now()) / 1000\n : NaN);\n const wait = Number.isFinite(seconds) ? Math.min(300, Math.max(1, Math.ceil(seconds))) : 30;\n retryAt = Date.now() + wait * 1000;\n recover('Too many attempts. Wait ' + wait + ' seconds before trying again.');\n tick();\n } else if (response.status === 503) {\n recover('Sign-in is temporarily unavailable. Try again shortly, or request a new sign-in link.');\n ready();\n } else {\n recover('Sign-in could not be completed. Try again, or request a new sign-in link.');\n ready();\n }\n } catch {\n if (!disposed && controller === active) {\n recover(timedOut\n ? 'Sign-in took too long. Try again, or request a new sign-in link.'\n : 'Unable to connect. Check your connection and try again, or request a new sign-in link.');\n ready();\n }\n } finally {\n active.abort();\n clearTimeout(requestTimeout);\n if (!disposed && controller === active) {\n controller = undefined;\n pending = false;\n document.getElementById('actions').removeAttribute('aria-busy');\n }\n }\n});`\n : '';\n const html = `<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"color-scheme\" content=\"light dark\">\n<meta name=\"referrer\" content=\"no-referrer\">\n<title>${escapeHtml(title)} | ${escapeHtml(appName)}</title>\n<script nonce=\"${nonce}\">history.replaceState(null, '', location.pathname);</script>\n<style nonce=\"${nonce}\">\n:root { color-scheme: light dark; --paper: #fcfbf9; --ink: #302d2b; --muted: #68615c; --accent: #934b2b; --hover: #783a20; --rule: #d6d0ca; --focus: #934b2b; }\n* { box-sizing: border-box; }\nbody { margin: 0; background: var(--paper); color: var(--ink); font: 1rem/1.6 'Avenir Next', 'Segoe UI', sans-serif; letter-spacing: 0; }\nmain { width: min(100%, 38rem); margin: 0 auto; padding: 5rem 1.5rem 3rem; }\nh1 { margin: 0 0 1.5rem; font-family: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Palatino, serif; font-size: 2.25rem; line-height: 1.2; font-weight: 500; overflow-wrap: anywhere; text-wrap: balance; }\np { margin: 0 0 1rem; }\n.detail { color: var(--muted); }\n.actions { margin-top: 2rem; display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; }\nbutton, .primary { min-height: 44px; max-width: 100%; padding: .75rem 1.25rem; border: 1px solid var(--accent); border-radius: 4px; background: var(--accent); color: #fff; font: inherit; font-weight: 600; text-align: center; text-decoration: none; overflow-wrap: anywhere; cursor: pointer; transition: background-color 120ms ease-out; }\nbutton { min-width: 12rem; }\nbutton:hover:not(:disabled), .primary:hover { background: var(--hover); }\nbutton:disabled { cursor: wait; opacity: .75; }\na { color: var(--accent); text-underline-offset: .2em; overflow-wrap: anywhere; }\na:not(.primary) { display: inline-flex; align-items: center; min-height: 44px; }\n:focus-visible { outline: 3px solid var(--focus); outline-offset: 4px; }\n::selection { background: var(--accent); color: #fff; }\n#status { min-height: 3.2em; margin-top: 1.5rem; }\nfooter { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--rule); }\n[hidden] { display: none !important; }\n@media (prefers-color-scheme: dark) { :root { --paper: #242220; --ink: #f2efeb; --muted: #bfb7b0; --accent: #e0a07b; --hover: #efb998; --rule: #504942; --focus: #e0a07b; } button, .primary { color: #242220; } ::selection { color: #242220; } }\n@media (max-width: 480px) { main { padding: 3rem 1.25rem 2rem; } h1 { font-size: 2rem; } .actions { align-items: stretch; flex-direction: column; } }\n@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; } }\n</style>\n</head>\n<body>\n<main id=\"main\" aria-labelledby=\"title\">\n<h1 id=\"title\" tabindex=\"-1\">${valid ? `Sign in to ${escapeHtml(appName)}` : escapeHtml(title)}</h1>\n<p>${valid ? (device ? 'Continue only if you requested sign-in from your app. This will sign in that app, not this browser.' : 'Continue only if you requested this sign-in link.') : `Request a new sign-in link for ${escapeHtml(appName)}.`}</p>\n${valid && device && deviceCode ? `<p>Confirm that this code matches the one shown in your app: <strong>${escapeHtml(deviceCode)}</strong>. If it does not match, do not continue.</p>` : ''}\n${valid ? `<p class=\"detail\">This link works once and expires ${LINK_TTL_MS / 60_000} minutes after it was requested.</p>` : ''}\n<div class=\"actions\" id=\"actions\">\n${valid ? '<button type=\"button\" id=\"confirm\" hidden>Confirm sign-in</button>' : ''}\n<a${valid ? '' : ' class=\"primary\"'} id=\"recovery\" href=\"${escapeHtml(`${publicOrigin}/account`)}\"${valid ? ' hidden' : ''}>Request a new sign-in link</a>\n</div>\n<p role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" id=\"status\"></p>\n${valid ? `<noscript><p>JavaScript is required to confirm this sign-in. No sign-in has been completed. <a href=\"${escapeHtml(`${publicOrigin}/account`)}\">Request a new sign-in link</a>.</p></noscript>` : ''}\n${valid ? `<footer id=\"exit\"><a href=\"${escapeHtml(`${publicOrigin}/`)}\">Back to app</a></footer>` : ''}\n</main>\n${\n valid\n ? `<script nonce=\"${nonce}\">${script}\n</script>`\n : ''\n}\n</body>\n</html>`;\n return new Response(html, {\n headers: {\n ...noStore,\n 'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,\n 'content-type': 'text/html; charset=utf-8',\n 'referrer-policy': 'no-referrer',\n 'x-content-type-options': 'nosniff',\n },\n status: valid ? 200 : errorStatus,\n });\n}\n"],"mappings":"A,6FAIA,IAAa,WAAc,GAA0BM,EAAM,QAAQ,WAAa,GAAS,KAAKC,EAAK,WAAW,CAAC,EAAE,EAAE,EAE7G,YAAe,GACnB,KAAK,UAAUD,CAAK,CAAC,CAAC,QACpB,qBACC,GAAS,MAAMC,EAAK,WAAW,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,SAAS,EAAG,GAAG,GACjE,EAEF,SAAgB,iBAAiB,CAC/B,UACA,SAAS,GACT,aACA,cAAc,IACd,QACA,eACA,SASW,CACX,IAAMC,EAAQC,IAAU,KAClBC,EAAQF,EACV,kBACAG,IAAgB,IACd,oDACA,iCACAC,EAASJ,EACX;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;mCAwE6B,YAAYK,EAAS,8BAAgC,qBAAqB,EAAE;;;wDAGvDb,EAAAA,sBAAsB;sCACxC,YAAYS,GAAS,EAAE,EAAE;;;;;;;;;6BASlC,YAAYI,EAAS,mBAAqB,WAAW,EAAE;iBACnE,YAAYA,EAAS,4DAA8D,2CAA2C,EAAE;QACzIA,EAAS,GAAK,oBAAoB,YAAY,GAAGC,EAAa,WAAW,EAAE,IAAI;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KA8CjF,GACEC,EAAO;;;;;;;SAON,WAAWL,CAAK,EAAE,KAAK,WAAWM,CAAO,EAAE;iBACnCC,EAAM;gBACPA,EAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;+BA2BST,EAAQ,cAAc,WAAWQ,CAAO,IAAM,WAAWN,CAAK,EAAE;KAC1FF,EAASK,EAAS,sGAAwG,oDAAuD,kCAAkC,WAAWG,CAAO,EAAE,GAAG;EAC7OR,GAASK,GAAUK,EAAa,wEAAwE,WAAWA,CAAU,EAAE,uDAAyD,GAAG;EAC3LV,EAAQ,sDAAsDP,EAAAA,YAAc,IAAO,sCAAwC,GAAG;;EAE9HO,EAAQ,qEAAuE,GAAG;IAChFA,EAAQ,GAAK,mBAAmB,uBAAuB,WAAW,GAAGM,EAAa,SAAS,EAAE,GAAGN,EAAQ,UAAY,GAAG;;;EAGzHA,EAAQ,wGAAwG,WAAW,GAAGM,EAAa,SAAS,EAAE,kDAAoD,GAAG;EAC7MN,EAAQ,8BAA8B,WAAW,GAAGM,EAAa,EAAE,EAAE,4BAA8B,GAAG;;EAGtGN,EACI,kBAAkBS,EAAM,IAAIL,EAAO;YAEnC,GACL;;SAGC,OAAO,IAAI,SAASG,EAAM,CACxB,QAAS,CACP,GAAGb,EAAAA,QACH,0BAA2B,yCAAyCe,EAAM,sBAAsBA,EAAM,oFACtG,eAAgB,2BAChB,kBAAmB,cACnB,yBAA0B,SAC5B,EACA,OAAQT,EAAQ,IAAMG,CACxB,CAAC,CACH,C"}
@@ -1,6 +1,9 @@
1
1
  export declare const escapeHtml: (value: string) => string;
2
- export declare function confirmationPage({ appName, nonce, publicOrigin, token, }: {
2
+ export declare function confirmationPage({ appName, device, deviceCode, errorStatus, nonce, publicOrigin, token, }: {
3
3
  appName: string;
4
+ device?: boolean;
5
+ deviceCode?: string;
6
+ errorStatus?: 400 | 410;
4
7
  nonce: string;
5
8
  publicOrigin: string;
6
9
  token: string | null;
@@ -1 +1 @@
1
- {"version":3,"file":"_confirmation.d.ts","sourceRoot":"","sources":["../../src/auth/_confirmation.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,UAAU,GAAI,OAAO,MAAM,KAAG,MAAyE,CAAC;AAQrH,wBAAgB,gBAAgB,CAAC,EAC/B,OAAO,EACP,KAAK,EACL,YAAY,EACZ,KAAK,GACN,EAAE;IACD,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB,GAAG,QAAQ,CA2MX"}
1
+ {"version":3,"file":"_confirmation.d.ts","sourceRoot":"","sources":["../../src/auth/_confirmation.ts"],"names":[],"mappings":"AAIA,eAAO,MAAM,UAAU,GAAI,OAAO,MAAM,KAAG,MAAyE,CAAC;AAQrH,wBAAgB,gBAAgB,CAAC,EAC/B,OAAO,EACP,MAAc,EACd,UAAU,EACV,WAAiB,EACjB,KAAK,EACL,YAAY,EACZ,KAAK,GACN,EAAE;IACD,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,GAAG,GAAG,GAAG,CAAC;IACxB,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;CACtB,GAAG,QAAQ,CAgNX"}
@@ -3,8 +3,8 @@ import { REQUEST_MARKER_HEADER as t } from "../contract.js";
3
3
  import { LINK_TTL_MS as n } from "./store.js";
4
4
  // src/auth/_confirmation.ts
5
5
  var escapeHtml = (e) => e.replace(/[&<>"']/g, (e) => `&#${e.charCodeAt(0)};`), scriptValue = (e) => JSON.stringify(e).replace(/[<>&\u2028\u2029]/g, (e) => `\\u${e.charCodeAt(0).toString(16).padStart(4, "0")}`);
6
- function confirmationPage({ appName: r, nonce: i, publicOrigin: a, token: o }) {
7
- let s = o !== null, c = s ? "Confirm sign-in" : "This sign-in link is not valid", l = s ? `
6
+ function confirmationPage({ appName: r, device: i = !1, deviceCode: a, errorStatus: o = 400, nonce: s, publicOrigin: c, token: l }) {
7
+ let u = l !== null, d = u ? "Confirm sign-in" : o === 410 ? "This sign-in link has expired or was already used" : "This sign-in link is not valid", f = u ? `
8
8
  const button = document.getElementById('confirm');
9
9
  const status = document.getElementById('status');
10
10
  const recovery = document.getElementById('recovery');
@@ -76,11 +76,11 @@ button.addEventListener('click', async () => {
76
76
  }, 15000);
77
77
  timeout = requestTimeout;
78
78
  try {
79
- const response = await fetch('/api/v1/auth/verify', {
79
+ const response = await fetch(${scriptValue(i ? "/api/v1/auth/device/approve" : "/api/v1/auth/verify")}, {
80
80
  method: 'POST',
81
81
  credentials: 'same-origin',
82
82
  headers: { 'content-type': 'application/json', '${t}': '1' },
83
- body: JSON.stringify({ token: ${scriptValue(o ?? "")} }),
83
+ body: JSON.stringify({ token: ${scriptValue(l ?? "")} }),
84
84
  signal: active.signal,
85
85
  redirect: 'error',
86
86
  referrerPolicy: 'no-referrer'
@@ -89,9 +89,9 @@ button.addEventListener('click', async () => {
89
89
  if (disposed || controller !== active) return;
90
90
  if (response.status === 204) {
91
91
  terminal = true;
92
- button.textContent = 'Signed in';
93
- announce('You are signed in. Continuing to the app.');
94
- location.replace(${scriptValue(`${a}/signed-in`)});
92
+ button.textContent = ${scriptValue(i ? "Sign-in approved" : "Signed in")};
93
+ announce(${scriptValue(i ? "Sign-in approved. Return to the app to finish signing in." : "You are signed in. Continuing to the app.")});
94
+ ${i ? "" : `location.replace(${scriptValue(`${c}/signed-in`)});`}
95
95
  } else if (response.status === 410 || response.status === 400) {
96
96
  terminal = true;
97
97
  button.hidden = true;
@@ -136,16 +136,16 @@ button.addEventListener('click', async () => {
136
136
  document.getElementById('actions').removeAttribute('aria-busy');
137
137
  }
138
138
  }
139
- });` : "", u = `<!doctype html>
139
+ });` : "", p = `<!doctype html>
140
140
  <html lang="en">
141
141
  <head>
142
142
  <meta charset="utf-8">
143
143
  <meta name="viewport" content="width=device-width, initial-scale=1">
144
144
  <meta name="color-scheme" content="light dark">
145
145
  <meta name="referrer" content="no-referrer">
146
- <title>${escapeHtml(c)} | ${escapeHtml(r)}</title>
147
- <script nonce="${i}">history.replaceState(null, '', location.pathname);<\/script>
148
- <style nonce="${i}">
146
+ <title>${escapeHtml(d)} | ${escapeHtml(r)}</title>
147
+ <script nonce="${s}">history.replaceState(null, '', location.pathname);<\/script>
148
+ <style nonce="${s}">
149
149
  :root { color-scheme: light dark; --paper: #fcfbf9; --ink: #302d2b; --muted: #68615c; --accent: #934b2b; --hover: #783a20; --rule: #d6d0ca; --focus: #934b2b; }
150
150
  * { box-sizing: border-box; }
151
151
  body { margin: 0; background: var(--paper); color: var(--ink); font: 1rem/1.6 'Avenir Next', 'Segoe UI', sans-serif; letter-spacing: 0; }
@@ -172,30 +172,31 @@ footer { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--rule);
172
172
  </head>
173
173
  <body>
174
174
  <main id="main" aria-labelledby="title">
175
- <h1 id="title" tabindex="-1">${s ? `Sign in to ${escapeHtml(r)}` : escapeHtml(c)}</h1>
176
- <p>${s ? "Continue only if you requested this sign-in link." : `Request a new sign-in link for ${escapeHtml(r)}.`}</p>
177
- ${s ? `<p class="detail">This link works once and expires ${n / 6e4} minutes after it was requested.</p>` : ""}
175
+ <h1 id="title" tabindex="-1">${u ? `Sign in to ${escapeHtml(r)}` : escapeHtml(d)}</h1>
176
+ <p>${u ? i ? "Continue only if you requested sign-in from your app. This will sign in that app, not this browser." : "Continue only if you requested this sign-in link." : `Request a new sign-in link for ${escapeHtml(r)}.`}</p>
177
+ ${u && i && a ? `<p>Confirm that this code matches the one shown in your app: <strong>${escapeHtml(a)}</strong>. If it does not match, do not continue.</p>` : ""}
178
+ ${u ? `<p class="detail">This link works once and expires ${n / 6e4} minutes after it was requested.</p>` : ""}
178
179
  <div class="actions" id="actions">
179
- ${s ? "<button type=\"button\" id=\"confirm\" hidden>Confirm sign-in</button>" : ""}
180
- <a${s ? "" : " class=\"primary\""} id="recovery" href="${escapeHtml(`${a}/account`)}"${s ? " hidden" : ""}>Request a new sign-in link</a>
180
+ ${u ? "<button type=\"button\" id=\"confirm\" hidden>Confirm sign-in</button>" : ""}
181
+ <a${u ? "" : " class=\"primary\""} id="recovery" href="${escapeHtml(`${c}/account`)}"${u ? " hidden" : ""}>Request a new sign-in link</a>
181
182
  </div>
182
183
  <p role="status" aria-live="polite" aria-atomic="true" id="status"></p>
183
- ${s ? `<noscript><p>JavaScript is required to confirm this sign-in. No sign-in has been completed. <a href="${escapeHtml(`${a}/account`)}">Request a new sign-in link</a>.</p></noscript>` : ""}
184
- ${s ? `<footer id="exit"><a href="${escapeHtml(`${a}/`)}">Back to app</a></footer>` : ""}
184
+ ${u ? `<noscript><p>JavaScript is required to confirm this sign-in. No sign-in has been completed. <a href="${escapeHtml(`${c}/account`)}">Request a new sign-in link</a>.</p></noscript>` : ""}
185
+ ${u ? `<footer id="exit"><a href="${escapeHtml(`${c}/`)}">Back to app</a></footer>` : ""}
185
186
  </main>
186
- ${s ? `<script nonce="${i}">${l}
187
+ ${u ? `<script nonce="${s}">${f}
187
188
  <\/script>` : ""}
188
189
  </body>
189
190
  </html>`;
190
- return new Response(u, {
191
+ return new Response(p, {
191
192
  headers: {
192
193
  ...e,
193
- "content-security-policy": `default-src 'none'; script-src 'nonce-${i}'; style-src 'nonce-${i}'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
194
+ "content-security-policy": `default-src 'none'; script-src 'nonce-${s}'; style-src 'nonce-${s}'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,
194
195
  "content-type": "text/html; charset=utf-8",
195
196
  "referrer-policy": "no-referrer",
196
197
  "x-content-type-options": "nosniff"
197
198
  },
198
- status: s ? 200 : 400
199
+ status: u ? 200 : o
199
200
  });
200
201
  }
201
202
  export { confirmationPage, escapeHtml };
@@ -1 +1 @@
1
- {"version":3,"file":"_confirmation.js","names":["value","char","valid","token","title","script","REQUEST_MARKER_HEADER","publicOrigin","html","appName","nonce","LINK_TTL_MS","noStore"],"sources":["../../src/auth/_confirmation.ts"],"sourcesContent":["import { noStore } from '../_server/http';\nimport { REQUEST_MARKER_HEADER } from '../contract';\nimport { LINK_TTL_MS } from './store';\n\nexport const escapeHtml = (value: string): string => value.replace(/[&<>\"']/g, (char) => `&#${char.charCodeAt(0)};`);\n\nconst scriptValue = (value: string): string =>\n JSON.stringify(value).replace(\n /[<>&\\u2028\\u2029]/g,\n (char) => `\\\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`,\n );\n\nexport function confirmationPage({\n appName,\n nonce,\n publicOrigin,\n token,\n}: {\n appName: string;\n nonce: string;\n publicOrigin: string;\n token: string | null;\n}): Response {\n const valid = token !== null;\n const title = valid ? 'Confirm sign-in' : 'This sign-in link is not valid';\n const script = valid\n ? `\nconst button = document.getElementById('confirm');\nconst status = document.getElementById('status');\nconst recovery = document.getElementById('recovery');\nlet pending = false;\nlet terminal = false;\nlet disposed = false;\nlet cooldown;\nlet timeout;\nlet controller;\nlet retryAt = 0;\nconst announce = (message) => { status.textContent = message; };\nconst ready = () => {\n button.disabled = false;\n button.textContent = 'Try again';\n};\nconst stop = () => {\n clearTimeout(cooldown);\n clearTimeout(timeout);\n controller?.abort();\n controller = undefined;\n};\nconst tick = () => {\n if (disposed) return;\n const seconds = Math.max(0, Math.ceil((retryAt - Date.now()) / 1000));\n if (seconds === 0) {\n ready();\n announce('You can try signing in again now.');\n return;\n }\n button.textContent = 'Try again in ' + seconds + 's';\n cooldown = setTimeout(tick, 1000);\n};\nconst recover = (message) => {\n announce(message);\n recovery.hidden = false;\n document.getElementById('exit').hidden = true;\n};\nbutton.hidden = false;\nbutton.focus();\naddEventListener('pagehide', () => {\n disposed = true;\n stop();\n});\naddEventListener('pageshow', (event) => {\n if (!event.persisted || !disposed) return;\n disposed = false;\n pending = false;\n document.getElementById('actions').removeAttribute('aria-busy');\n if (terminal) return;\n if (retryAt > Date.now()) tick();\n else {\n ready();\n announce('Confirm sign-in when you are ready.');\n }\n});\nbutton.addEventListener('click', async () => {\n if (disposed || pending || terminal || retryAt > Date.now()) return;\n pending = true;\n button.disabled = true;\n button.textContent = 'Signing you in';\n document.getElementById('actions').setAttribute('aria-busy', 'true');\n announce('Signing you in. Please wait.');\n const active = new AbortController();\n controller = active;\n let timedOut = false;\n const requestTimeout = setTimeout(() => {\n timedOut = true;\n active.abort();\n }, 15000);\n timeout = requestTimeout;\n try {\n const response = await fetch('/api/v1/auth/verify', {\n method: 'POST',\n credentials: 'same-origin',\n headers: { 'content-type': 'application/json', '${REQUEST_MARKER_HEADER}': '1' },\n body: JSON.stringify({ token: ${scriptValue(token ?? '')} }),\n signal: active.signal,\n redirect: 'error',\n referrerPolicy: 'no-referrer'\n });\n void response.body?.cancel().catch(() => {});\n if (disposed || controller !== active) return;\n if (response.status === 204) {\n terminal = true;\n button.textContent = 'Signed in';\n announce('You are signed in. Continuing to the app.');\n location.replace(${scriptValue(`${publicOrigin}/signed-in`)});\n } else if (response.status === 410 || response.status === 400) {\n terminal = true;\n button.hidden = true;\n recovery.setAttribute('class', 'primary');\n recover(response.status === 410\n ? 'This link has expired or was already used. Request a new sign-in link.'\n : 'This sign-in link is not valid. Request a new sign-in link.');\n recovery.focus();\n } else if (response.status === 429) {\n const value = response.headers.get('retry-after');\n const httpDate = /^(?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (?:0[1-9]|[12]\\\\d|3[01]) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \\\\d{4} (?:[01]\\\\d|2[0-3]):[0-5]\\\\d:[0-5]\\\\d GMT|(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (?:0[1-9]|[12]\\\\d|3[01])-(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\\\\d{2} (?:[01]\\\\d|2[0-3]):[0-5]\\\\d:[0-5]\\\\d GMT|(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (?: [1-9]|[12]\\\\d|3[01]) (?:[01]\\\\d|2[0-3]):[0-5]\\\\d:[0-5]\\\\d \\\\d{4})$/;\n // HTTP dates are always GMT; bare asctime forms need the suffix or Date.parse reads them as local time.\n const seconds = value && /^\\\\d+$/.test(value)\n ? Number(value)\n : (value && httpDate.test(value)\n ? (Date.parse(value.endsWith('GMT') ? value : value + ' GMT') - Date.now()) / 1000\n : NaN);\n const wait = Number.isFinite(seconds) ? Math.min(300, Math.max(1, Math.ceil(seconds))) : 30;\n retryAt = Date.now() + wait * 1000;\n recover('Too many attempts. Wait ' + wait + ' seconds before trying again.');\n tick();\n } else if (response.status === 503) {\n recover('Sign-in is temporarily unavailable. Try again shortly, or request a new sign-in link.');\n ready();\n } else {\n recover('Sign-in could not be completed. Try again, or request a new sign-in link.');\n ready();\n }\n } catch {\n if (!disposed && controller === active) {\n recover(timedOut\n ? 'Sign-in took too long. Try again, or request a new sign-in link.'\n : 'Unable to connect. Check your connection and try again, or request a new sign-in link.');\n ready();\n }\n } finally {\n active.abort();\n clearTimeout(requestTimeout);\n if (!disposed && controller === active) {\n controller = undefined;\n pending = false;\n document.getElementById('actions').removeAttribute('aria-busy');\n }\n }\n});`\n : '';\n const html = `<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"color-scheme\" content=\"light dark\">\n<meta name=\"referrer\" content=\"no-referrer\">\n<title>${escapeHtml(title)} | ${escapeHtml(appName)}</title>\n<script nonce=\"${nonce}\">history.replaceState(null, '', location.pathname);</script>\n<style nonce=\"${nonce}\">\n:root { color-scheme: light dark; --paper: #fcfbf9; --ink: #302d2b; --muted: #68615c; --accent: #934b2b; --hover: #783a20; --rule: #d6d0ca; --focus: #934b2b; }\n* { box-sizing: border-box; }\nbody { margin: 0; background: var(--paper); color: var(--ink); font: 1rem/1.6 'Avenir Next', 'Segoe UI', sans-serif; letter-spacing: 0; }\nmain { width: min(100%, 38rem); margin: 0 auto; padding: 5rem 1.5rem 3rem; }\nh1 { margin: 0 0 1.5rem; font-family: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Palatino, serif; font-size: 2.25rem; line-height: 1.2; font-weight: 500; overflow-wrap: anywhere; text-wrap: balance; }\np { margin: 0 0 1rem; }\n.detail { color: var(--muted); }\n.actions { margin-top: 2rem; display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; }\nbutton, .primary { min-height: 44px; max-width: 100%; padding: .75rem 1.25rem; border: 1px solid var(--accent); border-radius: 4px; background: var(--accent); color: #fff; font: inherit; font-weight: 600; text-align: center; text-decoration: none; overflow-wrap: anywhere; cursor: pointer; transition: background-color 120ms ease-out; }\nbutton { min-width: 12rem; }\nbutton:hover:not(:disabled), .primary:hover { background: var(--hover); }\nbutton:disabled { cursor: wait; opacity: .75; }\na { color: var(--accent); text-underline-offset: .2em; overflow-wrap: anywhere; }\na:not(.primary) { display: inline-flex; align-items: center; min-height: 44px; }\n:focus-visible { outline: 3px solid var(--focus); outline-offset: 4px; }\n::selection { background: var(--accent); color: #fff; }\n#status { min-height: 3.2em; margin-top: 1.5rem; }\nfooter { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--rule); }\n[hidden] { display: none !important; }\n@media (prefers-color-scheme: dark) { :root { --paper: #242220; --ink: #f2efeb; --muted: #bfb7b0; --accent: #e0a07b; --hover: #efb998; --rule: #504942; --focus: #e0a07b; } button, .primary { color: #242220; } ::selection { color: #242220; } }\n@media (max-width: 480px) { main { padding: 3rem 1.25rem 2rem; } h1 { font-size: 2rem; } .actions { align-items: stretch; flex-direction: column; } }\n@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; } }\n</style>\n</head>\n<body>\n<main id=\"main\" aria-labelledby=\"title\">\n<h1 id=\"title\" tabindex=\"-1\">${valid ? `Sign in to ${escapeHtml(appName)}` : escapeHtml(title)}</h1>\n<p>${valid ? 'Continue only if you requested this sign-in link.' : `Request a new sign-in link for ${escapeHtml(appName)}.`}</p>\n${valid ? `<p class=\"detail\">This link works once and expires ${LINK_TTL_MS / 60_000} minutes after it was requested.</p>` : ''}\n<div class=\"actions\" id=\"actions\">\n${valid ? '<button type=\"button\" id=\"confirm\" hidden>Confirm sign-in</button>' : ''}\n<a${valid ? '' : ' class=\"primary\"'} id=\"recovery\" href=\"${escapeHtml(`${publicOrigin}/account`)}\"${valid ? ' hidden' : ''}>Request a new sign-in link</a>\n</div>\n<p role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" id=\"status\"></p>\n${valid ? `<noscript><p>JavaScript is required to confirm this sign-in. No sign-in has been completed. <a href=\"${escapeHtml(`${publicOrigin}/account`)}\">Request a new sign-in link</a>.</p></noscript>` : ''}\n${valid ? `<footer id=\"exit\"><a href=\"${escapeHtml(`${publicOrigin}/`)}\">Back to app</a></footer>` : ''}\n</main>\n${\n valid\n ? `<script nonce=\"${nonce}\">${script}\n</script>`\n : ''\n}\n</body>\n</html>`;\n return new Response(html, {\n headers: {\n ...noStore,\n 'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,\n 'content-type': 'text/html; charset=utf-8',\n 'referrer-policy': 'no-referrer',\n 'x-content-type-options': 'nosniff',\n },\n status: valid ? 200 : 400,\n });\n}\n"],"mappings":";;;;AAIA,IAAa,cAAc,MAA0BA,EAAM,QAAQ,aAAa,MAAS,KAAKC,EAAK,WAAW,CAAC,EAAE,EAAE,GAE7G,eAAe,MACnB,KAAK,UAAUD,CAAK,CAAC,CAAC,QACpB,uBACC,MAAS,MAAMC,EAAK,WAAW,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG,GACjE;AAEF,SAAgB,iBAAiB,EAC/B,YACA,UACA,iBACA,YAMW;CACX,IAAMC,IAAQC,MAAU,MAClBC,IAAQF,IAAQ,oBAAoB,kCACpCG,IAASH,IACX;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wDA2EkDI,EAAsB;sCACxC,YAAYH,KAAS,EAAE,EAAE;;;;;;;;;;;yBAWtC,YAAY,GAAGI,EAAa,WAAW,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA8C5D,IACEC,IAAO;;;;;;;SAON,WAAWJ,CAAK,EAAE,KAAK,WAAWK,CAAO,EAAE;iBACnCC,EAAM;gBACPA,EAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;+BA2BSR,IAAQ,cAAc,WAAWO,CAAO,MAAM,WAAWL,CAAK,EAAE;KAC1FF,IAAQ,sDAAsD,kCAAkC,WAAWO,CAAO,EAAE,GAAG;EAC1HP,IAAQ,sDAAsDS,IAAc,IAAO,wCAAwC,GAAG;;EAE9HT,IAAQ,2EAAuE,GAAG;IAChFA,IAAQ,KAAK,qBAAmB,uBAAuB,WAAW,GAAGK,EAAa,SAAS,EAAE,GAAGL,IAAQ,YAAY,GAAG;;;EAGzHA,IAAQ,wGAAwG,WAAW,GAAGK,EAAa,SAAS,EAAE,oDAAoD,GAAG;EAC7ML,IAAQ,8BAA8B,WAAW,GAAGK,EAAa,EAAE,EAAE,8BAA8B,GAAG;;EAGtGL,IACI,kBAAkBQ,EAAM,IAAIL,EAAO;cAEnC,GACL;;;CAGC,OAAO,IAAI,SAASG,GAAM;EACxB,SAAS;GACP,GAAGI;GACH,2BAA2B,yCAAyCF,EAAM,sBAAsBA,EAAM;GACtG,gBAAgB;GAChB,mBAAmB;GACnB,0BAA0B;EAC5B;EACA,QAAQR,IAAQ,MAAM;CACxB,CAAC;AACH"}
1
+ {"version":3,"file":"_confirmation.js","names":["value","char","valid","token","title","errorStatus","script","device","REQUEST_MARKER_HEADER","publicOrigin","html","appName","nonce","deviceCode","LINK_TTL_MS","noStore"],"sources":["../../src/auth/_confirmation.ts"],"sourcesContent":["import { noStore } from '../_server/http';\nimport { REQUEST_MARKER_HEADER } from '../contract';\nimport { LINK_TTL_MS } from './store';\n\nexport const escapeHtml = (value: string): string => value.replace(/[&<>\"']/g, (char) => `&#${char.charCodeAt(0)};`);\n\nconst scriptValue = (value: string): string =>\n JSON.stringify(value).replace(\n /[<>&\\u2028\\u2029]/g,\n (char) => `\\\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`,\n );\n\nexport function confirmationPage({\n appName,\n device = false,\n deviceCode,\n errorStatus = 400,\n nonce,\n publicOrigin,\n token,\n}: {\n appName: string;\n device?: boolean;\n deviceCode?: string;\n errorStatus?: 400 | 410;\n nonce: string;\n publicOrigin: string;\n token: string | null;\n}): Response {\n const valid = token !== null;\n const title = valid\n ? 'Confirm sign-in'\n : errorStatus === 410\n ? 'This sign-in link has expired or was already used'\n : 'This sign-in link is not valid';\n const script = valid\n ? `\nconst button = document.getElementById('confirm');\nconst status = document.getElementById('status');\nconst recovery = document.getElementById('recovery');\nlet pending = false;\nlet terminal = false;\nlet disposed = false;\nlet cooldown;\nlet timeout;\nlet controller;\nlet retryAt = 0;\nconst announce = (message) => { status.textContent = message; };\nconst ready = () => {\n button.disabled = false;\n button.textContent = 'Try again';\n};\nconst stop = () => {\n clearTimeout(cooldown);\n clearTimeout(timeout);\n controller?.abort();\n controller = undefined;\n};\nconst tick = () => {\n if (disposed) return;\n const seconds = Math.max(0, Math.ceil((retryAt - Date.now()) / 1000));\n if (seconds === 0) {\n ready();\n announce('You can try signing in again now.');\n return;\n }\n button.textContent = 'Try again in ' + seconds + 's';\n cooldown = setTimeout(tick, 1000);\n};\nconst recover = (message) => {\n announce(message);\n recovery.hidden = false;\n document.getElementById('exit').hidden = true;\n};\nbutton.hidden = false;\nbutton.focus();\naddEventListener('pagehide', () => {\n disposed = true;\n stop();\n});\naddEventListener('pageshow', (event) => {\n if (!event.persisted || !disposed) return;\n disposed = false;\n pending = false;\n document.getElementById('actions').removeAttribute('aria-busy');\n if (terminal) return;\n if (retryAt > Date.now()) tick();\n else {\n ready();\n announce('Confirm sign-in when you are ready.');\n }\n});\nbutton.addEventListener('click', async () => {\n if (disposed || pending || terminal || retryAt > Date.now()) return;\n pending = true;\n button.disabled = true;\n button.textContent = 'Signing you in';\n document.getElementById('actions').setAttribute('aria-busy', 'true');\n announce('Signing you in. Please wait.');\n const active = new AbortController();\n controller = active;\n let timedOut = false;\n const requestTimeout = setTimeout(() => {\n timedOut = true;\n active.abort();\n }, 15000);\n timeout = requestTimeout;\n try {\n const response = await fetch(${scriptValue(device ? '/api/v1/auth/device/approve' : '/api/v1/auth/verify')}, {\n method: 'POST',\n credentials: 'same-origin',\n headers: { 'content-type': 'application/json', '${REQUEST_MARKER_HEADER}': '1' },\n body: JSON.stringify({ token: ${scriptValue(token ?? '')} }),\n signal: active.signal,\n redirect: 'error',\n referrerPolicy: 'no-referrer'\n });\n void response.body?.cancel().catch(() => {});\n if (disposed || controller !== active) return;\n if (response.status === 204) {\n terminal = true;\n button.textContent = ${scriptValue(device ? 'Sign-in approved' : 'Signed in')};\n announce(${scriptValue(device ? 'Sign-in approved. Return to the app to finish signing in.' : 'You are signed in. Continuing to the app.')});\n ${device ? '' : `location.replace(${scriptValue(`${publicOrigin}/signed-in`)});`}\n } else if (response.status === 410 || response.status === 400) {\n terminal = true;\n button.hidden = true;\n recovery.setAttribute('class', 'primary');\n recover(response.status === 410\n ? 'This link has expired or was already used. Request a new sign-in link.'\n : 'This sign-in link is not valid. Request a new sign-in link.');\n recovery.focus();\n } else if (response.status === 429) {\n const value = response.headers.get('retry-after');\n const httpDate = /^(?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), (?:0[1-9]|[12]\\\\d|3[01]) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \\\\d{4} (?:[01]\\\\d|2[0-3]):[0-5]\\\\d:[0-5]\\\\d GMT|(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), (?:0[1-9]|[12]\\\\d|3[01])-(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\\\\d{2} (?:[01]\\\\d|2[0-3]):[0-5]\\\\d:[0-5]\\\\d GMT|(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) (?: [1-9]|[12]\\\\d|3[01]) (?:[01]\\\\d|2[0-3]):[0-5]\\\\d:[0-5]\\\\d \\\\d{4})$/;\n // HTTP dates are always GMT; bare asctime forms need the suffix or Date.parse reads them as local time.\n const seconds = value && /^\\\\d+$/.test(value)\n ? Number(value)\n : (value && httpDate.test(value)\n ? (Date.parse(value.endsWith('GMT') ? value : value + ' GMT') - Date.now()) / 1000\n : NaN);\n const wait = Number.isFinite(seconds) ? Math.min(300, Math.max(1, Math.ceil(seconds))) : 30;\n retryAt = Date.now() + wait * 1000;\n recover('Too many attempts. Wait ' + wait + ' seconds before trying again.');\n tick();\n } else if (response.status === 503) {\n recover('Sign-in is temporarily unavailable. Try again shortly, or request a new sign-in link.');\n ready();\n } else {\n recover('Sign-in could not be completed. Try again, or request a new sign-in link.');\n ready();\n }\n } catch {\n if (!disposed && controller === active) {\n recover(timedOut\n ? 'Sign-in took too long. Try again, or request a new sign-in link.'\n : 'Unable to connect. Check your connection and try again, or request a new sign-in link.');\n ready();\n }\n } finally {\n active.abort();\n clearTimeout(requestTimeout);\n if (!disposed && controller === active) {\n controller = undefined;\n pending = false;\n document.getElementById('actions').removeAttribute('aria-busy');\n }\n }\n});`\n : '';\n const html = `<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n<meta name=\"color-scheme\" content=\"light dark\">\n<meta name=\"referrer\" content=\"no-referrer\">\n<title>${escapeHtml(title)} | ${escapeHtml(appName)}</title>\n<script nonce=\"${nonce}\">history.replaceState(null, '', location.pathname);</script>\n<style nonce=\"${nonce}\">\n:root { color-scheme: light dark; --paper: #fcfbf9; --ink: #302d2b; --muted: #68615c; --accent: #934b2b; --hover: #783a20; --rule: #d6d0ca; --focus: #934b2b; }\n* { box-sizing: border-box; }\nbody { margin: 0; background: var(--paper); color: var(--ink); font: 1rem/1.6 'Avenir Next', 'Segoe UI', sans-serif; letter-spacing: 0; }\nmain { width: min(100%, 38rem); margin: 0 auto; padding: 5rem 1.5rem 3rem; }\nh1 { margin: 0 0 1.5rem; font-family: 'Iowan Old Style', 'Palatino Linotype', 'Book Antiqua', Palatino, serif; font-size: 2.25rem; line-height: 1.2; font-weight: 500; overflow-wrap: anywhere; text-wrap: balance; }\np { margin: 0 0 1rem; }\n.detail { color: var(--muted); }\n.actions { margin-top: 2rem; display: flex; flex-wrap: wrap; gap: 1rem; align-items: center; }\nbutton, .primary { min-height: 44px; max-width: 100%; padding: .75rem 1.25rem; border: 1px solid var(--accent); border-radius: 4px; background: var(--accent); color: #fff; font: inherit; font-weight: 600; text-align: center; text-decoration: none; overflow-wrap: anywhere; cursor: pointer; transition: background-color 120ms ease-out; }\nbutton { min-width: 12rem; }\nbutton:hover:not(:disabled), .primary:hover { background: var(--hover); }\nbutton:disabled { cursor: wait; opacity: .75; }\na { color: var(--accent); text-underline-offset: .2em; overflow-wrap: anywhere; }\na:not(.primary) { display: inline-flex; align-items: center; min-height: 44px; }\n:focus-visible { outline: 3px solid var(--focus); outline-offset: 4px; }\n::selection { background: var(--accent); color: #fff; }\n#status { min-height: 3.2em; margin-top: 1.5rem; }\nfooter { margin-top: 2rem; padding-top: 1rem; border-top: 1px solid var(--rule); }\n[hidden] { display: none !important; }\n@media (prefers-color-scheme: dark) { :root { --paper: #242220; --ink: #f2efeb; --muted: #bfb7b0; --accent: #e0a07b; --hover: #efb998; --rule: #504942; --focus: #e0a07b; } button, .primary { color: #242220; } ::selection { color: #242220; } }\n@media (max-width: 480px) { main { padding: 3rem 1.25rem 2rem; } h1 { font-size: 2rem; } .actions { align-items: stretch; flex-direction: column; } }\n@media (prefers-reduced-motion: reduce) { *, *::before, *::after { transition: none !important; } }\n</style>\n</head>\n<body>\n<main id=\"main\" aria-labelledby=\"title\">\n<h1 id=\"title\" tabindex=\"-1\">${valid ? `Sign in to ${escapeHtml(appName)}` : escapeHtml(title)}</h1>\n<p>${valid ? (device ? 'Continue only if you requested sign-in from your app. This will sign in that app, not this browser.' : 'Continue only if you requested this sign-in link.') : `Request a new sign-in link for ${escapeHtml(appName)}.`}</p>\n${valid && device && deviceCode ? `<p>Confirm that this code matches the one shown in your app: <strong>${escapeHtml(deviceCode)}</strong>. If it does not match, do not continue.</p>` : ''}\n${valid ? `<p class=\"detail\">This link works once and expires ${LINK_TTL_MS / 60_000} minutes after it was requested.</p>` : ''}\n<div class=\"actions\" id=\"actions\">\n${valid ? '<button type=\"button\" id=\"confirm\" hidden>Confirm sign-in</button>' : ''}\n<a${valid ? '' : ' class=\"primary\"'} id=\"recovery\" href=\"${escapeHtml(`${publicOrigin}/account`)}\"${valid ? ' hidden' : ''}>Request a new sign-in link</a>\n</div>\n<p role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" id=\"status\"></p>\n${valid ? `<noscript><p>JavaScript is required to confirm this sign-in. No sign-in has been completed. <a href=\"${escapeHtml(`${publicOrigin}/account`)}\">Request a new sign-in link</a>.</p></noscript>` : ''}\n${valid ? `<footer id=\"exit\"><a href=\"${escapeHtml(`${publicOrigin}/`)}\">Back to app</a></footer>` : ''}\n</main>\n${\n valid\n ? `<script nonce=\"${nonce}\">${script}\n</script>`\n : ''\n}\n</body>\n</html>`;\n return new Response(html, {\n headers: {\n ...noStore,\n 'content-security-policy': `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`,\n 'content-type': 'text/html; charset=utf-8',\n 'referrer-policy': 'no-referrer',\n 'x-content-type-options': 'nosniff',\n },\n status: valid ? 200 : errorStatus,\n });\n}\n"],"mappings":";;;;AAIA,IAAa,cAAc,MAA0BA,EAAM,QAAQ,aAAa,MAAS,KAAKC,EAAK,WAAW,CAAC,EAAE,EAAE,GAE7G,eAAe,MACnB,KAAK,UAAUD,CAAK,CAAC,CAAC,QACpB,uBACC,MAAS,MAAMC,EAAK,WAAW,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG,GACjE;AAEF,SAAgB,iBAAiB,EAC/B,YACA,YAAS,IACT,eACA,iBAAc,KACd,UACA,iBACA,YASW;CACX,IAAMC,IAAQC,MAAU,MAClBC,IAAQF,IACV,oBACAG,MAAgB,MACd,sDACA,kCACAC,IAASJ,IACX;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;mCAwE6B,YAAYK,IAAS,gCAAgC,qBAAqB,EAAE;;;wDAGvDC,EAAsB;sCACxC,YAAYL,KAAS,EAAE,EAAE;;;;;;;;;6BASlC,YAAYI,IAAS,qBAAqB,WAAW,EAAE;iBACnE,YAAYA,IAAS,8DAA8D,2CAA2C,EAAE;QACzIA,IAAS,KAAK,oBAAoB,YAAY,GAAGE,EAAa,WAAW,EAAE,IAAI;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA8CjF,IACEC,IAAO;;;;;;;SAON,WAAWN,CAAK,EAAE,KAAK,WAAWO,CAAO,EAAE;iBACnCC,EAAM;gBACPA,EAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;+BA2BSV,IAAQ,cAAc,WAAWS,CAAO,MAAM,WAAWP,CAAK,EAAE;KAC1FF,IAASK,IAAS,wGAAwG,sDAAuD,kCAAkC,WAAWI,CAAO,EAAE,GAAG;EAC7OT,KAASK,KAAUM,IAAa,wEAAwE,WAAWA,CAAU,EAAE,yDAAyD,GAAG;EAC3LX,IAAQ,sDAAsDY,IAAc,IAAO,wCAAwC,GAAG;;EAE9HZ,IAAQ,2EAAuE,GAAG;IAChFA,IAAQ,KAAK,qBAAmB,uBAAuB,WAAW,GAAGO,EAAa,SAAS,EAAE,GAAGP,IAAQ,YAAY,GAAG;;;EAGzHA,IAAQ,wGAAwG,WAAW,GAAGO,EAAa,SAAS,EAAE,oDAAoD,GAAG;EAC7MP,IAAQ,8BAA8B,WAAW,GAAGO,EAAa,EAAE,EAAE,8BAA8B,GAAG;;EAGtGP,IACI,kBAAkBU,EAAM,IAAIN,EAAO;cAEnC,GACL;;;CAGC,OAAO,IAAI,SAASI,GAAM;EACxB,SAAS;GACP,GAAGK;GACH,2BAA2B,yCAAyCH,EAAM,sBAAsBA,EAAM;GACtG,gBAAgB;GAChB,mBAAmB;GACnB,0BAA0B;EAC5B;EACA,QAAQV,IAAQ,MAAMG;CACxB,CAAC;AACH"}
@@ -1,6 +1,7 @@
1
- const e=require("./errors.cjs");var t=9e5,n=36e5,r=6e4,i={account:`SELECT id, email, created_at, last_sign_in_at, session_version FROM auth_users WHERE id = ?`,budgetWindow:`SELECT window_start FROM auth_link_budget WHERE user_id = ?`,consumeToken:`UPDATE auth_tokens SET redeemed_at = ?1 WHERE token_hash = ?2 AND expires_at > ?3
1
+ const e=require("./errors.cjs");var t=9e5,n=36e5,r=6e4,i={account:`SELECT id, email, created_at, last_sign_in_at, session_version FROM auth_users WHERE id = ?`,approveDevice:`INSERT OR IGNORE INTO auth_tokens (token_hash, user_id, expires_at)
2
+ SELECT ?1, user_id, expires_at FROM auth_tokens WHERE token_hash = ?2 AND expires_at > ?3`,budgetWindow:`SELECT window_start FROM auth_link_budget WHERE user_id = ?`,consumeToken:`UPDATE auth_tokens SET redeemed_at = ?1 WHERE token_hash = ?2 AND expires_at > ?3
2
3
  RETURNING user_id AS id, (SELECT email FROM auth_users WHERE id = user_id) AS email,
3
- (SELECT session_version FROM auth_users WHERE id = user_id) AS sessionVersion`,deleteAccount:`DELETE FROM auth_users WHERE id = ?`,deleteToken:`DELETE FROM auth_tokens WHERE token_hash = ?`,ensureUser:`INSERT INTO auth_users (id, email, created_at, last_sign_in_at) VALUES (?, ?, ?, NULL) ON CONFLICT (email) DO UPDATE SET email = excluded.email RETURNING id, email`,insertToken:`INSERT INTO auth_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)`,revokeSessions:`UPDATE auth_users SET session_version = session_version + 1
4
+ (SELECT session_version FROM auth_users WHERE id = user_id) AS sessionVersion`,deleteAccount:`DELETE FROM auth_users WHERE id = ?`,deleteToken:`DELETE FROM auth_tokens WHERE token_hash = ?`,ensureUser:`INSERT INTO auth_users (id, email, created_at, last_sign_in_at) VALUES (?, ?, ?, NULL) ON CONFLICT (email) DO UPDATE SET email = excluded.email RETURNING id, email`,insertToken:`INSERT INTO auth_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)`,removeApprovedLink:`DELETE FROM auth_tokens WHERE token_hash = ? AND expires_at > ?`,revokeSessions:`UPDATE auth_users SET session_version = session_version + 1
4
5
  WHERE id = ? AND session_version = ? RETURNING session_version`,signInWindow:`SELECT window_start FROM auth_sign_in_budget WHERE id = 1`,spendBudget:`INSERT INTO auth_link_budget (user_id, window_start, sent) VALUES (?1, ?2, 1)
5
6
  ON CONFLICT (user_id) DO UPDATE SET
6
7
  window_start = CASE WHEN auth_link_budget.window_start <= ?3 THEN ?2 ELSE auth_link_budget.window_start END,
@@ -11,9 +12,9 @@ ON CONFLICT (id) DO UPDATE SET
11
12
  window_start = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN ?1 ELSE auth_sign_in_budget.window_start END,
12
13
  admitted = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN 1 ELSE auth_sign_in_budget.admitted + 1 END
13
14
  WHERE auth_sign_in_budget.window_start <= ?2 OR auth_sign_in_budget.admitted < ?3
14
- RETURNING admitted`,sweepBudget:`DELETE FROM auth_link_budget WHERE window_start <= ?`,sweepTokens:`DELETE FROM auth_tokens WHERE expires_at <= ?`,sweepUnverified:`DELETE FROM auth_users WHERE last_sign_in_at IS NULL AND NOT EXISTS (SELECT 1 FROM auth_tokens t WHERE t.user_id = auth_users.id) AND NOT EXISTS (SELECT 1 FROM auth_link_budget b WHERE b.user_id = auth_users.id)`};async function ensureUser(t,n){let r=await e.databaseOperation(()=>t.prepare(i.ensureUser).bind(crypto.randomUUID(),n,new Date().toISOString()).first());if(r===null)throw Error(`The account row could not be ensured.`);return r}async function spendSignInBudget(t,n){return e.databaseOperation(async()=>{if(await t.prepare(i.spendSignInBudget).bind(n,n-6e4,20).first()!==null)return null;let e=await t.prepare(i.signInWindow).first();return Math.max(1,Math.ceil(((e?.window_start??n)+r-n)/1e3))})}async function spendLinkBudget(t,r,a){return e.databaseOperation(async()=>{let e=a-n;if(await t.prepare(i.spendBudget).bind(r,a,e,3).first()!==null)return null;let o=((await t.prepare(i.budgetWindow).bind(r).first())?.window_start??a)+n;return Math.max(1,Math.ceil((o-a)/1e3))})}async function insertToken(n,r,a,o){await e.databaseOperation(()=>n.prepare(i.insertToken).bind(r,a,o+t).run())}async function deleteToken(t,n){await e.databaseOperation(()=>t.prepare(i.deleteToken).bind(n).run())}async function consumeToken(t,n,r){return e.databaseOperation(()=>t.prepare(i.consumeToken).bind(new Date(r).toISOString(),n,r).first())}function revokeSessions(t,n,r){return e.databaseOperation(()=>t.prepare(i.revokeSessions).bind(n,r).first())}async function authReadiness(t){return e.databaseOperation(async()=>{for(let[e,n]of Object.entries({auth_link_budget:[`user_id`,`window_start`,`sent`],auth_schema_meta:[`id`,`applied`],auth_sign_in_budget:[`id`,`window_start`,`admitted`],auth_tokens:[`token_hash`,`user_id`,`expires_at`,`redeemed_at`],auth_users:[`id`,`email`,`created_at`,`last_sign_in_at`,`session_version`]})){let r=await t.prepare(`PRAGMA table_info(${e})`).all();if(!n.every(e=>r.results?.some(t=>t.name===e)))return null}if((await t.prepare(`SELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN ('auth_token_redeemed', 'auth_sessions_revoked')`).all()).results?.length!==2)return null;let e=await t.prepare(`SELECT applied FROM auth_schema_meta WHERE id = 1`).first();return await t.prepare(`SELECT u.id, u.email, u.created_at, u.last_sign_in_at, u.session_version,
15
+ RETURNING admitted`,sweepBudget:`DELETE FROM auth_link_budget WHERE window_start <= ?`,sweepTokens:`DELETE FROM auth_tokens WHERE expires_at <= ?`,sweepUnverified:`DELETE FROM auth_users WHERE last_sign_in_at IS NULL AND NOT EXISTS (SELECT 1 FROM auth_tokens t WHERE t.user_id = auth_users.id) AND NOT EXISTS (SELECT 1 FROM auth_link_budget b WHERE b.user_id = auth_users.id)`};async function ensureUser(t,n){let r=await e.databaseOperation(()=>t.prepare(i.ensureUser).bind(crypto.randomUUID(),n,new Date().toISOString()).first());if(r===null)throw Error(`The account row could not be ensured.`);return r}async function spendSignInBudget(t,n){return e.databaseOperation(async()=>{if(await t.prepare(i.spendSignInBudget).bind(n,n-6e4,20).first()!==null)return null;let e=await t.prepare(i.signInWindow).first();return Math.max(1,Math.ceil(((e?.window_start??n)+r-n)/1e3))})}async function spendLinkBudget(t,r,a){return e.databaseOperation(async()=>{let e=a-n;if(await t.prepare(i.spendBudget).bind(r,a,e,3).first()!==null)return null;let o=((await t.prepare(i.budgetWindow).bind(r).first())?.window_start??a)+n;return Math.max(1,Math.ceil((o-a)/1e3))})}async function insertToken(n,r,a,o){await e.databaseOperation(()=>n.prepare(i.insertToken).bind(r,a,o+t).run())}async function deleteToken(t,n){await e.databaseOperation(()=>t.prepare(i.deleteToken).bind(n).run())}async function approveDevice(t,n,r,a){let o=await e.databaseOperation(()=>t.batch([t.prepare(i.approveDevice).bind(r,n,a),t.prepare(i.removeApprovedLink).bind(n,a)]));return o[0]?.meta?.changes===1&&o[1]?.meta?.changes===1}async function consumeToken(t,n,r){return e.databaseOperation(()=>t.prepare(i.consumeToken).bind(new Date(r).toISOString(),n,r).first())}function revokeSessions(t,n,r){return e.databaseOperation(()=>t.prepare(i.revokeSessions).bind(n,r).first())}async function authReadiness(t){return e.databaseOperation(async()=>{for(let[e,n]of Object.entries({auth_link_budget:[`user_id`,`window_start`,`sent`],auth_schema_meta:[`id`,`applied`],auth_sign_in_budget:[`id`,`window_start`,`admitted`],auth_tokens:[`token_hash`,`user_id`,`expires_at`,`redeemed_at`],auth_users:[`id`,`email`,`created_at`,`last_sign_in_at`,`session_version`]})){let r=await t.prepare(`PRAGMA table_info(${e})`).all();if(!n.every(e=>r.results?.some(t=>t.name===e)))return null}if((await t.prepare(`SELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN ('auth_token_redeemed', 'auth_sessions_revoked')`).all()).results?.length!==2)return null;let e=await t.prepare(`SELECT applied FROM auth_schema_meta WHERE id = 1`).first();return await t.prepare(`SELECT u.id, u.email, u.created_at, u.last_sign_in_at, u.session_version,
15
16
  t.token_hash, t.user_id, t.expires_at, t.redeemed_at,
16
17
  b.user_id, b.window_start, b.sent, s.id, s.window_start, s.admitted
17
18
  FROM auth_users u LEFT JOIN auth_tokens t ON 0 LEFT JOIN auth_link_budget b ON 0
18
- LEFT JOIN auth_sign_in_budget s ON 0 LIMIT 1`).first(),typeof e?.applied==`number`&&Number.isSafeInteger(e.applied)&&e.applied>=0?e.applied:null})}function readAccount(t,n){return e.databaseOperation(()=>t.prepare(i.account).bind(n).first())}async function deleteAccount(t,n){await e.databaseOperation(()=>t.prepare(i.deleteAccount).bind(n).run())}async function sweep(t,r){return(await e.databaseOperation(()=>t.batch([t.prepare(i.sweepTokens).bind(r),t.prepare(i.sweepBudget).bind(r-n),t.prepare(i.sweepUnverified)]))).reduce((e,t)=>e+(t.meta?.changes??0),0)}exports.LINK_TTL_MS=t,exports.LINK_WINDOW_MS=n,exports.SIGN_IN_WINDOW_MS=r,exports.authReadiness=authReadiness,exports.consumeToken=consumeToken,exports.deleteAccount=deleteAccount,exports.deleteToken=deleteToken,exports.ensureUser=ensureUser,exports.insertToken=insertToken,exports.readAccount=readAccount,exports.revokeSessions=revokeSessions,exports.spendLinkBudget=spendLinkBudget,exports.spendSignInBudget=spendSignInBudget,exports.sweep=sweep;
19
+ LEFT JOIN auth_sign_in_budget s ON 0 LIMIT 1`).first(),typeof e?.applied==`number`&&Number.isSafeInteger(e.applied)&&e.applied>=0?e.applied:null})}function readAccount(t,n){return e.databaseOperation(()=>t.prepare(i.account).bind(n).first())}async function deleteAccount(t,n){await e.databaseOperation(()=>t.prepare(i.deleteAccount).bind(n).run())}async function sweep(t,r){return(await e.databaseOperation(()=>t.batch([t.prepare(i.sweepTokens).bind(r),t.prepare(i.sweepBudget).bind(r-n),t.prepare(i.sweepUnverified)]))).reduce((e,t)=>e+(t.meta?.changes??0),0)}exports.LINK_TTL_MS=t,exports.LINK_WINDOW_MS=n,exports.SIGN_IN_WINDOW_MS=r,exports.approveDevice=approveDevice,exports.authReadiness=authReadiness,exports.consumeToken=consumeToken,exports.deleteAccount=deleteAccount,exports.deleteToken=deleteToken,exports.ensureUser=ensureUser,exports.insertToken=insertToken,exports.readAccount=readAccount,exports.revokeSessions=revokeSessions,exports.spendLinkBudget=spendLinkBudget,exports.spendSignInBudget=spendSignInBudget,exports.sweep=sweep;
19
20
  //# sourceMappingURL=store.cjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"store.cjs","names":["databaseOperation","require_errors","LINK_TTL_MS","LINK_WINDOW_MS","SIGN_IN_WINDOW_MS","SQL","row","db","email","nowMs","open","windowFloor","userId","reopensAt","tokenHash","subject","version","table","expected","actual","column","name","stamp","total","result"],"sources":["../../src/auth/store.ts"],"sourcesContent":["import type { D1Like } from '../_server/d1';\nimport { databaseOperation } from './errors';\n\n/**\n * The issuer's storage, written against exactly `migrations/auth/0001_auth.sql`.\n * Token rows are single-use credentials only; the send allowance lives in its own\n * durable table, so consuming or expiring a link never refunds the budget.\n */\nexport const LINK_TTL_MS = 15 * 60 * 1000;\nexport const LINK_WINDOW_MS = 60 * 60 * 1000;\nexport const LINKS_PER_WINDOW = 3;\nexport const SIGN_IN_WINDOW_MS = 60_000;\nexport const SIGN_INS_PER_WINDOW = 20;\n\nexport interface AccountRow {\n created_at: string;\n email: string;\n id: string;\n last_sign_in_at: string | null;\n session_version: number;\n}\n\nconst SQL = {\n account: 'SELECT id, email, created_at, last_sign_in_at, session_version FROM auth_users WHERE id = ?',\n budgetWindow: 'SELECT window_start FROM auth_link_budget WHERE user_id = ?',\n consumeToken: `UPDATE auth_tokens SET redeemed_at = ?1 WHERE token_hash = ?2 AND expires_at > ?3\nRETURNING user_id AS id, (SELECT email FROM auth_users WHERE id = user_id) AS email,\n (SELECT session_version FROM auth_users WHERE id = user_id) AS sessionVersion`,\n deleteAccount: 'DELETE FROM auth_users WHERE id = ?',\n deleteToken: 'DELETE FROM auth_tokens WHERE token_hash = ?',\n // The no-op update makes RETURNING answer for an existing row too.\n ensureUser:\n 'INSERT INTO auth_users (id, email, created_at, last_sign_in_at) VALUES (?, ?, ?, NULL) ON CONFLICT (email) DO UPDATE SET email = excluded.email RETURNING id, email',\n insertToken: 'INSERT INTO auth_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)',\n revokeSessions: `UPDATE auth_users SET session_version = session_version + 1\nWHERE id = ? AND session_version = ? RETURNING session_version`,\n signInWindow: 'SELECT window_start FROM auth_sign_in_budget WHERE id = 1',\n // One atomic statement opens a window, counts within it, or refuses: no row back\n // means the allowance is spent. Concurrent requests cannot both pass the bound.\n spendBudget: `INSERT INTO auth_link_budget (user_id, window_start, sent) VALUES (?1, ?2, 1)\nON CONFLICT (user_id) DO UPDATE SET\n window_start = CASE WHEN auth_link_budget.window_start <= ?3 THEN ?2 ELSE auth_link_budget.window_start END,\n sent = CASE WHEN auth_link_budget.window_start <= ?3 THEN 1 ELSE auth_link_budget.sent + 1 END\nWHERE auth_link_budget.window_start <= ?3 OR auth_link_budget.sent < ?4\nRETURNING sent`,\n spendSignInBudget: `INSERT INTO auth_sign_in_budget (id, window_start, admitted) VALUES (1, ?1, 1)\nON CONFLICT (id) DO UPDATE SET\n window_start = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN ?1 ELSE auth_sign_in_budget.window_start END,\n admitted = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN 1 ELSE auth_sign_in_budget.admitted + 1 END\nWHERE auth_sign_in_budget.window_start <= ?2 OR auth_sign_in_budget.admitted < ?3\nRETURNING admitted`,\n sweepBudget: 'DELETE FROM auth_link_budget WHERE window_start <= ?',\n sweepTokens: 'DELETE FROM auth_tokens WHERE expires_at <= ?',\n // Rows for emails that never completed a sign-in leave once nothing references them.\n sweepUnverified:\n 'DELETE FROM auth_users WHERE last_sign_in_at IS NULL AND NOT EXISTS (SELECT 1 FROM auth_tokens t WHERE t.user_id = auth_users.id) AND NOT EXISTS (SELECT 1 FROM auth_link_budget b WHERE b.user_id = auth_users.id)',\n} as const;\n\nexport async function ensureUser(db: D1Like, email: string): Promise<{ email: string; id: string }> {\n const row = await databaseOperation(() =>\n db\n .prepare(SQL.ensureUser)\n .bind(crypto.randomUUID(), email, new Date().toISOString())\n .first<{ email: string; id: string }>(),\n );\n if (row === null) throw new Error('The account row could not be ensured.');\n return row;\n}\n\n/** The deployment-wide admission budget is spent before account creation or delivery. */\nexport async function spendSignInBudget(db: D1Like, nowMs: number): Promise<number | null> {\n return databaseOperation(async () => {\n const spent = await db\n .prepare(SQL.spendSignInBudget)\n .bind(nowMs, nowMs - SIGN_IN_WINDOW_MS, SIGN_INS_PER_WINDOW)\n .first<{ admitted: number }>();\n if (spent !== null) return null;\n const open = await db.prepare(SQL.signInWindow).first<{ window_start: number }>();\n return Math.max(1, Math.ceil(((open?.window_start ?? nowMs) + SIGN_IN_WINDOW_MS - nowMs) / 1000));\n });\n}\n\n/** Spends one send from the hourly allowance; returns the seconds until it reopens when spent. */\nexport async function spendLinkBudget(db: D1Like, userId: string, nowMs: number): Promise<number | null> {\n return databaseOperation(async () => {\n const windowFloor = nowMs - LINK_WINDOW_MS;\n const spent = await db\n .prepare(SQL.spendBudget)\n .bind(userId, nowMs, windowFloor, LINKS_PER_WINDOW)\n .first<{ sent: number }>();\n if (spent !== null) return null;\n const open = await db.prepare(SQL.budgetWindow).bind(userId).first<{ window_start: number }>();\n const reopensAt = (open?.window_start ?? nowMs) + LINK_WINDOW_MS;\n return Math.max(1, Math.ceil((reopensAt - nowMs) / 1000));\n });\n}\n\nexport async function insertToken(db: D1Like, tokenHash: string, userId: string, nowMs: number): Promise<void> {\n await databaseOperation(() =>\n db\n .prepare(SQL.insertToken)\n .bind(tokenHash, userId, nowMs + LINK_TTL_MS)\n .run(),\n );\n}\n\nexport async function deleteToken(db: D1Like, tokenHash: string): Promise<void> {\n await databaseOperation(() => db.prepare(SQL.deleteToken).bind(tokenHash).run());\n}\n\n/** Single-use consume: the unexpired row answers once and is gone after; the account is stamped signed in. */\nexport async function consumeToken(\n db: D1Like,\n tokenHash: string,\n nowMs: number,\n): Promise<{ email: string; id: string; sessionVersion: number } | null> {\n return databaseOperation(() =>\n db\n .prepare(SQL.consumeToken)\n .bind(new Date(nowMs).toISOString(), tokenHash, nowMs)\n .first<{ email: string; id: string; sessionVersion: number }>(),\n );\n}\n\n/** The trigger deletes outstanding links within this same atomic version change. */\nexport function revokeSessions(\n db: D1Like,\n subject: string,\n version: number,\n): Promise<{ session_version: number } | null> {\n return databaseOperation(() =>\n db.prepare(SQL.revokeSessions).bind(subject, version).first<{ session_version: number }>(),\n );\n}\n\nexport async function authReadiness(db: D1Like): Promise<number | null> {\n return databaseOperation(async () => {\n const columns = {\n auth_link_budget: ['user_id', 'window_start', 'sent'],\n auth_schema_meta: ['id', 'applied'],\n auth_sign_in_budget: ['id', 'window_start', 'admitted'],\n auth_tokens: ['token_hash', 'user_id', 'expires_at', 'redeemed_at'],\n auth_users: ['id', 'email', 'created_at', 'last_sign_in_at', 'session_version'],\n } as const;\n for (const [table, expected] of Object.entries(columns)) {\n const actual = await db.prepare(`PRAGMA table_info(${table})`).all<{ name: string }>();\n if (!expected.every((name) => actual.results?.some((column) => column.name === name))) return null;\n }\n const triggers = await db\n .prepare(\n \"SELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN ('auth_token_redeemed', 'auth_sessions_revoked')\",\n )\n .all<{ name: string }>();\n if (triggers.results?.length !== 2) return null;\n const stamp = await db.prepare('SELECT applied FROM auth_schema_meta WHERE id = 1').first<{ applied: number }>();\n await db\n .prepare(`SELECT u.id, u.email, u.created_at, u.last_sign_in_at, u.session_version,\n t.token_hash, t.user_id, t.expires_at, t.redeemed_at,\n b.user_id, b.window_start, b.sent, s.id, s.window_start, s.admitted\n FROM auth_users u LEFT JOIN auth_tokens t ON 0 LEFT JOIN auth_link_budget b ON 0\n LEFT JOIN auth_sign_in_budget s ON 0 LIMIT 1`)\n .first();\n return typeof stamp?.applied === 'number' && Number.isSafeInteger(stamp.applied) && stamp.applied >= 0\n ? stamp.applied\n : null;\n });\n}\n\nexport function readAccount(db: D1Like, userId: string): Promise<AccountRow | null> {\n return databaseOperation(() => db.prepare(SQL.account).bind(userId).first<AccountRow>());\n}\n\n/** Removes the account; FK cascades take its tokens and send budget. Idempotent. */\nexport async function deleteAccount(db: D1Like, userId: string): Promise<void> {\n await databaseOperation(() => db.prepare(SQL.deleteAccount).bind(userId).run());\n}\n\n/** The scheduled sweep: expired links, closed send windows, then never-verified accounts left unreferenced. */\nexport async function sweep(db: D1Like, nowMs: number): Promise<number> {\n const results = await databaseOperation(() =>\n db.batch([\n db.prepare(SQL.sweepTokens).bind(nowMs),\n db.prepare(SQL.sweepBudget).bind(nowMs - LINK_WINDOW_MS),\n db.prepare(SQL.sweepUnverified),\n ]),\n );\n return results.reduce((total, result) => total + (result.meta?.changes ?? 0), 0);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"store.cjs","names":["databaseOperation","require_errors","LINK_TTL_MS","LINK_WINDOW_MS","SIGN_IN_WINDOW_MS","SQL","row","db","email","nowMs","open","windowFloor","userId","reopensAt","tokenHash","results","deviceHash","linkHash","subject","version","table","expected","actual","column","name","stamp","total","result"],"sources":["../../src/auth/store.ts"],"sourcesContent":["import type { D1Like } from '../_server/d1';\nimport { databaseOperation } from './errors';\n\n/**\n * The issuer's storage, written against exactly `migrations/auth/0001_auth.sql`.\n * Token rows are single-use credentials only; the send allowance lives in its own\n * durable table, so consuming or expiring a link never refunds the budget.\n */\nexport const LINK_TTL_MS = 15 * 60 * 1000;\nexport const LINK_WINDOW_MS = 60 * 60 * 1000;\nexport const LINKS_PER_WINDOW = 3;\nexport const SIGN_IN_WINDOW_MS = 60_000;\nexport const SIGN_INS_PER_WINDOW = 20;\n\nexport interface AccountRow {\n created_at: string;\n email: string;\n id: string;\n last_sign_in_at: string | null;\n session_version: number;\n}\n\nconst SQL = {\n account: 'SELECT id, email, created_at, last_sign_in_at, session_version FROM auth_users WHERE id = ?',\n // OR IGNORE answers a device-hash collision (the same challenge approved from\n // two live links) as zero changes, which the caller reports as 410.\n approveDevice: `INSERT OR IGNORE INTO auth_tokens (token_hash, user_id, expires_at)\nSELECT ?1, user_id, expires_at FROM auth_tokens WHERE token_hash = ?2 AND expires_at > ?3`,\n budgetWindow: 'SELECT window_start FROM auth_link_budget WHERE user_id = ?',\n consumeToken: `UPDATE auth_tokens SET redeemed_at = ?1 WHERE token_hash = ?2 AND expires_at > ?3\nRETURNING user_id AS id, (SELECT email FROM auth_users WHERE id = user_id) AS email,\n (SELECT session_version FROM auth_users WHERE id = user_id) AS sessionVersion`,\n deleteAccount: 'DELETE FROM auth_users WHERE id = ?',\n deleteToken: 'DELETE FROM auth_tokens WHERE token_hash = ?',\n // The no-op update makes RETURNING answer for an existing row too.\n ensureUser:\n 'INSERT INTO auth_users (id, email, created_at, last_sign_in_at) VALUES (?, ?, ?, NULL) ON CONFLICT (email) DO UPDATE SET email = excluded.email RETURNING id, email',\n insertToken: 'INSERT INTO auth_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)',\n removeApprovedLink: 'DELETE FROM auth_tokens WHERE token_hash = ? AND expires_at > ?',\n revokeSessions: `UPDATE auth_users SET session_version = session_version + 1\nWHERE id = ? AND session_version = ? RETURNING session_version`,\n signInWindow: 'SELECT window_start FROM auth_sign_in_budget WHERE id = 1',\n // One atomic statement opens a window, counts within it, or refuses: no row back\n // means the allowance is spent. Concurrent requests cannot both pass the bound.\n spendBudget: `INSERT INTO auth_link_budget (user_id, window_start, sent) VALUES (?1, ?2, 1)\nON CONFLICT (user_id) DO UPDATE SET\n window_start = CASE WHEN auth_link_budget.window_start <= ?3 THEN ?2 ELSE auth_link_budget.window_start END,\n sent = CASE WHEN auth_link_budget.window_start <= ?3 THEN 1 ELSE auth_link_budget.sent + 1 END\nWHERE auth_link_budget.window_start <= ?3 OR auth_link_budget.sent < ?4\nRETURNING sent`,\n spendSignInBudget: `INSERT INTO auth_sign_in_budget (id, window_start, admitted) VALUES (1, ?1, 1)\nON CONFLICT (id) DO UPDATE SET\n window_start = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN ?1 ELSE auth_sign_in_budget.window_start END,\n admitted = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN 1 ELSE auth_sign_in_budget.admitted + 1 END\nWHERE auth_sign_in_budget.window_start <= ?2 OR auth_sign_in_budget.admitted < ?3\nRETURNING admitted`,\n sweepBudget: 'DELETE FROM auth_link_budget WHERE window_start <= ?',\n sweepTokens: 'DELETE FROM auth_tokens WHERE expires_at <= ?',\n // Rows for emails that never completed a sign-in leave once nothing references them.\n sweepUnverified:\n 'DELETE FROM auth_users WHERE last_sign_in_at IS NULL AND NOT EXISTS (SELECT 1 FROM auth_tokens t WHERE t.user_id = auth_users.id) AND NOT EXISTS (SELECT 1 FROM auth_link_budget b WHERE b.user_id = auth_users.id)',\n} as const;\n\nexport async function ensureUser(db: D1Like, email: string): Promise<{ email: string; id: string }> {\n const row = await databaseOperation(() =>\n db\n .prepare(SQL.ensureUser)\n .bind(crypto.randomUUID(), email, new Date().toISOString())\n .first<{ email: string; id: string }>(),\n );\n if (row === null) throw new Error('The account row could not be ensured.');\n return row;\n}\n\n/** The deployment-wide admission budget is spent before account creation or delivery. */\nexport async function spendSignInBudget(db: D1Like, nowMs: number): Promise<number | null> {\n return databaseOperation(async () => {\n const spent = await db\n .prepare(SQL.spendSignInBudget)\n .bind(nowMs, nowMs - SIGN_IN_WINDOW_MS, SIGN_INS_PER_WINDOW)\n .first<{ admitted: number }>();\n if (spent !== null) return null;\n const open = await db.prepare(SQL.signInWindow).first<{ window_start: number }>();\n return Math.max(1, Math.ceil(((open?.window_start ?? nowMs) + SIGN_IN_WINDOW_MS - nowMs) / 1000));\n });\n}\n\n/** Spends one send from the hourly allowance; returns the seconds until it reopens when spent. */\nexport async function spendLinkBudget(db: D1Like, userId: string, nowMs: number): Promise<number | null> {\n return databaseOperation(async () => {\n const windowFloor = nowMs - LINK_WINDOW_MS;\n const spent = await db\n .prepare(SQL.spendBudget)\n .bind(userId, nowMs, windowFloor, LINKS_PER_WINDOW)\n .first<{ sent: number }>();\n if (spent !== null) return null;\n const open = await db.prepare(SQL.budgetWindow).bind(userId).first<{ window_start: number }>();\n const reopensAt = (open?.window_start ?? nowMs) + LINK_WINDOW_MS;\n return Math.max(1, Math.ceil((reopensAt - nowMs) / 1000));\n });\n}\n\nexport async function insertToken(db: D1Like, tokenHash: string, userId: string, nowMs: number): Promise<void> {\n await databaseOperation(() =>\n db\n .prepare(SQL.insertToken)\n .bind(tokenHash, userId, nowMs + LINK_TTL_MS)\n .run(),\n );\n}\n\nexport async function deleteToken(db: D1Like, tokenHash: string): Promise<void> {\n await databaseOperation(() => db.prepare(SQL.deleteToken).bind(tokenHash).run());\n}\n\n/** Atomically exchanges one live email link for the initiating device's proof. */\nexport async function approveDevice(db: D1Like, linkHash: string, deviceHash: string, nowMs: number): Promise<boolean> {\n const results = await databaseOperation(() =>\n db.batch([\n db.prepare(SQL.approveDevice).bind(deviceHash, linkHash, nowMs),\n db.prepare(SQL.removeApprovedLink).bind(linkHash, nowMs),\n ]),\n );\n return results[0]?.meta?.changes === 1 && results[1]?.meta?.changes === 1;\n}\n\n/** Single-use consume: the unexpired row answers once and is gone after; the account is stamped signed in. */\nexport async function consumeToken(\n db: D1Like,\n tokenHash: string,\n nowMs: number,\n): Promise<{ email: string; id: string; sessionVersion: number } | null> {\n return databaseOperation(() =>\n db\n .prepare(SQL.consumeToken)\n .bind(new Date(nowMs).toISOString(), tokenHash, nowMs)\n .first<{ email: string; id: string; sessionVersion: number }>(),\n );\n}\n\n/** The trigger deletes outstanding links within this same atomic version change. */\nexport function revokeSessions(\n db: D1Like,\n subject: string,\n version: number,\n): Promise<{ session_version: number } | null> {\n return databaseOperation(() =>\n db.prepare(SQL.revokeSessions).bind(subject, version).first<{ session_version: number }>(),\n );\n}\n\nexport async function authReadiness(db: D1Like): Promise<number | null> {\n return databaseOperation(async () => {\n const columns = {\n auth_link_budget: ['user_id', 'window_start', 'sent'],\n auth_schema_meta: ['id', 'applied'],\n auth_sign_in_budget: ['id', 'window_start', 'admitted'],\n auth_tokens: ['token_hash', 'user_id', 'expires_at', 'redeemed_at'],\n auth_users: ['id', 'email', 'created_at', 'last_sign_in_at', 'session_version'],\n } as const;\n for (const [table, expected] of Object.entries(columns)) {\n const actual = await db.prepare(`PRAGMA table_info(${table})`).all<{ name: string }>();\n if (!expected.every((name) => actual.results?.some((column) => column.name === name))) return null;\n }\n const triggers = await db\n .prepare(\n \"SELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN ('auth_token_redeemed', 'auth_sessions_revoked')\",\n )\n .all<{ name: string }>();\n if (triggers.results?.length !== 2) return null;\n const stamp = await db.prepare('SELECT applied FROM auth_schema_meta WHERE id = 1').first<{ applied: number }>();\n await db\n .prepare(`SELECT u.id, u.email, u.created_at, u.last_sign_in_at, u.session_version,\n t.token_hash, t.user_id, t.expires_at, t.redeemed_at,\n b.user_id, b.window_start, b.sent, s.id, s.window_start, s.admitted\n FROM auth_users u LEFT JOIN auth_tokens t ON 0 LEFT JOIN auth_link_budget b ON 0\n LEFT JOIN auth_sign_in_budget s ON 0 LIMIT 1`)\n .first();\n return typeof stamp?.applied === 'number' && Number.isSafeInteger(stamp.applied) && stamp.applied >= 0\n ? stamp.applied\n : null;\n });\n}\n\nexport function readAccount(db: D1Like, userId: string): Promise<AccountRow | null> {\n return databaseOperation(() => db.prepare(SQL.account).bind(userId).first<AccountRow>());\n}\n\n/** Removes the account; FK cascades take its tokens and send budget. Idempotent. */\nexport async function deleteAccount(db: D1Like, userId: string): Promise<void> {\n await databaseOperation(() => db.prepare(SQL.deleteAccount).bind(userId).run());\n}\n\n/** The scheduled sweep: expired links, closed send windows, then never-verified accounts left unreferenced. */\nexport async function sweep(db: D1Like, nowMs: number): Promise<number> {\n const results = await databaseOperation(() =>\n db.batch([\n db.prepare(SQL.sweepTokens).bind(nowMs),\n db.prepare(SQL.sweepBudget).bind(nowMs - LINK_WINDOW_MS),\n db.prepare(SQL.sweepUnverified),\n ]),\n );\n return results.reduce((total, result) => total + (result.meta?.changes ?? 0), 0);\n}\n"],"mappings":"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"}
@@ -26,6 +26,8 @@ export declare function spendSignInBudget(db: D1Like, nowMs: number): Promise<nu
26
26
  export declare function spendLinkBudget(db: D1Like, userId: string, nowMs: number): Promise<number | null>;
27
27
  export declare function insertToken(db: D1Like, tokenHash: string, userId: string, nowMs: number): Promise<void>;
28
28
  export declare function deleteToken(db: D1Like, tokenHash: string): Promise<void>;
29
+ /** Atomically exchanges one live email link for the initiating device's proof. */
30
+ export declare function approveDevice(db: D1Like, linkHash: string, deviceHash: string, nowMs: number): Promise<boolean>;
29
31
  /** Single-use consume: the unexpired row answers once and is gone after; the account is stamped signed in. */
30
32
  export declare function consumeToken(db: D1Like, tokenHash: string, nowMs: number): Promise<{
31
33
  email: string;
@@ -1 +1 @@
1
- {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/auth/store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAG5C;;;;GAIG;AACH,eAAO,MAAM,WAAW,QAAiB,CAAC;AAC1C,eAAO,MAAM,cAAc,QAAiB,CAAC;AAC7C,eAAO,MAAM,gBAAgB,IAAI,CAAC;AAClC,eAAO,MAAM,iBAAiB,QAAS,CAAC;AACxC,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAEtC,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,EAAE,EAAE,MAAM,CAAC;IACX,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,eAAe,EAAE,MAAM,CAAC;CACzB;AAsCD,wBAAsB,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC,CASlG;AAED,yFAAyF;AACzF,wBAAsB,iBAAiB,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAUzF;AAED,kGAAkG;AAClG,wBAAsB,eAAe,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAYvG;AAED,wBAAsB,WAAW,CAAC,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAO7G;AAED,wBAAsB,WAAW,CAAC,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE9E;AAED,8GAA8G;AAC9G,wBAAsB,YAAY,CAChC,EAAE,EAAE,MAAM,EACV,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAOvE;AAED,oFAAoF;AACpF,wBAAgB,cAAc,CAC5B,EAAE,EAAE,MAAM,EACV,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,GACd,OAAO,CAAC;IAAE,eAAe,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAI7C;AAED,wBAAsB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CA+BtE;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAElF;AAED,oFAAoF;AACpF,wBAAsB,aAAa,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE7E;AAED,+GAA+G;AAC/G,wBAAsB,KAAK,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAStE"}
1
+ {"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../src/auth/store.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,eAAe,CAAC;AAG5C;;;;GAIG;AACH,eAAO,MAAM,WAAW,QAAiB,CAAC;AAC1C,eAAO,MAAM,cAAc,QAAiB,CAAC;AAC7C,eAAO,MAAM,gBAAgB,IAAI,CAAC;AAClC,eAAO,MAAM,iBAAiB,QAAS,CAAC;AACxC,eAAO,MAAM,mBAAmB,KAAK,CAAC;AAEtC,MAAM,WAAW,UAAU;IACzB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,EAAE,EAAE,MAAM,CAAC;IACX,eAAe,EAAE,MAAM,GAAG,IAAI,CAAC;IAC/B,eAAe,EAAE,MAAM,CAAC;CACzB;AA2CD,wBAAsB,UAAU,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC,CASlG;AAED,yFAAyF;AACzF,wBAAsB,iBAAiB,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAUzF;AAED,kGAAkG;AAClG,wBAAsB,eAAe,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAYvG;AAED,wBAAsB,WAAW,CAAC,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAO7G;AAED,wBAAsB,WAAW,CAAC,EAAE,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE9E;AAED,kFAAkF;AAClF,wBAAsB,aAAa,CAAC,EAAE,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAQrH;AAED,8GAA8G;AAC9G,wBAAsB,YAAY,CAChC,EAAE,EAAE,MAAM,EACV,SAAS,EAAE,MAAM,EACjB,KAAK,EAAE,MAAM,GACZ,OAAO,CAAC;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,cAAc,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAOvE;AAED,oFAAoF;AACpF,wBAAgB,cAAc,CAC5B,EAAE,EAAE,MAAM,EACV,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,GACd,OAAO,CAAC;IAAE,eAAe,EAAE,MAAM,CAAA;CAAE,GAAG,IAAI,CAAC,CAI7C;AAED,wBAAsB,aAAa,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CA+BtE;AAED,wBAAgB,WAAW,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,GAAG,IAAI,CAAC,CAElF;AAED,oFAAoF;AACpF,wBAAsB,aAAa,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAE7E;AAED,+GAA+G;AAC/G,wBAAsB,KAAK,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAStE"}
@@ -2,12 +2,14 @@ import { databaseOperation as e } from "./errors.js";
2
2
  // src/auth/store.ts
3
3
  var t = 9e5, n = 36e5, r = 6e4, i = {
4
4
  account: "SELECT id, email, created_at, last_sign_in_at, session_version FROM auth_users WHERE id = ?",
5
+ approveDevice: "INSERT OR IGNORE INTO auth_tokens (token_hash, user_id, expires_at)\nSELECT ?1, user_id, expires_at FROM auth_tokens WHERE token_hash = ?2 AND expires_at > ?3",
5
6
  budgetWindow: "SELECT window_start FROM auth_link_budget WHERE user_id = ?",
6
7
  consumeToken: "UPDATE auth_tokens SET redeemed_at = ?1 WHERE token_hash = ?2 AND expires_at > ?3\nRETURNING user_id AS id, (SELECT email FROM auth_users WHERE id = user_id) AS email,\n (SELECT session_version FROM auth_users WHERE id = user_id) AS sessionVersion",
7
8
  deleteAccount: "DELETE FROM auth_users WHERE id = ?",
8
9
  deleteToken: "DELETE FROM auth_tokens WHERE token_hash = ?",
9
10
  ensureUser: "INSERT INTO auth_users (id, email, created_at, last_sign_in_at) VALUES (?, ?, ?, NULL) ON CONFLICT (email) DO UPDATE SET email = excluded.email RETURNING id, email",
10
11
  insertToken: "INSERT INTO auth_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)",
12
+ removeApprovedLink: "DELETE FROM auth_tokens WHERE token_hash = ? AND expires_at > ?",
11
13
  revokeSessions: "UPDATE auth_users SET session_version = session_version + 1\nWHERE id = ? AND session_version = ? RETURNING session_version",
12
14
  signInWindow: "SELECT window_start FROM auth_sign_in_budget WHERE id = 1",
13
15
  spendBudget: "INSERT INTO auth_link_budget (user_id, window_start, sent) VALUES (?1, ?2, 1)\nON CONFLICT (user_id) DO UPDATE SET\n window_start = CASE WHEN auth_link_budget.window_start <= ?3 THEN ?2 ELSE auth_link_budget.window_start END,\n sent = CASE WHEN auth_link_budget.window_start <= ?3 THEN 1 ELSE auth_link_budget.sent + 1 END\nWHERE auth_link_budget.window_start <= ?3 OR auth_link_budget.sent < ?4\nRETURNING sent",
@@ -42,6 +44,10 @@ async function insertToken(n, r, a, o) {
42
44
  async function deleteToken(t, n) {
43
45
  await e(() => t.prepare(i.deleteToken).bind(n).run());
44
46
  }
47
+ async function approveDevice(t, n, r, a) {
48
+ let o = await e(() => t.batch([t.prepare(i.approveDevice).bind(r, n, a), t.prepare(i.removeApprovedLink).bind(n, a)]));
49
+ return o[0]?.meta?.changes === 1 && o[1]?.meta?.changes === 1;
50
+ }
45
51
  async function consumeToken(t, n, r) {
46
52
  return e(() => t.prepare(i.consumeToken).bind(new Date(r).toISOString(), n, r).first());
47
53
  }
@@ -97,6 +103,6 @@ async function sweep(t, r) {
97
103
  t.prepare(i.sweepUnverified)
98
104
  ]))).reduce((e, t) => e + (t.meta?.changes ?? 0), 0);
99
105
  }
100
- export { t as LINK_TTL_MS, n as LINK_WINDOW_MS, r as SIGN_IN_WINDOW_MS, authReadiness, consumeToken, deleteAccount, deleteToken, ensureUser, insertToken, readAccount, revokeSessions, spendLinkBudget, spendSignInBudget, sweep };
106
+ export { t as LINK_TTL_MS, n as LINK_WINDOW_MS, r as SIGN_IN_WINDOW_MS, approveDevice, authReadiness, consumeToken, deleteAccount, deleteToken, ensureUser, insertToken, readAccount, revokeSessions, spendLinkBudget, spendSignInBudget, sweep };
101
107
 
102
108
  //# sourceMappingURL=store.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"store.js","names":["LINK_TTL_MS","LINK_WINDOW_MS","SIGN_IN_WINDOW_MS","SQL","row","databaseOperation","db","email","nowMs","open","windowFloor","userId","reopensAt","tokenHash","subject","version","table","expected","actual","column","name","stamp","total","result"],"sources":["../../src/auth/store.ts"],"sourcesContent":["import type { D1Like } from '../_server/d1';\nimport { databaseOperation } from './errors';\n\n/**\n * The issuer's storage, written against exactly `migrations/auth/0001_auth.sql`.\n * Token rows are single-use credentials only; the send allowance lives in its own\n * durable table, so consuming or expiring a link never refunds the budget.\n */\nexport const LINK_TTL_MS = 15 * 60 * 1000;\nexport const LINK_WINDOW_MS = 60 * 60 * 1000;\nexport const LINKS_PER_WINDOW = 3;\nexport const SIGN_IN_WINDOW_MS = 60_000;\nexport const SIGN_INS_PER_WINDOW = 20;\n\nexport interface AccountRow {\n created_at: string;\n email: string;\n id: string;\n last_sign_in_at: string | null;\n session_version: number;\n}\n\nconst SQL = {\n account: 'SELECT id, email, created_at, last_sign_in_at, session_version FROM auth_users WHERE id = ?',\n budgetWindow: 'SELECT window_start FROM auth_link_budget WHERE user_id = ?',\n consumeToken: `UPDATE auth_tokens SET redeemed_at = ?1 WHERE token_hash = ?2 AND expires_at > ?3\nRETURNING user_id AS id, (SELECT email FROM auth_users WHERE id = user_id) AS email,\n (SELECT session_version FROM auth_users WHERE id = user_id) AS sessionVersion`,\n deleteAccount: 'DELETE FROM auth_users WHERE id = ?',\n deleteToken: 'DELETE FROM auth_tokens WHERE token_hash = ?',\n // The no-op update makes RETURNING answer for an existing row too.\n ensureUser:\n 'INSERT INTO auth_users (id, email, created_at, last_sign_in_at) VALUES (?, ?, ?, NULL) ON CONFLICT (email) DO UPDATE SET email = excluded.email RETURNING id, email',\n insertToken: 'INSERT INTO auth_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)',\n revokeSessions: `UPDATE auth_users SET session_version = session_version + 1\nWHERE id = ? AND session_version = ? RETURNING session_version`,\n signInWindow: 'SELECT window_start FROM auth_sign_in_budget WHERE id = 1',\n // One atomic statement opens a window, counts within it, or refuses: no row back\n // means the allowance is spent. Concurrent requests cannot both pass the bound.\n spendBudget: `INSERT INTO auth_link_budget (user_id, window_start, sent) VALUES (?1, ?2, 1)\nON CONFLICT (user_id) DO UPDATE SET\n window_start = CASE WHEN auth_link_budget.window_start <= ?3 THEN ?2 ELSE auth_link_budget.window_start END,\n sent = CASE WHEN auth_link_budget.window_start <= ?3 THEN 1 ELSE auth_link_budget.sent + 1 END\nWHERE auth_link_budget.window_start <= ?3 OR auth_link_budget.sent < ?4\nRETURNING sent`,\n spendSignInBudget: `INSERT INTO auth_sign_in_budget (id, window_start, admitted) VALUES (1, ?1, 1)\nON CONFLICT (id) DO UPDATE SET\n window_start = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN ?1 ELSE auth_sign_in_budget.window_start END,\n admitted = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN 1 ELSE auth_sign_in_budget.admitted + 1 END\nWHERE auth_sign_in_budget.window_start <= ?2 OR auth_sign_in_budget.admitted < ?3\nRETURNING admitted`,\n sweepBudget: 'DELETE FROM auth_link_budget WHERE window_start <= ?',\n sweepTokens: 'DELETE FROM auth_tokens WHERE expires_at <= ?',\n // Rows for emails that never completed a sign-in leave once nothing references them.\n sweepUnverified:\n 'DELETE FROM auth_users WHERE last_sign_in_at IS NULL AND NOT EXISTS (SELECT 1 FROM auth_tokens t WHERE t.user_id = auth_users.id) AND NOT EXISTS (SELECT 1 FROM auth_link_budget b WHERE b.user_id = auth_users.id)',\n} as const;\n\nexport async function ensureUser(db: D1Like, email: string): Promise<{ email: string; id: string }> {\n const row = await databaseOperation(() =>\n db\n .prepare(SQL.ensureUser)\n .bind(crypto.randomUUID(), email, new Date().toISOString())\n .first<{ email: string; id: string }>(),\n );\n if (row === null) throw new Error('The account row could not be ensured.');\n return row;\n}\n\n/** The deployment-wide admission budget is spent before account creation or delivery. */\nexport async function spendSignInBudget(db: D1Like, nowMs: number): Promise<number | null> {\n return databaseOperation(async () => {\n const spent = await db\n .prepare(SQL.spendSignInBudget)\n .bind(nowMs, nowMs - SIGN_IN_WINDOW_MS, SIGN_INS_PER_WINDOW)\n .first<{ admitted: number }>();\n if (spent !== null) return null;\n const open = await db.prepare(SQL.signInWindow).first<{ window_start: number }>();\n return Math.max(1, Math.ceil(((open?.window_start ?? nowMs) + SIGN_IN_WINDOW_MS - nowMs) / 1000));\n });\n}\n\n/** Spends one send from the hourly allowance; returns the seconds until it reopens when spent. */\nexport async function spendLinkBudget(db: D1Like, userId: string, nowMs: number): Promise<number | null> {\n return databaseOperation(async () => {\n const windowFloor = nowMs - LINK_WINDOW_MS;\n const spent = await db\n .prepare(SQL.spendBudget)\n .bind(userId, nowMs, windowFloor, LINKS_PER_WINDOW)\n .first<{ sent: number }>();\n if (spent !== null) return null;\n const open = await db.prepare(SQL.budgetWindow).bind(userId).first<{ window_start: number }>();\n const reopensAt = (open?.window_start ?? nowMs) + LINK_WINDOW_MS;\n return Math.max(1, Math.ceil((reopensAt - nowMs) / 1000));\n });\n}\n\nexport async function insertToken(db: D1Like, tokenHash: string, userId: string, nowMs: number): Promise<void> {\n await databaseOperation(() =>\n db\n .prepare(SQL.insertToken)\n .bind(tokenHash, userId, nowMs + LINK_TTL_MS)\n .run(),\n );\n}\n\nexport async function deleteToken(db: D1Like, tokenHash: string): Promise<void> {\n await databaseOperation(() => db.prepare(SQL.deleteToken).bind(tokenHash).run());\n}\n\n/** Single-use consume: the unexpired row answers once and is gone after; the account is stamped signed in. */\nexport async function consumeToken(\n db: D1Like,\n tokenHash: string,\n nowMs: number,\n): Promise<{ email: string; id: string; sessionVersion: number } | null> {\n return databaseOperation(() =>\n db\n .prepare(SQL.consumeToken)\n .bind(new Date(nowMs).toISOString(), tokenHash, nowMs)\n .first<{ email: string; id: string; sessionVersion: number }>(),\n );\n}\n\n/** The trigger deletes outstanding links within this same atomic version change. */\nexport function revokeSessions(\n db: D1Like,\n subject: string,\n version: number,\n): Promise<{ session_version: number } | null> {\n return databaseOperation(() =>\n db.prepare(SQL.revokeSessions).bind(subject, version).first<{ session_version: number }>(),\n );\n}\n\nexport async function authReadiness(db: D1Like): Promise<number | null> {\n return databaseOperation(async () => {\n const columns = {\n auth_link_budget: ['user_id', 'window_start', 'sent'],\n auth_schema_meta: ['id', 'applied'],\n auth_sign_in_budget: ['id', 'window_start', 'admitted'],\n auth_tokens: ['token_hash', 'user_id', 'expires_at', 'redeemed_at'],\n auth_users: ['id', 'email', 'created_at', 'last_sign_in_at', 'session_version'],\n } as const;\n for (const [table, expected] of Object.entries(columns)) {\n const actual = await db.prepare(`PRAGMA table_info(${table})`).all<{ name: string }>();\n if (!expected.every((name) => actual.results?.some((column) => column.name === name))) return null;\n }\n const triggers = await db\n .prepare(\n \"SELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN ('auth_token_redeemed', 'auth_sessions_revoked')\",\n )\n .all<{ name: string }>();\n if (triggers.results?.length !== 2) return null;\n const stamp = await db.prepare('SELECT applied FROM auth_schema_meta WHERE id = 1').first<{ applied: number }>();\n await db\n .prepare(`SELECT u.id, u.email, u.created_at, u.last_sign_in_at, u.session_version,\n t.token_hash, t.user_id, t.expires_at, t.redeemed_at,\n b.user_id, b.window_start, b.sent, s.id, s.window_start, s.admitted\n FROM auth_users u LEFT JOIN auth_tokens t ON 0 LEFT JOIN auth_link_budget b ON 0\n LEFT JOIN auth_sign_in_budget s ON 0 LIMIT 1`)\n .first();\n return typeof stamp?.applied === 'number' && Number.isSafeInteger(stamp.applied) && stamp.applied >= 0\n ? stamp.applied\n : null;\n });\n}\n\nexport function readAccount(db: D1Like, userId: string): Promise<AccountRow | null> {\n return databaseOperation(() => db.prepare(SQL.account).bind(userId).first<AccountRow>());\n}\n\n/** Removes the account; FK cascades take its tokens and send budget. Idempotent. */\nexport async function deleteAccount(db: D1Like, userId: string): Promise<void> {\n await databaseOperation(() => db.prepare(SQL.deleteAccount).bind(userId).run());\n}\n\n/** The scheduled sweep: expired links, closed send windows, then never-verified accounts left unreferenced. */\nexport async function sweep(db: D1Like, nowMs: number): Promise<number> {\n const results = await databaseOperation(() =>\n db.batch([\n db.prepare(SQL.sweepTokens).bind(nowMs),\n db.prepare(SQL.sweepBudget).bind(nowMs - LINK_WINDOW_MS),\n db.prepare(SQL.sweepUnverified),\n ]),\n );\n return results.reduce((total, result) => total + (result.meta?.changes ?? 0), 0);\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"store.js","names":["LINK_TTL_MS","LINK_WINDOW_MS","SIGN_IN_WINDOW_MS","SQL","row","databaseOperation","db","email","nowMs","open","windowFloor","userId","reopensAt","tokenHash","results","deviceHash","linkHash","subject","version","table","expected","actual","column","name","stamp","total","result"],"sources":["../../src/auth/store.ts"],"sourcesContent":["import type { D1Like } from '../_server/d1';\nimport { databaseOperation } from './errors';\n\n/**\n * The issuer's storage, written against exactly `migrations/auth/0001_auth.sql`.\n * Token rows are single-use credentials only; the send allowance lives in its own\n * durable table, so consuming or expiring a link never refunds the budget.\n */\nexport const LINK_TTL_MS = 15 * 60 * 1000;\nexport const LINK_WINDOW_MS = 60 * 60 * 1000;\nexport const LINKS_PER_WINDOW = 3;\nexport const SIGN_IN_WINDOW_MS = 60_000;\nexport const SIGN_INS_PER_WINDOW = 20;\n\nexport interface AccountRow {\n created_at: string;\n email: string;\n id: string;\n last_sign_in_at: string | null;\n session_version: number;\n}\n\nconst SQL = {\n account: 'SELECT id, email, created_at, last_sign_in_at, session_version FROM auth_users WHERE id = ?',\n // OR IGNORE answers a device-hash collision (the same challenge approved from\n // two live links) as zero changes, which the caller reports as 410.\n approveDevice: `INSERT OR IGNORE INTO auth_tokens (token_hash, user_id, expires_at)\nSELECT ?1, user_id, expires_at FROM auth_tokens WHERE token_hash = ?2 AND expires_at > ?3`,\n budgetWindow: 'SELECT window_start FROM auth_link_budget WHERE user_id = ?',\n consumeToken: `UPDATE auth_tokens SET redeemed_at = ?1 WHERE token_hash = ?2 AND expires_at > ?3\nRETURNING user_id AS id, (SELECT email FROM auth_users WHERE id = user_id) AS email,\n (SELECT session_version FROM auth_users WHERE id = user_id) AS sessionVersion`,\n deleteAccount: 'DELETE FROM auth_users WHERE id = ?',\n deleteToken: 'DELETE FROM auth_tokens WHERE token_hash = ?',\n // The no-op update makes RETURNING answer for an existing row too.\n ensureUser:\n 'INSERT INTO auth_users (id, email, created_at, last_sign_in_at) VALUES (?, ?, ?, NULL) ON CONFLICT (email) DO UPDATE SET email = excluded.email RETURNING id, email',\n insertToken: 'INSERT INTO auth_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)',\n removeApprovedLink: 'DELETE FROM auth_tokens WHERE token_hash = ? AND expires_at > ?',\n revokeSessions: `UPDATE auth_users SET session_version = session_version + 1\nWHERE id = ? AND session_version = ? RETURNING session_version`,\n signInWindow: 'SELECT window_start FROM auth_sign_in_budget WHERE id = 1',\n // One atomic statement opens a window, counts within it, or refuses: no row back\n // means the allowance is spent. Concurrent requests cannot both pass the bound.\n spendBudget: `INSERT INTO auth_link_budget (user_id, window_start, sent) VALUES (?1, ?2, 1)\nON CONFLICT (user_id) DO UPDATE SET\n window_start = CASE WHEN auth_link_budget.window_start <= ?3 THEN ?2 ELSE auth_link_budget.window_start END,\n sent = CASE WHEN auth_link_budget.window_start <= ?3 THEN 1 ELSE auth_link_budget.sent + 1 END\nWHERE auth_link_budget.window_start <= ?3 OR auth_link_budget.sent < ?4\nRETURNING sent`,\n spendSignInBudget: `INSERT INTO auth_sign_in_budget (id, window_start, admitted) VALUES (1, ?1, 1)\nON CONFLICT (id) DO UPDATE SET\n window_start = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN ?1 ELSE auth_sign_in_budget.window_start END,\n admitted = CASE WHEN auth_sign_in_budget.window_start <= ?2 THEN 1 ELSE auth_sign_in_budget.admitted + 1 END\nWHERE auth_sign_in_budget.window_start <= ?2 OR auth_sign_in_budget.admitted < ?3\nRETURNING admitted`,\n sweepBudget: 'DELETE FROM auth_link_budget WHERE window_start <= ?',\n sweepTokens: 'DELETE FROM auth_tokens WHERE expires_at <= ?',\n // Rows for emails that never completed a sign-in leave once nothing references them.\n sweepUnverified:\n 'DELETE FROM auth_users WHERE last_sign_in_at IS NULL AND NOT EXISTS (SELECT 1 FROM auth_tokens t WHERE t.user_id = auth_users.id) AND NOT EXISTS (SELECT 1 FROM auth_link_budget b WHERE b.user_id = auth_users.id)',\n} as const;\n\nexport async function ensureUser(db: D1Like, email: string): Promise<{ email: string; id: string }> {\n const row = await databaseOperation(() =>\n db\n .prepare(SQL.ensureUser)\n .bind(crypto.randomUUID(), email, new Date().toISOString())\n .first<{ email: string; id: string }>(),\n );\n if (row === null) throw new Error('The account row could not be ensured.');\n return row;\n}\n\n/** The deployment-wide admission budget is spent before account creation or delivery. */\nexport async function spendSignInBudget(db: D1Like, nowMs: number): Promise<number | null> {\n return databaseOperation(async () => {\n const spent = await db\n .prepare(SQL.spendSignInBudget)\n .bind(nowMs, nowMs - SIGN_IN_WINDOW_MS, SIGN_INS_PER_WINDOW)\n .first<{ admitted: number }>();\n if (spent !== null) return null;\n const open = await db.prepare(SQL.signInWindow).first<{ window_start: number }>();\n return Math.max(1, Math.ceil(((open?.window_start ?? nowMs) + SIGN_IN_WINDOW_MS - nowMs) / 1000));\n });\n}\n\n/** Spends one send from the hourly allowance; returns the seconds until it reopens when spent. */\nexport async function spendLinkBudget(db: D1Like, userId: string, nowMs: number): Promise<number | null> {\n return databaseOperation(async () => {\n const windowFloor = nowMs - LINK_WINDOW_MS;\n const spent = await db\n .prepare(SQL.spendBudget)\n .bind(userId, nowMs, windowFloor, LINKS_PER_WINDOW)\n .first<{ sent: number }>();\n if (spent !== null) return null;\n const open = await db.prepare(SQL.budgetWindow).bind(userId).first<{ window_start: number }>();\n const reopensAt = (open?.window_start ?? nowMs) + LINK_WINDOW_MS;\n return Math.max(1, Math.ceil((reopensAt - nowMs) / 1000));\n });\n}\n\nexport async function insertToken(db: D1Like, tokenHash: string, userId: string, nowMs: number): Promise<void> {\n await databaseOperation(() =>\n db\n .prepare(SQL.insertToken)\n .bind(tokenHash, userId, nowMs + LINK_TTL_MS)\n .run(),\n );\n}\n\nexport async function deleteToken(db: D1Like, tokenHash: string): Promise<void> {\n await databaseOperation(() => db.prepare(SQL.deleteToken).bind(tokenHash).run());\n}\n\n/** Atomically exchanges one live email link for the initiating device's proof. */\nexport async function approveDevice(db: D1Like, linkHash: string, deviceHash: string, nowMs: number): Promise<boolean> {\n const results = await databaseOperation(() =>\n db.batch([\n db.prepare(SQL.approveDevice).bind(deviceHash, linkHash, nowMs),\n db.prepare(SQL.removeApprovedLink).bind(linkHash, nowMs),\n ]),\n );\n return results[0]?.meta?.changes === 1 && results[1]?.meta?.changes === 1;\n}\n\n/** Single-use consume: the unexpired row answers once and is gone after; the account is stamped signed in. */\nexport async function consumeToken(\n db: D1Like,\n tokenHash: string,\n nowMs: number,\n): Promise<{ email: string; id: string; sessionVersion: number } | null> {\n return databaseOperation(() =>\n db\n .prepare(SQL.consumeToken)\n .bind(new Date(nowMs).toISOString(), tokenHash, nowMs)\n .first<{ email: string; id: string; sessionVersion: number }>(),\n );\n}\n\n/** The trigger deletes outstanding links within this same atomic version change. */\nexport function revokeSessions(\n db: D1Like,\n subject: string,\n version: number,\n): Promise<{ session_version: number } | null> {\n return databaseOperation(() =>\n db.prepare(SQL.revokeSessions).bind(subject, version).first<{ session_version: number }>(),\n );\n}\n\nexport async function authReadiness(db: D1Like): Promise<number | null> {\n return databaseOperation(async () => {\n const columns = {\n auth_link_budget: ['user_id', 'window_start', 'sent'],\n auth_schema_meta: ['id', 'applied'],\n auth_sign_in_budget: ['id', 'window_start', 'admitted'],\n auth_tokens: ['token_hash', 'user_id', 'expires_at', 'redeemed_at'],\n auth_users: ['id', 'email', 'created_at', 'last_sign_in_at', 'session_version'],\n } as const;\n for (const [table, expected] of Object.entries(columns)) {\n const actual = await db.prepare(`PRAGMA table_info(${table})`).all<{ name: string }>();\n if (!expected.every((name) => actual.results?.some((column) => column.name === name))) return null;\n }\n const triggers = await db\n .prepare(\n \"SELECT name FROM sqlite_master WHERE type = 'trigger' AND name IN ('auth_token_redeemed', 'auth_sessions_revoked')\",\n )\n .all<{ name: string }>();\n if (triggers.results?.length !== 2) return null;\n const stamp = await db.prepare('SELECT applied FROM auth_schema_meta WHERE id = 1').first<{ applied: number }>();\n await db\n .prepare(`SELECT u.id, u.email, u.created_at, u.last_sign_in_at, u.session_version,\n t.token_hash, t.user_id, t.expires_at, t.redeemed_at,\n b.user_id, b.window_start, b.sent, s.id, s.window_start, s.admitted\n FROM auth_users u LEFT JOIN auth_tokens t ON 0 LEFT JOIN auth_link_budget b ON 0\n LEFT JOIN auth_sign_in_budget s ON 0 LIMIT 1`)\n .first();\n return typeof stamp?.applied === 'number' && Number.isSafeInteger(stamp.applied) && stamp.applied >= 0\n ? stamp.applied\n : null;\n });\n}\n\nexport function readAccount(db: D1Like, userId: string): Promise<AccountRow | null> {\n return databaseOperation(() => db.prepare(SQL.account).bind(userId).first<AccountRow>());\n}\n\n/** Removes the account; FK cascades take its tokens and send budget. Idempotent. */\nexport async function deleteAccount(db: D1Like, userId: string): Promise<void> {\n await databaseOperation(() => db.prepare(SQL.deleteAccount).bind(userId).run());\n}\n\n/** The scheduled sweep: expired links, closed send windows, then never-verified accounts left unreferenced. */\nexport async function sweep(db: D1Like, nowMs: number): Promise<number> {\n const results = await databaseOperation(() =>\n db.batch([\n db.prepare(SQL.sweepTokens).bind(nowMs),\n db.prepare(SQL.sweepBudget).bind(nowMs - LINK_WINDOW_MS),\n db.prepare(SQL.sweepUnverified),\n ]),\n );\n return results.reduce((total, result) => total + (result.meta?.changes ?? 0), 0);\n}\n"],"mappings":"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"}