@bkazemi/verily 0.4.0 → 0.6.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,6 +1,8 @@
1
+ import { accountsOf } from '../core/index.js';
2
+ import { routePath } from '../core/routes.js';
1
3
  import { badgeShown, quietBadge, renderBadge, renderBadgeMessage, renderBadgePending, renderConnectPill, } from './badge.js';
2
4
  import { openConnectDialog, } from './connect-dialog.js';
3
- import { openEvidenceDialog } from './evidence-dialog.js';
5
+ import { openEvidenceDialog, subjectUrl, } from './evidence-dialog.js';
4
6
  import { drawSignedWith, standing, watchSigned } from './signed.js';
5
7
  /**
6
8
  * What each host's dialog opens on and how it reads again, kept apart from the pill: the
@@ -90,15 +92,17 @@ function details(element) {
90
92
  throw error;
91
93
  }
92
94
  }
93
- }, latestGroup.get(element), accounts, managers.get(element));
95
+ }, latestGroup.get(element), accounts, managers.get(element), openHosts.has(element));
94
96
  }
95
97
  }
96
98
  /**
97
- * Hosts whose pill is stacked, and hosts whose pill opens no panel, where the page asked
98
- * for either. Kept here so every later refresh of the host draws it the same way.
99
+ * Hosts whose pill is stacked, hosts whose pill opens no panel, and hosts whose dialog
100
+ * starts with its details open, where the page asked for any of them. Kept here so every
101
+ * later refresh of the host draws it the same way.
99
102
  */
100
103
  const stackedHosts = new WeakSet();
101
104
  const quietHosts = new WeakSet();
105
+ const openHosts = new WeakSet();
102
106
  /**
103
107
  * How many accounts a pill names before it counts the rest. A stacked pill pays for each
104
108
  * row in the page's height. A panel floats over the page and pays nothing, but it cannot
@@ -107,7 +111,11 @@ const quietHosts = new WeakSet();
107
111
  const stackedAccounts = 4;
108
112
  const peekedAccounts = 8;
109
113
  /** Records how a host's pill is arranged, from what the page last said. */
110
- function arrange(element, { stacked, peek }) {
114
+ function arrange(element, { stacked, peek, detailsOpen }) {
115
+ if (detailsOpen)
116
+ openHosts.add(element);
117
+ else
118
+ openHosts.delete(element);
111
119
  if (stacked)
112
120
  stackedHosts.add(element);
113
121
  else
@@ -132,45 +140,16 @@ const live = (evidence) => evidence.status === 'verified' && evidence.expiresAt
132
140
  * Each entry names every record of the account not yet revoked, shown or not, so removing
133
141
  * the account removes them all. One left behind, lapsed or unconfirmed, could be confirmed
134
142
  * again later and bring the account back without its holder approving anything.
135
- *
136
- * A record from a backend older than `connectedAt` is placed by when it was approved.
137
143
  */
138
144
  function accounts(records) {
139
- const when = (e) => e.connectedAt ?? e.approvedAt;
140
- const byAccount = new Map();
141
- for (const record of records) {
142
- const key = JSON.stringify([record.provider, record.external.id]);
143
- byAccount.set(key, [...(byAccount.get(key) ?? []), record]);
144
- }
145
- return [...byAccount.values()]
146
- .map((held) => {
147
- const sorted = [...held].sort((a, b) => when(a) - when(b));
148
- const verified = sorted.filter(live);
149
- const base = verified[0] ?? sorted.reduce((a, b) => (b.approvedAt > a.approvedAt ? b : a));
150
- const external = [...base.attestations.external];
151
- for (const other of verified.slice(1))
152
- for (const attestation of other.attestations.external)
153
- if (!external.some((shown) => shown.method === attestation.method))
154
- external.push(attestation);
155
- return {
156
- ...base,
157
- connectedAt: when(sorted[0]),
158
- attestations: { ...base.attestations, external },
159
- links: sorted.filter((e) => e.status !== 'revoked').map((e) => e.id),
160
- // Every record the card speaks for, so its signature mark answers for all of them.
161
- sources: verified.length ? verified : [base],
162
- };
163
- })
164
- .sort((a, b) => place(a) - place(b) || a.connectedAt - b.connectedAt);
165
- }
166
- /**
167
- * Where an account is listed among a subject's others, before the order they were
168
- * connected in. This is all there is to preferring an account: there is no separate pin.
169
- */
170
- function place(account) {
171
- if (account.status === 'retired')
172
- return 3;
173
- return account.mark === 'preferred' ? 0 : account.mark === 'unused' ? 2 : 1;
145
+ return accountsOf(records, Date.now()).map(({ lead, held, verified, external, connectedAt }) => ({
146
+ ...lead,
147
+ connectedAt,
148
+ attestations: { ...lead.attestations, external },
149
+ links: held.filter((e) => e.status !== 'revoked').map((e) => e.id),
150
+ // Every record the card speaks for, so its signature mark answers for all of them.
151
+ sources: verified.length ? verified : [lead],
152
+ }));
174
153
  }
175
154
  /**
176
155
  * One subject's accounts as a single pill: the first of them, then how many more stand
@@ -197,6 +176,8 @@ function presentGroup(element, records, load) {
197
176
  const badge = renderBadge(element, lead, {
198
177
  more: Math.max(verified.length - 1, 0),
199
178
  linked: lead.visibility === 'public',
179
+ // A modified click follows the link, and must reach what a plain one shows: every account.
180
+ ...(ordered.length > 1 ? { href: subjectUrl(lead) } : {}),
200
181
  rows: stacked
201
182
  ? verified.slice(1, stackedAccounts)
202
183
  : peek
@@ -249,7 +230,10 @@ export function init({ backendUrl, handoffUrl }) {
249
230
  const base = new URL(backendUrl, location.href);
250
231
  if (!['https:', 'http:'].includes(base.protocol))
251
232
  throw new Error('Invalid backend URL');
252
- base.pathname = base.pathname.replace(/\/$/, '');
233
+ // Where the backend's routes begin, with no slash to end it: a backend mounted at the
234
+ // root of its origin has a path of `/` however it is written, and its routes are not
235
+ // under `//`.
236
+ const root = base.href.replace(/\/$/, '');
253
237
  /**
254
238
  * A backend on another origin: an instance this site is registered with. Its cookies
255
239
  * are another site's on this page, so the dialog carries what they would have. The
@@ -268,7 +252,7 @@ export function init({ backendUrl, handoffUrl }) {
268
252
  const vouched = handed.ok ? await handed.json() : undefined;
269
253
  if (!record(vouched) || typeof vouched.token !== 'string')
270
254
  throw new Error('Verily request unavailable');
271
- const traded = await fetch(`${base.href}/site/session`, {
255
+ const traded = await fetch(`${root}/site/session`, {
272
256
  method: 'POST',
273
257
  credentials: 'omit',
274
258
  cache: 'no-store',
@@ -285,11 +269,12 @@ export function init({ backendUrl, handoffUrl }) {
285
269
  if (!remote)
286
270
  return request(path, data);
287
271
  const binding = flow && bindings.get(flow);
288
- const response = await fetch(`${base.href}${path}`, {
272
+ const response = await fetch(`${root}${path}`, {
289
273
  credentials: 'omit',
290
274
  cache: 'no-store',
291
275
  method: data ? 'POST' : 'GET',
292
276
  headers: {
277
+ Accept: 'application/json',
293
278
  Authorization: `Bearer ${await (session ??= openSession())}`,
294
279
  ...(binding ? { 'X-Verily-Flow': binding } : {}),
295
280
  ...(data ? { 'Content-Type': 'application/json' } : {}),
@@ -306,16 +291,16 @@ export function init({ backendUrl, handoffUrl }) {
306
291
  return answer;
307
292
  }
308
293
  async function request(path, data) {
309
- const response = await fetch(`${base.href}${path}`, {
294
+ const response = await fetch(`${root}${path}`, {
310
295
  credentials: 'same-origin',
311
296
  cache: 'no-store',
312
- ...(data
313
- ? {
314
- method: 'POST',
315
- headers: { 'Content-Type': 'application/json' },
316
- body: JSON.stringify(data),
317
- }
318
- : {}),
297
+ // Says what is wanted back, whatever is sent. A header any page may send to another
298
+ // origin without asking first, so a badge on a static site reads as it did.
299
+ headers: {
300
+ Accept: 'application/json',
301
+ ...(data ? { 'Content-Type': 'application/json' } : {}),
302
+ },
303
+ ...(data ? { method: 'POST', body: JSON.stringify(data) } : {}),
319
304
  });
320
305
  if (!response.ok)
321
306
  throw new Error('Verily request unavailable');
@@ -324,20 +309,20 @@ export function init({ backendUrl, handoffUrl }) {
324
309
  /** The connect flow as the dialog drives it, every answer checked before it is drawn. */
325
310
  const flows = {
326
311
  async methods() {
327
- const data = await ask('/methods');
312
+ const data = await ask(routePath('methods'));
328
313
  if (!record(data) || !record(data.local) || !Array.isArray(data.methods))
329
314
  throw new Error('Invalid methods response');
330
315
  if (typeof data.local.profileUrl === 'string')
331
316
  safeUrl(data.local.profileUrl);
332
317
  return data;
333
318
  },
334
- start: async (provider, method, renew) => flowView(await ask('/sessions', renew
319
+ start: async (provider, method, renew) => flowView(await ask(routePath('start'), renew
335
320
  ? { kind: 'renew', connectionId: renew, provider, method }
336
321
  : { kind: 'connect', provider, method })),
337
- read: async (id) => flowView(await ask(`/flows/${encodeURIComponent(id)}?format=json`, undefined, id)),
338
- submit: async (id, artifact) => flowView(await ask(`/flows/${encodeURIComponent(id)}/submit`, { artifact }, id)),
322
+ read: async (id) => flowView(await ask(routePath('flow', id), undefined, id)),
323
+ submit: async (id, artifact) => flowView(await ask(routePath('submit', id), { artifact }, id)),
339
324
  async approve(id, visibility, cancel) {
340
- const data = await ask(`/flows/${encodeURIComponent(id)}/approve`, { action: cancel ? 'cancel' : 'approve', visibility }, id);
325
+ const data = await ask(routePath('approve', id), { action: cancel ? 'cancel' : 'approve', visibility }, id);
341
326
  if (!record(data))
342
327
  throw new Error('Invalid approval response');
343
328
  return data.outcome === 'complete' && typeof data.connectionId === 'string'
@@ -368,7 +353,7 @@ export function init({ backendUrl, handoffUrl }) {
368
353
  }, base.origin);
369
354
  };
370
355
  window.addEventListener('message', receive);
371
- popup.location.href = `${base.href}/site/enter`;
356
+ popup.location.href = `${root}/site/enter`;
372
357
  },
373
358
  }
374
359
  : {}),
@@ -396,7 +381,7 @@ export function init({ backendUrl, handoffUrl }) {
396
381
  pill.setAttribute('aria-haspopup', 'dialog');
397
382
  pill.onclick = async () => {
398
383
  if (typeof HTMLDialogElement === 'undefined' && !remote) {
399
- location.href = `${base.href}/verify`;
384
+ location.href = `${root}/verify`;
400
385
  return;
401
386
  }
402
387
  const result = await client.openConnect(element);
@@ -546,7 +531,7 @@ export function init({ backendUrl, handoffUrl }) {
546
531
  // On another origin this is vouched for afresh, as each opening of the dialog is.
547
532
  if (remote)
548
533
  session = undefined;
549
- return ask(`/connections/${encodeURIComponent(id)}/disconnect`, {});
534
+ return ask(routePath('disconnect', id), {});
550
535
  },
551
536
  /**
552
537
  * Says how the holder would have an account listed, or retires it. Names one record
@@ -555,10 +540,10 @@ export function init({ backendUrl, handoffUrl }) {
555
540
  mark(id, as) {
556
541
  if (remote)
557
542
  session = undefined;
558
- return ask(`/connections/${encodeURIComponent(id)}/mark`, { as });
543
+ return ask(routePath('mark', id), { as });
559
544
  },
560
- issueShare: (id) => request(`/connections/${encodeURIComponent(id)}/share`, {}),
561
- revokeShare: (id) => request(`/connections/${encodeURIComponent(id)}/share-revoke`, {}),
545
+ issueShare: (id) => request(routePath('share', id), {}),
546
+ revokeShare: (id) => request(routePath('shareRevoke', id), {}),
562
547
  };
563
548
  return client;
564
549
  }
@@ -643,6 +628,7 @@ function validAttestation(attestation) {
643
628
  typeof attestation.confirmedAt === 'number' &&
644
629
  Number.isFinite(attestation.confirmedAt) &&
645
630
  (attestation.expect === undefined || typeof attestation.expect === 'string') &&
631
+ (attestation.dnssec === undefined || typeof attestation.dnssec === 'boolean') &&
646
632
  // Rendered as a link later, so only http(s) may ever reach an href.
647
633
  (attestation.artifactUrl === undefined || httpUrl(attestation.artifactUrl)));
648
634
  }
@@ -723,6 +709,7 @@ if (typeof customElements !== 'undefined' && !customElements.get('verily-badge')
723
709
  'connect',
724
710
  'stacked',
725
711
  'peek',
712
+ 'details-open',
726
713
  ];
727
714
  handed;
728
715
  /**
@@ -782,6 +769,7 @@ if (typeof customElements !== 'undefined' && !customElements.get('verily-badge')
782
769
  const arrangement = {
783
770
  stacked: this.getAttribute('stacked') !== null,
784
771
  peek: this.getAttribute('peek') !== 'off',
772
+ detailsOpen: this.getAttribute('details-open') !== null,
785
773
  };
786
774
  // The holder's own badge, where the page says so: its dialog connects another
787
775
  // account, renews one and removes one, through the backend named. The host hears
@@ -76,9 +76,24 @@ export interface Attestation {
76
76
  hosted?: boolean;
77
77
  /** What a reader should expect to find at artifactUrl, such as a challenge token. */
78
78
  expect?: string;
79
+ /**
80
+ * Set where the method publishes something of the subject's, as its address, and this
81
+ * subject had nothing for it to publish: `expect` is then a string minted for the holder
82
+ * and names nobody. Such a proof holds whatever the subject's address becomes, where one
83
+ * that published an address is a proof of that address alone.
84
+ */
85
+ minted?: boolean;
86
+ /**
87
+ * Set where the proof was read from DNS and the resolver said it had validated the answer
88
+ * with DNSSEC. Without it the answer is only as good as the resolver's own lookup, which
89
+ * somebody on the path between it and the domain's name servers could have answered.
90
+ */
91
+ dnssec?: boolean;
79
92
  /**
80
93
  * When this side was last confirmed. For `declared` and `oauth` that is the moment it
81
94
  * was established; artifact methods drift out of date and are reconfirmed on a schedule.
95
+ * A `declared` side that carries a proof is the site's own DNS naming this verifier, and
96
+ * is reconfirmed like any other.
82
97
  */
83
98
  confirmedAt: number;
84
99
  }
@@ -143,6 +158,15 @@ export interface Flow {
143
158
  * unguessable and per-flow, so an artifact made for one flow cannot complete another.
144
159
  */
145
160
  expect?: string;
161
+ /** Whether `expect` was minted for a method that had nothing of the subject's to state. */
162
+ minted?: boolean;
163
+ /** Whether the proof was read from a DNS answer the resolver validated with DNSSEC. */
164
+ dnssec?: boolean;
165
+ /**
166
+ * How the local side is recorded once the flow is approved, where the site's own DNS was
167
+ * found to name this verifier. Absent means the site's word alone.
168
+ */
169
+ stated?: Attestation;
146
170
  /**
147
171
  * What the holder handed back: an address to read, or the proof itself. On a flow still
148
172
  * waiting it is what they last handed back and had refused, kept so they can put it right.
@@ -232,12 +256,26 @@ export interface Limit {
232
256
  count: number;
233
257
  expiresAt: number;
234
258
  }
259
+ /**
260
+ * When a site's DNS was last asked whether it names this verifier, by the maintenance run
261
+ * and nothing else. It is what orders the sites in a run, so it is kept for as long as the
262
+ * site has a record here and never expires by itself: an order that could lapse would
263
+ * start again from the same few sites.
264
+ */
265
+ export interface SiteRead {
266
+ /** The site's domain. */
267
+ id: string;
268
+ readAt: number;
269
+ /** Whether that read got an answer. One that did not is asked again sooner. */
270
+ answered: boolean;
271
+ }
235
272
  export interface Records {
236
273
  connections: Connection;
237
274
  flows: Flow;
238
275
  shares: Share;
239
276
  audit: Audit;
240
277
  limits: Limit;
278
+ sites: SiteRead;
241
279
  }
242
280
  export interface Transaction {
243
281
  get<K extends keyof Records>(kind: K, id: string): Promise<Records[K] | undefined>;
@@ -288,9 +326,18 @@ export interface ArtifactProvider {
288
326
  * says the far side points at this exact subject, so the subject's own address is the
289
327
  * whole of what is proved and a per-flow token could not be part of it. Absent means the
290
328
  * backend mints an unguessable string instead, which is what a method wants when any
291
- * fresh artifact will do. Throw when the subject cannot be proved this way at all.
329
+ * fresh artifact will do. So does returning nothing, for a subject that gives the method
330
+ * nothing to state: one with no address has no address to point at. Throw when the
331
+ * subject cannot be proved this way at all.
292
332
  */
293
- expect?(local: LocalAccount): string;
333
+ expect?(local: LocalAccount): string | undefined;
334
+ /**
335
+ * Words the string the backend mints, given its unguessable part. The default names the
336
+ * site, so the holder can see what they are agreeing to before they publish. A method
337
+ * that publishes where anyone can look without being told where, as DNS is, may not want
338
+ * the site said there at all.
339
+ */
340
+ mint?(secret: string): string;
294
341
  /**
295
342
  * Where the proof lives. `location` means the holder publishes it somewhere only they
296
343
  * can write and hands back an address, so the address is half of what is proved.
@@ -333,6 +380,14 @@ export interface ArtifactProvider {
333
380
  artifact: string;
334
381
  expect: string;
335
382
  }): Promise<ExternalAccount>;
383
+ /**
384
+ * The same check, for a method that learns more than whose the proof is. Where a method
385
+ * has one, this is what the backend calls, and `verify` gives the account it found.
386
+ */
387
+ prove?(input: {
388
+ artifact: string;
389
+ expect: string;
390
+ }): Promise<Proved>;
336
391
  /**
337
392
  * Whether the holder has since withdrawn the identity itself, wherever such a statement
338
393
  * is published. This revokes the connection, so it must be something the holder stated
@@ -340,6 +395,12 @@ export interface ArtifactProvider {
340
395
  */
341
396
  withdrawn?(account: ExternalAccount, artifact: string): Promise<boolean>;
342
397
  }
398
+ /** Whose a proof is, with what the method learned of how far the read can be trusted. */
399
+ export interface Proved {
400
+ account: ExternalAccount;
401
+ /** Whether the DNS answer the proof was read from was validated with DNSSEC. */
402
+ dnssec?: boolean;
403
+ }
343
404
  /**
344
405
  * Proves control by sending a message somewhere only the holder can read and having them
345
406
  * answer it: by pressing the link it carries, or by reading its code back. It is
@@ -457,6 +518,7 @@ export interface Evidence {
457
518
  signedUrl?: string;
458
519
  }
459
520
  export * from './signed.js';
521
+ export * from './routes.js';
460
522
  /**
461
523
  * How long a published proof stays good without being read again. An artifact method is
462
524
  * only true while the artifact is still there, and the holder can delete it without
@@ -485,6 +547,29 @@ export declare function lastProved(evidence: Pick<Evidence, 'authenticatedAt' |
485
547
  * that has run out since reads as expired whatever status it was issued with.
486
548
  */
487
549
  export declare function statusLabel(evidence: Pick<Evidence, 'status' | 'expiresAt'>, now: number): string;
550
+ /**
551
+ * Where an account is listed among a subject's others, before the order they were
552
+ * connected in. This is all there is to preferring an account: there is no separate pin.
553
+ */
554
+ export declare function place(account: Pick<Evidence, 'status' | 'mark'>): number;
555
+ /**
556
+ * One subject's records as its accounts, in the order they are listed everywhere: the
557
+ * preferred one, then the rest as first connected, then those unused, then those retired.
558
+ * Records are not accounts: the same account can stand on several. `held` is every record
559
+ * of the account, earliest first, and `verified` those of them that stand now. `lead`
560
+ * speaks for the account: the earliest that stands, or else the latest approved, which
561
+ * says what became of it. `external` is how the account was shown: the lead's methods,
562
+ * then any other its standing records add.
563
+ *
564
+ * A record from a backend older than `connectedAt` is placed by when it was approved.
565
+ */
566
+ export declare function accountsOf(records: Evidence[], now: number): {
567
+ lead: Evidence;
568
+ held: Evidence[];
569
+ verified: Evidence[];
570
+ external: Attestations['external'];
571
+ connectedAt: number;
572
+ }[];
488
573
  /**
489
574
  * What to call a link's local side, and what to write as its value. When the site itself is
490
575
  * what was linked there is no subject on it to name, so the site is the value rather than a
@@ -510,17 +595,23 @@ export declare function externalName(external: ExternalAccount): string;
510
595
  * judgement, which is the reason for publishing it rather than a verdict about it.
511
596
  *
512
597
  * An unrecognised method returns nothing, so a renderer omits the line instead of
513
- * describing a proof it does not understand.
598
+ * describing a proof it does not understand. Given the attestation, the words also say
599
+ * what its proof adds: that a DNS answer was validated, or that a site's DNS was read.
514
600
  */
515
601
  export declare function attestationLabel(method: string, names: {
516
602
  site: string;
517
603
  provider: string;
518
- }): string | undefined;
604
+ }, proof?: Pick<Attestation, 'artifactUrl' | 'dnssec'>): string | undefined;
519
605
  /**
520
606
  * A subject's second identifier, shown beneath its name, or nothing where the name is
521
607
  * already all of it: a mailbox's address and a domain are each their own id.
522
608
  */
523
609
  export declare function externalId(external: ExternalAccount): string | undefined;
610
+ /**
611
+ * A key's fingerprint in groups of four, as gpg prints it, so a renderer can break the line
612
+ * between groups. Nothing for any other subject, whose id is written as it stands.
613
+ */
614
+ export declare function externalIdGroups(external: ExternalAccount): string[] | undefined;
524
615
  /**
525
616
  * Where a subject's name may link, or nothing where it has no page of its own. A mailbox's
526
617
  * address is `mailto:`, which opens a message to it and shows a reader nothing.
@@ -21,6 +21,7 @@ export function providerMethod(provider) {
21
21
  return provider.method ?? 'oauth';
22
22
  }
23
23
  export * from './signed.js';
24
+ export * from './routes.js';
24
25
  /**
25
26
  * How long a published proof stays good without being read again. An artifact method is
26
27
  * only true while the artifact is still there, and the holder can delete it without
@@ -78,6 +79,47 @@ export function statusLabel(evidence, now) {
78
79
  return 'Expired';
79
80
  return evidence.status === 'unconfirmed' ? 'Unconfirmed' : 'Verified';
80
81
  }
82
+ /**
83
+ * Where an account is listed among a subject's others, before the order they were
84
+ * connected in. This is all there is to preferring an account: there is no separate pin.
85
+ */
86
+ export function place(account) {
87
+ if (account.status === 'retired')
88
+ return 3;
89
+ return account.mark === 'preferred' ? 0 : account.mark === 'unused' ? 2 : 1;
90
+ }
91
+ /**
92
+ * One subject's records as its accounts, in the order they are listed everywhere: the
93
+ * preferred one, then the rest as first connected, then those unused, then those retired.
94
+ * Records are not accounts: the same account can stand on several. `held` is every record
95
+ * of the account, earliest first, and `verified` those of them that stand now. `lead`
96
+ * speaks for the account: the earliest that stands, or else the latest approved, which
97
+ * says what became of it. `external` is how the account was shown: the lead's methods,
98
+ * then any other its standing records add.
99
+ *
100
+ * A record from a backend older than `connectedAt` is placed by when it was approved.
101
+ */
102
+ export function accountsOf(records, now) {
103
+ const when = (e) => e.connectedAt ?? e.approvedAt;
104
+ const byAccount = new Map();
105
+ for (const record of records) {
106
+ const key = JSON.stringify([record.provider, record.external.id]);
107
+ byAccount.set(key, [...(byAccount.get(key) ?? []), record]);
108
+ }
109
+ return [...byAccount.values()]
110
+ .map((unsorted) => {
111
+ const held = [...unsorted].sort((a, b) => when(a) - when(b));
112
+ const verified = held.filter((e) => e.status === 'verified' && e.expiresAt > now);
113
+ const lead = verified[0] ?? held.reduce((a, b) => (b.approvedAt > a.approvedAt ? b : a));
114
+ const external = [...lead.attestations.external];
115
+ for (const other of verified.slice(1))
116
+ for (const attestation of other.attestations.external)
117
+ if (!external.some((shown) => shown.method === attestation.method))
118
+ external.push(attestation);
119
+ return { lead, held, verified, external, connectedAt: when(held[0]) };
120
+ })
121
+ .sort((a, b) => place(a.lead) - place(b.lead) || a.connectedAt - b.connectedAt);
122
+ }
81
123
  /**
82
124
  * What to call a link's local side, and what to write as its value. When the site itself is
83
125
  * what was linked there is no subject on it to name, so the site is the value rather than a
@@ -113,16 +155,21 @@ export function externalName(external) {
113
155
  * judgement, which is the reason for publishing it rather than a verdict about it.
114
156
  *
115
157
  * An unrecognised method returns nothing, so a renderer omits the line instead of
116
- * describing a proof it does not understand.
158
+ * describing a proof it does not understand. Given the attestation, the words also say
159
+ * what its proof adds: that a DNS answer was validated, or that a site's DNS was read.
117
160
  */
118
- export function attestationLabel(method, names) {
161
+ export function attestationLabel(method, names, proof = {}) {
162
+ const dnssec = proof.dnssec ? ', validated by DNSSEC' : '';
119
163
  return {
120
- declared: `Stated by ${names.site}`,
164
+ // A site's word carries a proof only where its own DNS was read naming this verifier.
165
+ declared: proof.artifactUrl
166
+ ? `Stated by ${names.site}, whose DNS names this verifier${dnssec}`
167
+ : `Stated by ${names.site}`,
121
168
  oauth: `Signed in with ${names.provider}`,
122
169
  gist: `Published a proof on ${names.provider}`,
123
170
  backlink: `Linked back to ${names.site}`,
124
- dns: `Named ${names.site} in a DNS record`,
125
- wellknown: `Named ${names.site} in a file it serves`,
171
+ dns: `Published a proof in its DNS${dnssec}`,
172
+ wellknown: 'Published a proof in a file it serves',
126
173
  signature: 'Proved with a signature',
127
174
  code: 'Entered a code sent to this address',
128
175
  }[method];
@@ -134,6 +181,13 @@ export function attestationLabel(method, names) {
134
181
  export function externalId(external) {
135
182
  return external.kind === 'mailbox' || external.kind === 'domain' ? undefined : external.id;
136
183
  }
184
+ /**
185
+ * A key's fingerprint in groups of four, as gpg prints it, so a renderer can break the line
186
+ * between groups. Nothing for any other subject, whose id is written as it stands.
187
+ */
188
+ export function externalIdGroups(external) {
189
+ return external.kind === 'key' ? (external.id.match(/.{1,4}/g) ?? undefined) : undefined;
190
+ }
137
191
  /**
138
192
  * Where a subject's name may link, or nothing where it has no page of its own. A mailbox's
139
193
  * address is `mailto:`, which opens a message to it and shows a reader nothing.
@@ -0,0 +1,57 @@
1
+ export declare const routes: {
2
+ readonly methods: {
3
+ readonly method: "GET";
4
+ readonly path: "/methods";
5
+ };
6
+ readonly start: {
7
+ readonly method: "POST";
8
+ readonly path: "/sessions";
9
+ };
10
+ readonly flow: {
11
+ readonly method: "GET";
12
+ readonly path: "/flows/:id";
13
+ readonly format: "json";
14
+ };
15
+ readonly submit: {
16
+ readonly method: "POST";
17
+ readonly path: "/flows/:id/submit";
18
+ };
19
+ readonly approve: {
20
+ readonly method: "POST";
21
+ readonly path: "/flows/:id/approve";
22
+ };
23
+ readonly disconnect: {
24
+ readonly method: "POST";
25
+ readonly path: "/connections/:id/disconnect";
26
+ readonly manages: true;
27
+ };
28
+ readonly mark: {
29
+ readonly method: "POST";
30
+ readonly path: "/connections/:id/mark";
31
+ readonly manages: true;
32
+ };
33
+ readonly share: {
34
+ readonly method: "POST";
35
+ readonly path: "/connections/:id/share";
36
+ readonly manages: true;
37
+ readonly dialog: false;
38
+ };
39
+ readonly shareRevoke: {
40
+ readonly method: "POST";
41
+ readonly path: "/connections/:id/share-revoke";
42
+ readonly manages: true;
43
+ readonly dialog: false;
44
+ };
45
+ };
46
+ export type RouteName = keyof typeof routes;
47
+ /** The address of a route, for the record or flow it acts on where it names one. */
48
+ export declare function routePath(name: RouteName, id?: string): string;
49
+ /** The route a request is for, by its method and its path beneath the mount. */
50
+ export declare function routeOf(method: string, path: string): RouteName | undefined;
51
+ /**
52
+ * The route a dialog's request is for, or nothing where it is not one a dialog makes: a
53
+ * route no dialog calls, or a page asked for without the query that makes it JSON.
54
+ */
55
+ export declare function dialogRoute(method: string, path: string, query: URLSearchParams): RouteName | undefined;
56
+ /** Whether any dialog route is at a path, whatever the method: what a preflight asks about. */
57
+ export declare function dialogPath(path: string): boolean;
@@ -0,0 +1,52 @@
1
+ export const routes = {
2
+ methods: { method: 'GET', path: '/methods' },
3
+ start: { method: 'POST', path: '/sessions' },
4
+ flow: { method: 'GET', path: '/flows/:id', format: 'json' },
5
+ submit: { method: 'POST', path: '/flows/:id/submit' },
6
+ approve: { method: 'POST', path: '/flows/:id/approve' },
7
+ disconnect: { method: 'POST', path: '/connections/:id/disconnect', manages: true },
8
+ mark: { method: 'POST', path: '/connections/:id/mark', manages: true },
9
+ share: { method: 'POST', path: '/connections/:id/share', manages: true, dialog: false },
10
+ shareRevoke: {
11
+ method: 'POST',
12
+ path: '/connections/:id/share-revoke',
13
+ manages: true,
14
+ dialog: false,
15
+ },
16
+ };
17
+ const names = Object.keys(routes);
18
+ /** Whether a path is a route's, with any one segment where the route has `:id`. */
19
+ function matches(route, path) {
20
+ const want = route.path.split('/');
21
+ const got = path.split('/');
22
+ return (want.length === got.length &&
23
+ want.every((part, at) => (part === ':id' ? got[at] !== '' : part === got[at])));
24
+ }
25
+ /** The address of a route, for the record or flow it acts on where it names one. */
26
+ export function routePath(name, id) {
27
+ const route = routes[name];
28
+ const path = route.path.replace(':id', encodeURIComponent(id ?? ''));
29
+ return route.format ? `${path}?format=${route.format}` : path;
30
+ }
31
+ /** The route a request is for, by its method and its path beneath the mount. */
32
+ export function routeOf(method, path) {
33
+ return names.find((name) => routes[name].method === method && matches(routes[name], path));
34
+ }
35
+ /**
36
+ * The route a dialog's request is for, or nothing where it is not one a dialog makes: a
37
+ * route no dialog calls, or a page asked for without the query that makes it JSON.
38
+ */
39
+ export function dialogRoute(method, path, query) {
40
+ const name = routeOf(method, path);
41
+ const route = name && routes[name];
42
+ if (!route || route.dialog === false)
43
+ return undefined;
44
+ return !route.format || query.get('format') === route.format ? name : undefined;
45
+ }
46
+ /** Whether any dialog route is at a path, whatever the method: what a preflight asks about. */
47
+ export function dialogPath(path) {
48
+ return names.some((name) => {
49
+ const route = routes[name];
50
+ return route.dialog !== false && matches(route, path);
51
+ });
52
+ }