@bkazemi/verily 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +503 -0
  3. package/dist/browser/badge.d.ts +39 -0
  4. package/dist/browser/badge.js +440 -0
  5. package/dist/browser/connect-dialog.d.ts +83 -0
  6. package/dist/browser/connect-dialog.js +514 -0
  7. package/dist/browser/evidence-dialog.d.ts +59 -0
  8. package/dist/browser/evidence-dialog.js +541 -0
  9. package/dist/browser/index.d.ts +59 -0
  10. package/dist/browser/index.js +809 -0
  11. package/dist/browser/logo.d.ts +2 -0
  12. package/dist/browser/logo.js +17 -0
  13. package/dist/browser/mark.d.ts +19 -0
  14. package/dist/browser/mark.js +77 -0
  15. package/dist/browser/provider-mark.d.ts +6 -0
  16. package/dist/browser/provider-mark.js +99 -0
  17. package/dist/browser/signed.d.ts +40 -0
  18. package/dist/browser/signed.js +219 -0
  19. package/dist/browser.d.ts +3 -0
  20. package/dist/browser.js +3 -0
  21. package/dist/core/index.d.ts +454 -0
  22. package/dist/core/index.js +128 -0
  23. package/dist/core/signed.d.ts +49 -0
  24. package/dist/core/signed.js +113 -0
  25. package/dist/index.d.ts +5 -0
  26. package/dist/index.js +5 -0
  27. package/dist/logo.d.ts +21 -0
  28. package/dist/logo.js +38 -0
  29. package/dist/postgres/index.d.ts +12 -0
  30. package/dist/postgres/index.js +76 -0
  31. package/dist/server/addresses.d.ts +2 -0
  32. package/dist/server/addresses.js +61 -0
  33. package/dist/server/body.d.ts +11 -0
  34. package/dist/server/body.js +49 -0
  35. package/dist/server/copy.d.ts +8 -0
  36. package/dist/server/copy.js +42 -0
  37. package/dist/server/discord.d.ts +7 -0
  38. package/dist/server/discord.js +69 -0
  39. package/dist/server/email-logo.d.ts +11 -0
  40. package/dist/server/email-logo.js +122 -0
  41. package/dist/server/email.d.ts +46 -0
  42. package/dist/server/email.js +145 -0
  43. package/dist/server/escape.d.ts +2 -0
  44. package/dist/server/escape.js +2 -0
  45. package/dist/server/github-gist.d.ts +11 -0
  46. package/dist/server/github-gist.js +68 -0
  47. package/dist/server/github.d.ts +7 -0
  48. package/dist/server/github.js +65 -0
  49. package/dist/server/index.d.ts +70 -0
  50. package/dist/server/index.js +862 -0
  51. package/dist/server/link.d.ts +64 -0
  52. package/dist/server/link.js +458 -0
  53. package/dist/server/openpgp.d.ts +49 -0
  54. package/dist/server/openpgp.js +158 -0
  55. package/dist/server/pgp.d.ts +29 -0
  56. package/dist/server/pgp.js +198 -0
  57. package/dist/server/public-fetch.d.ts +23 -0
  58. package/dist/server/public-fetch.js +62 -0
  59. package/dist/server/resend.d.ts +13 -0
  60. package/dist/server/resend.js +37 -0
  61. package/dist/server/service.d.ts +311 -0
  62. package/dist/server/service.js +1178 -0
  63. package/dist/server/signing.d.ts +16 -0
  64. package/dist/server/signing.js +56 -0
  65. package/dist/server/style.d.ts +17 -0
  66. package/dist/server/style.js +244 -0
  67. package/dist/server/wkd.d.ts +7 -0
  68. package/dist/server/wkd.js +65 -0
  69. package/dist/server/youtube.d.ts +11 -0
  70. package/dist/server/youtube.js +99 -0
  71. package/dist/site/index.d.ts +139 -0
  72. package/dist/site/index.js +239 -0
  73. package/dist/verily.js +11907 -0
  74. package/dist/version.d.ts +10 -0
  75. package/dist/version.js +10 -0
  76. package/docs/licenses/gpl-3.0.txt +232 -0
  77. package/docs/licenses/octicons.txt +21 -0
  78. package/docs/licenses/openpgp.txt +172 -0
  79. package/docs/licenses/rubik.txt +93 -0
  80. package/docs/licenses/simple-icons.txt +8 -0
  81. package/package.json +86 -0
@@ -0,0 +1,862 @@
1
+ import { attestationLabel, externalLink, externalName, isArtifactProvider, isCodeProvider, isRedirectProvider, localSide, proofTitle, providerMethod, statusLabel, signedBy, } from '../core/index.js';
2
+ import { codeAttempts, VerilyService, Unavailable } from './service.js';
3
+ import { copyScript } from './copy.js';
4
+ import { escape } from './escape.js';
5
+ import { logo } from '../logo.js';
6
+ import { stylesheet, styleVersion } from './style.js';
7
+ export { VerilyService, Unavailable } from './service.js';
8
+ export { githubProvider } from './github.js';
9
+ export { discordProvider } from './discord.js';
10
+ export { youtubeProvider } from './youtube.js';
11
+ export { githubGistProvider } from './github-gist.js';
12
+ export { linkProvider, githubLinkProvider } from './link.js';
13
+ export { pgpProvider } from './pgp.js';
14
+ export { generateSigningKey, signer } from './signing.js';
15
+ export { emailProvider, } from './email.js';
16
+ export { resendSender } from './resend.js';
17
+ /**
18
+ * The largest request body any route accepts. A form field is a few hundred bytes, but a
19
+ * pasted OpenPGP key carries every certification it has ever collected and a long-lived
20
+ * one runs to tens of kilobytes. Still small enough that the size itself costs nothing.
21
+ */
22
+ const maxBodyBytes = 65536;
23
+ const page = (prefix, title, body) => `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(title)} · Verily</title><link rel="stylesheet" href="${escape(prefix)}/style.css?v=${styleVersion}"><body><main>${logo}<h1>${escape(title)}</h1>${body}</main></body></html>`;
24
+ const headers = {
25
+ 'Cache-Control': 'no-store',
26
+ 'Referrer-Policy': 'same-origin',
27
+ 'X-Robots-Tag': 'noindex, nofollow',
28
+ 'X-Content-Type-Options': 'nosniff',
29
+ 'Content-Security-Policy': "default-src 'none'; script-src 'self'; style-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
30
+ };
31
+ const html = (body, status = 200) => new Response(body, {
32
+ status,
33
+ headers: { ...headers, 'Content-Type': 'text/html; charset=utf-8' },
34
+ });
35
+ const json = (data, cookie) => new Response(JSON.stringify(data), {
36
+ headers: {
37
+ ...headers,
38
+ 'Content-Type': 'application/json',
39
+ ...(cookie ? { 'Set-Cookie': cookie } : {}),
40
+ },
41
+ });
42
+ /**
43
+ * A proof this backend publishes, served as the text it is so a reader can put it straight
44
+ * into their own tools. Never rendered and never interpreted: it is somebody else's bytes.
45
+ */
46
+ const plain = (body) => new Response(body, {
47
+ headers: {
48
+ ...headers,
49
+ 'Content-Type': 'text/plain; charset=utf-8',
50
+ 'Content-Disposition': 'inline',
51
+ 'Access-Control-Allow-Origin': '*',
52
+ },
53
+ });
54
+ const redirect = (url, cookie) => new Response(null, {
55
+ status: 303,
56
+ headers: { ...headers, Location: url, ...(cookie ? { 'Set-Cookie': cookie } : {}) },
57
+ });
58
+ /**
59
+ * One side of a link, with each thing known about it on its own line. A name, the
60
+ * identifier behind it and how it was shown are three separate claims, and a reader
61
+ * running them together in one sentence is the way to misread which was established.
62
+ *
63
+ * Both sides render as the same card, so the pair reads as a pair.
64
+ */
65
+ function card(heading, name, url, reference, ...extra) {
66
+ const profile = url && safeUrl(url);
67
+ return `<div class="side"><p class="who">${escape(heading)}</p><p class="name">${profile ? `<a href="${escape(profile)}" rel="noreferrer">${escape(name)}</a>` : escape(name)}</p>${reference ? `<p class="reference">${escape(reference)}</p>` : ''}${extra.join('')}</div>`;
68
+ }
69
+ /**
70
+ * Names how one side was established, inside that side's card. Where the method published
71
+ * a proof the name is the link to it, which is what lets the reader check the claim
72
+ * without taking this backend's word for it, and keeps several proofs apart. Methods are
73
+ * named, never ranked.
74
+ */
75
+ function attestationNote(attestation, names, additional = false) {
76
+ const label = attestationLabel(attestation.method, names);
77
+ if (!label)
78
+ return '';
79
+ // Set by a provider implementation from holder-supplied input, so it reaches an href
80
+ // only after being confirmed http(s).
81
+ const artifact = attestation.artifactUrl && safeUrl(attestation.artifactUrl);
82
+ const how = `how${additional ? ' additional' : ''}`;
83
+ return `<p class="${how}">${additional ? '+ ' : ''}${artifact
84
+ ? `<a href="${escape(artifact)}" rel="noreferrer" title="${escape(proofTitle(attestation, moment))}">${escape(label)}</a>`
85
+ : escape(label)}</p>`;
86
+ }
87
+ /** The external side's methods: the one it was first shown by, then each one since. */
88
+ function externalNotes(attestations, names) {
89
+ const [main, ...rest] = attestations.external;
90
+ return attestationNote(main, names) + rest.map((a) => attestationNote(a, names, true)).join('');
91
+ }
92
+ /**
93
+ * What a holder does to use one method, for a page offering several. A single method needs
94
+ * no such wording: continuing with the provider is all there is to choose.
95
+ */
96
+ function methodAction(provider) {
97
+ const actions = {
98
+ oauth: `Sign in with ${provider.name}`,
99
+ gist: `Publish a proof on ${provider.name}`,
100
+ backlink: `Link back from ${provider.name}`,
101
+ signature: `Sign with ${provider.name}`,
102
+ code: `Confirm by ${provider.name.toLowerCase()}`,
103
+ };
104
+ return actions[providerMethod(provider)] ?? `Continue with ${provider.name}`;
105
+ }
106
+ /** What the holder hands back for an artifact method, named for what it is. */
107
+ function artifactField(provider) {
108
+ if (provider.artifact === 'document')
109
+ return 'Your proof';
110
+ return provider.method === 'backlink'
111
+ ? 'Address of the page carrying your link'
112
+ : 'Address of your published proof';
113
+ }
114
+ /** Said beside every artifact method: what the holder publishes is public by design. */
115
+ function artifactNote(provider) {
116
+ return provider.method === 'backlink'
117
+ ? 'This link is public, as is the page you point at. Anyone reading either one can follow it here.'
118
+ : 'This line is public, as is whatever published it. Publish nothing else alongside it.';
119
+ }
120
+ /**
121
+ * Where a mailed code stands, for whichever renderer asks the holder for the next thing:
122
+ * the address to send to, or the code once it has gone. The code and its hash stay behind.
123
+ */
124
+ function codeStep(provider, flow) {
125
+ return {
126
+ field: provider.field,
127
+ input: provider.input,
128
+ ...(flow.sent
129
+ ? {
130
+ sentTo: flow.sent.account.handle,
131
+ wrong: flow.sent.attempts > 0,
132
+ triesLeft: codeAttempts - flow.sent.attempts,
133
+ }
134
+ : {}),
135
+ };
136
+ }
137
+ /** Said before the message is sent: the address becomes the name the connection shows. */
138
+ const codeNote = "We'll email this address to confirm it's yours. The address is shown on the connection, to whoever you let see it.";
139
+ /** Names the providers on offer once each, however many ways each can be shown. */
140
+ function providerNames(providers) {
141
+ return [...new Set(providers.map((p) => p.name))].join(' or ');
142
+ }
143
+ /** Seconds are the finest thing a record measured in days can mean; milliseconds are noise. */
144
+ const moment = (time) => new Date(time).toISOString().replace(/\.\d{3}Z$/, 'Z');
145
+ /**
146
+ * Every time on the page in one list. A date inside a sentence is a date a reader has to
147
+ * find; the times of a record belong together, where they can be compared at a glance.
148
+ */
149
+ function times(rows) {
150
+ return `<dl>${rows
151
+ .filter(([, time]) => time !== undefined)
152
+ .map(([label, time]) => `<dt>${escape(label)}</dt><dd>${escape(moment(time))}</dd>`)
153
+ .join('')}</dl>`;
154
+ }
155
+ /**
156
+ * What the holder is told, as the provider wrote it. A command is set as a block and never
157
+ * reflowed: it is copied character for character, and one wrapped line is a broken command.
158
+ */
159
+ function instructions(parts) {
160
+ return parts
161
+ .map((part) => typeof part === 'string'
162
+ ? `<p>${escape(part)}</p>`
163
+ : Array.isArray(part)
164
+ ? `<p>${part.map(inline).join('')}</p>`
165
+ : `<pre><code>${escape(part.code)}</code></pre>`)
166
+ .join('');
167
+ }
168
+ /** A piece of a paragraph. A link that is not http(s) is written as its text alone. */
169
+ function inline(piece) {
170
+ if (typeof piece === 'string')
171
+ return escape(piece);
172
+ const href = safeUrl(piece.href);
173
+ return href
174
+ ? `<a href="${escape(href)}" rel="noreferrer" target="_blank">${escape(piece.text)}</a>`
175
+ : escape(piece.text);
176
+ }
177
+ function safeUrl(value) {
178
+ try {
179
+ const url = new URL(value);
180
+ return ['https:', 'http:'].includes(url.protocol) ? url.href : undefined;
181
+ }
182
+ catch {
183
+ return undefined;
184
+ }
185
+ }
186
+ /**
187
+ * The holder's choice of who may read the record, or the one visibility there is when the
188
+ * subject has no choice. A subject from another site is told what each means there: that
189
+ * site can read an unlisted link and decides who on it sees the link, and nobody else can
190
+ * read it at all, while a public one is readable by anyone anywhere.
191
+ */
192
+ /**
193
+ * Said wherever a holder makes a record public on an instance that signs: removing the
194
+ * link stops new signed records, and does nothing to one already saved.
195
+ */
196
+ const signedNote = 'Anyone can save a signed record, which still shows this link was made after you remove it';
197
+ function visibilityChoice(local, allowed, signed) {
198
+ const unlisted = local.siteName
199
+ ? `Only ${local.siteName} can read this link, and it chooses who there sees it. Nobody else can.`
200
+ : 'Anyone with a sharing link can view and forward it. No link is created until you choose to share.';
201
+ const shown = (local.siteName
202
+ ? `Public: anyone can view both sides of this link, on ${local.siteName} or anywhere else`
203
+ : 'Public: anyone can view both sides of this link') + (signed ? `. ${signedNote}` : '');
204
+ if (allowed.length === 1)
205
+ return `<input type="hidden" name="visibility" value="${allowed[0]}"><p class="fine">${escape(allowed[0] === 'unlisted' ? `Unlisted. ${unlisted}` : `${shown}.`)}</p>`;
206
+ return `<fieldset><legend>Evidence visibility</legend><label><input type="radio" name="visibility" value="unlisted" checked>Unlisted</label><p>${escape(unlisted)}</p><label><input type="radio" name="visibility" value="public">${escape(shown)}</label></fieldset>`;
207
+ }
208
+ /**
209
+ * A link's local side is one of the site's accounts, a page, or the site itself.
210
+ * An absent kind means the site did not say, so nothing is asserted about it.
211
+ */
212
+ function subjectNoun(kind) {
213
+ return { account: 'account', page: 'page', site: 'website' }[kind ?? ''];
214
+ }
215
+ function evidencePage(e, base, report, keyId) {
216
+ const names = { site: e.siteName, provider: e.providerName };
217
+ const local = localSide(e.local, e.siteName);
218
+ const current = e.status !== 'revoked' && e.expiresAt > Date.now();
219
+ return page(base, `${statusLabel(e, Date.now())} connection`,
220
+ // The heading already names the site and the card already names the subject, so the
221
+ // site's own reference would be a third line saying the same thing. The provider's
222
+ // identifier stays: that one is the provider's word, not the site's own wording.
223
+ card(local.heading, local.value, e.local.profileUrl, undefined, attestationNote(e.attestations.local, names)) +
224
+ card(names.provider, externalName(e.external), externalLink(e.external),
225
+ // A mailbox's address is its name already, so there is no second identifier.
226
+ e.external.kind === 'mailbox' ? undefined : e.external.id, externalNotes(e.attestations, names)) +
227
+ times([
228
+ ['Approved', e.approvedAt],
229
+ ['Authenticated', e.authenticatedAt],
230
+ [current ? 'Valid until' : 'Expired on', e.status === 'revoked' ? undefined : e.expiresAt],
231
+ ['Revoked on', e.revokedAt],
232
+ // Only a method that publishes an artifact drifts; a sign-in does not go stale.
233
+ [
234
+ 'Last checked',
235
+ e.attestations.external[0].artifactUrl
236
+ ? e.attestations.external[0].confirmedAt
237
+ : undefined,
238
+ ],
239
+ ['Sharing link expires', e.linkExpiresAt],
240
+ ]) +
241
+ `${e.linkExpiresAt ? '<p class="fine">Anyone with this link can view and forward it.</p>' : ''}
242
+ ${e.signedUrl ? `<p class="signed" id="signed"><strong>Signed by ${escape(e.verifierName)}</strong>${keyId ? ` with OpenPGP key <a href="${escape(base)}/keys.asc">${escape(keyId)}</a>` : ''}. <a href="${escape(safeUrl(e.signedUrl))}">Download the signed record</a>, which <a href="${escape(base)}/check">can be checked</a> without this page.</p>` : ''}
243
+ <p class="fine">This connection does not establish legal identity, trustworthiness, content authorship, or permanent ownership.</p>
244
+ <p class="fine"><a href="${escape(base)}/external-revoke/${escape(e.id)}">Remove this connection using your external account</a></p>
245
+ ${e.visibility === 'unlisted' ? `<p class="fine"><a href="${escape(base)}/external-share-revoke/${escape(e.id)}">Revoke only this sharing link using your external account</a></p>` : ''}
246
+ <p class="fine"><a href="${escape(report)}" rel="noreferrer">Report an incorrect record</a></p>`);
247
+ }
248
+ const checkForm = '<form method="post"><label>Signed record <textarea name="record" rows="8" cols="72" required></textarea></label><button>Check</button></form>';
249
+ /**
250
+ * What a signed record says, once its signature has been checked against this verifier's
251
+ * keys. It is a record of the past: the page says when, and sends the reader to the live
252
+ * record for whether it still stands.
253
+ */
254
+ function checkedPage(d, keyId, base) {
255
+ const names = { site: d.siteName, provider: d.providerName };
256
+ const local = localSide(d.local, d.siteName);
257
+ return page(base, 'Signed record', `<p>${escape(d.verifierName)} signed this record. It shows what stood when it was signed, not what stands now.</p>` +
258
+ card(local.heading, local.value, d.local.profileUrl, undefined, attestationNote(d.attestations.local, names)) +
259
+ card(names.provider, externalName(d.external), externalLink(d.external), d.external.kind === 'mailbox' ? undefined : d.external.id, externalNotes(d.attestations, names)) +
260
+ times([
261
+ ['Signed', d.issuedAt],
262
+ ['Approved', d.approvedAt],
263
+ ['Valid until', d.expiresAt],
264
+ ]) +
265
+ `<p class="fine">Signing key <a href="${escape(base)}/keys.asc">${escape(keyId)}</a>.</p>
266
+ <p class="fine"><a href="${escape(safeUrl(d.evidenceUrl))}">See whether this connection still stands</a></p>`);
267
+ }
268
+ export function createVerily(options) {
269
+ const service = new VerilyService(options);
270
+ const signs = options.signingKey !== undefined;
271
+ const base = new URL(service.baseUrl), prefix = base.pathname.replace(/\/$/, '');
272
+ if (!['https:', 'http:', 'mailto:'].includes(new URL(options.reportUrl).protocol))
273
+ throw new Error('Invalid report URL');
274
+ for (const target of options.formTargets ?? []) {
275
+ const url = new URL(target);
276
+ if (!['https:', 'http:'].includes(url.protocol) || url.origin !== target)
277
+ throw new Error('A form target must be an origin');
278
+ }
279
+ const cookieName = `verily_flow_${Buffer.from(prefix).toString('hex')}`;
280
+ function binding(request) {
281
+ return (request.headers
282
+ .get('cookie')
283
+ ?.split(';')
284
+ .map((v) => v.trim())
285
+ .find((v) => v.startsWith(`${cookieName}=`))
286
+ ?.slice(cookieName.length + 1) ?? '');
287
+ }
288
+ async function local(request) {
289
+ const user = await options.authenticate(request);
290
+ if (!user)
291
+ throw new Unavailable();
292
+ return service.validateLocal(user);
293
+ }
294
+ async function body(request) {
295
+ const text = await request.text();
296
+ if (text.length > maxBodyBytes)
297
+ throw new Unavailable();
298
+ if (request.headers.get('content-type')?.startsWith('application/json')) {
299
+ const value = JSON.parse(text);
300
+ if (!value ||
301
+ typeof value !== 'object' ||
302
+ Array.isArray(value) ||
303
+ Object.values(value).some((v) => typeof v !== 'string'))
304
+ throw new Unavailable();
305
+ return value;
306
+ }
307
+ return Object.fromEntries(new URLSearchParams(text));
308
+ }
309
+ /**
310
+ * The configured methods a subject may use. Where a list was chosen for it, they come in
311
+ * that list's order, since the first is what a flow runs when nothing is asked for. A
312
+ * provider shown several ways keeps its methods together, in the order configured, and
313
+ * an id this instance does not configure is passed over.
314
+ */
315
+ function permitted(user) {
316
+ const ids = options.providersFor?.(user);
317
+ return ids
318
+ ? [...new Set(ids)].flatMap((id) => service.providers.filter((p) => p.id === id))
319
+ : service.providers;
320
+ }
321
+ /** The visibilities a subject may choose: both, unless fewer were chosen for it. */
322
+ function visibilities(user) {
323
+ const chosen = options.visibilityFor?.(user);
324
+ return chosen
325
+ ? ['unlisted', 'public'].filter((v) => chosen.includes(v))
326
+ : ['unlisted', 'public'];
327
+ }
328
+ /**
329
+ * The method a new flow runs, held to what its subject may use. Asking for nothing means
330
+ * the first one permitted, not the first one configured, and a flow on an existing record
331
+ * must be on a record whose provider the subject may still use.
332
+ */
333
+ async function choose(user, connectionId, choice) {
334
+ // Nobody local started it, so there is nobody whose choices could narrow it.
335
+ if (!user)
336
+ return choice;
337
+ const offered = permitted(user);
338
+ if (offered === service.providers)
339
+ return choice;
340
+ if (connectionId) {
341
+ const held = (await service.read(connectionId, user)).provider;
342
+ if (!offered.some((p) => p.id === held))
343
+ throw new Unavailable();
344
+ if (choice.provider === undefined && choice.method === undefined)
345
+ return choice;
346
+ }
347
+ const found = offered.find((p) => (choice.provider === undefined || p.id === choice.provider) &&
348
+ (choice.method === undefined || providerMethod(p) === choice.method));
349
+ if (!found)
350
+ throw new Unavailable();
351
+ return { provider: found.id, method: providerMethod(found) };
352
+ }
353
+ /**
354
+ * The methods a page offers. A flow on an existing record stays in its namespace, so
355
+ * where the holder is signed in that record narrows the list; removing a link from the
356
+ * external side refuses a standing proof, which anybody can hand back.
357
+ */
358
+ async function offer(kind, id, user, requested) {
359
+ let namespace = requested ?? undefined;
360
+ if (user && id)
361
+ namespace = (await service.read(id, user)).provider;
362
+ const offered = (user ? permitted(user) : service.providers).filter((p) => (namespace === undefined || p.id === namespace) &&
363
+ !(['revoke', 'share-revoke'].includes(kind) && isArtifactProvider(p) && p.expect));
364
+ if (!offered.length)
365
+ throw new Unavailable();
366
+ return offered;
367
+ }
368
+ /** A failure names its reason when the provider gave one meant for the holder. */
369
+ function result(outcome, id = '', reason) {
370
+ return html(page(prefix, 'Verification result', `<p>${escape(outcome)}</p>${reason ? `<p>${escape(reason)}.</p>` : ''}<div id="verily-result" data-outcome="${escape(outcome)}" data-id="${escape(id)}"></div><script src="${escape(prefix)}/result.js" defer></script><p>You can close this window and return to account settings.</p>`));
371
+ }
372
+ /**
373
+ * The page for a flow that has ended, or wherever `finish` sends the holder instead. Only
374
+ * the stored flow is read, so every load of it gives the same answer.
375
+ */
376
+ async function ended(flow) {
377
+ if (options.finish && flow.result) {
378
+ const next = await options.finish({
379
+ id: flow.id,
380
+ context: flow.context,
381
+ local: flow.local,
382
+ result: flow.result,
383
+ });
384
+ if (next !== undefined) {
385
+ const url = safeUrl(next);
386
+ if (!url)
387
+ throw new Error('finish returned an invalid URL');
388
+ return redirect(url);
389
+ }
390
+ }
391
+ return result(flow.phase, flow.resultId, flow.reason);
392
+ }
393
+ /** What the adopting application attaches to a new flow. External removal gets nothing. */
394
+ async function context(request, kind) {
395
+ return ['revoke', 'share-revoke'].includes(kind) ? undefined : options.context?.(request);
396
+ }
397
+ /**
398
+ * A redirect provider sends the holder to its own site. An artifact provider keeps them
399
+ * here, where the flow page tells them what to publish and takes the address back.
400
+ */
401
+ const entry = (flow) => flow.authorizationUrl ?? `${prefix}/flows/${flow.flowId}`;
402
+ /** The binding cookie that ties a flow to the browser that started it. */
403
+ const flowCookie = (binding) => `${cookieName}=${binding}; HttpOnly; SameSite=Lax; Path=${prefix || '/'}; Max-Age=${Math.ceil((options.flowTtlMs ?? 600000) / 1000)}${base.protocol === 'https:' ? '; Secure' : ''}`;
404
+ /** The subject as the holder's own card shows it. The private id never leaves here. */
405
+ const subject = (user) => ({
406
+ ...localSide(user, service.siteOf(user)),
407
+ profileUrl: user.profileUrl,
408
+ });
409
+ /**
410
+ * A connect flow as the in-page dialog draws it: the same steps the flow pages walk
411
+ * through, as data, so the holder never has to leave the page they started on.
412
+ */
413
+ async function flowView(flow, authorizationUrl) {
414
+ const provider = service.providerOf(flow);
415
+ const view = {
416
+ id: flow.id,
417
+ phase: flow.phase,
418
+ provider: { id: provider.id, name: provider.name, method: providerMethod(provider) },
419
+ };
420
+ if (authorizationUrl)
421
+ view.authorizationUrl = authorizationUrl;
422
+ if (flow.phase === 'pending' && flow.expect && isArtifactProvider(provider))
423
+ Object.assign(view, {
424
+ instructions: provider.instructions(flow.expect),
425
+ artifact: provider.artifact,
426
+ field: artifactField(provider),
427
+ note: artifactNote(provider),
428
+ });
429
+ if (flow.phase === 'pending' && isCodeProvider(provider))
430
+ Object.assign(view, { code: codeStep(provider, flow), note: codeNote });
431
+ if (flow.phase === 'approval') {
432
+ const joined = await service.joining(flow);
433
+ Object.assign(view, {
434
+ local: subject(flow.local),
435
+ external: flow.external,
436
+ ...(joined ? { joined: { visibility: joined.visibility } } : {}),
437
+ visibilities: visibilities(flow.local),
438
+ ...(signs ? { signedNote } : {}),
439
+ });
440
+ }
441
+ if (flow.phase === 'failed' && flow.reason)
442
+ view.reason = flow.reason;
443
+ if (flow.phase === 'complete')
444
+ view.connectionId = flow.resultId;
445
+ return view;
446
+ }
447
+ /** A flow the dialog may read: one this holder started, to connect an account. */
448
+ async function ownFlow(request, id) {
449
+ const flow = await service.flow(id, binding(request));
450
+ if (flow.kind !== 'connect' || (await local(request)).id !== flow.local?.id)
451
+ throw new Unavailable();
452
+ return flow;
453
+ }
454
+ async function handle(request) {
455
+ const url = new URL(request.url);
456
+ if (url.origin !== base.origin ||
457
+ !(url.pathname === prefix || url.pathname.startsWith(prefix + '/')))
458
+ return html(page(prefix, 'Unavailable', ''), 404);
459
+ const path = url.pathname.slice(prefix.length);
460
+ try {
461
+ if (!['GET', 'POST'].includes(request.method))
462
+ throw new Unavailable();
463
+ // Same-origin only. No CORS. Applies to every mutation, including approval forms.
464
+ if (request.method === 'POST' && request.headers.get('origin') !== base.origin)
465
+ throw new Unavailable();
466
+ if (request.method === 'GET') {
467
+ // The one thing these pages cache, and only because its address carries its
468
+ // version. It holds no record of anybody, so it is the one response that may sit
469
+ // in a shared cache.
470
+ if (path === '/style.css')
471
+ return new Response(stylesheet, {
472
+ headers: {
473
+ ...headers,
474
+ 'Cache-Control': url.searchParams.get('v') === styleVersion
475
+ ? 'public, max-age=31536000, immutable'
476
+ : 'no-store',
477
+ 'Content-Type': 'text/css; charset=utf-8',
478
+ },
479
+ });
480
+ if (path === '/copy.js')
481
+ return new Response(copyScript, {
482
+ headers: { ...headers, 'Content-Type': 'text/javascript' },
483
+ });
484
+ if (path === '/result.js')
485
+ return new Response(`const e=document.getElementById('verily-result');if(window.opener&&e){window.opener.postMessage({type:'verily-result',outcome:e.dataset.outcome,connectionId:e.dataset.id},location.origin);window.close()}`, { headers: { ...headers, 'Content-Type': 'text/javascript' } });
486
+ if (path === '/verify' ||
487
+ path.startsWith('/external-revoke/') ||
488
+ path.startsWith('/external-share-revoke/') ||
489
+ path.startsWith('/visibility/') ||
490
+ path.startsWith('/renew/')) {
491
+ const external = path.startsWith('/external-revoke/') || path.startsWith('/external-share-revoke/');
492
+ const kind = external
493
+ ? path.startsWith('/external-share-revoke/')
494
+ ? 'share-revoke'
495
+ : 'revoke'
496
+ : path.startsWith('/visibility/')
497
+ ? 'visibility'
498
+ : path.startsWith('/renew/')
499
+ ? 'renew'
500
+ : 'connect';
501
+ const user = external ? undefined : await local(request);
502
+ const id = kind === 'connect' ? '' : path.split('/').at(-1);
503
+ const offered = await offer(kind, id, user, url.searchParams.get('provider'));
504
+ // With one visibility there is nothing for the holder to change it to.
505
+ if (kind === 'visibility' && visibilities(user).length < 2)
506
+ throw new Unavailable();
507
+ // One form per method. With one on offer the provider is all there is to name.
508
+ const forms = offered
509
+ .map((p) => `<form method="${kind === 'connect' ? 'get' : 'post'}" action="${escape(prefix)}/sessions"><input type="hidden" name="kind" value="${kind}"><input type="hidden" name="connectionId" value="${escape(id)}"><input type="hidden" name="provider" value="${escape(p.id)}"><input type="hidden" name="method" value="${escape(providerMethod(p))}"><button>${escape(offered.length > 1 ? methodAction(p) : `Continue with ${p.name}`)}</button></form>`)
510
+ .join('');
511
+ return html(page(prefix, external
512
+ ? 'Remove a connection'
513
+ : kind === 'renew'
514
+ ? 'Renew this connection'
515
+ : `Verify with ${providerNames(offered)}`, `
516
+ ${user
517
+ ? card(subject(user).heading, subject(user).value, user.profileUrl)
518
+ : '<p>Authenticate with the matching external account to review and remove this link.</p>'}
519
+ <p>Confirm the connection between this ${escape(subjectNoun(user?.kind) ?? 'site')} and your ${escape(providerNames(offered))} account.</p>
520
+ ${kind === 'renew' && offered.length > 1 ? '<p class="fine">Renewing another way adds that method beneath the one this connection was first shown by.</p>' : ''}
521
+ ${forms}`));
522
+ }
523
+ if (path === '/callback') {
524
+ const state = url.searchParams.get('state');
525
+ if (!state)
526
+ throw new Unavailable();
527
+ const id = await service.callback(state, binding(request), url.searchParams.has('error') ? undefined : (url.searchParams.get('code') ?? undefined));
528
+ return redirect(`${prefix}/flows/${id}`);
529
+ }
530
+ if (path === '/sessions' && url.searchParams.get('kind') === 'connect') {
531
+ const user = await local(request);
532
+ const flow = await service.start(user, undefined, 'connect', await choose(user, undefined, {
533
+ provider: url.searchParams.get('provider') ?? undefined,
534
+ method: url.searchParams.get('method') ?? undefined,
535
+ }), await context(request, 'connect'));
536
+ return redirect(entry(flow), flowCookie(flow.binding));
537
+ }
538
+ // What the in-page dialog offers: every method the signed-in holder may use.
539
+ if (path === '/methods') {
540
+ const user = await local(request);
541
+ const offered = permitted(user);
542
+ const several = offered.length > 1;
543
+ return json({
544
+ siteName: service.siteOf(user),
545
+ verifierName: options.verifierName,
546
+ local: subject(user),
547
+ methods: offered.map((p) => ({
548
+ provider: p.id,
549
+ method: providerMethod(p),
550
+ name: p.name,
551
+ action: several ? methodAction(p) : `Continue with ${p.name}`,
552
+ })),
553
+ });
554
+ }
555
+ if (path.startsWith('/flows/') && url.searchParams.get('format') === 'json')
556
+ return json(await flowView(await ownFlow(request, path.slice(7))));
557
+ if (path.startsWith('/flows/')) {
558
+ const flow = await service.flow(path.slice(7), binding(request));
559
+ const provider = service.providerOf(flow);
560
+ // Holder-paced: nothing has been proved yet, so the page says what to publish
561
+ // and waits. The line is public by design, which is what makes it checkable.
562
+ if (flow.phase === 'pending' && flow.expect && isArtifactProvider(provider)) {
563
+ if (!['revoke', 'share-revoke'].includes(flow.kind))
564
+ if ((await local(request)).id !== flow.local?.id)
565
+ throw new Unavailable();
566
+ return html(page(prefix, `Verify with ${provider.name}`, `${instructions(provider.instructions(flow.expect))}
567
+ <form method="post" action="${escape(prefix)}/flows/${escape(flow.id)}/submit">
568
+ <label>${escape(artifactField(provider))} ${provider.artifact === 'document'
569
+ ? '<textarea name="artifact" rows="14" cols="72" required></textarea>'
570
+ : '<input name="artifact" type="url" required>'}</label>
571
+ <button>Check my proof</button></form>
572
+ <p class="fine">${escape(artifactNote(provider))}</p>
573
+ <script src="${escape(prefix)}/copy.js" defer></script>`));
574
+ }
575
+ // Holder-paced too, in two steps: where to send a code, then the code itself.
576
+ if (flow.phase === 'pending' && isCodeProvider(provider)) {
577
+ if (!['revoke', 'share-revoke'].includes(flow.kind))
578
+ if ((await local(request)).id !== flow.local?.id)
579
+ throw new Unavailable();
580
+ const step = codeStep(provider, flow);
581
+ return html(page(prefix, `Verify with ${provider.name}`, `${step.sentTo === undefined
582
+ ? ''
583
+ : `<p>A message was sent to ${escape(step.sentTo)}. Press the button in it, then come back to this page.</p><p><a href="${escape(prefix)}/flows/${escape(flow.id)}">I pressed the button</a></p><p>Or enter the code from the message here.</p>${step.wrong
584
+ ? `<p>That code did not match. ${step.triesLeft === 1 ? 'One try is' : `${step.triesLeft} tries are`} left.</p>`
585
+ : ''}`}
586
+ <form method="post" action="${escape(prefix)}/flows/${escape(flow.id)}/submit">
587
+ <label>${escape(step.sentTo === undefined ? step.field : 'Your code')} ${step.sentTo === undefined
588
+ ? `<input name="artifact" type="${step.input}" autocomplete="${step.input}" required>`
589
+ : '<input name="artifact" autocomplete="one-time-code" autocapitalize="characters" spellcheck="false" required>'}</label>
590
+ <button>${step.sentTo === undefined ? `Send the ${escape(provider.name.toLowerCase())}` : 'Check my code'}</button></form>
591
+ ${step.sentTo === undefined ? `<p class="fine">${escape(codeNote)}</p>` : ''}`));
592
+ }
593
+ if (flow.phase !== 'approval')
594
+ return await ended(flow);
595
+ if (!['revoke', 'share-revoke'].includes(flow.kind) &&
596
+ (await local(request)).id !== flow.local?.id)
597
+ throw new Unavailable();
598
+ // A second way of showing an account already linked here joins that record, whose
599
+ // visibility was chosen when it was made and is not the holder's to rechoose here.
600
+ const joined = await service.joining(flow);
601
+ const kept = ['revoke', 'share-revoke', 'renew'].includes(flow.kind) || joined;
602
+ return html(page(prefix, ['revoke', 'share-revoke'].includes(flow.kind)
603
+ ? 'Remove connection'
604
+ : flow.kind === 'renew'
605
+ ? 'Renew connection'
606
+ : joined
607
+ ? 'Add to connection'
608
+ : 'Confirm connection', `
609
+ ${card(subject(flow.local).heading, subject(flow.local).value, flow.local.profileUrl)}
610
+ ${card(provider.name, externalName(flow.external), externalLink(flow.external), flow.external.kind === 'mailbox' ? undefined : flow.external.id)}
611
+ ${joined ? `<p>This account is already linked here. Confirming adds this method to that connection, beneath the one it was first shown by, and it stays ${escape(joined.visibility)}.</p>` : ''}
612
+ <p class="fine">${escape(service.siteOf(flow.local))} receives the result. Verified via ${escape(options.verifierName)}.</p>
613
+ <form method="post" action="${escape(prefix)}/flows/${escape(flow.id)}/approve">
614
+ ${kept ? '<input type="hidden" name="visibility" value="unlisted">' : visibilityChoice(flow.local, visibilities(flow.local), signs)}
615
+ <button name="action" value="approve">${['revoke', 'share-revoke'].includes(flow.kind) ? (flow.kind === 'share-revoke' ? 'Revoke sharing link' : 'Revoke connection') : flow.kind === 'renew' ? 'Renew connection' : joined ? 'Add to connection' : 'Confirm connection'}</button>
616
+ <button name="action" value="cancel">Cancel</button></form><p><a href="${escape(prefix)}/verify">Use a different external account</a></p>`));
617
+ }
618
+ const confirmation = path.match(/^\/confirm\/([^/]+)$/);
619
+ // Where the button in a message leads, in whatever browser the holder reads mail
620
+ // in. Arriving confirms nothing: a scanner that opens every link in a message gets
621
+ // this page and stops, and the holder is shown what they are confirming first.
622
+ if (confirmation) {
623
+ const token = url.searchParams.get('token') ?? '';
624
+ const asked = await service.confirming(confirmation[1], token);
625
+ const next = {
626
+ connect: `link it to this ${subjectNoun(asked.local?.kind) ?? 'site'}`,
627
+ renew: 'renew its link',
628
+ visibility: 'change who can read its link',
629
+ revoke: 'remove its link',
630
+ 'share-revoke': 'revoke its sharing link',
631
+ };
632
+ return html(page(prefix, 'Confirm your address', `${asked.local ? card(subject(asked.local).heading, subject(asked.local).value, asked.local.profileUrl) : ''}
633
+ ${card(asked.provider.name, externalName(asked.account), undefined)}
634
+ <p>Confirming shows that this address is yours, so the page that asked can go on to ${escape(next[asked.kind])}. Nothing changes until that page is approved too.</p>
635
+ <form method="post" action="${escape(prefix)}/confirm/${escape(confirmation[1])}">
636
+ <input type="hidden" name="token" value="${escape(token)}">
637
+ <button>Confirm this address</button></form>
638
+ <p class="fine">If you did not ask for this, close this page. Nothing happens unless you confirm.</p>`));
639
+ }
640
+ // Lets a static embed track current connections without hardcoding an id.
641
+ if (path === '/published') {
642
+ const response = json(await service.published());
643
+ response.headers.set('Access-Control-Allow-Origin', '*');
644
+ return response;
645
+ }
646
+ // The keys a signed record is checked against. Public, like the records they sign.
647
+ if (path === '/keys') {
648
+ const response = json({ keys: await service.keys() });
649
+ response.headers.set('Access-Control-Allow-Origin', '*');
650
+ return response;
651
+ }
652
+ // The same keys as a file `gpg --import` reads.
653
+ if (path === '/keys.asc' && signs)
654
+ return plain(await service.armoredKeys());
655
+ if (path === '/check' && signs)
656
+ return html(page(prefix, 'Check a signed record', `<p>Paste the contents of a signed record saved from ${escape(options.verifierName)}.</p>${checkForm}`));
657
+ if (path === '/mine')
658
+ return json(await service.mine(await local(request)));
659
+ if (path.startsWith('/s/')) {
660
+ const evidence = await service.shared(path.slice(3));
661
+ return url.searchParams.get('format') === 'json'
662
+ ? json(evidence)
663
+ : html(evidencePage(evidence, prefix, options.reportUrl));
664
+ }
665
+ const hosted = path.match(/^\/connections\/([^/]+)\/proof$/);
666
+ // As public as the evidence it belongs to, and checked the same way.
667
+ if (hosted)
668
+ return plain(await service.proof(hosted[1]));
669
+ if (path.startsWith('/connections/')) {
670
+ const id = path.slice(13);
671
+ if (url.searchParams.get('format') === 'signed') {
672
+ const response = plain(await service.signed(id));
673
+ // Kept as a file, which is what `gpg --verify` is given.
674
+ response.headers.set('Content-Disposition', `attachment; filename="verily-${id}.asc"`);
675
+ return response;
676
+ }
677
+ // Canonical routes and widgets never use local-session privileges.
678
+ const evidence = await service.read(id);
679
+ if (url.searchParams.get('format') === 'json') {
680
+ const response = json(evidence);
681
+ // Public evidence can be embedded on static sites without credentials.
682
+ response.headers.set('Access-Control-Allow-Origin', '*');
683
+ return response;
684
+ }
685
+ return html(evidencePage(evidence, prefix, options.reportUrl, (await service.keys())[0]?.id));
686
+ }
687
+ if (path.startsWith('/manage/'))
688
+ return json(await service.read(path.slice(8), await local(request)));
689
+ }
690
+ else {
691
+ const data = await body(request);
692
+ // The in-page dialog asks in JSON and is answered in JSON; a form gets its page.
693
+ const asJson = request.headers.get('content-type')?.startsWith('application/json');
694
+ if (path === '/check' && signs) {
695
+ try {
696
+ const signed = data.record ?? '';
697
+ const document = await service.checked(signed);
698
+ if (document)
699
+ return html(checkedPage(document, (await signedBy(signed)), prefix));
700
+ }
701
+ catch {
702
+ // A record this build cannot draw is not one it can vouch for.
703
+ }
704
+ return html(page(prefix, 'Check a signed record', `<p>This is not a record signed by ${escape(options.verifierName)}.</p>${checkForm}`));
705
+ }
706
+ if (path === '/connect') {
707
+ const user = await local(request);
708
+ const provider = service.resolve((await choose(user, undefined, data)).provider);
709
+ return json({
710
+ url: `${service.baseUrl}/verify?provider=${encodeURIComponent(provider.id)}`,
711
+ });
712
+ }
713
+ if (path === '/sessions') {
714
+ if (!['connect', 'renew', 'revoke', 'visibility', 'share-revoke'].includes(data.kind ?? ''))
715
+ throw new Unavailable();
716
+ const kind = data.kind;
717
+ const user = ['revoke', 'share-revoke'].includes(kind) ? undefined : await local(request);
718
+ if (kind === 'visibility' && visibilities(user).length < 2)
719
+ throw new Unavailable();
720
+ const flow = await service.start(user, data.connectionId || undefined, kind, await choose(user, data.connectionId || undefined, {
721
+ provider: data.provider || undefined,
722
+ method: data.method || undefined,
723
+ }), await context(request, kind));
724
+ if (asJson && kind === 'connect')
725
+ return json(await flowView(await service.flow(flow.flowId, flow.binding), flow.authorizationUrl), flowCookie(flow.binding));
726
+ return redirect(entry(flow), flowCookie(flow.binding));
727
+ }
728
+ const confirmation = path.match(/^\/confirm\/([^/]+)$/);
729
+ if (confirmation) {
730
+ const flow = await service.confirm(confirmation[1], data.token ?? '');
731
+ // The flow is still the browser's that started it. This one may be that browser,
732
+ // and then it can carry on from here; any other is sent back to where it began.
733
+ const here = await service.flow(flow.id, binding(request)).catch(() => undefined);
734
+ return html(page(prefix, flow.phase === 'failed' ? 'Address not confirmed' : 'Address confirmed', flow.phase === 'failed'
735
+ ? `<p>${escape(flow.reason ?? 'This address could not be confirmed')}.</p>`
736
+ : `<p>Go back to the page where you started. It carries on from here.</p>${here
737
+ ? `<p><a href="${escape(prefix)}/flows/${escape(flow.id)}">Or carry on in this window</a></p>`
738
+ : '<p>You can close this window.</p>'}`));
739
+ }
740
+ const submission = path.match(/^\/flows\/([^/]+)\/submit$/);
741
+ if (submission) {
742
+ if (!data.artifact)
743
+ throw new Unavailable();
744
+ if (asJson)
745
+ await ownFlow(request, submission[1]);
746
+ await service.submit(submission[1], binding(request), data.artifact);
747
+ if (asJson)
748
+ return json(await flowView(await service.flow(submission[1], binding(request))));
749
+ return redirect(`${prefix}/flows/${submission[1]}`);
750
+ }
751
+ const approval = path.match(/^\/flows\/([^/]+)\/approve$/);
752
+ if (approval) {
753
+ if (!['approve', 'cancel'].includes(data.action ?? '') ||
754
+ !['public', 'unlisted'].includes(data.visibility ?? ''))
755
+ throw new Unavailable();
756
+ const flow = await service.flow(approval[1], binding(request));
757
+ // A form resubmitted after its response was lost, as reloading a POST does. The
758
+ // flow has ended, so this is its result page, which needs only the binding, as
759
+ // loading it does: whoever signed the holder in may have ended that session since.
760
+ if (!asJson && flow.result)
761
+ return await ended(flow);
762
+ const user = ['revoke', 'share-revoke'].includes(flow.kind)
763
+ ? undefined
764
+ : await local(request);
765
+ const id = await service.approve(flow.id, binding(request), user, data.visibility, data.action === 'cancel',
766
+ // Held to what the subject may choose where the approval sets the visibility.
767
+ user && visibilities(user));
768
+ if (asJson)
769
+ return json(id ? { outcome: 'complete', connectionId: id } : { outcome: 'cancelled' });
770
+ return await ended(await service.flow(flow.id, binding(request)));
771
+ }
772
+ const management = path.match(/^\/connections\/([^/]+)\/(disconnect|share|share-revoke|visibility)$/);
773
+ if (management) {
774
+ const user = await local(request), id = management[1];
775
+ if (management[2] === 'disconnect') {
776
+ await service.revoke(id, user);
777
+ return json({ ok: true });
778
+ }
779
+ if (management[2] === 'visibility') {
780
+ await service.read(id, user);
781
+ return json({ url: `${service.baseUrl}/visibility/${id}` });
782
+ }
783
+ return json((await service.share(id, user, management[2] === 'share-revoke')) ?? { ok: true });
784
+ }
785
+ }
786
+ throw new Unavailable();
787
+ }
788
+ catch (error) {
789
+ // Never serialize/log exceptions: provider responses and request URLs can contain secrets.
790
+ if (error instanceof Unavailable || error instanceof SyntaxError)
791
+ return html(page(prefix, 'Unavailable', '<p>This resource is unavailable.</p>'), 404);
792
+ return html(page(prefix, 'Request failed', '<p>Please try again.</p>'), 500);
793
+ }
794
+ }
795
+ // A sign-in form submits here and is redirected to the provider. Chromium applies
796
+ // form-action to every redirect of a form submission, so each provider's origin must be
797
+ // allowed alongside our own or the redirect is blocked. The origin is read from the
798
+ // provider's own authorization URL, which is built without side effects. An approval
799
+ // form redirected on by `finish` is held to the same rule, hence `formTargets`.
800
+ const formAction = [
801
+ "'self'",
802
+ ...new Set([
803
+ ...service.providers.filter(isRedirectProvider).map((p) => new URL(p.authorizationUrl({
804
+ state: 'state',
805
+ challenge: 'challenge',
806
+ redirectUri: `${service.baseUrl}/callback`,
807
+ })).origin),
808
+ ...(options.formTargets ?? []),
809
+ ]),
810
+ ].join(' ');
811
+ async function secured(request) {
812
+ const response = await handle(request);
813
+ const policy = response.headers.get('content-security-policy');
814
+ if (policy)
815
+ response.headers.set('content-security-policy', policy.replace("form-action 'self'", `form-action ${formAction}`));
816
+ return response;
817
+ }
818
+ return {
819
+ service,
820
+ handle: secured,
821
+ /**
822
+ * The flow binding, for a host that carries it some way other than this handler's own
823
+ * cookie: the cookie's name, to read a binding from a request or a response, and the
824
+ * header that sets it in a browser.
825
+ */
826
+ flowCookieName: cookieName,
827
+ flowCookie,
828
+ };
829
+ }
830
+ /** Mount on your chosen Node router. Public origin is configured, never inferred from Host. */
831
+ export function nodeHandler(handler, origin) {
832
+ return async (req, res) => {
833
+ try {
834
+ const chunks = [];
835
+ let length = 0;
836
+ for await (const chunk of req) {
837
+ length += chunk.length;
838
+ if (length > maxBodyBytes) {
839
+ res.writeHead(413);
840
+ res.end();
841
+ return;
842
+ }
843
+ chunks.push(Buffer.from(chunk));
844
+ }
845
+ const requestHeaders = new Headers();
846
+ for (const [key, value] of Object.entries(req.headers))
847
+ if (value)
848
+ requestHeaders.set(key, Array.isArray(value) ? value.join(', ') : value);
849
+ const response = await handler(new Request(new URL(req.url ?? '/', origin), {
850
+ method: req.method,
851
+ headers: requestHeaders,
852
+ ...(chunks.length ? { body: Buffer.concat(chunks) } : {}),
853
+ }));
854
+ res.writeHead(response.status, Object.fromEntries(response.headers));
855
+ res.end(Buffer.from(await response.arrayBuffer()));
856
+ }
857
+ catch {
858
+ res.writeHead(500, { 'Cache-Control': 'no-store' });
859
+ res.end('Request failed');
860
+ }
861
+ };
862
+ }