@bkazemi/verily 0.5.0 → 0.7.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, externalId, externalLink, externalName, isArtifactProvider, isCodeProvider, isRedirectProvider, lastProved, localSide, proofTitle, providerMethod, statusLabel, signedBy, } from '../core/index.js';
1
+ import { attestationLabel, externalId, externalIdGroups, externalLink, externalName, externalSince, 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
4
  import { escape } from './escape.js';
@@ -84,8 +84,24 @@ const redirect = (url, cookie) => new Response(null, {
84
84
  * Both sides render as the same card, so the pair reads as a pair.
85
85
  */
86
86
  function card(heading, name, url, reference, ...extra) {
87
+ return side(heading, name, url, reference, undefined, extra);
88
+ }
89
+ /**
90
+ * The card for an external account: its name, link and id, with the "Joined <date>" text
91
+ * on the name's line when the creation date is known.
92
+ */
93
+ function externalCard(heading, external, ...extra) {
94
+ return side(heading, externalName(external), externalLink(external),
95
+ // A mailbox's address or a domain is its name already, so there is no second identifier.
96
+ externalIdGroups(external) ?? externalId(external), externalSince(external, (time) => new Date(time).toISOString().slice(0, 10)), extra);
97
+ }
98
+ function side(heading, name, url, reference, since, extra) {
87
99
  const profile = url && safeUrl(url);
88
- return `<div class="side"><p class="who">${escape(heading)}</p><p class="name">${profile ? `<a href="${escape(profile)}" rel="noreferrer">${escape(name)}</a>` : escape(name)}</p>${reference ? `<p class="reference">${escape(reference)}</p>` : ''}${extra.join('')}</div>`;
100
+ return `<div class="side"><p class="who">${escape(heading)}</p><p class="name">${profile ? `<a href="${escape(profile)}" rel="noreferrer">${escape(name)}</a>` : escape(name)}${since ? `<span class="since">${escape(since)}</span>` : ''}</p>${typeof reference === 'string'
101
+ ? `<p class="reference">${escape(reference)}</p>`
102
+ : reference
103
+ ? `<p class="reference fingerprint">${reference.map((group) => `<span>${escape(group)}</span>`).join('')}</p>`
104
+ : ''}${extra.join('')}</div>`;
89
105
  }
90
106
  /**
91
107
  * Names how one side was established, inside that side's card. Where the method published
@@ -224,6 +240,17 @@ function times(rows) {
224
240
  .map(([label, time]) => `<dt>${escape(label)}</dt><dd>${escape(moment(time))}</dd>`)
225
241
  .join('')}</dl>`;
226
242
  }
243
+ /** Provider setup with the flow’s return instructions, shared by HTML and the dialog. */
244
+ function proofInstructions(provider, flow) {
245
+ return [
246
+ ...provider.instructions(flow.expect, flow.artifact ?? flow.suggested),
247
+ ...(flow.dnsDraftId
248
+ ? [
249
+ 'Your DNS setup is saved for seven days from when you started. To continue later, sign in and choose the DNS method again. For a renewal, reopen the same connection’s renewal. The proof must pass a fresh check; keep this TXT value while you wait.',
250
+ ]
251
+ : []),
252
+ ];
253
+ }
227
254
  /**
228
255
  * What the holder is told, as the provider wrote it. A command is set as a block and never
229
256
  * reflowed: it is copied character for character, and one wrapped line is a broken command.
@@ -293,9 +320,7 @@ function evidencePage(e, base, report, keyId, named) {
293
320
  // site's own reference would be a third line saying the same thing. The provider's
294
321
  // identifier stays: that one is the provider's word, not the site's own wording.
295
322
  card(local.heading, local.value, e.local.profileUrl, undefined, attestationNote(e.attestations.local, names)) +
296
- card(names.provider, externalName(e.external), externalLink(e.external),
297
- // A mailbox's address or a domain is its name already, so there is no second identifier.
298
- externalId(e.external), externalNotes(e.attestations, names)) +
323
+ externalCard(names.provider, e.external, externalNotes(e.attestations, names)) +
299
324
  times(e.status === 'retired'
300
325
  ? [
301
326
  ['Approved', e.approvedAt],
@@ -329,6 +354,66 @@ function evidencePage(e, base, report, keyId, named) {
329
354
  ${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>` : ''}
330
355
  <p class="fine"><a href="${escape(report)}" rel="noreferrer">Report an incorrect record</a></p>`);
331
356
  }
357
+ /** Joins a subject's card to its accounts: the link itself, drawn as the dialog draws it. */
358
+ const joiner = '<svg class="joiner" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">' +
359
+ [
360
+ 'M10.5 7.5 13 5a4.95 4.95 0 0 1 7 7l-2.5 2.5',
361
+ 'M13.5 16.5 11 19a4.95 4.95 0 0 1-7-7l2.5-2.5',
362
+ 'M9 15l6-6',
363
+ ]
364
+ .map((d) => `<path d="${d}" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>`)
365
+ .join('') +
366
+ '</svg>';
367
+ /**
368
+ * One account as a badge's dialog draws it: what it is and how it stands, the verifier's
369
+ * own page for it, how it was shown, how its holder lists it, and its times.
370
+ */
371
+ function accountCard(e, external, now) {
372
+ const retired = e.status === 'retired';
373
+ const record = escape(safeUrl(e.evidenceUrl));
374
+ return externalCard(e.providerName, e.external, `<p class="how">${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
375
+ ? [
376
+ ['Approved', e.approvedAt],
377
+ ['Last verified', lastProved(e)],
378
+ ['Retired', e.retiredAt],
379
+ ]
380
+ : [
381
+ ['Approved', e.approvedAt],
382
+ [e.expiresAt > now ? 'Valid until' : 'Expired on', e.expiresAt],
383
+ ['Last checked', external[0].artifactUrl ? external[0].confirmedAt : undefined],
384
+ ]),
385
+ // The holder's own word on the account, which nothing checked, so it sits apart from
386
+ // the methods, in the card's corner as the dialog has it.
387
+ retired || !e.mark
388
+ ? ''
389
+ : e.mark === 'preferred'
390
+ ? '<div class="listing preferred">Preferred</div>'
391
+ : '<div class="listing">No longer used</div>');
392
+ }
393
+ /**
394
+ * The public records given, as a page that shows what a badge's dialog does: a card to a
395
+ * subject, then one to each of its accounts in the badge's own order.
396
+ */
397
+ function publishedPage(records, base) {
398
+ const now = Date.now();
399
+ const subjects = new Map();
400
+ for (const e of records) {
401
+ const key = JSON.stringify([e.siteName, e.local.reference]);
402
+ subjects.set(key, [...(subjects.get(key) ?? []), e]);
403
+ }
404
+ return page(base, 'Public connections', records.length
405
+ ? [...subjects.values()]
406
+ .map((held) => {
407
+ const local = localSide(held[0].local, held[0].siteName);
408
+ return (card(local.heading, local.value, held[0].local.profileUrl) +
409
+ joiner +
410
+ `<div class="accounts">${accountsOf(held, now)
411
+ .map(({ lead, external }) => accountCard(lead, external, now))
412
+ .join('')}</div>`);
413
+ })
414
+ .join('')
415
+ : '<p>No public connections.</p>');
416
+ }
332
417
  /**
333
418
  * Names a signing key on one line: its last sixteen digits, linked to the key itself with
334
419
  * the whole fingerprint on hover, then whether a domain's DNS was read naming it. That is a
@@ -355,7 +440,7 @@ function checkedPage(d, keyId, base, named) {
355
440
  const retired = d.version === 2;
356
441
  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>` +
357
442
  card(local.heading, local.value, d.local.profileUrl, undefined, attestationNote(d.attestations.local, names)) +
358
- card(names.provider, externalName(d.external), externalLink(d.external), externalId(d.external), externalNotes(d.attestations, names)) +
443
+ externalCard(names.provider, d.external, externalNotes(d.attestations, names)) +
359
444
  times(retired
360
445
  ? [
361
446
  ['Signed', d.issuedAt],
@@ -538,7 +623,7 @@ export function createVerily(options) {
538
623
  view.authorizationUrl = authorizationUrl;
539
624
  if (flow.phase === 'pending' && flow.expect && isArtifactProvider(provider))
540
625
  Object.assign(view, {
541
- instructions: provider.instructions(flow.expect),
626
+ instructions: proofInstructions(provider, flow),
542
627
  artifact: provider.artifact,
543
628
  field: artifactField(provider),
544
629
  input: provider.input ?? 'url',
@@ -644,6 +729,8 @@ export function createVerily(options) {
644
729
  // With one visibility there is nothing for the holder to change it to.
645
730
  if (kind === 'visibility' && visibilities(user).length < 2)
646
731
  throw new Unavailable();
732
+ // Several providers are named by their buttons, not strung together in the heading.
733
+ const several = new Set(offered.map((p) => p.name)).size > 1;
647
734
  // One form per method. With one on offer the provider is all there is to name.
648
735
  // A new link started from this page always ends on its approval, as it did when
649
736
  // this form was a GET, so it says so: `/verify` is an address any site can send a
@@ -655,7 +742,9 @@ export function createVerily(options) {
655
742
  ? 'Remove a connection'
656
743
  : kind === 'renew'
657
744
  ? 'Renew this connection'
658
- : `Verify with ${providerNames(offered)}`, `
745
+ : several
746
+ ? 'Verify an account'
747
+ : `Verify with ${providerNames(offered)}`, `
659
748
  ${user
660
749
  ? card(subject(user).heading, subject(user).value, user.profileUrl)
661
750
  : '<p>Authenticate with the matching external account to review and remove this link.</p>'}
@@ -698,7 +787,7 @@ export function createVerily(options) {
698
787
  if (!['revoke', 'share-revoke'].includes(flow.kind))
699
788
  if ((await local(request)).id !== flow.local?.id)
700
789
  throw new Unavailable();
701
- return html(page(prefix, `Verify with ${provider.name}`, `${instructions(provider.instructions(flow.expect))}
790
+ return html(page(prefix, `Verify with ${provider.name}`, `${instructions(proofInstructions(provider, flow))}
702
791
  ${flow.tries ? `<p>${escape(refusedNote(flow))}</p>` : ''}
703
792
  <form method="post" action="${escape(prefix)}/flows/${escape(flow.id)}/submit">
704
793
  <label>${escape(artifactField(provider))} ${provider.artifact === 'document'
@@ -744,7 +833,7 @@ export function createVerily(options) {
744
833
  ? 'Add to connection'
745
834
  : 'Confirm connection', `
746
835
  ${card(subject(flow.local).heading, subject(flow.local).value, flow.local.profileUrl)}
747
- ${card(provider.name, externalName(flow.external), externalLink(flow.external), externalId(flow.external))}
836
+ ${externalCard(provider.name, flow.external)}
748
837
  ${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>` : ''}
749
838
  ${found ? `<p>${escape(found)}</p>` : ''}
750
839
  <p class="fine">${escape(service.siteOf(flow.local))} receives the result. Verified via ${escape(options.verifierName)}.</p>
@@ -782,7 +871,15 @@ export function createVerily(options) {
782
871
  }
783
872
  // Lets a static embed track current connections without hardcoding an id.
784
873
  if (path === '/published') {
785
- const response = json(await service.published());
874
+ const site = url.searchParams.get('site');
875
+ const reference = url.searchParams.get('reference') ?? undefined;
876
+ const subject = site === null ? undefined : { site, reference };
877
+ // A page only where one is asked for by name. An embed published before `Accept`
878
+ // was sent asks for nothing, and must go on getting the list.
879
+ if (url.searchParams.get('format') !== 'json' &&
880
+ /\btext\/html\b/i.test(request.headers.get('accept') ?? ''))
881
+ return html(publishedPage(await service.listed(subject), prefix));
882
+ const response = json(await service.published(subject));
786
883
  response.headers.set('Access-Control-Allow-Origin', '*');
787
884
  return response;
788
885
  }
@@ -1,8 +1,8 @@
1
1
  import { legacyHookDecode } from '@exodus/bytes/encoding.js';
2
2
  import htmlEncodingSniffer from 'html-encoding-sniffer';
3
- import { MIMEType } from 'node:util';
3
+ import { isDeepStrictEqual, MIMEType } from 'node:util';
4
4
  import { html, parse } from 'parse5';
5
- import { Refused, } from '../core/index.js';
5
+ import { Absent, Refused, } from '../core/index.js';
6
6
  import { discard, readBounded } from './body.js';
7
7
  import { transport } from './public-fetch.js';
8
8
  /**
@@ -99,6 +99,9 @@ async function backlinked(response, page, subject, maxBytes) {
99
99
  // The holder named this address, so a redirect would take the check somewhere else.
100
100
  if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400))
101
101
  throw new Refused('Page redirects, and a redirect is not followed');
102
+ // The page is gone. Any other failure is a failed read.
103
+ if (response.status === 404 || response.status === 410)
104
+ throw new Absent('Page unavailable');
102
105
  if (!response.ok)
103
106
  throw new Refused('Page unavailable');
104
107
  // A link relation is a link relation wherever it is declared, and a page with no
@@ -132,7 +135,9 @@ async function backlinked(response, page, subject, maxBytes) {
132
135
  return;
133
136
  }
134
137
  // Not finding it in part of a page is not the same as it not being there.
135
- throw new Refused(truncated ? 'Page is too large to read' : 'Page has no rel="me" link to this subject');
138
+ if (truncated)
139
+ throw new Refused('Page is too large to read');
140
+ throw new Absent('Page has no rel="me" link to this subject');
136
141
  }
137
142
  /**
138
143
  * The address the holder handed back, once it is one this may fetch. They choose it, so
@@ -259,7 +264,7 @@ function points(href, base, subject) {
259
264
  for (const key of new Set(subject.searchParams.keys())) {
260
265
  const wanted = subject.searchParams.getAll(key);
261
266
  const found = target.searchParams.getAll(key);
262
- if (found.length !== wanted.length || found.some((value, at) => value !== wanted[at]))
267
+ if (!isDeepStrictEqual(found, wanted))
263
268
  return false;
264
269
  }
265
270
  return true;
@@ -10,6 +10,10 @@ import { Refused } from '../core/index.js';
10
10
  export declare class Malformed extends Refused {
11
11
  constructor(what: string, options?: ErrorOptions);
12
12
  }
13
+ /** The block is not there at all: a holder who pasted half is told which half is missing. */
14
+ export declare class Missing extends Malformed {
15
+ constructor(what: string);
16
+ }
13
17
  export type Certificate = Key;
14
18
  export type Cleartext = CleartextMessage;
15
19
  /** The public key in armored text, wherever in the text its block is. */
@@ -34,6 +34,13 @@ export class Malformed extends Refused {
34
34
  super(`Malformed OpenPGP ${what}`, options);
35
35
  }
36
36
  }
37
+ /** The block is not there at all: a holder who pasted half is told which half is missing. */
38
+ export class Missing extends Malformed {
39
+ constructor(what) {
40
+ super(what);
41
+ this.message = `No OpenPGP ${what} found`;
42
+ }
43
+ }
37
44
  /**
38
45
  * The one armored block of the named kind out of text that may hold others: a holder
39
46
  * pastes the signed message and the key together, and the library reads only the first
@@ -43,8 +50,10 @@ function block(text, begin, end, what) {
43
50
  if (text.length > maxArmorBytes)
44
51
  throw new Malformed(what);
45
52
  const start = text.indexOf(`-----BEGIN PGP ${begin}-----`);
53
+ if (start < 0)
54
+ throw new Missing(what);
46
55
  const stop = text.indexOf(`-----END PGP ${end}-----`, start);
47
- if (start < 0 || stop < 0)
56
+ if (stop < 0)
48
57
  throw new Malformed(what);
49
58
  return text.slice(start, stop + `-----END PGP ${end}-----`.length);
50
59
  }
@@ -1,7 +1,15 @@
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';
1
+ import { type Change, 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
  import { type DnsOptions, type Published } from './domain.js';
3
3
  export declare const secret: () => string;
4
4
  export declare const hash: (value: string) => string;
5
+ /**
6
+ * A subject as its public records name it: the site it belongs to and its reference there.
7
+ * A reference means nothing outside its site, so with none this is everyone on the site.
8
+ */
9
+ export interface Subject {
10
+ site: string;
11
+ reference?: string;
12
+ }
5
13
  export declare class Unavailable extends Error {
6
14
  constructor();
7
15
  }
@@ -57,6 +65,18 @@ export interface ServiceOptions {
57
65
  * is on the site's domain or a subdomain of it. Unset reads nothing.
58
66
  */
59
67
  dns?: boolean | DnsOptions;
68
+ /**
69
+ * Called when a record's status, visibility, expiry or mark changes, with the record as
70
+ * it stands now. Changes made by a request are delivered before that request returns.
71
+ * Expiry and a proof going unconfirmed happen with time alone, so those are delivered by
72
+ * `recheck()` or `announce()`.
73
+ *
74
+ * Delivery is at least once: a call that throws, or takes longer than `recheckTimeoutMs`,
75
+ * is retried on the next `recheck()` or `announce()`. A call that timed out is not
76
+ * cancelled and may finish after a newer one, so apply a change only if its `revision`
77
+ * is higher than the last one applied for that record.
78
+ */
79
+ onChange?: (change: Change) => void | Promise<void>;
60
80
  now?: () => number;
61
81
  }
62
82
  export declare class VerilyService {
@@ -288,6 +308,37 @@ export declare class VerilyService {
288
308
  private ended;
289
309
  /** Every read of a connection goes through here, so a record in an older shape is upgraded. */
290
310
  private transaction;
311
+ /** The parts of a record that `onChange` reports, as they are at `now`. */
312
+ private reportable;
313
+ /**
314
+ * Delivers every record whose state differs from the one last reported to `onChange`,
315
+ * up to `budget` records. `ids` limits it to those records. Returns how many were
316
+ * delivered. Does nothing without `onChange`.
317
+ *
318
+ * Records whose last delivery failed are tried after the others, oldest attempt first,
319
+ * so a record that keeps failing does not hold up the rest.
320
+ */
321
+ announce(ids?: string[], budget?: number): Promise<number>;
322
+ /** A storage transaction whose writes are not themselves announced. */
323
+ private unreported;
324
+ /** The last delivery started. Each delivery waits for the one before it. */
325
+ private delivering;
326
+ /**
327
+ * Whether a record still has something to deliver: its state differs from the one last
328
+ * reported, or its last delivery started and was never confirmed. An unconfirmed
329
+ * delivery may have reached the receiver, so it is sent again even if the state has
330
+ * since gone back to the one reported.
331
+ */
332
+ private unsettled;
333
+ /**
334
+ * Delivers one record to `onChange` if it has something to deliver. Returns 1 if the
335
+ * delivery was confirmed.
336
+ *
337
+ * Each delivery reads the record and takes the next revision in one storage
338
+ * transaction, so a higher revision always carries a later state, whichever process or
339
+ * callback finishes first. Within this process deliveries also run one at a time.
340
+ */
341
+ private deliver;
291
342
  /**
292
343
  * The methods a reader is shown. An additional method whose proof has gone unread no
293
344
  * longer shows anything, so it is left out until it reads again; the main one is
@@ -345,7 +396,13 @@ export declare class VerilyService {
345
396
  * hardcoding an id that dies whenever one is revoked and replaced. Unlisted records are
346
397
  * never included: they must not appear in any directory listing.
347
398
  */
348
- published(): Promise<Evidence[]>;
399
+ published(subject?: Subject): Promise<Evidence[]>;
400
+ /**
401
+ * Every public record a badge could show, for the page a person reads: one that has lapsed
402
+ * or been retired is there to be marked, and one removed is not. `subject` narrows it to
403
+ * one site or one subject, named by what its records already publish. Earliest connected first.
404
+ */
405
+ listed(subject?: Subject): Promise<Evidence[]>;
349
406
  /**
350
407
  * Every record this instance holds, public and unlisted, for whoever runs it. No route
351
408
  * of the library serves this: a deployment that shows it must first know the reader is