@bkazemi/verily 0.2.0 → 0.4.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,4 +1,4 @@
1
- import { attestationLabel, externalLink, externalName, isArtifactProvider, isCodeProvider, isRedirectProvider, localSide, proofTitle, providerMethod, statusLabel, signedBy, } from '../core/index.js';
1
+ import { attestationLabel, externalId, externalLink, externalName, isArtifactProvider, isCodeProvider, isRedirectProvider, lastProved, 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
4
  import { escape } from './escape.js';
@@ -10,6 +10,7 @@ export { discordProvider } from './discord.js';
10
10
  export { youtubeProvider } from './youtube.js';
11
11
  export { githubGistProvider } from './github-gist.js';
12
12
  export { linkProvider, githubLinkProvider } from './link.js';
13
+ export { dnsProvider, wellKnownProvider, } from './domain.js';
13
14
  export { pgpProvider } from './pgp.js';
14
15
  export { generateSigningKey, signer } from './signing.js';
15
16
  export { emailProvider, } from './email.js';
@@ -98,6 +99,8 @@ function methodAction(provider) {
98
99
  oauth: `Sign in with ${provider.name}`,
99
100
  gist: `Publish a proof on ${provider.name}`,
100
101
  backlink: `Link back from ${provider.name}`,
102
+ dns: 'Add a DNS record',
103
+ wellknown: 'Serve a file',
101
104
  signature: `Sign with ${provider.name}`,
102
105
  code: `Confirm by ${provider.name.toLowerCase()}`,
103
106
  };
@@ -118,9 +121,13 @@ function artifactField(provider) {
118
121
  * standing: it goes on the record with this one, and they did not ask for it by name.
119
122
  */
120
123
  function standingNote(flow, site) {
121
- return flow.standing?.some((a) => a.method === 'backlink')
122
- ? `This account already links back to ${site}. Confirming records that too.`
123
- : undefined;
124
+ const found = {
125
+ backlink: `This account already links back to ${site}.`,
126
+ dns: `This domain already names ${site} in a DNS record.`,
127
+ wellknown: `This domain already names ${site} in a file it serves.`,
128
+ };
129
+ const note = flow.standing?.map((a) => found[a.method]).find((said) => said !== undefined);
130
+ return note ? `${note} Confirming records that too.` : undefined;
124
131
  }
125
132
  /**
126
133
  * Said above a proof that was handed back and refused, while the flow still waits for it:
@@ -134,9 +141,13 @@ function refusedNote(flow) {
134
141
  }
135
142
  /** Said beside every artifact method: what the holder publishes is public by design. */
136
143
  function artifactNote(provider) {
137
- return provider.method === 'backlink'
138
- ? 'This link is public, as is the page you point at. Anyone reading either one can follow it here.'
139
- : 'This line is public, as is whatever published it. Publish nothing else alongside it.';
144
+ const notes = {
145
+ backlink: 'This link is public, as is the page you point at. Anyone reading either one can follow it here.',
146
+ dns: 'This record is public, as is the page it names. Anyone reading either one can follow it here.',
147
+ wellknown: 'This file is public, as is the page it names. Anyone reading either one can follow it here.',
148
+ };
149
+ return (notes[provider.method] ??
150
+ 'This line is public, as is whatever published it. Publish nothing else alongside it.');
140
151
  }
141
152
  /**
142
153
  * Where a mailed code stands, for whichever renderer asks the holder for the next thing:
@@ -161,6 +172,21 @@ const codeNote = "We'll email this address to confirm it's yours. The address is
161
172
  function providerNames(providers) {
162
173
  return [...new Set(providers.map((p) => p.name))].join(' or ');
163
174
  }
175
+ /**
176
+ * What the holder would be linking, for the sentence that asks them to: an account with a
177
+ * sign-in provider, a key, an address, a domain. Each is named once, however many ways it
178
+ * can be shown.
179
+ */
180
+ function heldNames(providers) {
181
+ const held = (p) => ({
182
+ signature: `${p.name} key`,
183
+ code: `${p.name.toLowerCase()} address`,
184
+ dns: 'domain',
185
+ wellknown: 'domain',
186
+ })[providerMethod(p)] ?? `${p.name} account`;
187
+ const names = [...new Set(providers.map(held))];
188
+ return names.length > 1 ? `${names.slice(0, -1).join(', ')} or ${names.at(-1)}` : names[0];
189
+ }
164
190
  /** Seconds are the finest thing a record measured in days can mean; milliseconds are noise. */
165
191
  const moment = (time) => new Date(time).toISOString().replace(/\.\d{3}Z$/, 'Z');
166
192
  /**
@@ -243,22 +269,34 @@ function evidencePage(e, base, report, keyId) {
243
269
  // identifier stays: that one is the provider's word, not the site's own wording.
244
270
  card(local.heading, local.value, e.local.profileUrl, undefined, attestationNote(e.attestations.local, names)) +
245
271
  card(names.provider, externalName(e.external), externalLink(e.external),
246
- // A mailbox's address is its name already, so there is no second identifier.
247
- e.external.kind === 'mailbox' ? undefined : e.external.id, externalNotes(e.attestations, names)) +
248
- times([
249
- ['Approved', e.approvedAt],
250
- ['Authenticated', e.authenticatedAt],
251
- [current ? 'Valid until' : 'Expired on', e.status === 'revoked' ? undefined : e.expiresAt],
252
- ['Revoked on', e.revokedAt],
253
- // Only a method that publishes an artifact drifts; a sign-in does not go stale.
254
- [
255
- 'Last checked',
256
- e.attestations.external[0].artifactUrl
257
- ? e.attestations.external[0].confirmedAt
258
- : undefined,
259
- ],
260
- ['Sharing link expires', e.linkExpiresAt],
261
- ]) +
272
+ // A mailbox's address or a domain is its name already, so there is no second identifier.
273
+ externalId(e.external), externalNotes(e.attestations, names)) +
274
+ times(e.status === 'retired'
275
+ ? [
276
+ ['Approved', e.approvedAt],
277
+ // Two rows, always, so a reader sees any gap between them: an account retired
278
+ // weeks after it was last proved went unproved for those weeks.
279
+ ['Last verified', lastProved(e)],
280
+ ['Retired', e.retiredAt],
281
+ ['Sharing link expires', e.linkExpiresAt],
282
+ ]
283
+ : [
284
+ ['Approved', e.approvedAt],
285
+ ['Authenticated', e.authenticatedAt],
286
+ [
287
+ current ? 'Valid until' : 'Expired on',
288
+ e.status === 'revoked' ? undefined : e.expiresAt,
289
+ ],
290
+ ['Revoked on', e.revokedAt],
291
+ // Only a method that publishes an artifact drifts; a sign-in does not go stale.
292
+ [
293
+ 'Last checked',
294
+ e.attestations.external[0].artifactUrl
295
+ ? e.attestations.external[0].confirmedAt
296
+ : undefined,
297
+ ],
298
+ ['Sharing link expires', e.linkExpiresAt],
299
+ ]) +
262
300
  `${e.linkExpiresAt ? '<p class="fine">Anyone with this link can view and forward it.</p>' : ''}
263
301
  ${e.signedUrl ? `<p class="signed" id="signed"><strong>Signed by ${escape(e.verifierName)}</strong>${keyId ? ` with OpenPGP key <a href="${escape(base)}/keys.asc">${escape(keyId)}</a>` : ''}. <a href="${escape(safeUrl(e.signedUrl))}">Download the signed record</a>, which <a href="${escape(base)}/check">can be checked</a> without this page.</p>` : ''}
264
302
  <p class="fine">This connection does not establish legal identity, trustworthiness, content authorship, or permanent ownership.</p>
@@ -275,14 +313,23 @@ const checkForm = '<form method="post"><label>Signed record <textarea name="reco
275
313
  function checkedPage(d, keyId, base) {
276
314
  const names = { site: d.siteName, provider: d.providerName };
277
315
  const local = localSide(d.local, d.siteName);
278
- return page(base, 'Signed record', `<p>${escape(d.verifierName)} signed this record. It shows what stood when it was signed, not what stands now.</p>` +
316
+ // A retired record did not stand when it was signed, and its heading says so first.
317
+ const retired = d.version === 2;
318
+ 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>` +
279
319
  card(local.heading, local.value, d.local.profileUrl, undefined, attestationNote(d.attestations.local, names)) +
280
- card(names.provider, externalName(d.external), externalLink(d.external), d.external.kind === 'mailbox' ? undefined : d.external.id, externalNotes(d.attestations, names)) +
281
- times([
282
- ['Signed', d.issuedAt],
283
- ['Approved', d.approvedAt],
284
- ['Valid until', d.expiresAt],
285
- ]) +
320
+ card(names.provider, externalName(d.external), externalLink(d.external), externalId(d.external), externalNotes(d.attestations, names)) +
321
+ times(retired
322
+ ? [
323
+ ['Signed', d.issuedAt],
324
+ ['Approved', d.approvedAt],
325
+ ['Last verified', lastProved(d)],
326
+ ['Retired', d.retiredAt],
327
+ ]
328
+ : [
329
+ ['Signed', d.issuedAt],
330
+ ['Approved', d.approvedAt],
331
+ ['Valid until', d.expiresAt],
332
+ ]) +
286
333
  `<p class="fine">Signing key <a href="${escape(base)}/keys.asc">${escape(keyId)}</a>.</p>
287
334
  <p class="fine"><a href="${escape(safeUrl(d.evidenceUrl))}">See whether this connection still stands</a></p>`);
288
335
  }
@@ -374,13 +421,24 @@ export function createVerily(options) {
374
421
  /**
375
422
  * The methods a page offers. A flow on an existing record stays in its namespace, so
376
423
  * where the holder is signed in that record narrows the list; removing a link from the
377
- * external side refuses a standing proof, which anybody can hand back.
424
+ * external side refuses a standing proof, which anybody can hand back. A retired record
425
+ * is renewed by the method it was first shown by, so that one is offered alone.
378
426
  */
379
427
  async function offer(kind, id, user, requested) {
380
428
  let namespace = requested ?? undefined;
381
- if (user && id)
382
- namespace = (await service.read(id, user)).provider;
429
+ let only;
430
+ if (user && id) {
431
+ const held = await service.read(id, user);
432
+ namespace = held.provider;
433
+ if (held.status === 'retired') {
434
+ // Who can read a retired record is frozen with the rest of it.
435
+ if (kind === 'visibility')
436
+ throw new Unavailable();
437
+ only = held.attestations.external[0].method;
438
+ }
439
+ }
383
440
  const offered = (user ? permitted(user) : service.providers).filter((p) => (namespace === undefined || p.id === namespace) &&
441
+ (only === undefined || providerMethod(p) === only) &&
384
442
  !(['revoke', 'share-revoke'].includes(kind) && isArtifactProvider(p) && p.expect));
385
443
  if (!offered.length)
386
444
  throw new Unavailable();
@@ -428,8 +486,8 @@ export function createVerily(options) {
428
486
  profileUrl: user.profileUrl,
429
487
  });
430
488
  /**
431
- * A connect flow as the in-page dialog draws it: the same steps the flow pages walk
432
- * through, as data, so the holder never has to leave the page they started on.
489
+ * A connect or renew flow as the in-page dialog draws it: the same steps the flow pages
490
+ * walk through, as data, so the holder never has to leave the page they started on.
433
491
  */
434
492
  async function flowView(flow, authorizationUrl) {
435
493
  const provider = service.providerOf(flow);
@@ -472,10 +530,10 @@ export function createVerily(options) {
472
530
  view.connectionId = flow.resultId;
473
531
  return view;
474
532
  }
475
- /** A flow the dialog may read: one this holder started, to connect an account. */
533
+ /** A flow the dialog may read: one this holder started, to connect an account or renew one. */
476
534
  async function ownFlow(request, id) {
477
535
  const flow = await service.flow(id, binding(request));
478
- if (flow.kind !== 'connect' || (await local(request)).id !== flow.local?.id)
536
+ if (!['connect', 'renew'].includes(flow.kind) || (await local(request)).id !== flow.local?.id)
479
537
  throw new Unavailable();
480
538
  return flow;
481
539
  }
@@ -560,7 +618,7 @@ export function createVerily(options) {
560
618
  ${user
561
619
  ? card(subject(user).heading, subject(user).value, user.profileUrl)
562
620
  : '<p>Authenticate with the matching external account to review and remove this link.</p>'}
563
- <p>Confirm the connection between this ${escape(subjectNoun(user?.kind) ?? 'site')} and your ${escape(providerNames(offered))} account.</p>
621
+ <p>Confirm the connection between this ${escape(subjectNoun(user?.kind) ?? 'site')} and your ${escape(heldNames(offered))}.</p>
564
622
  ${kind === 'renew' && offered.length > 1 ? '<p class="fine">Renewing another way adds that method beneath the one this connection was first shown by.</p>' : ''}
565
623
  ${forms}`));
566
624
  }
@@ -653,7 +711,7 @@ export function createVerily(options) {
653
711
  ? 'Add to connection'
654
712
  : 'Confirm connection', `
655
713
  ${card(subject(flow.local).heading, subject(flow.local).value, flow.local.profileUrl)}
656
- ${card(provider.name, externalName(flow.external), externalLink(flow.external), flow.external.kind === 'mailbox' ? undefined : flow.external.id)}
714
+ ${card(provider.name, externalName(flow.external), externalLink(flow.external), externalId(flow.external))}
657
715
  ${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>` : ''}
658
716
  ${found ? `<p>${escape(found)}</p>` : ''}
659
717
  <p class="fine">${escape(service.siteOf(flow.local))} receives the result. Verified via ${escape(options.verifierName)}.</p>
@@ -775,7 +833,7 @@ export function createVerily(options) {
775
833
  }), await context(request, kind),
776
834
  // Posted from this origin, which the check on every POST has already held it to.
777
835
  true);
778
- if (asJson && kind === 'connect')
836
+ if (asJson && ['connect', 'renew'].includes(kind))
779
837
  return json(await flowView(await service.flow(flow.flowId, flow.binding), flow.authorizationUrl), flowCookie(flow.binding));
780
838
  return redirect(entry(flow), flowCookie(flow.binding));
781
839
  }
@@ -815,15 +873,21 @@ export function createVerily(options) {
815
873
  return json(id ? { outcome: 'complete', connectionId: id } : { outcome: 'cancelled' });
816
874
  return await ended(await service.flow(flow.id, binding(request)));
817
875
  }
818
- const management = path.match(/^\/connections\/([^/]+)\/(disconnect|share|share-revoke|visibility)$/);
876
+ const management = path.match(/^\/connections\/([^/]+)\/(disconnect|mark|share|share-revoke|visibility)$/);
819
877
  if (management) {
820
878
  const user = await local(request), id = management[1];
821
879
  if (management[2] === 'disconnect') {
822
880
  await service.revoke(id, user);
823
881
  return json({ ok: true });
824
882
  }
883
+ // Acts on the account the record names: every record of it this holder has.
884
+ if (management[2] === 'mark') {
885
+ await service.mark(id, user, data.as);
886
+ return json({ ok: true });
887
+ }
825
888
  if (management[2] === 'visibility') {
826
- await service.read(id, user);
889
+ if ((await service.read(id, user)).status === 'retired')
890
+ throw new Unavailable();
827
891
  return json({ url: `${service.baseUrl}/visibility/${id}` });
828
892
  }
829
893
  return json((await service.share(id, user, management[2] === 'share-revoke')) ?? { ok: true });
@@ -4,7 +4,7 @@ import { MIMEType } from 'node:util';
4
4
  import { html, parse } from 'parse5';
5
5
  import { Refused, } from '../core/index.js';
6
6
  import { discard, readBounded } from './body.js';
7
- import { pinnable, publicFetch } from './public-fetch.js';
7
+ import { transport } from './public-fetch.js';
8
8
  /**
9
9
  * Domain suffixes that must never be fetched. The holder names the address, so it names
10
10
  * the host, and these are the names that can resolve to something inside the network
@@ -134,21 +134,6 @@ async function backlinked(response, page, subject, maxBytes) {
134
134
  // Not finding it in part of a page is not the same as it not being there.
135
135
  throw new Refused(truncated ? 'Page is too large to read' : 'Page has no rel="me" link to this subject');
136
136
  }
137
- /**
138
- * How pages are fetched. A deployment that named its hosts has vouched for where they
139
- * resolve, so the platform's own fetch will do. One that reads any host has vouched for
140
- * nothing, and the holder picks the name: every address it resolves to is checked, on the
141
- * connection that uses it, which only Node lets this do.
142
- */
143
- function transport(options) {
144
- if (options.fetch)
145
- return options.fetch;
146
- if (options.hosts)
147
- return (url, init) => fetch(url, init);
148
- if (!pinnable())
149
- throw new Error('Reading any host needs Node, which lets it check where a name resolves; name the hosts to read instead');
150
- return publicFetch();
151
- }
152
137
  /**
153
138
  * The address the holder handed back, once it is one this may fetch. They choose it, so
154
139
  * an unguarded fetch here is an open proxy: HTTPS only, a public-looking name rather than
@@ -19,5 +19,15 @@ export type Fetch = (url: string, init: RequestInit) => Promise<Response>;
19
19
  * `allowed` decides which addresses may be connected to, and is only for tests.
20
20
  */
21
21
  export declare function publicFetch(resolve?: Resolve, allowed?: (address: string) => boolean): Fetch;
22
+ /**
23
+ * How an address the holder named is fetched. A deployment that named its hosts has
24
+ * vouched for where they resolve, so the platform's own fetch will do. One that reads any
25
+ * host has vouched for nothing, and the holder picks the name: every address it resolves
26
+ * to is checked, on the connection that uses it, which only Node lets this do.
27
+ */
28
+ export declare function transport(options: {
29
+ fetch?: typeof fetch;
30
+ hosts?: string[];
31
+ }): Fetch;
22
32
  /** Whether this runtime is Node, the only one that lets `publicFetch()` pin its lookup. */
23
33
  export declare function pinnable(): boolean;
@@ -37,6 +37,21 @@ export function publicFetch(resolve = system, allowed) {
37
37
  }
38
38
  };
39
39
  }
40
+ /**
41
+ * How an address the holder named is fetched. A deployment that named its hosts has
42
+ * vouched for where they resolve, so the platform's own fetch will do. One that reads any
43
+ * host has vouched for nothing, and the holder picks the name: every address it resolves
44
+ * to is checked, on the connection that uses it, which only Node lets this do.
45
+ */
46
+ export function transport(options) {
47
+ if (options.fetch)
48
+ return options.fetch;
49
+ if (options.hosts)
50
+ return (url, init) => fetch(url, init);
51
+ if (!pinnable())
52
+ throw new Error('Reading any host needs Node, which lets it check where a name resolves; name the hosts to read instead');
53
+ return publicFetch();
54
+ }
40
55
  /** A lookup that answers only with addresses that are all permitted, or not at all. */
41
56
  function pinned(resolve, permitted) {
42
57
  return (hostname, options, callback) => {
@@ -1,4 +1,4 @@
1
- import { type Attestations, type Connection, type Evidence, type ExternalAccount, type Flow, type LocalAccount, type Provider, type SignedDocument, type SignedEvidence, type Storage, type VerifierKey, type Visibility } from '../core/index.js';
1
+ import { type Attestations, type Connection, type Evidence, type ExternalAccount, type Flow, type LocalAccount, type Mark, type Provider, type SignedDocument, type SignedEvidence, type Storage, type VerifierKey, type Visibility } from '../core/index.js';
2
2
  export declare const secret: () => string;
3
3
  export declare const hash: (value: string) => string;
4
4
  export declare class Unavailable extends Error {
@@ -64,6 +64,10 @@ export declare class VerilyService {
64
64
  * A public record that stands, signed as of now. Signed when asked for, not when
65
65
  * approved, so a signed record says the record stood on the day it was taken and none can be
66
66
  * made once it is removed.
67
+ *
68
+ * A retired record is signed too, as what it is: the durable form of the claim that the
69
+ * account was the holder's when it was last proved. It did not stand when it was signed,
70
+ * so it is a version of its own that says so, which a reader of the first refuses.
67
71
  */
68
72
  signed(id: string): Promise<SignedEvidence>;
69
73
  /** What a signed record says, if this instance signed it. */
@@ -129,6 +133,10 @@ export declare class VerilyService {
129
133
  * Removing a link from the external side takes a method whose proof is fresh. A standing
130
134
  * proof, such as a link back, is there for anyone to point at: handing one back shows
131
135
  * the link exists, not that whoever handed it back holds the account.
136
+ *
137
+ * A retired record is renewed by the method it was first shown by and no other. Another
138
+ * would be listed beneath a main proof nobody has read since, and the record would come
139
+ * back unconfirmed.
132
140
  */
133
141
  private chosen;
134
142
  /**
@@ -243,7 +251,8 @@ export declare class VerilyService {
243
251
  * The live record a connect flow reproves by another method, if there is one: the same
244
252
  * subject linked to the same account on the same provider, first shown some other way.
245
253
  * The same method again is a second record, as it always was; renewing is how that
246
- * one is extended.
254
+ * one is extended. A retired record is never joined: it is frozen, so proving its account
255
+ * another way makes a new record and leaves that one as history behind it.
247
256
  */
248
257
  private joins;
249
258
  /**
@@ -289,6 +298,28 @@ export declare class VerilyService {
289
298
  mine(local: LocalAccount): Promise<Evidence[]>;
290
299
  private owned;
291
300
  revoke(id: string, local: LocalAccount): Promise<void>;
301
+ /**
302
+ * The holder's records of one account that are not revoked. A mark belongs to an account,
303
+ * and an account can stand on several records: shown a second way on a record of its own,
304
+ * or removed once and connected again. They are told apart as a badge tells them, by
305
+ * provider and the id the provider gave.
306
+ */
307
+ private account;
308
+ /**
309
+ * The mark an account's records carry, for a record about to stand beside them: every
310
+ * one neither revoked nor retired, lapsed ones included, since those hold a mark that
311
+ * must survive their own renewal.
312
+ */
313
+ private accountMark;
314
+ /**
315
+ * Marks an account as the holder would have it listed, or retires it. Names one record
316
+ * and acts on the account, or a mark on one record could vanish behind a sibling, and
317
+ * retiring one could leave the account reading as verified through another.
318
+ *
319
+ * No fresh proof is asked for. A mark never widens who can read a record or makes one
320
+ * read as verified, and a holder retiring an account often can no longer prove it.
321
+ */
322
+ mark(id: string, local: LocalAccount, as: 'preferred' | 'current' | 'unused' | 'retired'): Promise<void>;
292
323
  share(id: string, local: LocalAccount, revoke?: boolean): Promise<{
293
324
  url: string;
294
325
  expiresAt: number;
@@ -312,6 +343,8 @@ export declare class VerilyService {
312
343
  visibilityApprovedAt: number;
313
344
  expiresAt: number;
314
345
  revokedAt?: number;
346
+ mark?: Mark;
347
+ retiredAt?: number;
315
348
  signedUrl?: string;
316
349
  }>;
317
350
  /**
@@ -334,6 +367,8 @@ export declare class VerilyService {
334
367
  * `budget` bounds the reads per run, since providers rate-limit and the alarm this runs
335
368
  * on is shared. Oldest first, so nothing starves however many connections are waiting.
336
369
  *
370
+ * A retired record is never asked about: its proof is no longer expected to be there.
371
+ *
337
372
  * Returns how many were confirmed. A connection revoked here is not one of them.
338
373
  */
339
374
  recheck(budget?: number): Promise<number>;
@@ -347,7 +382,11 @@ export declare class VerilyService {
347
382
  private reread;
348
383
  /** A provider that never answers must not hold up the maintenance run behind it. */
349
384
  private deadline;
350
- /** Run periodically. Pending secrets expire immediately; historical evidence defaults to 90 days. */
385
+ /**
386
+ * Run periodically. Pending secrets expire immediately; historical evidence defaults to 90
387
+ * days. A retired record is history its holder asked to keep, so it stays for as long as
388
+ * it is not revoked, and once revoked goes like any other.
389
+ */
351
390
  prune(retentionMs?: number): Promise<void>;
352
391
  private invalidateShare;
353
392
  private audit;