@bkazemi/verily 0.8.1 → 0.9.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.
@@ -1,7 +1,7 @@
1
1
  import { attestationLabel, externalName, isArtifactProvider, isCodeProvider, isRedirectProvider, lastProved, accountsOf, localSide, proofTitle, providerMethod, statusLabel, signedBy, } from '../core/index.js';
2
2
  import { artifactAttempts, codeAttempts, VerilyService, Unavailable, } from './service.js';
3
3
  import { copyScript, waitScript } from './copy.js';
4
- import { escape } from './escape.js';
4
+ import { html } from './html.js';
5
5
  import { card, externalCard, marked, moment, page, safeUrl, times, verdict } from './render.js';
6
6
  import { toneOf } from '../marks.js';
7
7
  import { stylesheet, styleVersion } from './style.js';
@@ -49,7 +49,8 @@ function wants(request, url) {
49
49
  ? 'json'
50
50
  : 'page';
51
51
  }
52
- const html = (body, status = 200) => new Response(body, {
52
+ /** A page as a response. */
53
+ const pageResponse = (body, status = 200) => new Response(body, {
53
54
  status,
54
55
  headers: { ...headers, 'Content-Type': 'text/html; charset=utf-8' },
55
56
  });
@@ -85,19 +86,27 @@ const redirect = (url, cookie) => new Response(null, {
85
86
  function attestationNote(attestation, names, additional = false) {
86
87
  const label = attestationLabel(attestation.method, names, attestation);
87
88
  if (!label)
88
- return '';
89
+ return undefined;
89
90
  // Set by a provider implementation from holder-supplied input, so it reaches an href
90
91
  // only after being confirmed http(s).
91
92
  const artifact = attestation.artifactUrl && safeUrl(attestation.artifactUrl);
92
93
  const how = `how${additional ? ' additional' : ''}`;
93
- return `<p class="${how}">${additional ? '+ ' : ''}${artifact
94
- ? `<a href="${escape(artifact)}" rel="noreferrer" title="${escape(proofTitle(attestation, moment))}">${escape(label)}</a>`
95
- : escape(label)}</p>`;
94
+ return html `
95
+ <p class="${how}">
96
+ ${additional && '+ '}${artifact
97
+ ? html `
98
+ <a href="${artifact}" rel="noreferrer" title="${proofTitle(attestation, moment)}">
99
+ ${label}
100
+ </a>
101
+ `
102
+ : label}
103
+ </p>
104
+ `;
96
105
  }
97
106
  /** The external side's methods: the one it was first shown by, then each one since. */
98
107
  function externalNotes(attestations, names) {
99
108
  const [main, ...rest] = attestations.external;
100
- return attestationNote(main, names) + rest.map((a) => attestationNote(a, names, true)).join('');
109
+ return html `${attestationNote(main, names)}${rest.map((a) => attestationNote(a, names, true))}`;
101
110
  }
102
111
  /**
103
112
  * What a holder does to use one method, for a page offering several. A single method needs
@@ -230,22 +239,20 @@ function proofInstructions(provider, flow) {
230
239
  * reflowed: it is copied character for character, and one wrapped line is a broken command.
231
240
  */
232
241
  function instructions(parts) {
233
- return parts
234
- .map((part) => typeof part === 'string'
235
- ? `<p>${escape(part)}</p>`
242
+ return html `${parts.map((part) => typeof part === 'string'
243
+ ? html `<p>${part}</p>`
236
244
  : Array.isArray(part)
237
- ? `<p>${part.map(inline).join('')}</p>`
238
- : `<pre><code>${escape(part.code)}</code></pre>`)
239
- .join('');
245
+ ? html `<p>${part.map(inline)}</p>`
246
+ : html `<pre><code>${part.code}</code></pre>`)}`;
240
247
  }
241
248
  /** A piece of a paragraph. A link that is not http(s) is written as its text alone. */
242
249
  function inline(piece) {
243
250
  if (typeof piece === 'string')
244
- return escape(piece);
251
+ return piece;
245
252
  const href = safeUrl(piece.href);
246
253
  return href
247
- ? `<a href="${escape(href)}" rel="noreferrer" target="_blank">${escape(piece.text)}</a>`
248
- : escape(piece.text);
254
+ ? html `<a href="${href}" rel="noreferrer" target="_blank">${piece.text}</a>`
255
+ : piece.text;
249
256
  }
250
257
  /**
251
258
  * The holder's choice of who may read the record, or the one visibility there is when the
@@ -266,8 +273,18 @@ function visibilityChoice(local, allowed, signed) {
266
273
  ? `Public: anyone can view both sides of this link, on ${local.siteName} or anywhere else`
267
274
  : 'Public: anyone can view both sides of this link') + (signed ? `. ${signedNote}` : '');
268
275
  if (allowed.length === 1)
269
- return `<input type="hidden" name="visibility" value="${allowed[0]}"><p class="fine">${escape(allowed[0] === 'unlisted' ? `Unlisted. ${unlisted}` : `${shown}.`)}</p>`;
270
- 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>`;
276
+ return html `
277
+ <input type="hidden" name="visibility" value="${allowed[0]}">
278
+ <p class="fine">${allowed[0] === 'unlisted' ? `Unlisted. ${unlisted}` : `${shown}.`}</p>
279
+ `;
280
+ return html `
281
+ <fieldset>
282
+ <legend>Evidence visibility</legend>
283
+ <label><input type="radio" name="visibility" value="unlisted" checked>Unlisted</label>
284
+ <p>${unlisted}</p>
285
+ <label><input type="radio" name="visibility" value="public">${shown}</label>
286
+ </fieldset>
287
+ `;
271
288
  }
272
289
  /**
273
290
  * A link's local side is one of the site's accounts, a page, or the site itself.
@@ -284,60 +301,91 @@ function evidencePage(e, base, report, keyId, named) {
284
301
  // The heading already names the site and the card already names the subject, so the
285
302
  // site's own reference would be a third line saying the same thing. The provider's
286
303
  // identifier stays: that one is the provider's word, not the site's own wording.
287
- card(local.heading, local.value, e.local.profileUrl, undefined, attestationNote(e.attestations.local, names)) +
288
- joiner +
289
- externalCard(marked(names.provider, e.provider, e.attestations.external[0].method), e.external, externalNotes(e.attestations, names)) +
290
- times(e.status === 'retired'
291
- ? [
292
- ['Approved', e.approvedAt],
293
- // Two rows, always, so a reader sees any gap between them: an account retired
294
- // weeks after it was last proved went unproved for those weeks.
295
- ['Last verified', lastProved(e)],
296
- ['Retired', e.retiredAt],
297
- ['Sharing link expires', e.linkExpiresAt],
298
- ]
299
- : [
300
- ['Approved', e.approvedAt],
301
- ['Authenticated', e.authenticatedAt],
302
- [
303
- current ? 'Valid until' : 'Expired on',
304
- e.status === 'revoked' ? undefined : e.expiresAt,
305
- ],
306
- ['Revoked on', e.revokedAt],
307
- // Only a method that publishes an artifact drifts; a sign-in does not go stale.
308
- [
309
- 'Last checked',
310
- e.attestations.external[0].artifactUrl
311
- ? e.attestations.external[0].confirmedAt
312
- : undefined,
313
- ],
314
- ['Sharing link expires', e.linkExpiresAt],
315
- ]) +
316
- `${e.linkExpiresAt ? '<p class="fine">Anyone with this link can view and forward it.</p>' : ''}
317
- ${e.signedUrl ? `<p class="signed" id="signed"><strong>Signed by ${escape(e.verifierName)}</strong>${keyId ? `<br>${keyLine(base, keyId, named)}` : ''}<br><a href="${escape(safeUrl(e.signedUrl))}">Download signed record</a> · <a href="${escape(base)}/check">Check a record</a></p>` : ''}
318
- <p class="fine">This connection does not establish legal identity, trustworthiness, content authorship, or permanent ownership.</p>
319
- <footer><p class="fine">Is this your account? <a href="${escape(base)}/external-revoke/${escape(e.id)}?provider=${encodeURIComponent(e.provider)}">Remove this connection</a></p>
320
- ${e.visibility === 'unlisted' ? `<p class="fine"><a href="${escape(base)}/external-share-revoke/${escape(e.id)}?provider=${encodeURIComponent(e.provider)}">Revoke only this sharing link using your external account</a></p>` : ''}
321
- <p class="fine"><a href="${escape(report)}" rel="noreferrer">Report an incorrect record</a></p></footer>`, '', '', verdict(toneOf(e, Date.now()), true));
304
+ html `
305
+ ${card(local.heading, local.value, e.local.profileUrl, undefined, attestationNote(e.attestations.local, names))}
306
+ ${joiner}
307
+ ${externalCard(marked(names.provider, e.provider, e.attestations.external[0].method), e.external, externalNotes(e.attestations, names))}
308
+ ${times(e.status === 'retired'
309
+ ? [
310
+ ['Approved', e.approvedAt],
311
+ // Two rows, always, so a reader sees any gap between them: an account retired
312
+ // weeks after it was last proved went unproved for those weeks.
313
+ ['Last verified', lastProved(e)],
314
+ ['Retired', e.retiredAt],
315
+ ['Sharing link expires', e.linkExpiresAt],
316
+ ]
317
+ : [
318
+ ['Approved', e.approvedAt],
319
+ ['Authenticated', e.authenticatedAt],
320
+ [
321
+ current ? 'Valid until' : 'Expired on',
322
+ e.status === 'revoked' ? undefined : e.expiresAt,
323
+ ],
324
+ ['Revoked on', e.revokedAt],
325
+ // Only a method that publishes an artifact drifts; a sign-in does not go stale.
326
+ [
327
+ 'Last checked',
328
+ e.attestations.external[0].artifactUrl
329
+ ? e.attestations.external[0].confirmedAt
330
+ : undefined,
331
+ ],
332
+ ['Sharing link expires', e.linkExpiresAt],
333
+ ])}
334
+ ${e.linkExpiresAt !== undefined &&
335
+ html `<p class="fine">Anyone with this link can view and forward it.</p>`}
336
+ ${e.signedUrl &&
337
+ html `
338
+ <p class="signed" id="signed">
339
+ <strong>Signed by ${e.verifierName}</strong>${keyId && html `<br>${keyLine(base, keyId, named)}`}<br>
340
+ <a href="${safeUrl(e.signedUrl)}">Download signed record</a> · <a href="${base}/check">Check a record</a>
341
+ </p>
342
+ `}
343
+ <p class="fine">
344
+ This connection does not establish legal identity, trustworthiness, content authorship,
345
+ or permanent ownership.
346
+ </p>
347
+ <footer>
348
+ <p class="fine">
349
+ Is this your account? <a
350
+ href="${base}/external-revoke/${e.id}?provider=${encodeURIComponent(e.provider)}">
351
+ Remove this connection
352
+ </a>
353
+ </p>
354
+ ${e.visibility === 'unlisted' &&
355
+ html `
356
+ <p class="fine">
357
+ <a
358
+ href="${base}/external-share-revoke/${e.id}?provider=${encodeURIComponent(e.provider)}">
359
+ Revoke only this sharing link using your external account
360
+ </a>
361
+ </p>
362
+ `}
363
+ <p class="fine"><a href="${report}" rel="noreferrer">Report an incorrect record</a></p>
364
+ </footer>
365
+ `, '', '', verdict(toneOf(e, Date.now()), true));
322
366
  }
323
367
  /** Joins a subject's card to its accounts: the link itself, drawn as the dialog draws it. */
324
- const joiner = '<svg class="joiner" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">' +
325
- [
326
- 'M10.5 7.5 13 5a4.95 4.95 0 0 1 7 7l-2.5 2.5',
327
- 'M13.5 16.5 11 19a4.95 4.95 0 0 1-7-7l2.5-2.5',
328
- 'M9 15l6-6',
329
- ]
330
- .map((d) => `<path d="${d}" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>`)
331
- .join('') +
332
- '</svg>';
368
+ const joiner = html `
369
+ <svg class="joiner" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">
370
+ ${[
371
+ 'M10.5 7.5 13 5a4.95 4.95 0 0 1 7 7l-2.5 2.5',
372
+ 'M13.5 16.5 11 19a4.95 4.95 0 0 1-7-7l2.5-2.5',
373
+ 'M9 15l6-6',
374
+ ].map((d) => html `<path d="${d}" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>`)}
375
+ </svg>
376
+ `;
333
377
  /**
334
378
  * One account as a badge's dialog draws it: what it is and how it stands, the verifier's
335
379
  * own page for it, how it was shown, how its holder lists it, and its times.
336
380
  */
337
381
  function accountCard(e, external, now) {
338
382
  const retired = e.status === 'retired';
339
- const record = escape(safeUrl(e.evidenceUrl));
340
- return externalCard(marked(e.providerName, e.provider, e.attestations.external[0].method), e.external, `<p class="how">${verdict(toneOf(e, now))}${escape(statusLabel(e, now))} · via: <a href="${record}">${escape(e.verifierName)}</a>${e.signedUrl ? `<a class="signed-mark" href="${record}#signed">signed</a>` : ''}</p>`, externalNotes({ ...e.attestations, external }, { site: e.siteName, provider: e.providerName }), times(retired
383
+ const record = safeUrl(e.evidenceUrl);
384
+ return externalCard(marked(e.providerName, e.provider, e.attestations.external[0].method), e.external, html `
385
+ <p class="how">
386
+ ${verdict(toneOf(e, now))}${statusLabel(e, now)} · via: <a href="${record}">${e.verifierName}</a>${e.signedUrl && html `<a class="signed-mark" href="${record}#signed">signed</a>`}
387
+ </p>
388
+ `, externalNotes({ ...e.attestations, external }, { site: e.siteName, provider: e.providerName }), times(retired
341
389
  ? [
342
390
  ['Approved', e.approvedAt],
343
391
  ['Last verified', lastProved(e)],
@@ -350,11 +398,11 @@ function accountCard(e, external, now) {
350
398
  ]),
351
399
  // The holder's own word on the account, which nothing checked, so it sits apart from
352
400
  // the methods, in the card's corner as the dialog has it.
353
- retired || !e.mark
354
- ? ''
355
- : e.mark === 'preferred'
356
- ? '<div class="listing preferred">Preferred</div>'
357
- : '<div class="listing">No longer used</div>');
401
+ !retired &&
402
+ e.mark &&
403
+ (e.mark === 'preferred'
404
+ ? html `<div class="listing preferred">Preferred</div>`
405
+ : html `<div class="listing">No longer used</div>`));
358
406
  }
359
407
  /**
360
408
  * The public records given, as a page that shows what a badge's dialog does: a card to a
@@ -368,17 +416,19 @@ function publishedPage(records, base) {
368
416
  subjects.set(key, [...(subjects.get(key) ?? []), e]);
369
417
  }
370
418
  return page(base, 'Public connections', records.length
371
- ? [...subjects.values()]
372
- .map((held) => {
419
+ ? html `
420
+ ${[...subjects.values()].map((held) => {
373
421
  const local = localSide(held[0].local, held[0].siteName);
374
- return (card(local.heading, local.value, held[0].local.profileUrl) +
375
- joiner +
376
- `<div class="accounts">${accountsOf(held, now)
377
- .map(({ lead, external }) => accountCard(lead, external, now))
378
- .join('')}</div>`);
379
- })
380
- .join('')
381
- : '<p>No public connections.</p>');
422
+ return html `
423
+ ${card(local.heading, local.value, held[0].local.profileUrl)}
424
+ ${joiner}
425
+ <div class="accounts">
426
+ ${accountsOf(held, now).map(({ lead, external }) => accountCard(lead, external, now))}
427
+ </div>
428
+ `;
429
+ })}
430
+ `
431
+ : html `<p>No public connections.</p>`);
382
432
  }
383
433
  /**
384
434
  * Names a signing key on one line: its last sixteen digits, linked to the key itself with
@@ -388,12 +438,22 @@ function publishedPage(records, base) {
388
438
  * beside it gives for its verifier.
389
439
  */
390
440
  function keyLine(base, keyId, named) {
391
- const key = `Key <a href="${escape(base)}/keys.asc" title="${escape(keyId)}">${escape(short(keyId))}</a>`;
441
+ const key = html `Key <a href="${base}/keys.asc" title="${keyId}">${short(keyId)}</a>`;
392
442
  return named
393
- ? `${key} · <a href="${escape(named.lookup)}" rel="noreferrer" title="${escape(`View the lookup for ${named.domain}`)}">confirmed in DNS${named.dnssec ? ' (DNSSEC)' : ''}</a>`
443
+ ? html `
444
+ ${key} · <a
445
+ href="${named.lookup}"
446
+ rel="noreferrer"
447
+ title="${`View the lookup for ${named.domain}`}">confirmed in DNS${named.dnssec && ' (DNSSEC)'}</a>
448
+ `
394
449
  : key;
395
450
  }
396
- const checkForm = '<form method="post"><label>Signed record <textarea name="record" rows="8" cols="72" required></textarea></label><button>Check</button></form>';
451
+ const checkForm = html `
452
+ <form method="post">
453
+ <label>Signed record <textarea name="record" rows="8" cols="72" required></textarea></label>
454
+ <button>Check</button>
455
+ </form>
456
+ `;
397
457
  /**
398
458
  * What a signed record says, once its signature has been checked against this verifier's
399
459
  * keys. It is a record of the past: the page says when, and sends the reader to the live
@@ -404,24 +464,31 @@ function checkedPage(d, keyId, base, named) {
404
464
  const local = localSide(d.local, d.siteName);
405
465
  // A retired record did not stand when it was signed, and its heading says so first.
406
466
  const retired = d.version === 2;
407
- return page(base, retired ? 'Retired connection' : 'Signed record', `<p>${escape(d.verifierName)} signed this record. It shows what stood when it was signed, not what stands now.</p>` +
408
- card(local.heading, local.value, d.local.profileUrl, undefined, attestationNote(d.attestations.local, names)) +
409
- joiner +
410
- externalCard(marked(names.provider, d.provider, d.attestations.external[0].method), d.external, externalNotes(d.attestations, names)) +
411
- times(retired
412
- ? [
413
- ['Signed', d.issuedAt],
414
- ['Approved', d.approvedAt],
415
- ['Last verified', lastProved(d)],
416
- ['Retired', d.retiredAt],
417
- ]
418
- : [
419
- ['Signed', d.issuedAt],
420
- ['Approved', d.approvedAt],
421
- ['Valid until', d.expiresAt],
422
- ]) +
423
- `<p class="fine">${keyLine(base, keyId, named)}</p>
424
- <p class="fine"><a href="${escape(safeUrl(d.evidenceUrl))}">See whether this connection still stands</a></p>`);
467
+ return page(base, retired ? 'Retired connection' : 'Signed record', html `
468
+ <p>
469
+ ${d.verifierName} signed this record. It shows what stood when it was signed, not what
470
+ stands now.
471
+ </p>
472
+ ${card(local.heading, local.value, d.local.profileUrl, undefined, attestationNote(d.attestations.local, names))}
473
+ ${joiner}
474
+ ${externalCard(marked(names.provider, d.provider, d.attestations.external[0].method), d.external, externalNotes(d.attestations, names))}
475
+ ${times(retired
476
+ ? [
477
+ ['Signed', d.issuedAt],
478
+ ['Approved', d.approvedAt],
479
+ ['Last verified', lastProved(d)],
480
+ ['Retired', d.retiredAt],
481
+ ]
482
+ : [
483
+ ['Signed', d.issuedAt],
484
+ ['Approved', d.approvedAt],
485
+ ['Valid until', d.expiresAt],
486
+ ])}
487
+ <p class="fine">${keyLine(base, keyId, named)}</p>
488
+ <p class="fine">
489
+ <a href="${safeUrl(d.evidenceUrl)}">See whether this connection still stands</a>
490
+ </p>
491
+ `);
425
492
  }
426
493
  export function createVerily(options) {
427
494
  const service = new VerilyService(options);
@@ -536,7 +603,13 @@ export function createVerily(options) {
536
603
  }
537
604
  /** A failure names its reason when the provider gave one meant for the holder. */
538
605
  function result(outcome, id = '', reason) {
539
- 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>`));
606
+ return pageResponse(page(prefix, 'Verification result', html `
607
+ <p>${outcome}</p>
608
+ ${reason && html `<p>${reason}.</p>`}
609
+ <div id="verily-result" data-outcome="${outcome}" data-id="${id}"></div>
610
+ <script src="${prefix}/result.js" defer></script>
611
+ <p>You can close this window and return to account settings.</p>
612
+ `));
540
613
  }
541
614
  /**
542
615
  * The page for a flow that has ended, or wherever `finish` sends the holder instead. Only
@@ -632,14 +705,14 @@ export function createVerily(options) {
632
705
  * page that asked is waiting on the flow and carries on from there, so this one offers
633
706
  * nowhere to go.
634
707
  */
635
- const confirmed = (flow) => html(page(prefix, flow.phase === 'failed' ? 'Address not confirmed' : 'Address confirmed', flow.phase === 'failed'
636
- ? `<p>${escape(flow.reason ?? 'This address could not be confirmed')}.</p>`
637
- : '<p>You can close this tab now.</p>'));
708
+ const confirmed = (flow) => pageResponse(page(prefix, flow.phase === 'failed' ? 'Address not confirmed' : 'Address confirmed', flow.phase === 'failed'
709
+ ? html `<p>${flow.reason ?? 'This address could not be confirmed'}.</p>`
710
+ : html `<p>You can close this tab now.</p>`));
638
711
  async function handle(request) {
639
712
  const url = new URL(request.url);
640
713
  if (url.origin !== base.origin ||
641
714
  !(url.pathname === prefix || url.pathname.startsWith(prefix + '/')))
642
- return html(page(prefix, 'Unavailable', ''), 404);
715
+ return pageResponse(page(prefix, 'Unavailable', html ``), 404);
643
716
  const path = url.pathname.slice(prefix.length);
644
717
  try {
645
718
  if (!['GET', 'POST'].includes(request.method))
@@ -706,15 +779,24 @@ export function createVerily(options) {
706
779
  const label = (text, p) => {
707
780
  const heading = marked(text, p.id, providerMethod(p));
708
781
  return typeof heading === 'string'
709
- ? escape(heading)
710
- : `${heading.mark}<span>${escape(heading.text)}</span>`;
782
+ ? heading
783
+ : html `${heading.mark}<span>${heading.text}</span>`;
711
784
  };
712
- const forms = offered
713
- .map((p) => `<form method="post" action="${escape(prefix)}/sessions"><input type="hidden" name="kind" value="${kind}">${kind === 'connect' ? '<input type="hidden" name="unattended" value="1">' : ''}<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>${label(offered.length > 1 ? methodAction(p) : `Continue with ${p.name}`, p)}</button></form>`)
714
- .join('');
785
+ const forms = offered.map((p) => html `
786
+ <form method="post" action="${prefix}/sessions">
787
+ <input type="hidden" name="kind" value="${kind}">
788
+ ${kind === 'connect' && html `<input type="hidden" name="unattended" value="1">`}
789
+ <input type="hidden" name="connectionId" value="${id}">
790
+ <input type="hidden" name="provider" value="${p.id}">
791
+ <input type="hidden" name="method" value="${providerMethod(p)}">
792
+ <button>
793
+ ${label(offered.length > 1 ? methodAction(p) : `Continue with ${p.name}`, p)}
794
+ </button>
795
+ </form>
796
+ `);
715
797
  // several methods stack as one list of equal buttons, as in the dialog
716
- const choices = offered.length > 1 ? `<div class="choices">${forms}</div>` : forms;
717
- return html(page(prefix, external
798
+ const choices = offered.length > 1 ? html `<div class="choices">${forms}</div>` : forms;
799
+ return pageResponse(page(prefix, external
718
800
  ? kind === 'share-revoke'
719
801
  ? 'Revoke a sharing link'
720
802
  : 'Remove a connection'
@@ -724,11 +806,19 @@ export function createVerily(options) {
724
806
  ? 'Change who can see this connection'
725
807
  : several
726
808
  ? 'Verify an account'
727
- : `Verify with ${providerNames(offered)}`, `
728
- ${user ? card(subject(user).heading, subject(user).value, user.profileUrl) : ''}
729
- <p>${escape(purpose(kind, heldNames(offered), subjectNoun(user?.kind) ?? 'site', several))}</p>
730
- ${kind === 'renew' && offered.length > 1 ? '<p class="fine">Renewing another way adds that method beneath the one this connection was first shown by.</p>' : ''}
731
- ${choices}`));
809
+ : `Verify with ${providerNames(offered)}`, html `
810
+ ${user && card(subject(user).heading, subject(user).value, user.profileUrl)}
811
+ <p>${purpose(kind, heldNames(offered), subjectNoun(user?.kind) ?? 'site', several)}</p>
812
+ ${kind === 'renew' &&
813
+ offered.length > 1 &&
814
+ html `
815
+ <p class="fine">
816
+ Renewing another way adds that method beneath the one this connection was first
817
+ shown by.
818
+ </p>
819
+ `}
820
+ ${choices}
821
+ `));
732
822
  }
733
823
  if (path === '/callback') {
734
824
  const state = url.searchParams.get('state');
@@ -765,15 +855,30 @@ export function createVerily(options) {
765
855
  if (!['revoke', 'share-revoke'].includes(flow.kind))
766
856
  if ((await local(request)).id !== flow.local?.id)
767
857
  throw new Unavailable();
768
- return html(page(prefix, `Verify with ${provider.name}`, `${instructions(proofInstructions(provider, flow))}
769
- ${flow.tries ? `<p>${escape(refusedNote(flow))}</p>` : ''}
770
- <form method="post" action="${escape(prefix)}/flows/${escape(flow.id)}/submit">
771
- <label>${escape(artifactField(provider))} ${provider.artifact === 'document'
772
- ? `<textarea name="artifact" rows="14" cols="72" required>${escape(flow.artifact ?? '')}</textarea>`
773
- : `<input name="artifact" type="${provider.input ?? 'url'}" value="${escape(flow.artifact ?? flow.suggested ?? '')}" autocapitalize="none" spellcheck="false" required>`}</label>
774
- <button>Check my proof</button></form>
775
- <p class="fine">${escape(artifactNote(provider, flow.expect))}</p>
776
- <script src="${escape(prefix)}/copy.js" defer></script>`));
858
+ return pageResponse(page(prefix, `Verify with ${provider.name}`, html `
859
+ ${instructions(proofInstructions(provider, flow))}
860
+ ${flow.tries ? html `<p>${refusedNote(flow)}</p>` : ''}
861
+ <form method="post" action="${prefix}/flows/${flow.id}/submit">
862
+ <label>
863
+ ${artifactField(provider)} ${provider.artifact === 'document'
864
+ ? html `
865
+ <textarea name="artifact" rows="14" cols="72" required>${flow.artifact ?? ''}</textarea>
866
+ `
867
+ : html `
868
+ <input
869
+ name="artifact"
870
+ type="${provider.input ?? 'url'}"
871
+ value="${flow.artifact ?? flow.suggested ?? ''}"
872
+ autocapitalize="none"
873
+ spellcheck="false"
874
+ required>
875
+ `}
876
+ </label>
877
+ <button>Check my proof</button>
878
+ </form>
879
+ <p class="fine">${artifactNote(provider, flow.expect)}</p>
880
+ <script src="${prefix}/copy.js" defer></script>
881
+ `));
777
882
  }
778
883
  // Holder-paced too, in two steps: where to send a code, then the code itself.
779
884
  if (flow.phase === 'pending' && isCodeProvider(provider)) {
@@ -781,17 +886,53 @@ export function createVerily(options) {
781
886
  if ((await local(request)).id !== flow.local?.id)
782
887
  throw new Unavailable();
783
888
  const step = codeStep(provider, flow);
784
- return html(page(prefix, `Verify with ${provider.name}`, `${step.sentTo === undefined
785
- ? ''
786
- : `<p>A message was sent to ${escape(step.sentTo)}. Press the button in it, and this page carries on.</p><noscript><p><a href="${escape(prefix)}/flows/${escape(flow.id)}">I pressed the button</a></p></noscript><p>Or enter the code from the message here.</p>${step.wrong
787
- ? `<p>That code did not match. ${step.triesLeft === 1 ? 'One try is' : `${step.triesLeft} tries are`} left.</p>`
788
- : ''}`}
789
- <form method="post" action="${escape(prefix)}/flows/${escape(flow.id)}/submit">
790
- <label>${escape(step.sentTo === undefined ? step.field : 'Your code')} ${step.sentTo === undefined
791
- ? `<input name="artifact" type="${step.input}" autocomplete="${step.input}" required>`
792
- : '<input name="artifact" autocomplete="one-time-code" autocapitalize="characters" spellcheck="false" required>'}</label>
793
- <button>${step.sentTo === undefined ? `Send the ${escape(provider.name.toLowerCase())}` : 'Check my code'}</button></form>
794
- ${step.sentTo === undefined ? `<p class="fine">${escape(codeNote)}</p>` : `<script src="${escape(prefix)}/wait.js" defer></script>`}`));
889
+ return pageResponse(page(prefix, `Verify with ${provider.name}`, html `
890
+ ${step.sentTo !== undefined &&
891
+ html `
892
+ <p>
893
+ A message was sent to ${step.sentTo}. Press the button in it, and this page
894
+ carries on.
895
+ </p>
896
+ <noscript>
897
+ <p><a href="${prefix}/flows/${flow.id}">I pressed the button</a></p>
898
+ </noscript>
899
+ <p>Or enter the code from the message here.</p>
900
+ ${step.wrong &&
901
+ html `
902
+ <p>
903
+ That code did not match. ${step.triesLeft === 1 ? 'One try is' : `${step.triesLeft} tries are`} left.
904
+ </p>
905
+ `}
906
+ `}
907
+ <form method="post" action="${prefix}/flows/${flow.id}/submit">
908
+ <label>
909
+ ${step.sentTo === undefined ? step.field : 'Your code'} ${step.sentTo === undefined
910
+ ? html `
911
+ <input
912
+ name="artifact"
913
+ type="${step.input}"
914
+ autocomplete="${step.input}"
915
+ required>
916
+ `
917
+ : html `
918
+ <input
919
+ name="artifact"
920
+ autocomplete="one-time-code"
921
+ autocapitalize="characters"
922
+ spellcheck="false"
923
+ required>
924
+ `}
925
+ </label>
926
+ <button>
927
+ ${step.sentTo === undefined
928
+ ? `Send the ${provider.name.toLowerCase()}`
929
+ : 'Check my code'}
930
+ </button>
931
+ </form>
932
+ ${step.sentTo === undefined
933
+ ? html `<p class="fine">${codeNote}</p>`
934
+ : html `<script src="${prefix}/wait.js" defer></script>`}
935
+ `));
795
936
  }
796
937
  if (flow.phase !== 'approval')
797
938
  return await ended(flow);
@@ -803,23 +944,47 @@ export function createVerily(options) {
803
944
  const joined = await service.joining(flow);
804
945
  const kept = ['revoke', 'share-revoke', 'renew'].includes(flow.kind) || joined;
805
946
  const found = standingNote(flow, service.siteOf(flow.local));
806
- return html(page(prefix, ['revoke', 'share-revoke'].includes(flow.kind)
947
+ return pageResponse(page(prefix, ['revoke', 'share-revoke'].includes(flow.kind)
807
948
  ? 'Remove connection'
808
949
  : flow.kind === 'renew'
809
950
  ? 'Renew connection'
810
951
  : joined
811
952
  ? 'Add to connection'
812
- : 'Confirm connection', `
813
- ${card(subject(flow.local).heading, subject(flow.local).value, flow.local.profileUrl)}
814
- ${joiner}
815
- ${externalCard(marked(provider.name, provider.id, providerMethod(provider)), flow.external)}
816
- ${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>` : ''}
817
- ${found ? `<p>${escape(found)}</p>` : ''}
818
- <p class="fine">${escape(service.siteOf(flow.local))} receives the result. Verified via ${escape(options.verifierName)}.</p>
819
- <form method="post" action="${escape(prefix)}/flows/${escape(flow.id)}/approve">
820
- ${kept ? '<input type="hidden" name="visibility" value="unlisted">' : visibilityChoice(flow.local, visibilities(flow.local), signs)}
821
- <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>
822
- <button name="action" value="cancel">Cancel</button></form><p><a href="${escape(prefix)}/verify">Use a different external account</a></p>`));
953
+ : 'Confirm connection', html `
954
+ ${card(subject(flow.local).heading, subject(flow.local).value, flow.local.profileUrl)}
955
+ ${joiner}
956
+ ${externalCard(marked(provider.name, provider.id, providerMethod(provider)), flow.external)}
957
+ ${joined &&
958
+ html `
959
+ <p>
960
+ This account is already linked here. Confirming adds this method to that
961
+ connection, beneath the one it was first shown by, and it stays
962
+ ${joined.visibility}.
963
+ </p>
964
+ `}
965
+ ${found && html `<p>${found}</p>`}
966
+ <p class="fine">
967
+ ${service.siteOf(flow.local)} receives the result. Verified via
968
+ ${options.verifierName}.
969
+ </p>
970
+ <form method="post" action="${prefix}/flows/${flow.id}/approve">
971
+ ${kept
972
+ ? html `<input type="hidden" name="visibility" value="unlisted">`
973
+ : visibilityChoice(flow.local, visibilities(flow.local), signs)}
974
+ <button name="action" value="approve">
975
+ ${['revoke', 'share-revoke'].includes(flow.kind)
976
+ ? flow.kind === 'share-revoke'
977
+ ? 'Revoke sharing link'
978
+ : 'Revoke connection'
979
+ : flow.kind === 'renew'
980
+ ? 'Renew connection'
981
+ : joined
982
+ ? 'Add to connection'
983
+ : 'Confirm connection'}
984
+ </button> <button name="action" value="cancel">Cancel</button>
985
+ </form>
986
+ <p><a href="${prefix}/verify">Use a different external account</a></p>
987
+ `));
823
988
  }
824
989
  const confirmation = path.match(/^\/confirm\/([^/]+)$/);
825
990
  // Where the button in a message leads, in whatever browser the holder reads mail
@@ -840,13 +1005,22 @@ export function createVerily(options) {
840
1005
  revoke: 'remove its link',
841
1006
  'share-revoke': 'revoke its sharing link',
842
1007
  };
843
- return html(page(prefix, 'Confirm your address', `${asked.local ? card(subject(asked.local).heading, subject(asked.local).value, asked.local.profileUrl) : ''}
844
- ${card(asked.provider.name, externalName(asked.account), undefined)}
845
- <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>
846
- <form method="post" action="${escape(prefix)}/confirm/${escape(confirmation[1])}">
847
- <input type="hidden" name="token" value="${escape(token)}">
848
- <button>Confirm this address</button></form>
849
- <p class="fine">If you did not ask for this, close this page. Nothing happens unless you confirm.</p>`));
1008
+ return pageResponse(page(prefix, 'Confirm your address', html `
1009
+ ${asked.local &&
1010
+ card(subject(asked.local).heading, subject(asked.local).value, asked.local.profileUrl)}
1011
+ ${card(asked.provider.name, externalName(asked.account), undefined)}
1012
+ <p>
1013
+ Confirming shows that this address is yours, so the page that asked can go on to
1014
+ ${next[asked.kind]}. Nothing changes until that page is approved too.
1015
+ </p>
1016
+ <form method="post" action="${prefix}/confirm/${confirmation[1]}">
1017
+ <input type="hidden" name="token" value="${token}">
1018
+ <button>Confirm this address</button>
1019
+ </form>
1020
+ <p class="fine">
1021
+ If you did not ask for this, close this page. Nothing happens unless you confirm.
1022
+ </p>
1023
+ `));
850
1024
  }
851
1025
  // Lets a static embed track current connections without hardcoding an id.
852
1026
  if (path === '/published') {
@@ -857,7 +1031,7 @@ export function createVerily(options) {
857
1031
  // was sent asks for nothing, and must go on getting the list.
858
1032
  if (url.searchParams.get('format') !== 'json' &&
859
1033
  /\btext\/html\b/i.test(request.headers.get('accept') ?? ''))
860
- return html(publishedPage(await service.listed(subject), prefix));
1034
+ return pageResponse(publishedPage(await service.listed(subject), prefix));
861
1035
  const response = json(await service.published(subject));
862
1036
  response.headers.set('Access-Control-Allow-Origin', '*');
863
1037
  return response;
@@ -874,14 +1048,14 @@ export function createVerily(options) {
874
1048
  if (path === '/keys.asc' && signs)
875
1049
  return plain(await service.armoredKeys());
876
1050
  if (path === '/check' && signs)
877
- return html(page(prefix, 'Check a signed record', `<p>Paste the contents of a signed record saved from ${escape(options.verifierName)}.</p>${checkForm}`));
1051
+ return pageResponse(page(prefix, 'Check a signed record', html `<p>Paste the contents of a signed record saved from ${options.verifierName}.</p>${checkForm}`));
878
1052
  if (path === '/mine')
879
1053
  return json(await service.mine(await local(request)));
880
1054
  if (path.startsWith('/s/')) {
881
1055
  const evidence = await service.shared(path.slice(3));
882
1056
  return wants(request, url) === 'json'
883
1057
  ? json(evidence)
884
- : html(evidencePage(evidence, prefix, options.reportUrl));
1058
+ : pageResponse(evidencePage(evidence, prefix, options.reportUrl));
885
1059
  }
886
1060
  const hosted = path.match(/^\/connections\/([^/]+)\/proof$/);
887
1061
  // As public as the evidence it belongs to, and checked the same way.
@@ -904,7 +1078,7 @@ export function createVerily(options) {
904
1078
  return response;
905
1079
  }
906
1080
  const keyId = (await service.keys())[0]?.id;
907
- return html(evidencePage(evidence, prefix, options.reportUrl, keyId, keyId && evidence.signedUrl ? await service.keyNamed(keyId) : undefined));
1081
+ return pageResponse(evidencePage(evidence, prefix, options.reportUrl, keyId, keyId && evidence.signedUrl ? await service.keyNamed(keyId) : undefined));
908
1082
  }
909
1083
  if (path.startsWith('/manage/'))
910
1084
  return json(await service.read(path.slice(8), await local(request)));
@@ -919,12 +1093,12 @@ export function createVerily(options) {
919
1093
  const document = await service.checked(signed);
920
1094
  const by = (await signedBy(signed));
921
1095
  if (document)
922
- return html(checkedPage(document, by, prefix, await service.keyNamed(by)));
1096
+ return pageResponse(checkedPage(document, by, prefix, await service.keyNamed(by)));
923
1097
  }
924
1098
  catch {
925
1099
  // A record this build cannot draw is not one it can vouch for.
926
1100
  }
927
- return html(page(prefix, 'Check a signed record', `<p>This is not a record signed by ${escape(options.verifierName)}.</p>${checkForm}`));
1101
+ return pageResponse(page(prefix, 'Check a signed record', html `<p>This is not a record signed by ${options.verifierName}.</p>${checkForm}`));
928
1102
  }
929
1103
  if (path === '/connect') {
930
1104
  const user = await local(request);
@@ -1012,8 +1186,8 @@ export function createVerily(options) {
1012
1186
  catch (error) {
1013
1187
  // Never serialize/log exceptions: provider responses and request URLs can contain secrets.
1014
1188
  if (error instanceof Unavailable || error instanceof SyntaxError)
1015
- return html(page(prefix, 'Unavailable', '<p>This resource is unavailable.</p>'), 404);
1016
- return html(page(prefix, 'Request failed', '<p>Please try again.</p>'), 500);
1189
+ return pageResponse(page(prefix, 'Unavailable', html `<p>This resource is unavailable.</p>`), 404);
1190
+ return pageResponse(page(prefix, 'Request failed', html `<p>Please try again.</p>`), 500);
1017
1191
  }
1018
1192
  }
1019
1193
  // A sign-in form submits here and is redirected to the provider. Chromium applies