@bkazemi/verily 0.8.0 → 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
@@ -201,6 +210,19 @@ function heldNames(providers) {
201
210
  const names = [...new Set(providers.map(held))];
202
211
  return names.length > 1 ? `${names.slice(0, -1).join(', ')} or ${names.at(-1)}` : names[0];
203
212
  }
213
+ /** What proving control is for on each kind of /verify page. */
214
+ function purpose(kind, held, noun, several) {
215
+ const ways = {
216
+ connect: several
217
+ ? `Choose an account to link to this ${noun}. You show you control it, then review the link before it's made.`
218
+ : `Show you control your ${held} to link it to this ${noun}. You review the link before it's made.`,
219
+ renew: `A connection lapses unless it's renewed. Show you still control your ${held} to extend it. It keeps its ID, so anywhere that shows it keeps working.`,
220
+ visibility: `Only someone who controls both accounts can change this. Show you still control your ${held}, then choose.`,
221
+ revoke: `Show you control the ${held} on this connection, and you can then remove it.`,
222
+ 'share-revoke': `Show you control the ${held} on this connection, and you can then revoke its sharing link. The connection itself stays.`,
223
+ };
224
+ return ways[kind];
225
+ }
204
226
  /** Provider setup with the flow’s return instructions, shared by HTML and the dialog. */
205
227
  function proofInstructions(provider, flow) {
206
228
  return [
@@ -217,22 +239,20 @@ function proofInstructions(provider, flow) {
217
239
  * reflowed: it is copied character for character, and one wrapped line is a broken command.
218
240
  */
219
241
  function instructions(parts) {
220
- return parts
221
- .map((part) => typeof part === 'string'
222
- ? `<p>${escape(part)}</p>`
242
+ return html `${parts.map((part) => typeof part === 'string'
243
+ ? html `<p>${part}</p>`
223
244
  : Array.isArray(part)
224
- ? `<p>${part.map(inline).join('')}</p>`
225
- : `<pre><code>${escape(part.code)}</code></pre>`)
226
- .join('');
245
+ ? html `<p>${part.map(inline)}</p>`
246
+ : html `<pre><code>${part.code}</code></pre>`)}`;
227
247
  }
228
248
  /** A piece of a paragraph. A link that is not http(s) is written as its text alone. */
229
249
  function inline(piece) {
230
250
  if (typeof piece === 'string')
231
- return escape(piece);
251
+ return piece;
232
252
  const href = safeUrl(piece.href);
233
253
  return href
234
- ? `<a href="${escape(href)}" rel="noreferrer" target="_blank">${escape(piece.text)}</a>`
235
- : escape(piece.text);
254
+ ? html `<a href="${href}" rel="noreferrer" target="_blank">${piece.text}</a>`
255
+ : piece.text;
236
256
  }
237
257
  /**
238
258
  * The holder's choice of who may read the record, or the one visibility there is when the
@@ -253,8 +273,18 @@ function visibilityChoice(local, allowed, signed) {
253
273
  ? `Public: anyone can view both sides of this link, on ${local.siteName} or anywhere else`
254
274
  : 'Public: anyone can view both sides of this link') + (signed ? `. ${signedNote}` : '');
255
275
  if (allowed.length === 1)
256
- return `<input type="hidden" name="visibility" value="${allowed[0]}"><p class="fine">${escape(allowed[0] === 'unlisted' ? `Unlisted. ${unlisted}` : `${shown}.`)}</p>`;
257
- 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
+ `;
258
288
  }
259
289
  /**
260
290
  * A link's local side is one of the site's accounts, a page, or the site itself.
@@ -271,59 +301,91 @@ function evidencePage(e, base, report, keyId, named) {
271
301
  // The heading already names the site and the card already names the subject, so the
272
302
  // site's own reference would be a third line saying the same thing. The provider's
273
303
  // identifier stays: that one is the provider's word, not the site's own wording.
274
- card(local.heading, local.value, e.local.profileUrl, undefined, attestationNote(e.attestations.local, names)) +
275
- externalCard(marked(names.provider, e.provider, e.attestations.external[0].method), e.external, externalNotes(e.attestations, names)) +
276
- times(e.status === 'retired'
277
- ? [
278
- ['Approved', e.approvedAt],
279
- // Two rows, always, so a reader sees any gap between them: an account retired
280
- // weeks after it was last proved went unproved for those weeks.
281
- ['Last verified', lastProved(e)],
282
- ['Retired', e.retiredAt],
283
- ['Sharing link expires', e.linkExpiresAt],
284
- ]
285
- : [
286
- ['Approved', e.approvedAt],
287
- ['Authenticated', e.authenticatedAt],
288
- [
289
- current ? 'Valid until' : 'Expired on',
290
- e.status === 'revoked' ? undefined : e.expiresAt,
291
- ],
292
- ['Revoked on', e.revokedAt],
293
- // Only a method that publishes an artifact drifts; a sign-in does not go stale.
294
- [
295
- 'Last checked',
296
- e.attestations.external[0].artifactUrl
297
- ? e.attestations.external[0].confirmedAt
298
- : undefined,
299
- ],
300
- ['Sharing link expires', e.linkExpiresAt],
301
- ]) +
302
- `${e.linkExpiresAt ? '<p class="fine">Anyone with this link can view and forward it.</p>' : ''}
303
- ${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>` : ''}
304
- <p class="fine">This connection does not establish legal identity, trustworthiness, content authorship, or permanent ownership.</p>
305
- <p class="fine"><a href="${escape(base)}/external-revoke/${escape(e.id)}">Remove this connection using your external account</a></p>
306
- ${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>` : ''}
307
- <p class="fine"><a href="${escape(report)}" rel="noreferrer">Report an incorrect record</a></p>`, '', '', verdict(toneOf(e, Date.now())));
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));
308
366
  }
309
367
  /** Joins a subject's card to its accounts: the link itself, drawn as the dialog draws it. */
310
- const joiner = '<svg class="joiner" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">' +
311
- [
312
- 'M10.5 7.5 13 5a4.95 4.95 0 0 1 7 7l-2.5 2.5',
313
- 'M13.5 16.5 11 19a4.95 4.95 0 0 1-7-7l2.5-2.5',
314
- 'M9 15l6-6',
315
- ]
316
- .map((d) => `<path d="${d}" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>`)
317
- .join('') +
318
- '</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
+ `;
319
377
  /**
320
378
  * One account as a badge's dialog draws it: what it is and how it stands, the verifier's
321
379
  * own page for it, how it was shown, how its holder lists it, and its times.
322
380
  */
323
381
  function accountCard(e, external, now) {
324
382
  const retired = e.status === 'retired';
325
- const record = escape(safeUrl(e.evidenceUrl));
326
- 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
327
389
  ? [
328
390
  ['Approved', e.approvedAt],
329
391
  ['Last verified', lastProved(e)],
@@ -336,11 +398,11 @@ function accountCard(e, external, now) {
336
398
  ]),
337
399
  // The holder's own word on the account, which nothing checked, so it sits apart from
338
400
  // the methods, in the card's corner as the dialog has it.
339
- retired || !e.mark
340
- ? ''
341
- : e.mark === 'preferred'
342
- ? '<div class="listing preferred">Preferred</div>'
343
- : '<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>`));
344
406
  }
345
407
  /**
346
408
  * The public records given, as a page that shows what a badge's dialog does: a card to a
@@ -354,17 +416,19 @@ function publishedPage(records, base) {
354
416
  subjects.set(key, [...(subjects.get(key) ?? []), e]);
355
417
  }
356
418
  return page(base, 'Public connections', records.length
357
- ? [...subjects.values()]
358
- .map((held) => {
419
+ ? html `
420
+ ${[...subjects.values()].map((held) => {
359
421
  const local = localSide(held[0].local, held[0].siteName);
360
- return (card(local.heading, local.value, held[0].local.profileUrl) +
361
- joiner +
362
- `<div class="accounts">${accountsOf(held, now)
363
- .map(({ lead, external }) => accountCard(lead, external, now))
364
- .join('')}</div>`);
365
- })
366
- .join('')
367
- : '<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>`);
368
432
  }
369
433
  /**
370
434
  * Names a signing key on one line: its last sixteen digits, linked to the key itself with
@@ -374,12 +438,22 @@ function publishedPage(records, base) {
374
438
  * beside it gives for its verifier.
375
439
  */
376
440
  function keyLine(base, keyId, named) {
377
- 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>`;
378
442
  return named
379
- ? `${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
+ `
380
449
  : key;
381
450
  }
382
- 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
+ `;
383
457
  /**
384
458
  * What a signed record says, once its signature has been checked against this verifier's
385
459
  * keys. It is a record of the past: the page says when, and sends the reader to the live
@@ -390,23 +464,31 @@ function checkedPage(d, keyId, base, named) {
390
464
  const local = localSide(d.local, d.siteName);
391
465
  // A retired record did not stand when it was signed, and its heading says so first.
392
466
  const retired = d.version === 2;
393
- 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>` +
394
- card(local.heading, local.value, d.local.profileUrl, undefined, attestationNote(d.attestations.local, names)) +
395
- externalCard(marked(names.provider, d.provider, d.attestations.external[0].method), d.external, externalNotes(d.attestations, names)) +
396
- times(retired
397
- ? [
398
- ['Signed', d.issuedAt],
399
- ['Approved', d.approvedAt],
400
- ['Last verified', lastProved(d)],
401
- ['Retired', d.retiredAt],
402
- ]
403
- : [
404
- ['Signed', d.issuedAt],
405
- ['Approved', d.approvedAt],
406
- ['Valid until', d.expiresAt],
407
- ]) +
408
- `<p class="fine">${keyLine(base, keyId, named)}</p>
409
- <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
+ `);
410
492
  }
411
493
  export function createVerily(options) {
412
494
  const service = new VerilyService(options);
@@ -521,7 +603,13 @@ export function createVerily(options) {
521
603
  }
522
604
  /** A failure names its reason when the provider gave one meant for the holder. */
523
605
  function result(outcome, id = '', reason) {
524
- 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
+ `));
525
613
  }
526
614
  /**
527
615
  * The page for a flow that has ended, or wherever `finish` sends the holder instead. Only
@@ -617,14 +705,14 @@ export function createVerily(options) {
617
705
  * page that asked is waiting on the flow and carries on from there, so this one offers
618
706
  * nowhere to go.
619
707
  */
620
- const confirmed = (flow) => html(page(prefix, flow.phase === 'failed' ? 'Address not confirmed' : 'Address confirmed', flow.phase === 'failed'
621
- ? `<p>${escape(flow.reason ?? 'This address could not be confirmed')}.</p>`
622
- : '<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>`));
623
711
  async function handle(request) {
624
712
  const url = new URL(request.url);
625
713
  if (url.origin !== base.origin ||
626
714
  !(url.pathname === prefix || url.pathname.startsWith(prefix + '/')))
627
- return html(page(prefix, 'Unavailable', ''), 404);
715
+ return pageResponse(page(prefix, 'Unavailable', html ``), 404);
628
716
  const path = url.pathname.slice(prefix.length);
629
717
  try {
630
718
  if (!['GET', 'POST'].includes(request.method))
@@ -687,22 +775,50 @@ export function createVerily(options) {
687
775
  // A new link started from this page always ends on its approval, as it did when
688
776
  // this form was a GET, so it says so: `/verify` is an address any site can send a
689
777
  // browser to, and the approval is where the holder sees what they are linking.
690
- const forms = offered
691
- .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>${escape(offered.length > 1 ? methodAction(p) : `Continue with ${p.name}`)}</button></form>`)
692
- .join('');
693
- return html(page(prefix, external
694
- ? 'Remove a connection'
778
+ // the provider's mark where it has one, then what the button does
779
+ const label = (text, p) => {
780
+ const heading = marked(text, p.id, providerMethod(p));
781
+ return typeof heading === 'string'
782
+ ? heading
783
+ : html `${heading.mark}<span>${heading.text}</span>`;
784
+ };
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
+ `);
797
+ // several methods stack as one list of equal buttons, as in the dialog
798
+ const choices = offered.length > 1 ? html `<div class="choices">${forms}</div>` : forms;
799
+ return pageResponse(page(prefix, external
800
+ ? kind === 'share-revoke'
801
+ ? 'Revoke a sharing link'
802
+ : 'Remove a connection'
695
803
  : kind === 'renew'
696
804
  ? 'Renew this connection'
697
- : several
698
- ? 'Verify an account'
699
- : `Verify with ${providerNames(offered)}`, `
700
- ${user
701
- ? card(subject(user).heading, subject(user).value, user.profileUrl)
702
- : '<p>Authenticate with the matching external account to review and remove this link.</p>'}
703
- <p>Confirm the connection between this ${escape(subjectNoun(user?.kind) ?? 'site')} and your ${escape(heldNames(offered))}.</p>
704
- ${kind === 'renew' && offered.length > 1 ? '<p class="fine">Renewing another way adds that method beneath the one this connection was first shown by.</p>' : ''}
705
- ${forms}`));
805
+ : kind === 'visibility'
806
+ ? 'Change who can see this connection'
807
+ : several
808
+ ? 'Verify an account'
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
+ `));
706
822
  }
707
823
  if (path === '/callback') {
708
824
  const state = url.searchParams.get('state');
@@ -739,15 +855,30 @@ export function createVerily(options) {
739
855
  if (!['revoke', 'share-revoke'].includes(flow.kind))
740
856
  if ((await local(request)).id !== flow.local?.id)
741
857
  throw new Unavailable();
742
- return html(page(prefix, `Verify with ${provider.name}`, `${instructions(proofInstructions(provider, flow))}
743
- ${flow.tries ? `<p>${escape(refusedNote(flow))}</p>` : ''}
744
- <form method="post" action="${escape(prefix)}/flows/${escape(flow.id)}/submit">
745
- <label>${escape(artifactField(provider))} ${provider.artifact === 'document'
746
- ? `<textarea name="artifact" rows="14" cols="72" required>${escape(flow.artifact ?? '')}</textarea>`
747
- : `<input name="artifact" type="${provider.input ?? 'url'}" value="${escape(flow.artifact ?? flow.suggested ?? '')}" autocapitalize="none" spellcheck="false" required>`}</label>
748
- <button>Check my proof</button></form>
749
- <p class="fine">${escape(artifactNote(provider, flow.expect))}</p>
750
- <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
+ `));
751
882
  }
752
883
  // Holder-paced too, in two steps: where to send a code, then the code itself.
753
884
  if (flow.phase === 'pending' && isCodeProvider(provider)) {
@@ -755,17 +886,53 @@ export function createVerily(options) {
755
886
  if ((await local(request)).id !== flow.local?.id)
756
887
  throw new Unavailable();
757
888
  const step = codeStep(provider, flow);
758
- return html(page(prefix, `Verify with ${provider.name}`, `${step.sentTo === undefined
759
- ? ''
760
- : `<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
761
- ? `<p>That code did not match. ${step.triesLeft === 1 ? 'One try is' : `${step.triesLeft} tries are`} left.</p>`
762
- : ''}`}
763
- <form method="post" action="${escape(prefix)}/flows/${escape(flow.id)}/submit">
764
- <label>${escape(step.sentTo === undefined ? step.field : 'Your code')} ${step.sentTo === undefined
765
- ? `<input name="artifact" type="${step.input}" autocomplete="${step.input}" required>`
766
- : '<input name="artifact" autocomplete="one-time-code" autocapitalize="characters" spellcheck="false" required>'}</label>
767
- <button>${step.sentTo === undefined ? `Send the ${escape(provider.name.toLowerCase())}` : 'Check my code'}</button></form>
768
- ${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
+ `));
769
936
  }
770
937
  if (flow.phase !== 'approval')
771
938
  return await ended(flow);
@@ -777,22 +944,47 @@ export function createVerily(options) {
777
944
  const joined = await service.joining(flow);
778
945
  const kept = ['revoke', 'share-revoke', 'renew'].includes(flow.kind) || joined;
779
946
  const found = standingNote(flow, service.siteOf(flow.local));
780
- return html(page(prefix, ['revoke', 'share-revoke'].includes(flow.kind)
947
+ return pageResponse(page(prefix, ['revoke', 'share-revoke'].includes(flow.kind)
781
948
  ? 'Remove connection'
782
949
  : flow.kind === 'renew'
783
950
  ? 'Renew connection'
784
951
  : joined
785
952
  ? 'Add to connection'
786
- : 'Confirm connection', `
787
- ${card(subject(flow.local).heading, subject(flow.local).value, flow.local.profileUrl)}
788
- ${externalCard(marked(provider.name, provider.id, providerMethod(provider)), flow.external)}
789
- ${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>` : ''}
790
- ${found ? `<p>${escape(found)}</p>` : ''}
791
- <p class="fine">${escape(service.siteOf(flow.local))} receives the result. Verified via ${escape(options.verifierName)}.</p>
792
- <form method="post" action="${escape(prefix)}/flows/${escape(flow.id)}/approve">
793
- ${kept ? '<input type="hidden" name="visibility" value="unlisted">' : visibilityChoice(flow.local, visibilities(flow.local), signs)}
794
- <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>
795
- <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
+ `));
796
988
  }
797
989
  const confirmation = path.match(/^\/confirm\/([^/]+)$/);
798
990
  // Where the button in a message leads, in whatever browser the holder reads mail
@@ -813,13 +1005,22 @@ export function createVerily(options) {
813
1005
  revoke: 'remove its link',
814
1006
  'share-revoke': 'revoke its sharing link',
815
1007
  };
816
- return html(page(prefix, 'Confirm your address', `${asked.local ? card(subject(asked.local).heading, subject(asked.local).value, asked.local.profileUrl) : ''}
817
- ${card(asked.provider.name, externalName(asked.account), undefined)}
818
- <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>
819
- <form method="post" action="${escape(prefix)}/confirm/${escape(confirmation[1])}">
820
- <input type="hidden" name="token" value="${escape(token)}">
821
- <button>Confirm this address</button></form>
822
- <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
+ `));
823
1024
  }
824
1025
  // Lets a static embed track current connections without hardcoding an id.
825
1026
  if (path === '/published') {
@@ -830,7 +1031,7 @@ export function createVerily(options) {
830
1031
  // was sent asks for nothing, and must go on getting the list.
831
1032
  if (url.searchParams.get('format') !== 'json' &&
832
1033
  /\btext\/html\b/i.test(request.headers.get('accept') ?? ''))
833
- return html(publishedPage(await service.listed(subject), prefix));
1034
+ return pageResponse(publishedPage(await service.listed(subject), prefix));
834
1035
  const response = json(await service.published(subject));
835
1036
  response.headers.set('Access-Control-Allow-Origin', '*');
836
1037
  return response;
@@ -847,14 +1048,14 @@ export function createVerily(options) {
847
1048
  if (path === '/keys.asc' && signs)
848
1049
  return plain(await service.armoredKeys());
849
1050
  if (path === '/check' && signs)
850
- 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}`));
851
1052
  if (path === '/mine')
852
1053
  return json(await service.mine(await local(request)));
853
1054
  if (path.startsWith('/s/')) {
854
1055
  const evidence = await service.shared(path.slice(3));
855
1056
  return wants(request, url) === 'json'
856
1057
  ? json(evidence)
857
- : html(evidencePage(evidence, prefix, options.reportUrl));
1058
+ : pageResponse(evidencePage(evidence, prefix, options.reportUrl));
858
1059
  }
859
1060
  const hosted = path.match(/^\/connections\/([^/]+)\/proof$/);
860
1061
  // As public as the evidence it belongs to, and checked the same way.
@@ -877,7 +1078,7 @@ export function createVerily(options) {
877
1078
  return response;
878
1079
  }
879
1080
  const keyId = (await service.keys())[0]?.id;
880
- 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));
881
1082
  }
882
1083
  if (path.startsWith('/manage/'))
883
1084
  return json(await service.read(path.slice(8), await local(request)));
@@ -892,12 +1093,12 @@ export function createVerily(options) {
892
1093
  const document = await service.checked(signed);
893
1094
  const by = (await signedBy(signed));
894
1095
  if (document)
895
- return html(checkedPage(document, by, prefix, await service.keyNamed(by)));
1096
+ return pageResponse(checkedPage(document, by, prefix, await service.keyNamed(by)));
896
1097
  }
897
1098
  catch {
898
1099
  // A record this build cannot draw is not one it can vouch for.
899
1100
  }
900
- 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}`));
901
1102
  }
902
1103
  if (path === '/connect') {
903
1104
  const user = await local(request);
@@ -985,8 +1186,8 @@ export function createVerily(options) {
985
1186
  catch (error) {
986
1187
  // Never serialize/log exceptions: provider responses and request URLs can contain secrets.
987
1188
  if (error instanceof Unavailable || error instanceof SyntaxError)
988
- return html(page(prefix, 'Unavailable', '<p>This resource is unavailable.</p>'), 404);
989
- 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);
990
1191
  }
991
1192
  }
992
1193
  // A sign-in form submits here and is redirected to the provider. Chromium applies