@vielzeug/archive 26.10.14 → 26.10.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/_confirmation.cjs +5 -1
- package/dist/auth/_confirmation.cjs.map +1 -1
- package/dist/auth/_confirmation.d.ts.map +1 -1
- package/dist/auth/_confirmation.js +5 -1
- package/dist/auth/_confirmation.js.map +1 -1
- package/dist/worker/http.cjs +1 -1
- package/dist/worker/http.cjs.map +1 -1
- package/dist/worker/http.d.ts +3 -1
- package/dist/worker/http.d.ts.map +1 -1
- package/dist/worker/http.js +1 -1
- package/dist/worker/http.js.map +1 -1
- package/dist/worker/publications.cjs +1 -1
- package/dist/worker/publications.cjs.map +1 -1
- package/dist/worker/publications.d.ts.map +1 -1
- package/dist/worker/publications.js +1 -1
- package/dist/worker/publications.js.map +1 -1
- package/dist/worker/sql.cjs +3 -3
- package/dist/worker/sql.cjs.map +1 -1
- package/dist/worker/sql.d.ts +4 -2
- package/dist/worker/sql.d.ts.map +1 -1
- package/dist/worker/sql.js +3 -3
- package/dist/worker/sql.js.map +1 -1
- package/package.json +2 -2
|
@@ -97,8 +97,12 @@ button.addEventListener('click', async () => {
|
|
|
97
97
|
} else if (response.status === 429) {
|
|
98
98
|
const value = response.headers.get('retry-after');
|
|
99
99
|
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})$/;
|
|
100
|
+
// HTTP dates are always GMT; bare asctime forms need the suffix or Date.parse reads them as local time.
|
|
100
101
|
const seconds = value && /^\\d+$/.test(value)
|
|
101
|
-
? Number(value)
|
|
102
|
+
? Number(value)
|
|
103
|
+
: (value && httpDate.test(value)
|
|
104
|
+
? (Date.parse(value.endsWith('GMT') ? value : value + ' GMT') - Date.now()) / 1000
|
|
105
|
+
: NaN);
|
|
102
106
|
const wait = Number.isFinite(seconds) ? Math.min(300, Math.max(1, Math.ceil(seconds))) : 30;
|
|
103
107
|
retryAt = Date.now() + wait * 1000;
|
|
104
108
|
recover('Too many attempts. Wait ' + wait + ' seconds before trying again.');
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"_confirmation.cjs","names":[],"sources":["../../src/auth/_confirmation.ts"],"sourcesContent":["import { REQUEST_MARKER_HEADER } from '../contract';\nimport { noStore } from '../worker/http';\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 const seconds = value && /^\\\\d+$/.test(value)\n ? Number(value) : (value && httpDate.test(value) ? (Date.parse(value) - Date.now()) / 1000 : 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":"4FAIA,IAAa,WAAc,GAA0B,EAAM,QAAQ,WAAa,GAAS,KAAK,EAAK,WAAW,CAAC,EAAE,EAAE,EAE7G,YAAe,GACnB,KAAK,UAAU,CAAK,CAAC,CAAC,QACpB,qBACC,GAAS,MAAM,EAAK,WAAW,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,SAAS,EAAG,GAAG,GACjE,EAEF,SAAgB,iBAAiB,CAC/B,UACA,QACA,eACA,SAMW,CACX,IAAM,EAAQ,IAAU,KAClB,EAAQ,EAAQ,kBAAoB,iCACpC,EAAS,EACX;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wDA2EkD,EAAA,sBAAsB;sCACxC,YAAY,GAAS,EAAE,EAAE;;;;;;;;;;;yBAWtC,YAAY,GAAG,EAAa,WAAW,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KA0C5D,GACE,EAAO;;;;;;;SAON,WAAW,CAAK,EAAE,KAAK,WAAW,CAAO,EAAE;iBACnC,EAAM;gBACP,EAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;+BA2BS,EAAQ,cAAc,WAAW,CAAO,IAAM,WAAW,CAAK,EAAE;KAC1F,EAAQ,oDAAsD,kCAAkC,WAAW,CAAO,EAAE,GAAG;EAC1H,EAAQ,sDAAsD,EAAA,YAAc,IAAO,sCAAwC,GAAG;;EAE9H,EAAQ,qEAAuE,GAAG;IAChF,EAAQ,GAAK,mBAAmB,uBAAuB,WAAW,GAAG,EAAa,SAAS,EAAE,GAAG,EAAQ,UAAY,GAAG;;;EAGzH,EAAQ,wGAAwG,WAAW,GAAG,EAAa,SAAS,EAAE,kDAAoD,GAAG;EAC7M,EAAQ,8BAA8B,WAAW,GAAG,EAAa,EAAE,EAAE,4BAA8B,GAAG;;EAGtG,EACI,kBAAkB,EAAM,IAAI,EAAO;YAEnC,GACL;;SAGC,OAAO,IAAI,SAAS,EAAM,CACxB,QAAS,CACP,GAAG,EAAA,QACH,0BAA2B,yCAAyC,EAAM,sBAAsB,EAAM,oFACtG,eAAgB,2BAChB,kBAAmB,cACnB,yBAA0B,SAC5B,EACA,OAAQ,EAAQ,IAAM,GACxB,CAAC,CACH"}
|
|
1
|
+
{"version":3,"file":"_confirmation.cjs","names":[],"sources":["../../src/auth/_confirmation.ts"],"sourcesContent":["import { REQUEST_MARKER_HEADER } from '../contract';\nimport { noStore } from '../worker/http';\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":"4FAIA,IAAa,WAAc,GAA0B,EAAM,QAAQ,WAAa,GAAS,KAAK,EAAK,WAAW,CAAC,EAAE,EAAE,EAE7G,YAAe,GACnB,KAAK,UAAU,CAAK,CAAC,CAAC,QACpB,qBACC,GAAS,MAAM,EAAK,WAAW,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,SAAS,EAAG,GAAG,GACjE,EAEF,SAAgB,iBAAiB,CAC/B,UACA,QACA,eACA,SAMW,CACX,IAAM,EAAQ,IAAU,KAClB,EAAQ,EAAQ,kBAAoB,iCACpC,EAAS,EACX;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wDA2EkD,EAAA,sBAAsB;sCACxC,YAAY,GAAS,EAAE,EAAE;;;;;;;;;;;yBAWtC,YAAY,GAAG,EAAa,WAAW,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KA8C5D,GACE,EAAO;;;;;;;SAON,WAAW,CAAK,EAAE,KAAK,WAAW,CAAO,EAAE;iBACnC,EAAM;gBACP,EAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;+BA2BS,EAAQ,cAAc,WAAW,CAAO,IAAM,WAAW,CAAK,EAAE;KAC1F,EAAQ,oDAAsD,kCAAkC,WAAW,CAAO,EAAE,GAAG;EAC1H,EAAQ,sDAAsD,EAAA,YAAc,IAAO,sCAAwC,GAAG;;EAE9H,EAAQ,qEAAuE,GAAG;IAChF,EAAQ,GAAK,mBAAmB,uBAAuB,WAAW,GAAG,EAAa,SAAS,EAAE,GAAG,EAAQ,UAAY,GAAG;;;EAGzH,EAAQ,wGAAwG,WAAW,GAAG,EAAa,SAAS,EAAE,kDAAoD,GAAG;EAC7M,EAAQ,8BAA8B,WAAW,GAAG,EAAa,EAAE,EAAE,4BAA8B,GAAG;;EAGtG,EACI,kBAAkB,EAAM,IAAI,EAAO;YAEnC,GACL;;SAGC,OAAO,IAAI,SAAS,EAAM,CACxB,QAAS,CACP,GAAG,EAAA,QACH,0BAA2B,yCAAyC,EAAM,sBAAsB,EAAM,oFACtG,eAAgB,2BAChB,kBAAmB,cACnB,yBAA0B,SAC5B,EACA,OAAQ,EAAQ,IAAM,GACxB,CAAC,CACH"}
|
|
@@ -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,
|
|
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"}
|
|
@@ -97,8 +97,12 @@ button.addEventListener('click', async () => {
|
|
|
97
97
|
} else if (response.status === 429) {
|
|
98
98
|
const value = response.headers.get('retry-after');
|
|
99
99
|
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})$/;
|
|
100
|
+
// HTTP dates are always GMT; bare asctime forms need the suffix or Date.parse reads them as local time.
|
|
100
101
|
const seconds = value && /^\\d+$/.test(value)
|
|
101
|
-
? Number(value)
|
|
102
|
+
? Number(value)
|
|
103
|
+
: (value && httpDate.test(value)
|
|
104
|
+
? (Date.parse(value.endsWith('GMT') ? value : value + ' GMT') - Date.now()) / 1000
|
|
105
|
+
: NaN);
|
|
102
106
|
const wait = Number.isFinite(seconds) ? Math.min(300, Math.max(1, Math.ceil(seconds))) : 30;
|
|
103
107
|
retryAt = Date.now() + wait * 1000;
|
|
104
108
|
recover('Too many attempts. Wait ' + wait + ' seconds before trying again.');
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"_confirmation.js","names":[],"sources":["../../src/auth/_confirmation.ts"],"sourcesContent":["import { REQUEST_MARKER_HEADER } from '../contract';\nimport { noStore } from '../worker/http';\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 const seconds = value && /^\\\\d+$/.test(value)\n ? Number(value) : (value && httpDate.test(value) ? (Date.parse(value) - Date.now()) / 1000 : 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":"4IAIA,IAAa,WAAc,GAA0B,EAAM,QAAQ,WAAa,GAAS,KAAK,EAAK,WAAW,CAAC,EAAE,EAAE,EAE7G,YAAe,GACnB,KAAK,UAAU,CAAK,CAAC,CAAC,QACpB,qBACC,GAAS,MAAM,EAAK,WAAW,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,SAAS,EAAG,GAAG,GACjE,EAEF,SAAgB,iBAAiB,CAC/B,UACA,QACA,eACA,SAMW,CACX,IAAM,EAAQ,IAAU,KAClB,EAAQ,EAAQ,kBAAoB,iCACpC,EAAS,EACX;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wDA2EkD,EAAsB;sCACxC,YAAY,GAAS,EAAE,EAAE;;;;;;;;;;;yBAWtC,YAAY,GAAG,EAAa,WAAW,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KA0C5D,GACE,EAAO;;;;;;;SAON,WAAW,CAAK,EAAE,KAAK,WAAW,CAAO,EAAE;iBACnC,EAAM;gBACP,EAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;+BA2BS,EAAQ,cAAc,WAAW,CAAO,IAAM,WAAW,CAAK,EAAE;KAC1F,EAAQ,oDAAsD,kCAAkC,WAAW,CAAO,EAAE,GAAG;EAC1H,EAAQ,sDAAsD,EAAc,IAAO,sCAAwC,GAAG;;EAE9H,EAAQ,qEAAuE,GAAG;IAChF,EAAQ,GAAK,mBAAmB,uBAAuB,WAAW,GAAG,EAAa,SAAS,EAAE,GAAG,EAAQ,UAAY,GAAG;;;EAGzH,EAAQ,wGAAwG,WAAW,GAAG,EAAa,SAAS,EAAE,kDAAoD,GAAG;EAC7M,EAAQ,8BAA8B,WAAW,GAAG,EAAa,EAAE,EAAE,4BAA8B,GAAG;;EAGtG,EACI,kBAAkB,EAAM,IAAI,EAAO;YAEnC,GACL;;SAGC,OAAO,IAAI,SAAS,EAAM,CACxB,QAAS,CACP,GAAG,EACH,0BAA2B,yCAAyC,EAAM,sBAAsB,EAAM,oFACtG,eAAgB,2BAChB,kBAAmB,cACnB,yBAA0B,SAC5B,EACA,OAAQ,EAAQ,IAAM,GACxB,CAAC,CACH"}
|
|
1
|
+
{"version":3,"file":"_confirmation.js","names":[],"sources":["../../src/auth/_confirmation.ts"],"sourcesContent":["import { REQUEST_MARKER_HEADER } from '../contract';\nimport { noStore } from '../worker/http';\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":"4IAIA,IAAa,WAAc,GAA0B,EAAM,QAAQ,WAAa,GAAS,KAAK,EAAK,WAAW,CAAC,EAAE,EAAE,EAE7G,YAAe,GACnB,KAAK,UAAU,CAAK,CAAC,CAAC,QACpB,qBACC,GAAS,MAAM,EAAK,WAAW,CAAC,CAAC,CAAC,SAAS,EAAE,CAAC,CAAC,SAAS,EAAG,GAAG,GACjE,EAEF,SAAgB,iBAAiB,CAC/B,UACA,QACA,eACA,SAMW,CACX,IAAM,EAAQ,IAAU,KAClB,EAAQ,EAAQ,kBAAoB,iCACpC,EAAS,EACX;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;wDA2EkD,EAAsB;sCACxC,YAAY,GAAS,EAAE,EAAE;;;;;;;;;;;yBAWtC,YAAY,GAAG,EAAa,WAAW,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;KA8C5D,GACE,EAAO;;;;;;;SAON,WAAW,CAAK,EAAE,KAAK,WAAW,CAAO,EAAE;iBACnC,EAAM;gBACP,EAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;+BA2BS,EAAQ,cAAc,WAAW,CAAO,IAAM,WAAW,CAAK,EAAE;KAC1F,EAAQ,oDAAsD,kCAAkC,WAAW,CAAO,EAAE,GAAG;EAC1H,EAAQ,sDAAsD,EAAc,IAAO,sCAAwC,GAAG;;EAE9H,EAAQ,qEAAuE,GAAG;IAChF,EAAQ,GAAK,mBAAmB,uBAAuB,WAAW,GAAG,EAAa,SAAS,EAAE,GAAG,EAAQ,UAAY,GAAG;;;EAGzH,EAAQ,wGAAwG,WAAW,GAAG,EAAa,SAAS,EAAE,kDAAoD,GAAG;EAC7M,EAAQ,8BAA8B,WAAW,GAAG,EAAa,EAAE,EAAE,4BAA8B,GAAG;;EAGtG,EACI,kBAAkB,EAAM,IAAI,EAAO;YAEnC,GACL;;SAGC,OAAO,IAAI,SAAS,EAAM,CACxB,QAAS,CACP,GAAG,EACH,0BAA2B,yCAAyC,EAAM,sBAAsB,EAAM,oFACtG,eAAgB,2BAChB,kBAAmB,cACnB,yBAA0B,SAC5B,EACA,OAAQ,EAAQ,IAAM,GACxB,CAAC,CACH"}
|
package/dist/worker/http.cjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=require("../contract.cjs");var ApiError=class extends Error{code;retryAfterSeconds;status;diagnostic;constructor(e,t,n,r){super(n,r),this.name=`ApiError`,this.code=t,this.retryAfterSeconds=r?.retryAfterSeconds,this.status=e,this.diagnostic=r?.diagnostic}},badRequest=(e=`The request is not valid.`)=>new ApiError(400,`bad-request`,e),unauthenticated=()=>new ApiError(401,`unauthenticated`,`Sign-in is required.`),forbidden=(e=`That action is not allowed.`)=>new ApiError(403,`forbidden`,e),notFound=()=>new ApiError(404,`not-found`,`No such entry.`),conflict=(e=`The entry changed since you saw it.`)=>new ApiError(409,`conflict`,e),invalid=(e=`The payload is not valid.`)=>new ApiError(422,`invalid-payload`,e),json=(e,t,n={})=>new Response(JSON.stringify(t),{headers:{"content-type":`application/json; charset=utf-8`,...n},status:e}),t={"cache-control":`no-store`},errorBody=(e,n,r,i)=>json(e,{error:{code:n,message:r,requestId:i}},t);function toErrorResponse(e,n){if(e instanceof ApiError){let r=e.retryAfterSeconds===void 0?t:{...t,"retry-after":String(e.retryAfterSeconds)};return json(e.status,{error:{code:e.code,message:e.message,requestId:n}},r)}return errorBody(500,`internal`,`Something went wrong.`,n)}function queryParams(e,t){let
|
|
1
|
+
const e=require("../contract.cjs");var ApiError=class extends Error{code;retryAfterSeconds;status;diagnostic;constructor(e,t,n,r){super(n,r),this.name=`ApiError`,this.code=t,this.retryAfterSeconds=r?.retryAfterSeconds,this.status=e,this.diagnostic=r?.diagnostic}},badRequest=(e=`The request is not valid.`)=>new ApiError(400,`bad-request`,e),unauthenticated=()=>new ApiError(401,`unauthenticated`,`Sign-in is required.`),forbidden=(e=`That action is not allowed.`)=>new ApiError(403,`forbidden`,e),notFound=()=>new ApiError(404,`not-found`,`No such entry.`),conflict=(e=`The entry changed since you saw it.`)=>new ApiError(409,`conflict`,e),invalid=(e=`The payload is not valid.`)=>new ApiError(422,`invalid-payload`,e),json=(e,t,n={})=>new Response(JSON.stringify(t),{headers:{"content-type":`application/json; charset=utf-8`,...n},status:e}),t={"cache-control":`no-store`},errorBody=(e,n,r,i)=>json(e,{error:{code:n,message:r,requestId:i}},t);function toErrorResponse(e,n){if(e instanceof ApiError){let r=e.retryAfterSeconds===void 0?t:{...t,"retry-after":String(e.retryAfterSeconds)};return json(e.status,{error:{code:e.code,message:e.message,requestId:n}},r)}return errorBody(500,`internal`,`Something went wrong.`,n)}function queryParams(e,t,n=[]){let r=e.searchParams,i=new Set;for(let e of r.keys()){if(!t.includes(e)||i.has(e)&&!n.includes(e))throw badRequest();i.add(e)}return r}function requireMutationGuard(e,t){let n=e.headers.get(`origin`);if(n===null||!t.allowedOrigins.has(n)||e.headers.get(`x-archive-request`)!==`1`)throw forbidden()}function corsHeaders(e,t){let n=e.headers.get(`origin`);return n===null||!t.allowedOrigins.has(n)?{}:{"access-control-allow-credentials":`true`,"access-control-allow-origin":n,vary:`Origin`}}function preflight(t,n){let r=t.headers.get(`origin`);return r===null||!n.allowedOrigins.has(r)?errorBody(403,`forbidden`,`That action is not allowed.`,crypto.randomUUID()):new Response(null,{headers:{"access-control-allow-credentials":`true`,"access-control-allow-headers":`Content-Type, ${e.PUBLISHER_HEADER}, ${e.REQUEST_MARKER_HEADER}`,"access-control-allow-methods":`GET, POST, PUT, DELETE, OPTIONS`,"access-control-allow-origin":r,"access-control-max-age":`600`,vary:`Origin`},status:204})}var n=1e4,bodyTimeout=()=>new ApiError(408,`timeout`,`The request body took too long.`);async function readJsonBody(t){if((t.headers.get(`content-type`)??``).split(`;`,1)[0].trim().toLowerCase()!==`application/json`)throw new ApiError(415,`unsupported-media`,`JSON bodies only.`);let r=t.body?.getReader();if(!r)throw badRequest();let i=Date.now(),a,o=[],s=0;try{for(;;){let{done:t,value:c}=await Promise.race([r.read(),new Promise((e,t)=>{a=setTimeout(()=>t(bodyTimeout()),Math.max(0,n-(Date.now()-i)))})]);if(clearTimeout(a),a=void 0,t)break;if(s+=c.byteLength,s>e.ARCHIVE_LIMITS.requestBytes)throw new ApiError(413,`too-large`,`The request is too large.`);o.push(c)}}catch(e){throw r.cancel().catch(()=>void 0),e}finally{a!==void 0&&clearTimeout(a),r.releaseLock()}let c=new Uint8Array(s),l=0;for(let e of o)c.set(e,l),l+=e.byteLength;if(s===0)throw badRequest();try{return JSON.parse(new TextDecoder(`utf-8`,{fatal:!0}).decode(c))}catch{throw badRequest()}}var r=4096;function sessionToken(e){let t=e.headers.get(`cookie`);if(t===null)return null;if(t.length>r)throw badRequest();let n=[];for(let e of t.split(`;`)){let t=e.indexOf(`=`);t!==-1&&e.slice(0,t).trim()===`archive_session`&&n.push(e.slice(t+1).trim())}if(n.length>1)throw badRequest();return n.length===1&&n[0].length>0?n[0]:null}var expiredSessionCookie=t=>e.sessionCookieValue(``,{maxAgeSeconds:0,secure:t});exports.ApiError=ApiError,exports.HTTP_BODY_TIMEOUT_MS=n,exports.badRequest=badRequest,exports.conflict=conflict,exports.corsHeaders=corsHeaders,exports.errorBody=errorBody,exports.expiredSessionCookie=expiredSessionCookie,exports.forbidden=forbidden,exports.invalid=invalid,exports.json=json,exports.noStore=t,exports.notFound=notFound,exports.preflight=preflight,exports.queryParams=queryParams,exports.readJsonBody=readJsonBody,exports.requireMutationGuard=requireMutationGuard,exports.sessionToken=sessionToken,exports.toErrorResponse=toErrorResponse,exports.unauthenticated=unauthenticated;
|
|
2
2
|
//# sourceMappingURL=http.cjs.map
|
package/dist/worker/http.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http.cjs","names":[],"sources":["../../src/worker/http.ts"],"sourcesContent":["import {\n ARCHIVE_LIMITS,\n type ArchiveErrorCode,\n PUBLISHER_HEADER,\n REQUEST_MARKER_HEADER,\n SESSION_COOKIE_NAME,\n sessionCookieValue,\n} from '../contract';\nimport type { ApiConfig } from './env';\n\n/**\n * HTTP plumbing shared by every route: the one error shape, bounded body reads, strict\n * query/header guards and the CORS contract. Handlers throw ApiError; the entry point\n * sanitizes — no stack, SQL, body, or PII ever reaches a response. Internal to the\n * Worker: clients parse the wire envelope (`archiveErrorResponseSchema`), never this class.\n */\nexport class ApiError extends Error {\n readonly code: ArchiveErrorCode;\n readonly retryAfterSeconds?: number;\n readonly status: number;\n readonly diagnostic?: ArchiveDiagnostic;\n constructor(\n status: number,\n code: ArchiveErrorCode,\n message: string,\n opts?: ErrorOptions & { retryAfterSeconds?: number; diagnostic?: ArchiveDiagnostic },\n ) {\n super(message, opts);\n this.name = 'ApiError';\n this.code = code;\n this.retryAfterSeconds = opts?.retryAfterSeconds;\n this.status = status;\n this.diagnostic = opts?.diagnostic;\n }\n}\n\nexport type ArchiveDiagnostic =\n | { readonly kind: 'configuration'; readonly field: string }\n | { readonly kind: 'database' | 'record' | 'schema' | 'internal' | 'cache' | 'collection' };\n\nexport const badRequest = (message = 'The request is not valid.'): ApiError =>\n new ApiError(400, 'bad-request', message);\nexport const unauthenticated = (): ApiError => new ApiError(401, 'unauthenticated', 'Sign-in is required.');\nexport const forbidden = (message = 'That action is not allowed.'): ApiError => new ApiError(403, 'forbidden', message);\nexport const notFound = (): ApiError => new ApiError(404, 'not-found', 'No such entry.');\nexport const conflict = (message = 'The entry changed since you saw it.'): ApiError =>\n new ApiError(409, 'conflict', message);\nexport const invalid = (message = 'The payload is not valid.'): ApiError =>\n new ApiError(422, 'invalid-payload', message);\n\nexport const json = (status: number, body: unknown, headers: HeadersInit = {}): Response =>\n new Response(JSON.stringify(body), {\n headers: { 'content-type': 'application/json; charset=utf-8', ...headers },\n status,\n });\n\nexport const noStore = { 'cache-control': 'no-store' } as const;\n\nexport const errorBody = (status: number, code: string, message: string, requestId: string): Response =>\n json(status, { error: { code, message, requestId } }, noStore);\n\n/** Sanitizes any thrown value into the public error shape; unknowns become 500. */\nexport function toErrorResponse(error: unknown, requestId: string): Response {\n if (error instanceof ApiError) {\n const headers: HeadersInit =\n error.retryAfterSeconds === undefined ? noStore : { ...noStore, 'retry-after': String(error.retryAfterSeconds) };\n return json(error.status, { error: { code: error.code, message: error.message, requestId } }, headers);\n }\n return errorBody(500, 'internal', 'Something went wrong.', requestId);\n}\n\n/**\n * Rejects query strings with unknown or repeated parameters. Every accepted route\n * names its full allow-list; anything else is a client bug, not something to ignore.\n */\nexport function queryParams(url: URL, allowed: readonly string[]): URLSearchParams {\n const params = url.searchParams;\n const seen = new Set<string>();\n for (const key of params.keys()) {\n if (!allowed.includes(key) || seen.has(key)) throw badRequest();\n seen.add(key);\n }\n return params;\n}\n\n/** Exact-origin plus request-marker guard for every mutation: no wildcards, no nulls. */\nexport function requireMutationGuard(request: Request, config: ApiConfig): void {\n const origin = request.headers.get('origin');\n if (origin === null || !config.allowedOrigins.has(origin)) throw forbidden();\n if (request.headers.get(REQUEST_MARKER_HEADER) !== '1') throw forbidden();\n}\n\n/** CORS response headers for one already-allowed origin; absent for foreign origins. */\nexport function corsHeaders(request: Request, config: ApiConfig): Record<string, string> {\n const origin = request.headers.get('origin');\n if (origin === null || !config.allowedOrigins.has(origin)) return {};\n return {\n 'access-control-allow-credentials': 'true',\n 'access-control-allow-origin': origin,\n vary: 'Origin',\n };\n}\n\n/** Answers a preflight for an allowed origin; foreign or missing origins get 403. */\nexport function preflight(request: Request, config: ApiConfig): Response {\n const origin = request.headers.get('origin');\n if (origin === null || !config.allowedOrigins.has(origin)) {\n return errorBody(403, 'forbidden', 'That action is not allowed.', crypto.randomUUID());\n }\n return new Response(null, {\n headers: {\n 'access-control-allow-credentials': 'true',\n // Header names are case-insensitive; the values derive from the contract constants.\n 'access-control-allow-headers': `Content-Type, ${PUBLISHER_HEADER}, ${REQUEST_MARKER_HEADER}`,\n 'access-control-allow-methods': 'GET, POST, PUT, DELETE, OPTIONS',\n 'access-control-allow-origin': origin,\n 'access-control-max-age': '600',\n vary: 'Origin',\n },\n status: 204,\n });\n}\n\n/** Hard deadline for reading a request body; slow streams fail as 408. */\nexport const HTTP_BODY_TIMEOUT_MS = 10000;\n\nconst bodyTimeout = (): ApiError => new ApiError(408, 'timeout', 'The request body took too long.');\n\n/**\n * Reads a JSON body with a hard byte ceiling and a wall-clock deadline enforced while\n * streaming — a lying or absent Content-Length and a stalled sender are both cut off.\n * Wrong media types are 415, oversized 413, timed out 408, malformed JSON 400; schema\n * validation happens afterwards at the route boundary.\n */\nexport async function readJsonBody(request: Request): Promise<unknown> {\n // Exactly the application/json media type; parameters (charset etc.) may follow.\n const mediaType = (request.headers.get('content-type') ?? '').split(';', 1)[0].trim().toLowerCase();\n if (mediaType !== 'application/json') {\n throw new ApiError(415, 'unsupported-media', 'JSON bodies only.');\n }\n const reader = request.body?.getReader();\n if (!reader) throw badRequest();\n const startedAt = Date.now();\n let timeoutId: ReturnType<typeof setTimeout> | undefined;\n const chunks: Uint8Array[] = [];\n let total = 0;\n try {\n for (;;) {\n const { done, value } = await Promise.race([\n reader.read(),\n new Promise<never>((_r, reject) => {\n timeoutId = setTimeout(\n () => reject(bodyTimeout()),\n Math.max(0, HTTP_BODY_TIMEOUT_MS - (Date.now() - startedAt)),\n );\n }),\n ]);\n clearTimeout(timeoutId);\n timeoutId = undefined;\n if (done) break;\n total += value.byteLength;\n if (total > ARCHIVE_LIMITS.requestBytes) throw new ApiError(413, 'too-large', 'The request is too large.');\n chunks.push(value);\n }\n } catch (error) {\n // Release the remainder so the failure isn't blocked on a stalled sender.\n reader.cancel().catch(() => undefined);\n throw error;\n } finally {\n if (timeoutId !== undefined) clearTimeout(timeoutId);\n reader.releaseLock();\n }\n const merged = new Uint8Array(total);\n let offset = 0;\n for (const chunk of chunks) {\n merged.set(chunk, offset);\n offset += chunk.byteLength;\n }\n if (total === 0) throw badRequest();\n try {\n return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(merged));\n } catch {\n throw badRequest();\n }\n}\n\nconst SESSION_COOKIE_MAX = 4096;\n\n/**\n * Reads the session cookie. A duplicate cookie name or an oversized header is a\n * malformed request, not a missing session: callers distinguish null (anonymous)\n * from a token to verify.\n */\nexport function sessionToken(request: Request): string | null {\n const header = request.headers.get('cookie');\n if (header === null) return null;\n if (header.length > SESSION_COOKIE_MAX) throw badRequest();\n const matches: string[] = [];\n for (const part of header.split(';')) {\n const eq = part.indexOf('=');\n if (eq === -1) continue;\n if (part.slice(0, eq).trim() === SESSION_COOKIE_NAME) matches.push(part.slice(eq + 1).trim());\n }\n if (matches.length > 1) throw badRequest();\n return matches.length === 1 && matches[0].length > 0 ? matches[0] : null;\n}\n\n/** The expired-session Set-Cookie value the sign-out route and account erasure send. */\nexport const expiredSessionCookie = (secure: boolean): string => sessionCookieValue('', { maxAgeSeconds: 0, secure });\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"http.cjs","names":[],"sources":["../../src/worker/http.ts"],"sourcesContent":["import {\n ARCHIVE_LIMITS,\n type ArchiveErrorCode,\n PUBLISHER_HEADER,\n REQUEST_MARKER_HEADER,\n SESSION_COOKIE_NAME,\n sessionCookieValue,\n} from '../contract';\nimport type { ApiConfig } from './env';\n\n/**\n * HTTP plumbing shared by every route: the one error shape, bounded body reads, strict\n * query/header guards and the CORS contract. Handlers throw ApiError; the entry point\n * sanitizes — no stack, SQL, body, or PII ever reaches a response. Internal to the\n * Worker: clients parse the wire envelope (`archiveErrorResponseSchema`), never this class.\n */\nexport class ApiError extends Error {\n readonly code: ArchiveErrorCode;\n readonly retryAfterSeconds?: number;\n readonly status: number;\n readonly diagnostic?: ArchiveDiagnostic;\n constructor(\n status: number,\n code: ArchiveErrorCode,\n message: string,\n opts?: ErrorOptions & { retryAfterSeconds?: number; diagnostic?: ArchiveDiagnostic },\n ) {\n super(message, opts);\n this.name = 'ApiError';\n this.code = code;\n this.retryAfterSeconds = opts?.retryAfterSeconds;\n this.status = status;\n this.diagnostic = opts?.diagnostic;\n }\n}\n\nexport type ArchiveDiagnostic =\n | { readonly kind: 'configuration'; readonly field: string }\n | { readonly kind: 'database' | 'record' | 'schema' | 'internal' | 'cache' | 'collection' };\n\nexport const badRequest = (message = 'The request is not valid.'): ApiError =>\n new ApiError(400, 'bad-request', message);\nexport const unauthenticated = (): ApiError => new ApiError(401, 'unauthenticated', 'Sign-in is required.');\nexport const forbidden = (message = 'That action is not allowed.'): ApiError => new ApiError(403, 'forbidden', message);\nexport const notFound = (): ApiError => new ApiError(404, 'not-found', 'No such entry.');\nexport const conflict = (message = 'The entry changed since you saw it.'): ApiError =>\n new ApiError(409, 'conflict', message);\nexport const invalid = (message = 'The payload is not valid.'): ApiError =>\n new ApiError(422, 'invalid-payload', message);\n\nexport const json = (status: number, body: unknown, headers: HeadersInit = {}): Response =>\n new Response(JSON.stringify(body), {\n headers: { 'content-type': 'application/json; charset=utf-8', ...headers },\n status,\n });\n\nexport const noStore = { 'cache-control': 'no-store' } as const;\n\nexport const errorBody = (status: number, code: string, message: string, requestId: string): Response =>\n json(status, { error: { code, message, requestId } }, noStore);\n\n/** Sanitizes any thrown value into the public error shape; unknowns become 500. */\nexport function toErrorResponse(error: unknown, requestId: string): Response {\n if (error instanceof ApiError) {\n const headers: HeadersInit =\n error.retryAfterSeconds === undefined ? noStore : { ...noStore, 'retry-after': String(error.retryAfterSeconds) };\n return json(error.status, { error: { code: error.code, message: error.message, requestId } }, headers);\n }\n return errorBody(500, 'internal', 'Something went wrong.', requestId);\n}\n\n/**\n * Rejects query strings with unknown or repeated parameters. Every accepted route\n * names its full allow-list; anything else is a client bug, not something to ignore.\n * Keys listed in `repeatable` may carry several values (read back with `getAll`);\n * every other key stays single-valued.\n */\nexport function queryParams(url: URL, allowed: readonly string[], repeatable: readonly string[] = []): URLSearchParams {\n const params = url.searchParams;\n const seen = new Set<string>();\n for (const key of params.keys()) {\n if (!allowed.includes(key) || (seen.has(key) && !repeatable.includes(key))) throw badRequest();\n seen.add(key);\n }\n return params;\n}\n\n/** Exact-origin plus request-marker guard for every mutation: no wildcards, no nulls. */\nexport function requireMutationGuard(request: Request, config: ApiConfig): void {\n const origin = request.headers.get('origin');\n if (origin === null || !config.allowedOrigins.has(origin)) throw forbidden();\n if (request.headers.get(REQUEST_MARKER_HEADER) !== '1') throw forbidden();\n}\n\n/** CORS response headers for one already-allowed origin; absent for foreign origins. */\nexport function corsHeaders(request: Request, config: ApiConfig): Record<string, string> {\n const origin = request.headers.get('origin');\n if (origin === null || !config.allowedOrigins.has(origin)) return {};\n return {\n 'access-control-allow-credentials': 'true',\n 'access-control-allow-origin': origin,\n vary: 'Origin',\n };\n}\n\n/** Answers a preflight for an allowed origin; foreign or missing origins get 403. */\nexport function preflight(request: Request, config: ApiConfig): Response {\n const origin = request.headers.get('origin');\n if (origin === null || !config.allowedOrigins.has(origin)) {\n return errorBody(403, 'forbidden', 'That action is not allowed.', crypto.randomUUID());\n }\n return new Response(null, {\n headers: {\n 'access-control-allow-credentials': 'true',\n // Header names are case-insensitive; the values derive from the contract constants.\n 'access-control-allow-headers': `Content-Type, ${PUBLISHER_HEADER}, ${REQUEST_MARKER_HEADER}`,\n 'access-control-allow-methods': 'GET, POST, PUT, DELETE, OPTIONS',\n 'access-control-allow-origin': origin,\n 'access-control-max-age': '600',\n vary: 'Origin',\n },\n status: 204,\n });\n}\n\n/** Hard deadline for reading a request body; slow streams fail as 408. */\nexport const HTTP_BODY_TIMEOUT_MS = 10000;\n\nconst bodyTimeout = (): ApiError => new ApiError(408, 'timeout', 'The request body took too long.');\n\n/**\n * Reads a JSON body with a hard byte ceiling and a wall-clock deadline enforced while\n * streaming — a lying or absent Content-Length and a stalled sender are both cut off.\n * Wrong media types are 415, oversized 413, timed out 408, malformed JSON 400; schema\n * validation happens afterwards at the route boundary.\n */\nexport async function readJsonBody(request: Request): Promise<unknown> {\n // Exactly the application/json media type; parameters (charset etc.) may follow.\n const mediaType = (request.headers.get('content-type') ?? '').split(';', 1)[0].trim().toLowerCase();\n if (mediaType !== 'application/json') {\n throw new ApiError(415, 'unsupported-media', 'JSON bodies only.');\n }\n const reader = request.body?.getReader();\n if (!reader) throw badRequest();\n const startedAt = Date.now();\n let timeoutId: ReturnType<typeof setTimeout> | undefined;\n const chunks: Uint8Array[] = [];\n let total = 0;\n try {\n for (;;) {\n const { done, value } = await Promise.race([\n reader.read(),\n new Promise<never>((_r, reject) => {\n timeoutId = setTimeout(\n () => reject(bodyTimeout()),\n Math.max(0, HTTP_BODY_TIMEOUT_MS - (Date.now() - startedAt)),\n );\n }),\n ]);\n clearTimeout(timeoutId);\n timeoutId = undefined;\n if (done) break;\n total += value.byteLength;\n if (total > ARCHIVE_LIMITS.requestBytes) throw new ApiError(413, 'too-large', 'The request is too large.');\n chunks.push(value);\n }\n } catch (error) {\n // Release the remainder so the failure isn't blocked on a stalled sender.\n reader.cancel().catch(() => undefined);\n throw error;\n } finally {\n if (timeoutId !== undefined) clearTimeout(timeoutId);\n reader.releaseLock();\n }\n const merged = new Uint8Array(total);\n let offset = 0;\n for (const chunk of chunks) {\n merged.set(chunk, offset);\n offset += chunk.byteLength;\n }\n if (total === 0) throw badRequest();\n try {\n return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(merged));\n } catch {\n throw badRequest();\n }\n}\n\nconst SESSION_COOKIE_MAX = 4096;\n\n/**\n * Reads the session cookie. A duplicate cookie name or an oversized header is a\n * malformed request, not a missing session: callers distinguish null (anonymous)\n * from a token to verify.\n */\nexport function sessionToken(request: Request): string | null {\n const header = request.headers.get('cookie');\n if (header === null) return null;\n if (header.length > SESSION_COOKIE_MAX) throw badRequest();\n const matches: string[] = [];\n for (const part of header.split(';')) {\n const eq = part.indexOf('=');\n if (eq === -1) continue;\n if (part.slice(0, eq).trim() === SESSION_COOKIE_NAME) matches.push(part.slice(eq + 1).trim());\n }\n if (matches.length > 1) throw badRequest();\n return matches.length === 1 && matches[0].length > 0 ? matches[0] : null;\n}\n\n/** The expired-session Set-Cookie value the sign-out route and account erasure send. */\nexport const expiredSessionCookie = (secure: boolean): string => sessionCookieValue('', { maxAgeSeconds: 0, secure });\n"],"mappings":"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"}
|
package/dist/worker/http.d.ts
CHANGED
|
@@ -38,8 +38,10 @@ export declare function toErrorResponse(error: unknown, requestId: string): Resp
|
|
|
38
38
|
/**
|
|
39
39
|
* Rejects query strings with unknown or repeated parameters. Every accepted route
|
|
40
40
|
* names its full allow-list; anything else is a client bug, not something to ignore.
|
|
41
|
+
* Keys listed in `repeatable` may carry several values (read back with `getAll`);
|
|
42
|
+
* every other key stays single-valued.
|
|
41
43
|
*/
|
|
42
|
-
export declare function queryParams(url: URL, allowed: readonly string[]): URLSearchParams;
|
|
44
|
+
export declare function queryParams(url: URL, allowed: readonly string[], repeatable?: readonly string[]): URLSearchParams;
|
|
43
45
|
/** Exact-origin plus request-marker guard for every mutation: no wildcards, no nulls. */
|
|
44
46
|
export declare function requireMutationGuard(request: Request, config: ApiConfig): void;
|
|
45
47
|
/** CORS response headers for one already-allowed origin; absent for foreign origins. */
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/worker/http.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,gBAAgB,EAKtB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC;;;;;GAKG;AACH,qBAAa,QAAS,SAAQ,KAAK;IACjC,QAAQ,CAAC,IAAI,EAAE,gBAAgB,CAAC;IAChC,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,UAAU,CAAC,EAAE,iBAAiB,CAAC;gBAEtC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,gBAAgB,EACtB,OAAO,EAAE,MAAM,EACf,IAAI,CAAC,EAAE,YAAY,GAAG;QAAE,iBAAiB,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,iBAAiB,CAAA;KAAE;CASvF;AAED,MAAM,MAAM,iBAAiB,GACzB;IAAE,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAC1D;IAAE,QAAQ,CAAC,IAAI,EAAE,UAAU,GAAG,QAAQ,GAAG,QAAQ,GAAG,UAAU,GAAG,OAAO,GAAG,YAAY,CAAA;CAAE,CAAC;AAE9F,eAAO,MAAM,UAAU,GAAI,gBAAqC,KAAG,QACxB,CAAC;AAC5C,eAAO,MAAM,eAAe,QAAO,QAAwE,CAAC;AAC5G,eAAO,MAAM,SAAS,GAAI,gBAAuC,KAAG,QAAmD,CAAC;AACxH,eAAO,MAAM,QAAQ,QAAO,QAA4D,CAAC;AACzF,eAAO,MAAM,QAAQ,GAAI,gBAA+C,KAAG,QACnC,CAAC;AACzC,eAAO,MAAM,OAAO,GAAI,gBAAqC,KAAG,QACjB,CAAC;AAEhD,eAAO,MAAM,IAAI,GAAI,QAAQ,MAAM,EAAE,MAAM,OAAO,EAAE,UAAS,WAAgB,KAAG,QAI5E,CAAC;AAEL,eAAO,MAAM,OAAO;;CAA2C,CAAC;AAEhE,eAAO,MAAM,SAAS,GAAI,QAAQ,MAAM,EAAE,MAAM,MAAM,EAAE,SAAS,MAAM,EAAE,WAAW,MAAM,KAAG,QAC7B,CAAC;AAEjE,mFAAmF;AACnF,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,GAAG,QAAQ,CAO3E;AAED
|
|
1
|
+
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/worker/http.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,gBAAgB,EAKtB,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvC;;;;;GAKG;AACH,qBAAa,QAAS,SAAQ,KAAK;IACjC,QAAQ,CAAC,IAAI,EAAE,gBAAgB,CAAC;IAChC,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,UAAU,CAAC,EAAE,iBAAiB,CAAC;gBAEtC,MAAM,EAAE,MAAM,EACd,IAAI,EAAE,gBAAgB,EACtB,OAAO,EAAE,MAAM,EACf,IAAI,CAAC,EAAE,YAAY,GAAG;QAAE,iBAAiB,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,iBAAiB,CAAA;KAAE;CASvF;AAED,MAAM,MAAM,iBAAiB,GACzB;IAAE,QAAQ,CAAC,IAAI,EAAE,eAAe,CAAC;IAAC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;CAAE,GAC1D;IAAE,QAAQ,CAAC,IAAI,EAAE,UAAU,GAAG,QAAQ,GAAG,QAAQ,GAAG,UAAU,GAAG,OAAO,GAAG,YAAY,CAAA;CAAE,CAAC;AAE9F,eAAO,MAAM,UAAU,GAAI,gBAAqC,KAAG,QACxB,CAAC;AAC5C,eAAO,MAAM,eAAe,QAAO,QAAwE,CAAC;AAC5G,eAAO,MAAM,SAAS,GAAI,gBAAuC,KAAG,QAAmD,CAAC;AACxH,eAAO,MAAM,QAAQ,QAAO,QAA4D,CAAC;AACzF,eAAO,MAAM,QAAQ,GAAI,gBAA+C,KAAG,QACnC,CAAC;AACzC,eAAO,MAAM,OAAO,GAAI,gBAAqC,KAAG,QACjB,CAAC;AAEhD,eAAO,MAAM,IAAI,GAAI,QAAQ,MAAM,EAAE,MAAM,OAAO,EAAE,UAAS,WAAgB,KAAG,QAI5E,CAAC;AAEL,eAAO,MAAM,OAAO;;CAA2C,CAAC;AAEhE,eAAO,MAAM,SAAS,GAAI,QAAQ,MAAM,EAAE,MAAM,MAAM,EAAE,SAAS,MAAM,EAAE,WAAW,MAAM,KAAG,QAC7B,CAAC;AAEjE,mFAAmF;AACnF,wBAAgB,eAAe,CAAC,KAAK,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,GAAG,QAAQ,CAO3E;AAED;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,GAAG,EAAE,OAAO,EAAE,SAAS,MAAM,EAAE,EAAE,UAAU,GAAE,SAAS,MAAM,EAAO,GAAG,eAAe,CAQrH;AAED,yFAAyF;AACzF,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,GAAG,IAAI,CAI9E;AAED,wFAAwF;AACxF,wBAAgB,WAAW,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAQvF;AAED,qFAAqF;AACrF,wBAAgB,SAAS,CAAC,OAAO,EAAE,OAAO,EAAE,MAAM,EAAE,SAAS,GAAG,QAAQ,CAiBvE;AAED,0EAA0E;AAC1E,eAAO,MAAM,oBAAoB,QAAQ,CAAC;AAI1C;;;;;GAKG;AACH,wBAAsB,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAkDrE;AAID;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,OAAO,GAAG,MAAM,GAAG,IAAI,CAY5D;AAED,wFAAwF;AACxF,eAAO,MAAM,oBAAoB,GAAI,QAAQ,OAAO,KAAG,MAA8D,CAAC"}
|
package/dist/worker/http.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import{ARCHIVE_LIMITS as e,PUBLISHER_HEADER as t,REQUEST_MARKER_HEADER as n,sessionCookieValue as r}from"../contract.js";var ApiError=class extends Error{code;retryAfterSeconds;status;diagnostic;constructor(e,t,n,r){super(n,r),this.name=`ApiError`,this.code=t,this.retryAfterSeconds=r?.retryAfterSeconds,this.status=e,this.diagnostic=r?.diagnostic}},badRequest=(e=`The request is not valid.`)=>new ApiError(400,`bad-request`,e),unauthenticated=()=>new ApiError(401,`unauthenticated`,`Sign-in is required.`),forbidden=(e=`That action is not allowed.`)=>new ApiError(403,`forbidden`,e),notFound=()=>new ApiError(404,`not-found`,`No such entry.`),conflict=(e=`The entry changed since you saw it.`)=>new ApiError(409,`conflict`,e),invalid=(e=`The payload is not valid.`)=>new ApiError(422,`invalid-payload`,e),json=(e,t,n={})=>new Response(JSON.stringify(t),{headers:{"content-type":`application/json; charset=utf-8`,...n},status:e}),i={"cache-control":`no-store`},errorBody=(e,t,n,r)=>json(e,{error:{code:t,message:n,requestId:r}},i);function toErrorResponse(e,t){if(e instanceof ApiError){let n=e.retryAfterSeconds===void 0?i:{...i,"retry-after":String(e.retryAfterSeconds)};return json(e.status,{error:{code:e.code,message:e.message,requestId:t}},n)}return errorBody(500,`internal`,`Something went wrong.`,t)}function queryParams(e,t){let
|
|
1
|
+
import{ARCHIVE_LIMITS as e,PUBLISHER_HEADER as t,REQUEST_MARKER_HEADER as n,sessionCookieValue as r}from"../contract.js";var ApiError=class extends Error{code;retryAfterSeconds;status;diagnostic;constructor(e,t,n,r){super(n,r),this.name=`ApiError`,this.code=t,this.retryAfterSeconds=r?.retryAfterSeconds,this.status=e,this.diagnostic=r?.diagnostic}},badRequest=(e=`The request is not valid.`)=>new ApiError(400,`bad-request`,e),unauthenticated=()=>new ApiError(401,`unauthenticated`,`Sign-in is required.`),forbidden=(e=`That action is not allowed.`)=>new ApiError(403,`forbidden`,e),notFound=()=>new ApiError(404,`not-found`,`No such entry.`),conflict=(e=`The entry changed since you saw it.`)=>new ApiError(409,`conflict`,e),invalid=(e=`The payload is not valid.`)=>new ApiError(422,`invalid-payload`,e),json=(e,t,n={})=>new Response(JSON.stringify(t),{headers:{"content-type":`application/json; charset=utf-8`,...n},status:e}),i={"cache-control":`no-store`},errorBody=(e,t,n,r)=>json(e,{error:{code:t,message:n,requestId:r}},i);function toErrorResponse(e,t){if(e instanceof ApiError){let n=e.retryAfterSeconds===void 0?i:{...i,"retry-after":String(e.retryAfterSeconds)};return json(e.status,{error:{code:e.code,message:e.message,requestId:t}},n)}return errorBody(500,`internal`,`Something went wrong.`,t)}function queryParams(e,t,n=[]){let r=e.searchParams,i=new Set;for(let e of r.keys()){if(!t.includes(e)||i.has(e)&&!n.includes(e))throw badRequest();i.add(e)}return r}function requireMutationGuard(e,t){let n=e.headers.get(`origin`);if(n===null||!t.allowedOrigins.has(n)||e.headers.get(`x-archive-request`)!==`1`)throw forbidden()}function corsHeaders(e,t){let n=e.headers.get(`origin`);return n===null||!t.allowedOrigins.has(n)?{}:{"access-control-allow-credentials":`true`,"access-control-allow-origin":n,vary:`Origin`}}function preflight(e,r){let i=e.headers.get(`origin`);return i===null||!r.allowedOrigins.has(i)?errorBody(403,`forbidden`,`That action is not allowed.`,crypto.randomUUID()):new Response(null,{headers:{"access-control-allow-credentials":`true`,"access-control-allow-headers":`Content-Type, ${t}, ${n}`,"access-control-allow-methods":`GET, POST, PUT, DELETE, OPTIONS`,"access-control-allow-origin":i,"access-control-max-age":`600`,vary:`Origin`},status:204})}var a=1e4,bodyTimeout=()=>new ApiError(408,`timeout`,`The request body took too long.`);async function readJsonBody(t){if((t.headers.get(`content-type`)??``).split(`;`,1)[0].trim().toLowerCase()!==`application/json`)throw new ApiError(415,`unsupported-media`,`JSON bodies only.`);let n=t.body?.getReader();if(!n)throw badRequest();let r=Date.now(),i,o=[],s=0;try{for(;;){let{done:t,value:c}=await Promise.race([n.read(),new Promise((e,t)=>{i=setTimeout(()=>t(bodyTimeout()),Math.max(0,a-(Date.now()-r)))})]);if(clearTimeout(i),i=void 0,t)break;if(s+=c.byteLength,s>e.requestBytes)throw new ApiError(413,`too-large`,`The request is too large.`);o.push(c)}}catch(e){throw n.cancel().catch(()=>void 0),e}finally{i!==void 0&&clearTimeout(i),n.releaseLock()}let c=new Uint8Array(s),l=0;for(let e of o)c.set(e,l),l+=e.byteLength;if(s===0)throw badRequest();try{return JSON.parse(new TextDecoder(`utf-8`,{fatal:!0}).decode(c))}catch{throw badRequest()}}var o=4096;function sessionToken(e){let t=e.headers.get(`cookie`);if(t===null)return null;if(t.length>o)throw badRequest();let n=[];for(let e of t.split(`;`)){let t=e.indexOf(`=`);t!==-1&&e.slice(0,t).trim()===`archive_session`&&n.push(e.slice(t+1).trim())}if(n.length>1)throw badRequest();return n.length===1&&n[0].length>0?n[0]:null}var expiredSessionCookie=e=>r(``,{maxAgeSeconds:0,secure:e});export{ApiError,a as HTTP_BODY_TIMEOUT_MS,badRequest,conflict,corsHeaders,errorBody,expiredSessionCookie,forbidden,invalid,json,i as noStore,notFound,preflight,queryParams,readJsonBody,requireMutationGuard,sessionToken,toErrorResponse,unauthenticated};
|
|
2
2
|
//# sourceMappingURL=http.js.map
|
package/dist/worker/http.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"http.js","names":[],"sources":["../../src/worker/http.ts"],"sourcesContent":["import {\n ARCHIVE_LIMITS,\n type ArchiveErrorCode,\n PUBLISHER_HEADER,\n REQUEST_MARKER_HEADER,\n SESSION_COOKIE_NAME,\n sessionCookieValue,\n} from '../contract';\nimport type { ApiConfig } from './env';\n\n/**\n * HTTP plumbing shared by every route: the one error shape, bounded body reads, strict\n * query/header guards and the CORS contract. Handlers throw ApiError; the entry point\n * sanitizes — no stack, SQL, body, or PII ever reaches a response. Internal to the\n * Worker: clients parse the wire envelope (`archiveErrorResponseSchema`), never this class.\n */\nexport class ApiError extends Error {\n readonly code: ArchiveErrorCode;\n readonly retryAfterSeconds?: number;\n readonly status: number;\n readonly diagnostic?: ArchiveDiagnostic;\n constructor(\n status: number,\n code: ArchiveErrorCode,\n message: string,\n opts?: ErrorOptions & { retryAfterSeconds?: number; diagnostic?: ArchiveDiagnostic },\n ) {\n super(message, opts);\n this.name = 'ApiError';\n this.code = code;\n this.retryAfterSeconds = opts?.retryAfterSeconds;\n this.status = status;\n this.diagnostic = opts?.diagnostic;\n }\n}\n\nexport type ArchiveDiagnostic =\n | { readonly kind: 'configuration'; readonly field: string }\n | { readonly kind: 'database' | 'record' | 'schema' | 'internal' | 'cache' | 'collection' };\n\nexport const badRequest = (message = 'The request is not valid.'): ApiError =>\n new ApiError(400, 'bad-request', message);\nexport const unauthenticated = (): ApiError => new ApiError(401, 'unauthenticated', 'Sign-in is required.');\nexport const forbidden = (message = 'That action is not allowed.'): ApiError => new ApiError(403, 'forbidden', message);\nexport const notFound = (): ApiError => new ApiError(404, 'not-found', 'No such entry.');\nexport const conflict = (message = 'The entry changed since you saw it.'): ApiError =>\n new ApiError(409, 'conflict', message);\nexport const invalid = (message = 'The payload is not valid.'): ApiError =>\n new ApiError(422, 'invalid-payload', message);\n\nexport const json = (status: number, body: unknown, headers: HeadersInit = {}): Response =>\n new Response(JSON.stringify(body), {\n headers: { 'content-type': 'application/json; charset=utf-8', ...headers },\n status,\n });\n\nexport const noStore = { 'cache-control': 'no-store' } as const;\n\nexport const errorBody = (status: number, code: string, message: string, requestId: string): Response =>\n json(status, { error: { code, message, requestId } }, noStore);\n\n/** Sanitizes any thrown value into the public error shape; unknowns become 500. */\nexport function toErrorResponse(error: unknown, requestId: string): Response {\n if (error instanceof ApiError) {\n const headers: HeadersInit =\n error.retryAfterSeconds === undefined ? noStore : { ...noStore, 'retry-after': String(error.retryAfterSeconds) };\n return json(error.status, { error: { code: error.code, message: error.message, requestId } }, headers);\n }\n return errorBody(500, 'internal', 'Something went wrong.', requestId);\n}\n\n/**\n * Rejects query strings with unknown or repeated parameters. Every accepted route\n * names its full allow-list; anything else is a client bug, not something to ignore.\n */\nexport function queryParams(url: URL, allowed: readonly string[]): URLSearchParams {\n const params = url.searchParams;\n const seen = new Set<string>();\n for (const key of params.keys()) {\n if (!allowed.includes(key) || seen.has(key)) throw badRequest();\n seen.add(key);\n }\n return params;\n}\n\n/** Exact-origin plus request-marker guard for every mutation: no wildcards, no nulls. */\nexport function requireMutationGuard(request: Request, config: ApiConfig): void {\n const origin = request.headers.get('origin');\n if (origin === null || !config.allowedOrigins.has(origin)) throw forbidden();\n if (request.headers.get(REQUEST_MARKER_HEADER) !== '1') throw forbidden();\n}\n\n/** CORS response headers for one already-allowed origin; absent for foreign origins. */\nexport function corsHeaders(request: Request, config: ApiConfig): Record<string, string> {\n const origin = request.headers.get('origin');\n if (origin === null || !config.allowedOrigins.has(origin)) return {};\n return {\n 'access-control-allow-credentials': 'true',\n 'access-control-allow-origin': origin,\n vary: 'Origin',\n };\n}\n\n/** Answers a preflight for an allowed origin; foreign or missing origins get 403. */\nexport function preflight(request: Request, config: ApiConfig): Response {\n const origin = request.headers.get('origin');\n if (origin === null || !config.allowedOrigins.has(origin)) {\n return errorBody(403, 'forbidden', 'That action is not allowed.', crypto.randomUUID());\n }\n return new Response(null, {\n headers: {\n 'access-control-allow-credentials': 'true',\n // Header names are case-insensitive; the values derive from the contract constants.\n 'access-control-allow-headers': `Content-Type, ${PUBLISHER_HEADER}, ${REQUEST_MARKER_HEADER}`,\n 'access-control-allow-methods': 'GET, POST, PUT, DELETE, OPTIONS',\n 'access-control-allow-origin': origin,\n 'access-control-max-age': '600',\n vary: 'Origin',\n },\n status: 204,\n });\n}\n\n/** Hard deadline for reading a request body; slow streams fail as 408. */\nexport const HTTP_BODY_TIMEOUT_MS = 10000;\n\nconst bodyTimeout = (): ApiError => new ApiError(408, 'timeout', 'The request body took too long.');\n\n/**\n * Reads a JSON body with a hard byte ceiling and a wall-clock deadline enforced while\n * streaming — a lying or absent Content-Length and a stalled sender are both cut off.\n * Wrong media types are 415, oversized 413, timed out 408, malformed JSON 400; schema\n * validation happens afterwards at the route boundary.\n */\nexport async function readJsonBody(request: Request): Promise<unknown> {\n // Exactly the application/json media type; parameters (charset etc.) may follow.\n const mediaType = (request.headers.get('content-type') ?? '').split(';', 1)[0].trim().toLowerCase();\n if (mediaType !== 'application/json') {\n throw new ApiError(415, 'unsupported-media', 'JSON bodies only.');\n }\n const reader = request.body?.getReader();\n if (!reader) throw badRequest();\n const startedAt = Date.now();\n let timeoutId: ReturnType<typeof setTimeout> | undefined;\n const chunks: Uint8Array[] = [];\n let total = 0;\n try {\n for (;;) {\n const { done, value } = await Promise.race([\n reader.read(),\n new Promise<never>((_r, reject) => {\n timeoutId = setTimeout(\n () => reject(bodyTimeout()),\n Math.max(0, HTTP_BODY_TIMEOUT_MS - (Date.now() - startedAt)),\n );\n }),\n ]);\n clearTimeout(timeoutId);\n timeoutId = undefined;\n if (done) break;\n total += value.byteLength;\n if (total > ARCHIVE_LIMITS.requestBytes) throw new ApiError(413, 'too-large', 'The request is too large.');\n chunks.push(value);\n }\n } catch (error) {\n // Release the remainder so the failure isn't blocked on a stalled sender.\n reader.cancel().catch(() => undefined);\n throw error;\n } finally {\n if (timeoutId !== undefined) clearTimeout(timeoutId);\n reader.releaseLock();\n }\n const merged = new Uint8Array(total);\n let offset = 0;\n for (const chunk of chunks) {\n merged.set(chunk, offset);\n offset += chunk.byteLength;\n }\n if (total === 0) throw badRequest();\n try {\n return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(merged));\n } catch {\n throw badRequest();\n }\n}\n\nconst SESSION_COOKIE_MAX = 4096;\n\n/**\n * Reads the session cookie. A duplicate cookie name or an oversized header is a\n * malformed request, not a missing session: callers distinguish null (anonymous)\n * from a token to verify.\n */\nexport function sessionToken(request: Request): string | null {\n const header = request.headers.get('cookie');\n if (header === null) return null;\n if (header.length > SESSION_COOKIE_MAX) throw badRequest();\n const matches: string[] = [];\n for (const part of header.split(';')) {\n const eq = part.indexOf('=');\n if (eq === -1) continue;\n if (part.slice(0, eq).trim() === SESSION_COOKIE_NAME) matches.push(part.slice(eq + 1).trim());\n }\n if (matches.length > 1) throw badRequest();\n return matches.length === 1 && matches[0].length > 0 ? matches[0] : null;\n}\n\n/** The expired-session Set-Cookie value the sign-out route and account erasure send. */\nexport const expiredSessionCookie = (secure: boolean): string => sessionCookieValue('', { maxAgeSeconds: 0, secure });\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"http.js","names":[],"sources":["../../src/worker/http.ts"],"sourcesContent":["import {\n ARCHIVE_LIMITS,\n type ArchiveErrorCode,\n PUBLISHER_HEADER,\n REQUEST_MARKER_HEADER,\n SESSION_COOKIE_NAME,\n sessionCookieValue,\n} from '../contract';\nimport type { ApiConfig } from './env';\n\n/**\n * HTTP plumbing shared by every route: the one error shape, bounded body reads, strict\n * query/header guards and the CORS contract. Handlers throw ApiError; the entry point\n * sanitizes — no stack, SQL, body, or PII ever reaches a response. Internal to the\n * Worker: clients parse the wire envelope (`archiveErrorResponseSchema`), never this class.\n */\nexport class ApiError extends Error {\n readonly code: ArchiveErrorCode;\n readonly retryAfterSeconds?: number;\n readonly status: number;\n readonly diagnostic?: ArchiveDiagnostic;\n constructor(\n status: number,\n code: ArchiveErrorCode,\n message: string,\n opts?: ErrorOptions & { retryAfterSeconds?: number; diagnostic?: ArchiveDiagnostic },\n ) {\n super(message, opts);\n this.name = 'ApiError';\n this.code = code;\n this.retryAfterSeconds = opts?.retryAfterSeconds;\n this.status = status;\n this.diagnostic = opts?.diagnostic;\n }\n}\n\nexport type ArchiveDiagnostic =\n | { readonly kind: 'configuration'; readonly field: string }\n | { readonly kind: 'database' | 'record' | 'schema' | 'internal' | 'cache' | 'collection' };\n\nexport const badRequest = (message = 'The request is not valid.'): ApiError =>\n new ApiError(400, 'bad-request', message);\nexport const unauthenticated = (): ApiError => new ApiError(401, 'unauthenticated', 'Sign-in is required.');\nexport const forbidden = (message = 'That action is not allowed.'): ApiError => new ApiError(403, 'forbidden', message);\nexport const notFound = (): ApiError => new ApiError(404, 'not-found', 'No such entry.');\nexport const conflict = (message = 'The entry changed since you saw it.'): ApiError =>\n new ApiError(409, 'conflict', message);\nexport const invalid = (message = 'The payload is not valid.'): ApiError =>\n new ApiError(422, 'invalid-payload', message);\n\nexport const json = (status: number, body: unknown, headers: HeadersInit = {}): Response =>\n new Response(JSON.stringify(body), {\n headers: { 'content-type': 'application/json; charset=utf-8', ...headers },\n status,\n });\n\nexport const noStore = { 'cache-control': 'no-store' } as const;\n\nexport const errorBody = (status: number, code: string, message: string, requestId: string): Response =>\n json(status, { error: { code, message, requestId } }, noStore);\n\n/** Sanitizes any thrown value into the public error shape; unknowns become 500. */\nexport function toErrorResponse(error: unknown, requestId: string): Response {\n if (error instanceof ApiError) {\n const headers: HeadersInit =\n error.retryAfterSeconds === undefined ? noStore : { ...noStore, 'retry-after': String(error.retryAfterSeconds) };\n return json(error.status, { error: { code: error.code, message: error.message, requestId } }, headers);\n }\n return errorBody(500, 'internal', 'Something went wrong.', requestId);\n}\n\n/**\n * Rejects query strings with unknown or repeated parameters. Every accepted route\n * names its full allow-list; anything else is a client bug, not something to ignore.\n * Keys listed in `repeatable` may carry several values (read back with `getAll`);\n * every other key stays single-valued.\n */\nexport function queryParams(url: URL, allowed: readonly string[], repeatable: readonly string[] = []): URLSearchParams {\n const params = url.searchParams;\n const seen = new Set<string>();\n for (const key of params.keys()) {\n if (!allowed.includes(key) || (seen.has(key) && !repeatable.includes(key))) throw badRequest();\n seen.add(key);\n }\n return params;\n}\n\n/** Exact-origin plus request-marker guard for every mutation: no wildcards, no nulls. */\nexport function requireMutationGuard(request: Request, config: ApiConfig): void {\n const origin = request.headers.get('origin');\n if (origin === null || !config.allowedOrigins.has(origin)) throw forbidden();\n if (request.headers.get(REQUEST_MARKER_HEADER) !== '1') throw forbidden();\n}\n\n/** CORS response headers for one already-allowed origin; absent for foreign origins. */\nexport function corsHeaders(request: Request, config: ApiConfig): Record<string, string> {\n const origin = request.headers.get('origin');\n if (origin === null || !config.allowedOrigins.has(origin)) return {};\n return {\n 'access-control-allow-credentials': 'true',\n 'access-control-allow-origin': origin,\n vary: 'Origin',\n };\n}\n\n/** Answers a preflight for an allowed origin; foreign or missing origins get 403. */\nexport function preflight(request: Request, config: ApiConfig): Response {\n const origin = request.headers.get('origin');\n if (origin === null || !config.allowedOrigins.has(origin)) {\n return errorBody(403, 'forbidden', 'That action is not allowed.', crypto.randomUUID());\n }\n return new Response(null, {\n headers: {\n 'access-control-allow-credentials': 'true',\n // Header names are case-insensitive; the values derive from the contract constants.\n 'access-control-allow-headers': `Content-Type, ${PUBLISHER_HEADER}, ${REQUEST_MARKER_HEADER}`,\n 'access-control-allow-methods': 'GET, POST, PUT, DELETE, OPTIONS',\n 'access-control-allow-origin': origin,\n 'access-control-max-age': '600',\n vary: 'Origin',\n },\n status: 204,\n });\n}\n\n/** Hard deadline for reading a request body; slow streams fail as 408. */\nexport const HTTP_BODY_TIMEOUT_MS = 10000;\n\nconst bodyTimeout = (): ApiError => new ApiError(408, 'timeout', 'The request body took too long.');\n\n/**\n * Reads a JSON body with a hard byte ceiling and a wall-clock deadline enforced while\n * streaming — a lying or absent Content-Length and a stalled sender are both cut off.\n * Wrong media types are 415, oversized 413, timed out 408, malformed JSON 400; schema\n * validation happens afterwards at the route boundary.\n */\nexport async function readJsonBody(request: Request): Promise<unknown> {\n // Exactly the application/json media type; parameters (charset etc.) may follow.\n const mediaType = (request.headers.get('content-type') ?? '').split(';', 1)[0].trim().toLowerCase();\n if (mediaType !== 'application/json') {\n throw new ApiError(415, 'unsupported-media', 'JSON bodies only.');\n }\n const reader = request.body?.getReader();\n if (!reader) throw badRequest();\n const startedAt = Date.now();\n let timeoutId: ReturnType<typeof setTimeout> | undefined;\n const chunks: Uint8Array[] = [];\n let total = 0;\n try {\n for (;;) {\n const { done, value } = await Promise.race([\n reader.read(),\n new Promise<never>((_r, reject) => {\n timeoutId = setTimeout(\n () => reject(bodyTimeout()),\n Math.max(0, HTTP_BODY_TIMEOUT_MS - (Date.now() - startedAt)),\n );\n }),\n ]);\n clearTimeout(timeoutId);\n timeoutId = undefined;\n if (done) break;\n total += value.byteLength;\n if (total > ARCHIVE_LIMITS.requestBytes) throw new ApiError(413, 'too-large', 'The request is too large.');\n chunks.push(value);\n }\n } catch (error) {\n // Release the remainder so the failure isn't blocked on a stalled sender.\n reader.cancel().catch(() => undefined);\n throw error;\n } finally {\n if (timeoutId !== undefined) clearTimeout(timeoutId);\n reader.releaseLock();\n }\n const merged = new Uint8Array(total);\n let offset = 0;\n for (const chunk of chunks) {\n merged.set(chunk, offset);\n offset += chunk.byteLength;\n }\n if (total === 0) throw badRequest();\n try {\n return JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(merged));\n } catch {\n throw badRequest();\n }\n}\n\nconst SESSION_COOKIE_MAX = 4096;\n\n/**\n * Reads the session cookie. A duplicate cookie name or an oversized header is a\n * malformed request, not a missing session: callers distinguish null (anonymous)\n * from a token to verify.\n */\nexport function sessionToken(request: Request): string | null {\n const header = request.headers.get('cookie');\n if (header === null) return null;\n if (header.length > SESSION_COOKIE_MAX) throw badRequest();\n const matches: string[] = [];\n for (const part of header.split(';')) {\n const eq = part.indexOf('=');\n if (eq === -1) continue;\n if (part.slice(0, eq).trim() === SESSION_COOKIE_NAME) matches.push(part.slice(eq + 1).trim());\n }\n if (matches.length > 1) throw badRequest();\n return matches.length === 1 && matches[0].length > 0 ? matches[0] : null;\n}\n\n/** The expired-session Set-Cookie value the sign-out route and account erasure send. */\nexport const expiredSessionCookie = (secure: boolean): string => sessionCookieValue('', { maxAgeSeconds: 0, secure });\n"],"mappings":"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"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
const e=require("../contract.cjs"),t=require("./http.cjs"),n=require("./sql.cjs"),r=require("./_pagination.cjs");let i=require("@vielzeug/spell");async function databaseCall(e){try{return await e()}catch(e){throw new t.ApiError(500,`internal`,`The service is not available.`,{cause:e,diagnostic:{kind:`database`}})}}var first=(e,t,...n)=>databaseCall(()=>e.DB.prepare(t).bind(...n).first()),all=(e,t,...n)=>databaseCall(async()=>{let r=await e.DB.prepare(t).bind(...n).all();if(!Array.isArray(r.results))throw Error(`Missing database query results`);return r.results}),run=(e,t,...n)=>databaseCall(()=>e.DB.prepare(t).bind(...n).run()),uuid=()=>crypto.randomUUID(),now=()=>new Date().toISOString(),recordUnavailable=()=>new t.ApiError(503,`record-unavailable`,`The record is not available.`,{diagnostic:{kind:`record`}}),a=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,rowToEntry=(e,t)=>{let n;try{n=JSON.parse(t.payload_json)}catch{throw recordUnavailable()}let r=e.contract.entry.safeParse({author:{id:t.owner_id,name:t.author_name},contentHash:t.content_hash,contentVersion:t.content_version,id:t.id,likeCount:t.like_count,payload:n,publishedAt:t.published_at,revision:t.revision,schemaVersion:t.schema_version,updatedAt:t.updated_at});if(!r.success)throw recordUnavailable();return r.data},rowToOwnedEntry=(t,n)=>{let r=rowToEntry(t,n);if(!e.uuidSchema.safeParse(n.client_key).success)throw recordUnavailable();return{...r,clientKey:n.client_key,hidden:n.hidden_at!==null}},entryById=async(e,t,r)=>{let i=await first(t,n.SQL.publication,r,e.name);return i===null?null:rowToEntry(e,i)},searchMatchFor=n=>{if(n===null)return;if(n.length>e.ARCHIVE_LIMITS.searchChars)throw t.badRequest();let r=(n.match(/[\p{L}\p{N}]+/gu)??[]).slice(0,e.ARCHIVE_LIMITS.searchTerms);if(r.length!==0)return r.map(e=>`"${e}"*`).join(` `)},entryCursorContext=e=>JSON.stringify([e.collection,e.sort,e.author??``,e.filter??``,e.tag??``,e.searchMatch??``]),cursorFromRow=e=>({createdAt:e.published_at,id:e.id,likeCount:e.like_count});async function listEntries(i,o,s){let c=new URL(o.url),l=t.queryParams(c,[`author`,`cursor`,`filter`,`limit`,`search`,`sort`,`tag`]),u=l.get(`sort`)??`recent`,d=e.archiveSortSchema.safeParse(u);if(!d.success)throw t.badRequest();let f=l.get(`filter`);if(f!==null&&!i.filters.includes(f))throw t.badRequest();let p=l.get(`tag`);if(p!==null&&!i.tags.includes(p))throw t.badRequest();let m=l.get(`author`);if(m!==null&&!a.test(m))throw t.badRequest();let h=searchMatchFor(l.get(`search`)),g=entryCursorContext({author:m??void 0,collection:i.name,filter:f??void 0,searchMatch:h,sort:d.data,tag:p??void 0}),{cursor:_,limit:v}=r.forwardPageParams(l,g),y={author:m??void 0,collection:i.name,cursor:_===null?void 0:{createdAt:_.createdAt,id:_.id,likeCount:_.likeCount??0},filter:f??void 0,limit:v+1,searchMatch:h,sort:d.data,tag:p??void 0},b=n.listPublicationsSql(y),x=await all(s,b.sql,...b.parameters),S=x.slice(0,v),C=S.at(-1),w={cursor:x.length>v&&C!==void 0?r.nextCursor(cursorFromRow(C),g):null,entries:S.map(e=>rowToEntry(i,e))};return t.json(200,w,t.noStore)}function collectionMeta(n){let r=e.archiveCollectionMetaSchema.safeParse({filters:[...n.filters].sort(),name:n.name,tags:[...n.tags].sort()});if(!r.success)throw recordUnavailable();return t.json(200,r.data,t.noStore)}async function getEntry(e,n,r){let i=await entryById(e,n,r);if(i===null)throw t.notFound();return t.json(200,i,t.noStore)}async function healthJson(r,i){let a=null,o=null;try{a=(await first(i,n.SQL.schemaVersion))?.applied??0}catch{o=`database-unavailable`}o===null&&a!==1&&(o=`schema-mismatch`);let s=e.archiveHealthSchema.safeParse({dbSchemaVersion:a,features:{likes:r.likesEnabled,publishing:r.publishingEnabled},limits:e.ARCHIVE_LIMITS,reason:o,schemaVersion:1,status:o===null?`ok`:`degraded`});if(!s.success)throw recordUnavailable();return t.json(o===null?200:503,s.data,t.noStore)}var identityJson=(n,r)=>{let i=e.archiveIdentitySchema.safeParse(r);if(!i.success)throw recordUnavailable();return t.json(n,i.data,t.noStore)};function getMe(e){return identityJson(200,e?.publisher?{id:e.publisher.id,name:e.publisher.displayName,operator:e.operator}:null)}async function registerMe(e,r,i){let a=await t.readJsonBody(e),s=o.safeParse(a);if(!s.success)throw t.invalid();let c=s.data.name??``,l=await first(r,n.SQL.publisher,i.subject.issuer,i.subject.subject);if(l!==null)return identityJson(200,{id:l.id,name:l.display_name,operator:i.operator});let u=uuid();await run(r,n.SQL.insertPublisher,u,i.subject.issuer,i.subject.subject,c,now(),i.subject.issuer,i.subject.subject,Date.now());let d=await first(r,n.SQL.publisher,i.subject.issuer,i.subject.subject);if(d===null)throw await first(r,n.SQL.revokedSubject,i.subject.issuer,i.subject.subject,Date.now())===null?t.conflict():new t.ApiError(401,`unauthenticated`,`Sign-in is required.`);return identityJson(201,{id:d.id,name:d.display_name,operator:i.operator})}async function renameMe(e,r,i){let a=requirePublisherRow(i),o=await t.readJsonBody(e),c=s.safeParse(o);if(!c.success)throw t.invalid();return c.data.name!==a.displayName&&await run(r,n.SQL.renamePublisher,c.data.name,a.id,c.data.name),identityJson(200,{id:a.id,name:c.data.name,operator:i.operator})}async function deleteProfile(e,t){return t.publisher!==null&&await run(e,n.SQL.deletePublisher,t.publisher.id),new Response(null,{headers:{"cache-control":`no-store`},status:204})}async function deleteAccountProfile(r,i){let{issuer:a,subject:o}=i.subject;return await databaseCall(()=>r.DB.batch([r.DB.prepare(n.SQL.revokeSubject).bind(a,o,Date.now()+(e.SESSION_MAX_AGE_SECONDS+10)*1e3),r.DB.prepare(n.SQL.deleteSubjectPublisher).bind(a,o)])),new Response(null,{headers:t.noStore,status:204})}async function erasePublisher(e,r,i){let a=requireOperatorRow(r);return await databaseCall(()=>e.DB.batch([e.DB.prepare(n.SQL.auditErasure).bind(uuid(),a.id,now(),i),e.DB.prepare(n.SQL.deletePublisher).bind(i)])),new Response(null,{headers:t.noStore,status:204})}async function listModerationLog(i,a,o){requireOperatorRow(o);let s=`moderation-log`,{cursor:c,limit:l}=r.forwardPage(i,s),u=n.moderationLogSql({cursor:c,limit:l+1}),d=await all(a,u.sql,...u.parameters),f=d.slice(0,l),p=f.at(-1),m=e.archiveModerationLogSchema.safeParse({actions:f.map(e=>({action:e.action,collection:e.collection,createdAt:e.created_at,id:e.id,operatorId:e.operator_id,targetId:e.target_id})),cursor:d.length>l&&p!==void 0?r.nextCursor({createdAt:p.created_at,id:p.id},s):null});if(!m.success)throw recordUnavailable();return t.json(200,m.data,t.noStore)}var requirePublisherRow=e=>{if(e.publisher===null)throw t.forbidden(`Register before publishing.`);return e.publisher},requireOperatorRow=e=>{if(!e.operator)throw t.forbidden(`Operator access is required.`);if(e.publisher===null)throw t.forbidden(`Register before moderating.`);return e.publisher},o=i.s.object({name:e.archiveAliasSchema.optional()}),s=i.s.object({name:e.archiveAliasSchema});async function myPublications(r,i,a){if(a.publisher===null)return t.json(200,{entries:[]},t.noStore);let o=await all(i,n.SQL.ownPublications,a.publisher.id,r.name,e.ARCHIVE_LIMITS.maxPublications);return t.json(200,{entries:o.map(e=>rowToOwnedEntry(r,e))},t.noStore)}async function myLikes(i,o,s){let c=new URL(i.url).searchParams.get(`ids`);if(c===null){let a=`likes:${s.publisher?.id??`unregistered`}`,{cursor:c,limit:l}=r.forwardPage(i,a);if(s.publisher===null)return t.json(200,{cursor:null,ids:[]},t.noStore);let u=n.likesPageSql(s.publisher.id,{cursor:c,limit:l+1}),d=await all(o,u.sql,...u.parameters),f=d.slice(0,l),p=f.at(-1),m=e.archiveLikesPageSchema.safeParse({cursor:d.length>l&&p!==void 0?r.nextCursor({createdAt:p.created_at,id:p.publication_id},a):null,ids:f.map(e=>e.publication_id)});if(!m.success)throw recordUnavailable();return t.json(200,m.data,t.noStore)}t.queryParams(new URL(i.url),[`ids`]);let l=[...new Set(c.split(`,`))];if(l.length>e.ARCHIVE_LIMITS.likeStateIds||l.some(e=>!a.test(e)))throw t.badRequest();if(s.publisher===null)return t.json(200,{ids:[]},t.noStore);let u=n.likedPublicationsSql(s.publisher.id,l),d=await all(o,u.sql,...u.parameters),f=new Set(d.map(e=>e.publication_id)),p={ids:l.filter(e=>f.has(e))},m=e.archiveLikedIdsSchema.safeParse(p);if(!m.success)throw recordUnavailable();return t.json(200,m.data,t.noStore)}var c=i.s.object({clientKey:e.uuidSchema,payload:i.s.unknown()}),l=i.s.object({expectedRevision:i.s.number().int().min(1).max(2**53-1),payload:i.s.unknown()});async function resolveRetryRecord(e,n,r,i){if(r===null||r.expires_at<=Date.now())return null;if(r.payload_hash!==i)throw t.conflict(`That key was used for a different payload.`);if(r.publication_id===null)throw new t.ApiError(410,`gone`,`That publication was removed.`);let a=await entryById(e,n,r.publication_id);return a===null?`vanished`:t.json(200,a,t.noStore)}async function publishEntry(r,i,a,o,s){if(!o.publishingEnabled)throw new t.ApiError(503,`publishing-disabled`,`Publishing is currently disabled.`);let l=requirePublisherRow(s),u=await t.readJsonBody(i),d=c.safeParse(u);if(!d.success)throw t.invalid();let{clientKey:f}=d.data,p=await r.derive(d.data.payload);if(p===null)throw t.invalid();let m=p.canonicalJson,h=p.contentHash,g=l.id,_=r.name,v=await resolveRetryRecord(r,a,await first(a,n.SQL.request,g,_,f),h);if(v!==null&&v!==`vanished`)return v;let y=await first(a,n.SQL.publicationByKey,g,_,f);if(y!==null){if(y.hidden_at!==null)throw t.forbidden(`This entry is under moderation review.`);if(y.content_hash!==h)throw t.conflict(`That key was used for a different payload.`);return t.json(200,rowToEntry(r,y),t.noStore)}let b=now(),x=Date.now()+e.ARCHIVE_LIMITS.idempotencyRetentionMs,S=JSON.stringify(p.tags);if(((await databaseCall(()=>a.DB.batch([a.DB.prepare(n.SQL.expireRequests).bind(Date.now()),a.DB.prepare(n.SQL.insertPublication).bind(uuid(),g,_,f,1,p.contentVersion,m,h,p.filter,p.title,S,b,b,g,_,e.ARCHIVE_LIMITS.maxPublications,g,_,f,Date.now()),a.DB.prepare(n.SQL.insertRequest).bind(g,_,f,h,b,x,g,_,f,h)])))[1]?.meta?.changes??0)===0){let e=await resolveRetryRecord(r,a,await first(a,n.SQL.request,g,_,f),h);if(e===`vanished`)throw new t.ApiError(410,`gone`,`That publication was removed.`);if(e!==null)return e;let i=await first(a,n.SQL.publicationByKey,g,_,f);if(i!==null&&i.hidden_at!==null)throw t.forbidden(`This entry is under moderation review.`);if(i!==null&&i.content_hash===h)return t.json(200,rowToEntry(r,i),t.noStore);throw i===null?new t.ApiError(429,`quota`,`Publication quota reached.`):t.conflict(`That key was used for a different payload.`)}let C=await first(a,n.SQL.publicationByKey,g,_,f);if(C===null)throw t.conflict();return t.json(201,rowToEntry(r,C),t.noStore)}async function updateEntry(e,r,i,a,o,s){if(!a.publishingEnabled)throw new t.ApiError(503,`publishing-disabled`,`Publishing is currently disabled.`);let c=requirePublisherRow(o),u=await first(i,n.SQL.publicationAny,s,e.name);if(u===null||u.owner_id!==c.id)throw t.notFound();if(u.hidden_at!==null)throw t.forbidden(`This entry is under moderation review.`);let d=await t.readJsonBody(r),f=l.safeParse(d);if(!f.success)throw t.invalid();let{expectedRevision:p}=f.data;if(p!==u.revision)throw t.conflict();let m=await e.derive(f.data.payload);if(m===null)throw t.invalid();let h=m.canonicalJson,g=m.contentHash;if(g===u.content_hash){let r=await first(i,n.SQL.publicationAny,s,e.name);if(r===null||r.owner_id!==c.id)throw t.notFound();if(r.hidden_at!==null)throw t.forbidden(`This entry is under moderation review.`);if(r.revision!==p)throw t.conflict();return t.json(200,rowToEntry(e,r),t.noStore)}let _=JSON.stringify(m.tags);if(((await run(i,n.SQL.updatePublication,1,m.contentVersion,h,g,m.filter,m.title,_,now(),s,c.id,p,g)).meta?.changes??0)===0){let r=await first(i,n.SQL.publicationAny,s,e.name);throw r===null?t.notFound():r.hidden_at===null?t.conflict():t.forbidden(`This entry is under moderation review.`)}let v=await entryById(e,i,s);if(v===null)throw t.notFound();return t.json(200,v,t.noStore)}async function deleteEntry(e,r,i,a){if(i.operator){let t=requireOperatorRow(i);await databaseCall(()=>r.DB.batch([r.DB.prepare(n.SQL.auditRemoval).bind(uuid(),t.id,now(),a,e.name),r.DB.prepare(n.SQL.removePublicationAny).bind(a,e.name)]))}else{let o=requirePublisherRow(i),s=await first(r,n.SQL.publicationAny,a,e.name);if(s===null)return new Response(null,{headers:t.noStore,status:204});if(s.owner_id!==o.id)throw t.notFound();await run(r,n.SQL.removePublication,a,o.id)}return new Response(null,{headers:t.noStore,status:204})}async function setLike(r,i,a,o,s,c){let l=requirePublisherRow(o);if(c&&!a.likesEnabled)throw new t.ApiError(503,`likes-disabled`,`Likes are currently disabled.`);if(await first(i,n.SQL.publication,s,r.name)===null)throw t.notFound();c?await run(i,n.SQL.insertLike,l.id,now(),s):await run(i,n.SQL.removeLike,s,l.id);let u=await first(i,n.SQL.likeState,l.id,s);if(u===null)throw t.notFound();let d={id:u.id,likeCount:u.like_count,liked:u.liked===1},f=e.archiveLikeStateSchema.safeParse(d);if(!f.success)throw recordUnavailable();return t.json(200,f.data,t.noStore)}var u=i.s.object({reason:i.s.string().check(e.isWellFormedText).trim().min(1).max(e.ARCHIVE_LIMITS.reportReasonChars)});async function reportEntry(e,r,i,a,o){let s=requirePublisherRow(a),c=await t.readJsonBody(r),l=u.safeParse(c);if(!l.success)throw t.invalid();if(await first(i,n.SQL.publication,o,e.name)===null)throw t.notFound();return await run(i,n.SQL.insertReport,uuid(),s.id,l.data.reason,now(),o,e.name),new Response(null,{headers:t.noStore,status:204})}async function setHidden(e,r,i,a,o){let s=requireOperatorRow(i);if(await first(r,n.SQL.publicationAny,a,e.name)===null)throw t.notFound();let c=o?r.DB.prepare(n.SQL.hidePublication).bind(now(),a,e.name):r.DB.prepare(n.SQL.unhidePublication).bind(a,e.name);return await databaseCall(()=>r.DB.batch([r.DB.prepare(n.SQL.auditHide).bind(uuid(),s.id,o?`hide`:`unhide`,now(),a,e.name,Number(o)),c])),new Response(null,{headers:t.noStore,status:204})}async function listReports(i,a,o,s){requireOperatorRow(s);let c=new URL(a.url).searchParams.get(`status`)??`unresolved`;if(c!==`unresolved`&&c!==`resolved`)throw t.badRequest();let l=`reports:${i.name}:${c}`,{cursor:u,limit:d}=r.forwardPage(a,l,[`status`]),f=n.reportsPageSql({collection:i.name,resolved:c===`resolved`},{cursor:u,limit:d+1}),p=await all(o,f.sql,...f.parameters),m=p.slice(0,d),h=m.at(-1),g=e.archiveReportsSchema.safeParse({cursor:p.length>d&&h!==void 0?r.nextCursor({createdAt:h.created_at,id:h.id},l):null,reports:m.map(e=>({createdAt:e.created_at,entryId:e.publication_id,entryTitle:e.entry_title,id:e.id,reason:e.reason,reporter:{id:e.reporter_id,name:e.reporter_name},resolvedAt:e.resolved_at}))});if(!g.success)throw recordUnavailable();return t.json(200,g.data,t.noStore)}async function resolveReport(e,r,i,a){if(requireOperatorRow(i),await first(r,n.SQL.reportById,a,e.name)===null)throw t.notFound();return await run(r,n.SQL.resolveReport,now(),a,e.name),new Response(null,{headers:t.noStore,status:204})}async function myReports(i,a,o){let s=`own-reports:${o.publisher?.id??`unregistered`}`,{cursor:c,limit:l}=r.forwardPage(i,s);if(o.publisher===null)return t.json(200,{cursor:null,reports:[]},t.noStore);let u=n.reportsPageSql({publisherId:o.publisher.id},{cursor:c,limit:l+1}),d=await all(a,u.sql,...u.parameters),f=d.slice(0,l),p=f.at(-1),m=e.archiveOwnReportsSchema.safeParse({cursor:d.length>l&&p!==void 0?r.nextCursor({createdAt:p.created_at,id:p.id},s):null,reports:f.map(e=>({collection:e.collection,createdAt:e.created_at,entryId:e.publication_id,id:e.id,reason:e.reason,resolvedAt:e.resolved_at}))});if(!m.success)throw recordUnavailable();return t.json(200,m.data,t.noStore)}exports.collectionMeta=collectionMeta,exports.deleteAccountProfile=deleteAccountProfile,exports.deleteEntry=deleteEntry,exports.deleteProfile=deleteProfile,exports.erasePublisher=erasePublisher,exports.getEntry=getEntry,exports.getMe=getMe,exports.healthJson=healthJson,exports.listEntries=listEntries,exports.listModerationLog=listModerationLog,exports.listReports=listReports,exports.myLikes=myLikes,exports.myPublications=myPublications,exports.myReports=myReports,exports.publishEntry=publishEntry,exports.registerMe=registerMe,exports.renameMe=renameMe,exports.reportEntry=reportEntry,exports.requireOperatorRow=requireOperatorRow,exports.requirePublisherRow=requirePublisherRow,exports.resolveReport=resolveReport,exports.setHidden=setHidden,exports.setLike=setLike,exports.updateEntry=updateEntry;
|
|
1
|
+
const e=require("../contract.cjs"),t=require("./http.cjs"),n=require("./sql.cjs"),r=require("./_pagination.cjs");let i=require("@vielzeug/spell");async function databaseCall(e){try{return await e()}catch(e){throw new t.ApiError(500,`internal`,`The service is not available.`,{cause:e,diagnostic:{kind:`database`}})}}var first=(e,t,...n)=>databaseCall(()=>e.DB.prepare(t).bind(...n).first()),all=(e,t,...n)=>databaseCall(async()=>{let r=await e.DB.prepare(t).bind(...n).all();if(!Array.isArray(r.results))throw Error(`Missing database query results`);return r.results}),run=(e,t,...n)=>databaseCall(()=>e.DB.prepare(t).bind(...n).run()),uuid=()=>crypto.randomUUID(),now=()=>new Date().toISOString(),recordUnavailable=()=>new t.ApiError(503,`record-unavailable`,`The record is not available.`,{diagnostic:{kind:`record`}}),a=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i,rowToEntry=(e,t)=>{let n;try{n=JSON.parse(t.payload_json)}catch{throw recordUnavailable()}let r=e.contract.entry.safeParse({author:{id:t.owner_id,name:t.author_name},contentHash:t.content_hash,contentVersion:t.content_version,id:t.id,likeCount:t.like_count,payload:n,publishedAt:t.published_at,revision:t.revision,schemaVersion:t.schema_version,updatedAt:t.updated_at});if(!r.success)throw recordUnavailable();return r.data},rowToOwnedEntry=(t,n)=>{let r=rowToEntry(t,n);if(!e.uuidSchema.safeParse(n.client_key).success)throw recordUnavailable();return{...r,clientKey:n.client_key,hidden:n.hidden_at!==null}},entryById=async(e,t,r)=>{let i=await first(t,n.SQL.publication,r,e.name);return i===null?null:rowToEntry(e,i)},searchMatchFor=n=>{if(n===null)return;if(n.length>e.ARCHIVE_LIMITS.searchChars)throw t.badRequest();let r=(n.match(/[\p{L}\p{N}]+/gu)??[]).slice(0,e.ARCHIVE_LIMITS.searchTerms);if(r.length!==0)return r.map(e=>`"${e}"*`).join(` `)},entryCursorContext=e=>JSON.stringify([e.collection,e.sort,e.author??``,e.filters,e.tags,e.searchMatch??``]),cursorFromRow=e=>({createdAt:e.published_at,id:e.id,likeCount:e.like_count}),o=16,vocabValues=(e,n,r)=>{let i=e.getAll(n);if(i.length>o)throw t.badRequest();let a=[...new Set(i)].sort();if(a.some(e=>!r.includes(e)))throw t.badRequest();return a};async function listEntries(i,o,s){let c=new URL(o.url),l=t.queryParams(c,[`author`,`cursor`,`filter`,`limit`,`search`,`sort`,`tag`],[`filter`,`tag`]),u=l.get(`sort`)??`recent`,d=e.archiveSortSchema.safeParse(u);if(!d.success)throw t.badRequest();let f=vocabValues(l,`filter`,i.filters),p=vocabValues(l,`tag`,i.tags),m=l.get(`author`);if(m!==null&&!a.test(m))throw t.badRequest();let h=searchMatchFor(l.get(`search`)),g=entryCursorContext({author:m??void 0,collection:i.name,filters:f,searchMatch:h,sort:d.data,tags:p}),{cursor:_,limit:v}=r.forwardPageParams(l,g),y={author:m??void 0,collection:i.name,cursor:_===null?void 0:{createdAt:_.createdAt,id:_.id,likeCount:_.likeCount??0},filters:f,limit:v+1,searchMatch:h,sort:d.data,tags:p},b=n.listPublicationsSql(y),x=await all(s,b.sql,...b.parameters),S=x.slice(0,v),C=S.at(-1),w={cursor:x.length>v&&C!==void 0?r.nextCursor(cursorFromRow(C),g):null,entries:S.map(e=>rowToEntry(i,e))};return t.json(200,w,t.noStore)}function collectionMeta(n){let r=e.archiveCollectionMetaSchema.safeParse({filters:[...n.filters].sort(),name:n.name,tags:[...n.tags].sort()});if(!r.success)throw recordUnavailable();return t.json(200,r.data,t.noStore)}async function getEntry(e,n,r){let i=await entryById(e,n,r);if(i===null)throw t.notFound();return t.json(200,i,t.noStore)}async function healthJson(r,i){let a=null,o=null;try{a=(await first(i,n.SQL.schemaVersion))?.applied??0}catch{o=`database-unavailable`}o===null&&a!==1&&(o=`schema-mismatch`);let s=e.archiveHealthSchema.safeParse({dbSchemaVersion:a,features:{likes:r.likesEnabled,publishing:r.publishingEnabled},limits:e.ARCHIVE_LIMITS,reason:o,schemaVersion:1,status:o===null?`ok`:`degraded`});if(!s.success)throw recordUnavailable();return t.json(o===null?200:503,s.data,t.noStore)}var identityJson=(n,r)=>{let i=e.archiveIdentitySchema.safeParse(r);if(!i.success)throw recordUnavailable();return t.json(n,i.data,t.noStore)};function getMe(e){return identityJson(200,e?.publisher?{id:e.publisher.id,name:e.publisher.displayName,operator:e.operator}:null)}async function registerMe(e,r,i){let a=await t.readJsonBody(e),o=s.safeParse(a);if(!o.success)throw t.invalid();let c=o.data.name??``,l=await first(r,n.SQL.publisher,i.subject.issuer,i.subject.subject);if(l!==null)return identityJson(200,{id:l.id,name:l.display_name,operator:i.operator});let u=uuid();await run(r,n.SQL.insertPublisher,u,i.subject.issuer,i.subject.subject,c,now(),i.subject.issuer,i.subject.subject,Date.now());let d=await first(r,n.SQL.publisher,i.subject.issuer,i.subject.subject);if(d===null)throw await first(r,n.SQL.revokedSubject,i.subject.issuer,i.subject.subject,Date.now())===null?t.conflict():new t.ApiError(401,`unauthenticated`,`Sign-in is required.`);return identityJson(201,{id:d.id,name:d.display_name,operator:i.operator})}async function renameMe(e,r,i){let a=requirePublisherRow(i),o=await t.readJsonBody(e),s=c.safeParse(o);if(!s.success)throw t.invalid();return s.data.name!==a.displayName&&await run(r,n.SQL.renamePublisher,s.data.name,a.id,s.data.name),identityJson(200,{id:a.id,name:s.data.name,operator:i.operator})}async function deleteProfile(e,t){return t.publisher!==null&&await run(e,n.SQL.deletePublisher,t.publisher.id),new Response(null,{headers:{"cache-control":`no-store`},status:204})}async function deleteAccountProfile(r,i){let{issuer:a,subject:o}=i.subject;return await databaseCall(()=>r.DB.batch([r.DB.prepare(n.SQL.revokeSubject).bind(a,o,Date.now()+(e.SESSION_MAX_AGE_SECONDS+10)*1e3),r.DB.prepare(n.SQL.deleteSubjectPublisher).bind(a,o)])),new Response(null,{headers:t.noStore,status:204})}async function erasePublisher(e,r,i){let a=requireOperatorRow(r);return await databaseCall(()=>e.DB.batch([e.DB.prepare(n.SQL.auditErasure).bind(uuid(),a.id,now(),i),e.DB.prepare(n.SQL.deletePublisher).bind(i)])),new Response(null,{headers:t.noStore,status:204})}async function listModerationLog(i,a,o){requireOperatorRow(o);let s=`moderation-log`,{cursor:c,limit:l}=r.forwardPage(i,s),u=n.moderationLogSql({cursor:c,limit:l+1}),d=await all(a,u.sql,...u.parameters),f=d.slice(0,l),p=f.at(-1),m=e.archiveModerationLogSchema.safeParse({actions:f.map(e=>({action:e.action,collection:e.collection,createdAt:e.created_at,id:e.id,operatorId:e.operator_id,targetId:e.target_id})),cursor:d.length>l&&p!==void 0?r.nextCursor({createdAt:p.created_at,id:p.id},s):null});if(!m.success)throw recordUnavailable();return t.json(200,m.data,t.noStore)}var requirePublisherRow=e=>{if(e.publisher===null)throw t.forbidden(`Register before publishing.`);return e.publisher},requireOperatorRow=e=>{if(!e.operator)throw t.forbidden(`Operator access is required.`);if(e.publisher===null)throw t.forbidden(`Register before moderating.`);return e.publisher},s=i.s.object({name:e.archiveAliasSchema.optional()}),c=i.s.object({name:e.archiveAliasSchema});async function myPublications(r,i,a){if(a.publisher===null)return t.json(200,{entries:[]},t.noStore);let o=await all(i,n.SQL.ownPublications,a.publisher.id,r.name,e.ARCHIVE_LIMITS.maxPublications);return t.json(200,{entries:o.map(e=>rowToOwnedEntry(r,e))},t.noStore)}async function myLikes(i,o,s){let c=new URL(i.url).searchParams.get(`ids`);if(c===null){let a=`likes:${s.publisher?.id??`unregistered`}`,{cursor:c,limit:l}=r.forwardPage(i,a);if(s.publisher===null)return t.json(200,{cursor:null,ids:[]},t.noStore);let u=n.likesPageSql(s.publisher.id,{cursor:c,limit:l+1}),d=await all(o,u.sql,...u.parameters),f=d.slice(0,l),p=f.at(-1),m=e.archiveLikesPageSchema.safeParse({cursor:d.length>l&&p!==void 0?r.nextCursor({createdAt:p.created_at,id:p.publication_id},a):null,ids:f.map(e=>e.publication_id)});if(!m.success)throw recordUnavailable();return t.json(200,m.data,t.noStore)}t.queryParams(new URL(i.url),[`ids`]);let l=[...new Set(c.split(`,`))];if(l.length>e.ARCHIVE_LIMITS.likeStateIds||l.some(e=>!a.test(e)))throw t.badRequest();if(s.publisher===null)return t.json(200,{ids:[]},t.noStore);let u=n.likedPublicationsSql(s.publisher.id,l),d=await all(o,u.sql,...u.parameters),f=new Set(d.map(e=>e.publication_id)),p={ids:l.filter(e=>f.has(e))},m=e.archiveLikedIdsSchema.safeParse(p);if(!m.success)throw recordUnavailable();return t.json(200,m.data,t.noStore)}var l=i.s.object({clientKey:e.uuidSchema,payload:i.s.unknown()}),u=i.s.object({expectedRevision:i.s.number().int().min(1).max(2**53-1),payload:i.s.unknown()});async function resolveRetryRecord(e,n,r,i){if(r===null||r.expires_at<=Date.now())return null;if(r.payload_hash!==i)throw t.conflict(`That key was used for a different payload.`);if(r.publication_id===null)throw new t.ApiError(410,`gone`,`That publication was removed.`);let a=await entryById(e,n,r.publication_id);return a===null?`vanished`:t.json(200,a,t.noStore)}async function publishEntry(r,i,a,o,s){if(!o.publishingEnabled)throw new t.ApiError(503,`publishing-disabled`,`Publishing is currently disabled.`);let c=requirePublisherRow(s),u=await t.readJsonBody(i),d=l.safeParse(u);if(!d.success)throw t.invalid();let{clientKey:f}=d.data,p=await r.derive(d.data.payload);if(p===null)throw t.invalid();let m=p.canonicalJson,h=p.contentHash,g=c.id,_=r.name,v=await resolveRetryRecord(r,a,await first(a,n.SQL.request,g,_,f),h);if(v!==null&&v!==`vanished`)return v;let y=await first(a,n.SQL.publicationByKey,g,_,f);if(y!==null){if(y.hidden_at!==null)throw t.forbidden(`This entry is under moderation review.`);if(y.content_hash!==h)throw t.conflict(`That key was used for a different payload.`);return t.json(200,rowToEntry(r,y),t.noStore)}let b=now(),x=Date.now()+e.ARCHIVE_LIMITS.idempotencyRetentionMs,S=JSON.stringify(p.tags);if(((await databaseCall(()=>a.DB.batch([a.DB.prepare(n.SQL.expireRequests).bind(Date.now()),a.DB.prepare(n.SQL.insertPublication).bind(uuid(),g,_,f,1,p.contentVersion,m,h,p.filter,p.title,S,b,b,g,_,e.ARCHIVE_LIMITS.maxPublications,g,_,f,Date.now()),a.DB.prepare(n.SQL.insertRequest).bind(g,_,f,h,b,x,g,_,f,h)])))[1]?.meta?.changes??0)===0){let e=await resolveRetryRecord(r,a,await first(a,n.SQL.request,g,_,f),h);if(e===`vanished`)throw new t.ApiError(410,`gone`,`That publication was removed.`);if(e!==null)return e;let i=await first(a,n.SQL.publicationByKey,g,_,f);if(i!==null&&i.hidden_at!==null)throw t.forbidden(`This entry is under moderation review.`);if(i!==null&&i.content_hash===h)return t.json(200,rowToEntry(r,i),t.noStore);throw i===null?new t.ApiError(429,`quota`,`Publication quota reached.`):t.conflict(`That key was used for a different payload.`)}let C=await first(a,n.SQL.publicationByKey,g,_,f);if(C===null)throw t.conflict();return t.json(201,rowToEntry(r,C),t.noStore)}async function updateEntry(e,r,i,a,o,s){if(!a.publishingEnabled)throw new t.ApiError(503,`publishing-disabled`,`Publishing is currently disabled.`);let c=requirePublisherRow(o),l=await first(i,n.SQL.publicationAny,s,e.name);if(l===null||l.owner_id!==c.id)throw t.notFound();if(l.hidden_at!==null)throw t.forbidden(`This entry is under moderation review.`);let d=await t.readJsonBody(r),f=u.safeParse(d);if(!f.success)throw t.invalid();let{expectedRevision:p}=f.data;if(p!==l.revision)throw t.conflict();let m=await e.derive(f.data.payload);if(m===null)throw t.invalid();let h=m.canonicalJson,g=m.contentHash;if(g===l.content_hash){let r=await first(i,n.SQL.publicationAny,s,e.name);if(r===null||r.owner_id!==c.id)throw t.notFound();if(r.hidden_at!==null)throw t.forbidden(`This entry is under moderation review.`);if(r.revision!==p)throw t.conflict();return t.json(200,rowToEntry(e,r),t.noStore)}let _=JSON.stringify(m.tags);if(((await run(i,n.SQL.updatePublication,1,m.contentVersion,h,g,m.filter,m.title,_,now(),s,c.id,p,g)).meta?.changes??0)===0){let r=await first(i,n.SQL.publicationAny,s,e.name);throw r===null?t.notFound():r.hidden_at===null?t.conflict():t.forbidden(`This entry is under moderation review.`)}let v=await entryById(e,i,s);if(v===null)throw t.notFound();return t.json(200,v,t.noStore)}async function deleteEntry(e,r,i,a){if(i.operator){let t=requireOperatorRow(i);await databaseCall(()=>r.DB.batch([r.DB.prepare(n.SQL.auditRemoval).bind(uuid(),t.id,now(),a,e.name),r.DB.prepare(n.SQL.removePublicationAny).bind(a,e.name)]))}else{let o=requirePublisherRow(i),s=await first(r,n.SQL.publicationAny,a,e.name);if(s===null)return new Response(null,{headers:t.noStore,status:204});if(s.owner_id!==o.id)throw t.notFound();await run(r,n.SQL.removePublication,a,o.id)}return new Response(null,{headers:t.noStore,status:204})}async function setLike(r,i,a,o,s,c){let l=requirePublisherRow(o);if(c&&!a.likesEnabled)throw new t.ApiError(503,`likes-disabled`,`Likes are currently disabled.`);if(await first(i,n.SQL.publication,s,r.name)===null)throw t.notFound();c?await run(i,n.SQL.insertLike,l.id,now(),s):await run(i,n.SQL.removeLike,s,l.id);let u=await first(i,n.SQL.likeState,l.id,s);if(u===null)throw t.notFound();let d={id:u.id,likeCount:u.like_count,liked:u.liked===1},f=e.archiveLikeStateSchema.safeParse(d);if(!f.success)throw recordUnavailable();return t.json(200,f.data,t.noStore)}var d=i.s.object({reason:i.s.string().check(e.isWellFormedText).trim().min(1).max(e.ARCHIVE_LIMITS.reportReasonChars)});async function reportEntry(e,r,i,a,o){let s=requirePublisherRow(a),c=await t.readJsonBody(r),l=d.safeParse(c);if(!l.success)throw t.invalid();if(await first(i,n.SQL.publication,o,e.name)===null)throw t.notFound();return await run(i,n.SQL.insertReport,uuid(),s.id,l.data.reason,now(),o,e.name),new Response(null,{headers:t.noStore,status:204})}async function setHidden(e,r,i,a,o){let s=requireOperatorRow(i);if(await first(r,n.SQL.publicationAny,a,e.name)===null)throw t.notFound();let c=o?r.DB.prepare(n.SQL.hidePublication).bind(now(),a,e.name):r.DB.prepare(n.SQL.unhidePublication).bind(a,e.name);return await databaseCall(()=>r.DB.batch([r.DB.prepare(n.SQL.auditHide).bind(uuid(),s.id,o?`hide`:`unhide`,now(),a,e.name,Number(o)),c])),new Response(null,{headers:t.noStore,status:204})}async function listReports(i,a,o,s){requireOperatorRow(s);let c=new URL(a.url).searchParams.get(`status`)??`unresolved`;if(c!==`unresolved`&&c!==`resolved`)throw t.badRequest();let l=`reports:${i.name}:${c}`,{cursor:u,limit:d}=r.forwardPage(a,l,[`status`]),f=n.reportsPageSql({collection:i.name,resolved:c===`resolved`},{cursor:u,limit:d+1}),p=await all(o,f.sql,...f.parameters),m=p.slice(0,d),h=m.at(-1),g=e.archiveReportsSchema.safeParse({cursor:p.length>d&&h!==void 0?r.nextCursor({createdAt:h.created_at,id:h.id},l):null,reports:m.map(e=>({createdAt:e.created_at,entryId:e.publication_id,entryTitle:e.entry_title,id:e.id,reason:e.reason,reporter:{id:e.reporter_id,name:e.reporter_name},resolvedAt:e.resolved_at}))});if(!g.success)throw recordUnavailable();return t.json(200,g.data,t.noStore)}async function resolveReport(e,r,i,a){if(requireOperatorRow(i),await first(r,n.SQL.reportById,a,e.name)===null)throw t.notFound();return await run(r,n.SQL.resolveReport,now(),a,e.name),new Response(null,{headers:t.noStore,status:204})}async function myReports(i,a,o){let s=`own-reports:${o.publisher?.id??`unregistered`}`,{cursor:c,limit:l}=r.forwardPage(i,s);if(o.publisher===null)return t.json(200,{cursor:null,reports:[]},t.noStore);let u=n.reportsPageSql({publisherId:o.publisher.id},{cursor:c,limit:l+1}),d=await all(a,u.sql,...u.parameters),f=d.slice(0,l),p=f.at(-1),m=e.archiveOwnReportsSchema.safeParse({cursor:d.length>l&&p!==void 0?r.nextCursor({createdAt:p.created_at,id:p.id},s):null,reports:f.map(e=>({collection:e.collection,createdAt:e.created_at,entryId:e.publication_id,id:e.id,reason:e.reason,resolvedAt:e.resolved_at}))});if(!m.success)throw recordUnavailable();return t.json(200,m.data,t.noStore)}exports.collectionMeta=collectionMeta,exports.deleteAccountProfile=deleteAccountProfile,exports.deleteEntry=deleteEntry,exports.deleteProfile=deleteProfile,exports.erasePublisher=erasePublisher,exports.getEntry=getEntry,exports.getMe=getMe,exports.healthJson=healthJson,exports.listEntries=listEntries,exports.listModerationLog=listModerationLog,exports.listReports=listReports,exports.myLikes=myLikes,exports.myPublications=myPublications,exports.myReports=myReports,exports.publishEntry=publishEntry,exports.registerMe=registerMe,exports.renameMe=renameMe,exports.reportEntry=reportEntry,exports.requireOperatorRow=requireOperatorRow,exports.requirePublisherRow=requirePublisherRow,exports.resolveReport=resolveReport,exports.setHidden=setHidden,exports.setLike=setLike,exports.updateEntry=updateEntry;
|
|
2
2
|
//# sourceMappingURL=publications.cjs.map
|