@bkazemi/verily 0.4.0 → 0.5.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.
package/README.md CHANGED
@@ -145,7 +145,7 @@ import { init } from '@bkazemi/verily'; // also registers <verily-badge>
145
145
  const client = init({ backendUrl: '/api/verily' });
146
146
  ```
147
147
 
148
- Without one, serve `node_modules/@bkazemi/verily/dist/verily.js` yourself, or load it from a CDN such as `https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.4.0/dist/verily.js`. The script defines a global `Verily`.
148
+ Without one, serve `node_modules/@bkazemi/verily/dist/verily.js` yourself, or load it from a CDN such as `https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.5.0/dist/verily.js`. The script defines a global `Verily`.
149
149
 
150
150
  Then place the badge wherever the account appears:
151
151
 
@@ -222,8 +222,8 @@ import {
222
222
  | `githubGistProvider()` | publishing a public gist containing a line Verily gives them. | None. |
223
223
  | `linkProvider()` | adding a `rel="me"` link to their profile on a page they control. | The local account needs a `profileUrl`. |
224
224
  | `githubLinkProvider()` | putting their profile URL in the website field of their GitHub profile, then entering their GitHub username. | The local account needs a `profileUrl`. |
225
- | `dnsProvider()` | adding a TXT record to their domain's DNS, then entering the domain. | The local account needs a `profileUrl`. |
226
- | `wellKnownProvider()` | serving a text file at a fixed path on their domain, then entering the domain. | The local account needs a `profileUrl`. |
225
+ | `dnsProvider()` | adding a TXT record to their domain's DNS, then entering the domain. | None. |
226
+ | `wellKnownProvider()` | serving a text file at a fixed path on their domain, then entering the domain. | None. |
227
227
  | `pgpProvider()` | signing a line Verily gives them with their OpenPGP key, then pasting it and the key. | None. |
228
228
  | `emailProvider()` | pressing a button, or entering a code, that Verily mails to their address. | A function that sends one message. |
229
229
 
@@ -243,7 +243,7 @@ Proving the same account a second way adds that proof to the existing record ins
243
243
 
244
244
  **Link-backs.** Many people already have one, since GitHub and Mastodon mark profile links `rel="me"`. By default the page may be on any public host, and the account is named by the page's address. This mode needs Node, because Verily checks every connection it makes to stop the page's address from pointing inside your network. Pass `hosts` to read only certain hosts (required on Cloudflare Workers), and `profile` to name the account by handle, as `githubLinkProvider()` does for github.com. If you pass your own `fetch`, it replaces that network check, so it must enforce the same rule itself. Only real `<a>` and `<link>` elements in HTML pages, or a `Link:` header, count; [`src/server/link.ts`](src/server/link.ts) has the exact rules.
245
245
 
246
- **Domains.** `dnsProvider()` proves a domain that serves no page of its own, or only forwards elsewhere. The user adds a TXT record named `_verily` whose value is their `profileUrl`, and enters the domain. Verily looks up `_verily.<domain>` through `https://dns.google/resolve`, a fixed resolver, so it runs on Cloudflare Workers, and the evidence links to that lookup for a reader to repeat. `wellKnownProvider()` reads `https://<domain>/.well-known/verily.txt` instead and looks for the `profileUrl` on a line of its own. It fetches a host the user names, so it follows the link-back's rules: Node, or `hosts`. Both name the account by the exact domain entered, and say nothing about its subdomains. List both and a domain proved one way picks up the other if it is already there.
246
+ **Domains.** `dnsProvider()` proves a domain that serves no page of its own, or only forwards elsewhere. The user adds a TXT record named `_verily` whose value is their `profileUrl`, and enters the domain. Verily looks up `_verily.<domain>` through `https://dns.google/resolve`, a fixed resolver, so it runs on Cloudflare Workers, and the evidence links to that lookup for a reader to repeat. `wellKnownProvider()` reads `https://<domain>/.well-known/verily.txt` instead and looks for the `profileUrl` on a line of its own. It fetches a host the user names, so it follows the link-back's rules: Node, or `hosts`. Both name the account by the exact domain entered, and say nothing about its subdomains. Where the resolver validated the DNS answer with DNSSEC, the proof records it and the evidence says so; a domain that does not sign its zone is proved all the same, on the resolver's own lookup. `recheck()` updates the flag at each read. A local account with no `profileUrl` has no address to publish, so the user is given a random value, `verily-proof=…`, instead. It names neither the account nor your site, since DNS is public, and a renewal asks for the same value, so the record is added once. List both and a domain proved one way picks up the other if it is already there.
247
247
 
248
248
  **Your own method.** A provider is an object, and [`src/core/index.ts`](src/core/index.ts) documents each shape. An artifact provider may also set `field` and `input` to name what it asks for, `resolve` to turn something short such as a username into the address to read, and `known` to say where to look for an account already on record, which is what lets a proof be found without asking. A sign-in provider's `authorizationUrl` is passed `account` on a renewal or visibility change, so it can ask for that account by name.
249
249
 
@@ -295,13 +295,13 @@ A static site only needs the script and the badge markup; the backend runs somew
295
295
 
296
296
  1. Clone this repository, run `npm ci`, and copy `wrangler.example.jsonc` to `wrangler.jsonc`. Fill in your account, origin and owner details.
297
297
  2. Run `wrangler secret put` for `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` and `OWNER_KEY`, then `npm run cloudflare:deploy`.
298
- 3. On the backend's site, sign in with `OWNER_KEY`, verify, and approve a **public** connection. Its id is listed at `/api/verily/mine`.
298
+ 3. On the backend's site, sign in with `OWNER_KEY`, verify, and approve a **public** connection. Its id is listed at `/mine`.
299
299
  4. Add the script and the badge to your site:
300
300
 
301
301
  ```html
302
- <script src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.4.0/dist/verily.js" defer></script>
302
+ <script src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.5.0/dist/verily.js" defer></script>
303
303
  <verily-badge
304
- backend-url="https://your-backend.example/api/verily"
304
+ backend-url="https://your-backend.example"
305
305
  connection-id="CONNECTION_ID"
306
306
  ></verily-badge>
307
307
  ```
@@ -355,7 +355,7 @@ The authorize and return URLs the instance's operator registers for you are `<pr
355
355
  ```html
356
356
  <script src="/assets/verily.js" defer></script>
357
357
  <verily-connect
358
- backend-url="https://verily.example/api/verily"
358
+ backend-url="https://verily.example"
359
359
  handoff-url="/api/verily/handoff"
360
360
  ></verily-connect>
361
361
  ```
@@ -367,7 +367,7 @@ Once a user has a link, show their badge in place of the pill, and give it the s
367
367
  ```html
368
368
  <verily-badge
369
369
  connections="[...the records, as JSON...]"
370
- backend-url="https://verily.example/api/verily"
370
+ backend-url="https://verily.example"
371
371
  handoff-url="/api/verily/handoff"
372
372
  ></verily-badge>
373
373
  ```
@@ -468,6 +468,43 @@ A signed record proves what the verifier claimed and when (`issuedAt`). It does
468
468
 
469
469
  The Cloudflare Worker signs by default, with a key it makes and stores itself. Set the `SIGNING_KEY` secret to supply your own, or `SIGNING` to `off` to sign nothing.
470
470
 
471
+ ## What DNS says of the verifier
472
+
473
+ Two TXT records let a reader check the verifier against DNS instead of taking its word. Both sit at `_verily.<domain>`, the name the domain method uses, and neither is required.
474
+
475
+ | Record | Published on | Says |
476
+ | --------------------------------------- | --------------------- | ------------------------------------------------------- |
477
+ | `verily-key=<fingerprint>` | the verifier's domain | this OpenPGP key signs the verifier's records. |
478
+ | `verily-verifier=<the verifier's host>` | a site's domain | this verifier speaks for the site's accounts and pages. |
479
+
480
+ **The signing key.** A signed record is checked against keys the verifier's own server lists, so a reader has nowhere else to confirm them and nothing once the server is gone. Publish one `verily-key=` record for each key, current and retired, on the host in `baseUrl`. `GET <baseUrl>/keys` returns the exact records under `dns`. A reader then confirms the key against the domain, for as long as the domain is kept:
481
+
482
+ ```ts
483
+ import { dnsKeys, signedBy, verifySigned } from '@bkazemi/verily';
484
+
485
+ const record = await verifySigned(file, keys); // keys saved with the file, or from /keys
486
+ const named = await dnsKeys(new URL(record.evidenceUrl).hostname);
487
+
488
+ named.ids.includes(await signedBy(file)); // the verifier's domain names the key that signed it
489
+ named.dnssec; // whether the resolver validated that answer
490
+ ```
491
+
492
+ **The site's verifier.** A record's local side is the verifier's statement of what the site told it. On the site's own pages that is enough, since the site put the badge there. On a shared evidence link or a saved signed record, nothing shows the site chose this verifier. A site whose verifier is on another domain publishes `verily-verifier=<host>` on the domain of its `profileUrl`, with the port if `baseUrl` has one. `dnsVerifiers(domain)` reads it back. A verifier on the site's own domain, or a subdomain of it, needs no record and none is looked for.
493
+
494
+ Set `dns: true` and the instance reads both through `https://dns.google/resolve`, the resolver the domain method asks:
495
+
496
+ ```ts
497
+ createVerily({
498
+ // ...
499
+ dns: true, // or { fetch, timeoutMs }
500
+ });
501
+ ```
502
+
503
+ - The evidence page and `<baseUrl>/check` say the signing key is named in the verifier's DNS, with a link to the lookup, once it is. The answer is kept for ten minutes.
504
+ - When a link is approved, the site's domain is looked up. If it names this verifier, the record's local side carries the lookup as `artifactUrl` and the record as `expect`, the evidence reads "Stated by … whose DNS names this verifier" and links to the lookup, and the signed record includes it. `recheck()` reads each site again every `recheckMs`, one lookup for all of a site's records. A record loses the proof when the site stops naming the verifier, or when the lookup has gone unread for `freshnessMs`; its status is unaffected either way.
505
+
506
+ The Cloudflare Worker sets `dns: true`.
507
+
471
508
  ## Operations
472
509
 
473
510
  Verification lasts 30 days, flows 10 minutes, and sharing links 7 days. Change them with `validityMs`, `flowTtlMs` and `shareTtlMs`. Evidence is never cached.
@@ -489,7 +526,7 @@ setInterval(
489
526
  ```
490
527
 
491
528
  - **`prune()`** deletes expired flows, and deletes expired or revoked records after 90 days. A retired record is kept until it is removed.
492
- - **`recheck()`** re-reads gists and link-backs that are due, up to 5 per run (its `budget` argument). Each proof is re-read every 24 hours (`recheckMs`) and stays current for 7 days after its last successful read (`freshnessMs`); after that it shows as unconfirmed until a read succeeds. A failed read changes nothing. For PGP, it asks the keyserver whether the key has been revoked, and revokes the connection if so.
529
+ - **`recheck()`** re-reads gists, link-backs, DNS records and files that are due, up to 5 per run (its `budget` argument). Each proof is re-read every 24 hours (`recheckMs`) and stays current for 7 days after its last successful read (`freshnessMs`); after that it shows as unconfirmed until a read succeeds. A failed read changes nothing. For PGP, it asks the keyserver whether the key has been revoked, and revokes the connection if so.
493
530
 
494
531
  **If you never call `recheck()`, set `freshnessMs: Infinity`.** Otherwise connections proved by gist, link-back or PGP lapse after a week.
495
532
 
@@ -117,7 +117,7 @@ export function linkMark() {
117
117
  * Additional methods sit beneath the main one, indented under it.
118
118
  */
119
119
  function attestationNote(attestation, names, additional = false) {
120
- const label = attestationLabel(attestation.method, names);
120
+ const label = attestationLabel(attestation.method, names, attestation);
121
121
  if (!label)
122
122
  return [];
123
123
  const note = node('div', additional ? '+ ' : '', `muted ${additional ? 'additional' : 'method'}`);
@@ -1,3 +1,4 @@
1
+ import { routePath } from '../core/routes.js';
1
2
  import { badgeShown, quietBadge, renderBadge, renderBadgeMessage, renderBadgePending, renderConnectPill, } from './badge.js';
2
3
  import { openConnectDialog, } from './connect-dialog.js';
3
4
  import { openEvidenceDialog } from './evidence-dialog.js';
@@ -249,7 +250,10 @@ export function init({ backendUrl, handoffUrl }) {
249
250
  const base = new URL(backendUrl, location.href);
250
251
  if (!['https:', 'http:'].includes(base.protocol))
251
252
  throw new Error('Invalid backend URL');
252
- base.pathname = base.pathname.replace(/\/$/, '');
253
+ // Where the backend's routes begin, with no slash to end it: a backend mounted at the
254
+ // root of its origin has a path of `/` however it is written, and its routes are not
255
+ // under `//`.
256
+ const root = base.href.replace(/\/$/, '');
253
257
  /**
254
258
  * A backend on another origin: an instance this site is registered with. Its cookies
255
259
  * are another site's on this page, so the dialog carries what they would have. The
@@ -268,7 +272,7 @@ export function init({ backendUrl, handoffUrl }) {
268
272
  const vouched = handed.ok ? await handed.json() : undefined;
269
273
  if (!record(vouched) || typeof vouched.token !== 'string')
270
274
  throw new Error('Verily request unavailable');
271
- const traded = await fetch(`${base.href}/site/session`, {
275
+ const traded = await fetch(`${root}/site/session`, {
272
276
  method: 'POST',
273
277
  credentials: 'omit',
274
278
  cache: 'no-store',
@@ -285,11 +289,12 @@ export function init({ backendUrl, handoffUrl }) {
285
289
  if (!remote)
286
290
  return request(path, data);
287
291
  const binding = flow && bindings.get(flow);
288
- const response = await fetch(`${base.href}${path}`, {
292
+ const response = await fetch(`${root}${path}`, {
289
293
  credentials: 'omit',
290
294
  cache: 'no-store',
291
295
  method: data ? 'POST' : 'GET',
292
296
  headers: {
297
+ Accept: 'application/json',
293
298
  Authorization: `Bearer ${await (session ??= openSession())}`,
294
299
  ...(binding ? { 'X-Verily-Flow': binding } : {}),
295
300
  ...(data ? { 'Content-Type': 'application/json' } : {}),
@@ -306,16 +311,16 @@ export function init({ backendUrl, handoffUrl }) {
306
311
  return answer;
307
312
  }
308
313
  async function request(path, data) {
309
- const response = await fetch(`${base.href}${path}`, {
314
+ const response = await fetch(`${root}${path}`, {
310
315
  credentials: 'same-origin',
311
316
  cache: 'no-store',
312
- ...(data
313
- ? {
314
- method: 'POST',
315
- headers: { 'Content-Type': 'application/json' },
316
- body: JSON.stringify(data),
317
- }
318
- : {}),
317
+ // Says what is wanted back, whatever is sent. A header any page may send to another
318
+ // origin without asking first, so a badge on a static site reads as it did.
319
+ headers: {
320
+ Accept: 'application/json',
321
+ ...(data ? { 'Content-Type': 'application/json' } : {}),
322
+ },
323
+ ...(data ? { method: 'POST', body: JSON.stringify(data) } : {}),
319
324
  });
320
325
  if (!response.ok)
321
326
  throw new Error('Verily request unavailable');
@@ -324,20 +329,20 @@ export function init({ backendUrl, handoffUrl }) {
324
329
  /** The connect flow as the dialog drives it, every answer checked before it is drawn. */
325
330
  const flows = {
326
331
  async methods() {
327
- const data = await ask('/methods');
332
+ const data = await ask(routePath('methods'));
328
333
  if (!record(data) || !record(data.local) || !Array.isArray(data.methods))
329
334
  throw new Error('Invalid methods response');
330
335
  if (typeof data.local.profileUrl === 'string')
331
336
  safeUrl(data.local.profileUrl);
332
337
  return data;
333
338
  },
334
- start: async (provider, method, renew) => flowView(await ask('/sessions', renew
339
+ start: async (provider, method, renew) => flowView(await ask(routePath('start'), renew
335
340
  ? { kind: 'renew', connectionId: renew, provider, method }
336
341
  : { 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)),
342
+ read: async (id) => flowView(await ask(routePath('flow', id), undefined, id)),
343
+ submit: async (id, artifact) => flowView(await ask(routePath('submit', id), { artifact }, id)),
339
344
  async approve(id, visibility, cancel) {
340
- const data = await ask(`/flows/${encodeURIComponent(id)}/approve`, { action: cancel ? 'cancel' : 'approve', visibility }, id);
345
+ const data = await ask(routePath('approve', id), { action: cancel ? 'cancel' : 'approve', visibility }, id);
341
346
  if (!record(data))
342
347
  throw new Error('Invalid approval response');
343
348
  return data.outcome === 'complete' && typeof data.connectionId === 'string'
@@ -368,7 +373,7 @@ export function init({ backendUrl, handoffUrl }) {
368
373
  }, base.origin);
369
374
  };
370
375
  window.addEventListener('message', receive);
371
- popup.location.href = `${base.href}/site/enter`;
376
+ popup.location.href = `${root}/site/enter`;
372
377
  },
373
378
  }
374
379
  : {}),
@@ -396,7 +401,7 @@ export function init({ backendUrl, handoffUrl }) {
396
401
  pill.setAttribute('aria-haspopup', 'dialog');
397
402
  pill.onclick = async () => {
398
403
  if (typeof HTMLDialogElement === 'undefined' && !remote) {
399
- location.href = `${base.href}/verify`;
404
+ location.href = `${root}/verify`;
400
405
  return;
401
406
  }
402
407
  const result = await client.openConnect(element);
@@ -546,7 +551,7 @@ export function init({ backendUrl, handoffUrl }) {
546
551
  // On another origin this is vouched for afresh, as each opening of the dialog is.
547
552
  if (remote)
548
553
  session = undefined;
549
- return ask(`/connections/${encodeURIComponent(id)}/disconnect`, {});
554
+ return ask(routePath('disconnect', id), {});
550
555
  },
551
556
  /**
552
557
  * Says how the holder would have an account listed, or retires it. Names one record
@@ -555,10 +560,10 @@ export function init({ backendUrl, handoffUrl }) {
555
560
  mark(id, as) {
556
561
  if (remote)
557
562
  session = undefined;
558
- return ask(`/connections/${encodeURIComponent(id)}/mark`, { as });
563
+ return ask(routePath('mark', id), { as });
559
564
  },
560
- issueShare: (id) => request(`/connections/${encodeURIComponent(id)}/share`, {}),
561
- revokeShare: (id) => request(`/connections/${encodeURIComponent(id)}/share-revoke`, {}),
565
+ issueShare: (id) => request(routePath('share', id), {}),
566
+ revokeShare: (id) => request(routePath('shareRevoke', id), {}),
562
567
  };
563
568
  return client;
564
569
  }
@@ -643,6 +648,7 @@ function validAttestation(attestation) {
643
648
  typeof attestation.confirmedAt === 'number' &&
644
649
  Number.isFinite(attestation.confirmedAt) &&
645
650
  (attestation.expect === undefined || typeof attestation.expect === 'string') &&
651
+ (attestation.dnssec === undefined || typeof attestation.dnssec === 'boolean') &&
646
652
  // Rendered as a link later, so only http(s) may ever reach an href.
647
653
  (attestation.artifactUrl === undefined || httpUrl(attestation.artifactUrl)));
648
654
  }
@@ -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.
332
+ */
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.
292
339
  */
293
- expect?(local: LocalAccount): string;
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
@@ -510,12 +572,13 @@ export declare function externalName(external: ExternalAccount): string;
510
572
  * judgement, which is the reason for publishing it rather than a verdict about it.
511
573
  *
512
574
  * An unrecognised method returns nothing, so a renderer omits the line instead of
513
- * describing a proof it does not understand.
575
+ * describing a proof it does not understand. Given the attestation, the words also say
576
+ * what its proof adds: that a DNS answer was validated, or that a site's DNS was read.
514
577
  */
515
578
  export declare function attestationLabel(method: string, names: {
516
579
  site: string;
517
580
  provider: string;
518
- }): string | undefined;
581
+ }, proof?: Pick<Attestation, 'artifactUrl' | 'dnssec'>): string | undefined;
519
582
  /**
520
583
  * A subject's second identifier, shown beneath its name, or nothing where the name is
521
584
  * already all of it: a mailbox's address and a domain are each their own id.
@@ -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
@@ -113,16 +114,21 @@ export function externalName(external) {
113
114
  * judgement, which is the reason for publishing it rather than a verdict about it.
114
115
  *
115
116
  * An unrecognised method returns nothing, so a renderer omits the line instead of
116
- * describing a proof it does not understand.
117
+ * describing a proof it does not understand. Given the attestation, the words also say
118
+ * what its proof adds: that a DNS answer was validated, or that a site's DNS was read.
117
119
  */
118
- export function attestationLabel(method, names) {
120
+ export function attestationLabel(method, names, proof = {}) {
121
+ const dnssec = proof.dnssec ? ', validated by DNSSEC' : '';
119
122
  return {
120
- declared: `Stated by ${names.site}`,
123
+ // A site's word carries a proof only where its own DNS was read naming this verifier.
124
+ declared: proof.artifactUrl
125
+ ? `Stated by ${names.site}, whose DNS names this verifier${dnssec}`
126
+ : `Stated by ${names.site}`,
121
127
  oauth: `Signed in with ${names.provider}`,
122
128
  gist: `Published a proof on ${names.provider}`,
123
129
  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`,
130
+ dns: `Published a proof in its DNS${dnssec}`,
131
+ wellknown: 'Published a proof in a file it serves',
126
132
  signature: 'Proved with a signature',
127
133
  code: 'Entered a code sent to this address',
128
134
  }[method];
@@ -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
+ }