@bkazemi/verily 0.6.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 { Refused } from '../core/index.js';
1
+ import { Absent, Refused } from '../core/index.js';
2
2
  /**
3
3
  * Proves control of a GitHub account by having its holder publish a given line in a public
4
4
  * gist. No app registration, no client secret, and no token: the proof is a public document
@@ -34,11 +34,14 @@ export function githubGistProvider(options = {}) {
34
34
  signal: AbortSignal.timeout(15000),
35
35
  });
36
36
  const gist = await response.json();
37
+ // GitHub answers 404 for a gist that was deleted. Any other failure is a failed read.
38
+ if (response.status === 404)
39
+ throw new Absent('Gist unavailable');
37
40
  if (!response.ok || !isRecord(gist))
38
41
  throw new Refused('Gist unavailable');
39
42
  // A secret gist proves nothing a reader could check for themselves.
40
43
  if (gist.public !== true)
41
- throw new Refused('Gist is not public');
44
+ throw new Absent('Gist is not public');
42
45
  const owner = gist.owner;
43
46
  if (!isRecord(owner) ||
44
47
  !Number.isSafeInteger(owner.id) ||
@@ -46,8 +49,13 @@ export function githubGistProvider(options = {}) {
46
49
  typeof owner.login !== 'string' ||
47
50
  !/^[a-zA-Z0-9-]+$/.test(owner.login))
48
51
  throw new Refused('Invalid gist owner');
49
- if (!isRecord(gist.files) || !contains(gist.files, expect))
50
- throw new Refused('Gist does not contain the expected line');
52
+ if (!isRecord(gist.files) || !contains(gist.files, expect)) {
53
+ // Part of the gist was left out of the answer, and the line may be in that part.
54
+ // That is a failed read, so the proof keeps counting.
55
+ if (!isRecord(gist.files) || gist.truncated === true || partial(gist.files))
56
+ throw new Refused('Gist is too large to read');
57
+ throw new Absent('Gist does not contain the expected line');
58
+ }
51
59
  return {
52
60
  id: String(owner.id),
53
61
  handle: owner.login,
@@ -63,6 +71,10 @@ function contains(files, expect) {
63
71
  typeof file.content === 'string' &&
64
72
  file.content.includes(expect));
65
73
  }
74
+ /** Whether any file came back cut short or without its content. */
75
+ function partial(files) {
76
+ return Object.values(files).some((file) => !isRecord(file) || file.truncated === true || typeof file.content !== 'string');
77
+ }
66
78
  function isRecord(value) {
67
79
  return value !== null && typeof value === 'object';
68
80
  }
@@ -54,10 +54,13 @@ export function githubProvider(options) {
54
54
  typeof user.login !== 'string' ||
55
55
  !/^[a-zA-Z0-9-]+$/.test(user.login))
56
56
  throw new Error('Invalid provider identity');
57
+ // Optional: a missing or unparseable date is left off and the sign-in still succeeds.
58
+ const createdAt = typeof user.created_at === 'string' ? Date.parse(user.created_at) : NaN;
57
59
  return {
58
60
  id: String(user.id),
59
61
  handle: user.login,
60
62
  profileUrl: `https://github.com/${user.login}`,
63
+ ...(Number.isFinite(createdAt) ? { createdAt } : {}),
61
64
  };
62
65
  },
63
66
  };
@@ -1,4 +1,4 @@
1
- import { attestationLabel, externalId, externalIdGroups, externalLink, externalName, isArtifactProvider, isCodeProvider, isRedirectProvider, lastProved, accountsOf, 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,20 @@ 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>${typeof reference === 'string'
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'
89
101
  ? `<p class="reference">${escape(reference)}</p>`
90
102
  : reference
91
103
  ? `<p class="reference fingerprint">${reference.map((group) => `<span>${escape(group)}</span>`).join('')}</p>`
@@ -228,6 +240,17 @@ function times(rows) {
228
240
  .map(([label, time]) => `<dt>${escape(label)}</dt><dd>${escape(moment(time))}</dd>`)
229
241
  .join('')}</dl>`;
230
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
+ }
231
254
  /**
232
255
  * What the holder is told, as the provider wrote it. A command is set as a block and never
233
256
  * reflowed: it is copied character for character, and one wrapped line is a broken command.
@@ -297,9 +320,7 @@ function evidencePage(e, base, report, keyId, named) {
297
320
  // site's own reference would be a third line saying the same thing. The provider's
298
321
  // identifier stays: that one is the provider's word, not the site's own wording.
299
322
  card(local.heading, local.value, e.local.profileUrl, undefined, attestationNote(e.attestations.local, names)) +
300
- card(names.provider, externalName(e.external), externalLink(e.external),
301
- // A mailbox's address or a domain is its name already, so there is no second identifier.
302
- externalIdGroups(e.external) ?? externalId(e.external), externalNotes(e.attestations, names)) +
323
+ externalCard(names.provider, e.external, externalNotes(e.attestations, names)) +
303
324
  times(e.status === 'retired'
304
325
  ? [
305
326
  ['Approved', e.approvedAt],
@@ -350,7 +371,7 @@ const joiner = '<svg class="joiner" viewBox="0 0 24 24" fill="none" aria-hidden=
350
371
  function accountCard(e, external, now) {
351
372
  const retired = e.status === 'retired';
352
373
  const record = escape(safeUrl(e.evidenceUrl));
353
- return card(e.providerName, externalName(e.external), externalLink(e.external), externalIdGroups(e.external) ?? externalId(e.external), `<p class="how">${escape(statusLabel(e, now))} · via: <a href="${record}">${escape(e.verifierName)}</a>${e.signedUrl ? ` · <a href="${record}#signed">signed</a>` : ''}</p>`, externalNotes({ ...e.attestations, external }, { site: e.siteName, provider: e.providerName }), times(retired
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
354
375
  ? [
355
376
  ['Approved', e.approvedAt],
356
377
  ['Last verified', lastProved(e)],
@@ -419,7 +440,7 @@ function checkedPage(d, keyId, base, named) {
419
440
  const retired = d.version === 2;
420
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>` +
421
442
  card(local.heading, local.value, d.local.profileUrl, undefined, attestationNote(d.attestations.local, names)) +
422
- card(names.provider, externalName(d.external), externalLink(d.external), externalIdGroups(d.external) ?? externalId(d.external), externalNotes(d.attestations, names)) +
443
+ externalCard(names.provider, d.external, externalNotes(d.attestations, names)) +
423
444
  times(retired
424
445
  ? [
425
446
  ['Signed', d.issuedAt],
@@ -602,7 +623,7 @@ export function createVerily(options) {
602
623
  view.authorizationUrl = authorizationUrl;
603
624
  if (flow.phase === 'pending' && flow.expect && isArtifactProvider(provider))
604
625
  Object.assign(view, {
605
- instructions: provider.instructions(flow.expect),
626
+ instructions: proofInstructions(provider, flow),
606
627
  artifact: provider.artifact,
607
628
  field: artifactField(provider),
608
629
  input: provider.input ?? 'url',
@@ -766,7 +787,7 @@ export function createVerily(options) {
766
787
  if (!['revoke', 'share-revoke'].includes(flow.kind))
767
788
  if ((await local(request)).id !== flow.local?.id)
768
789
  throw new Unavailable();
769
- 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))}
770
791
  ${flow.tries ? `<p>${escape(refusedNote(flow))}</p>` : ''}
771
792
  <form method="post" action="${escape(prefix)}/flows/${escape(flow.id)}/submit">
772
793
  <label>${escape(artifactField(provider))} ${provider.artifact === 'document'
@@ -812,7 +833,7 @@ export function createVerily(options) {
812
833
  ? 'Add to connection'
813
834
  : 'Confirm connection', `
814
835
  ${card(subject(flow.local).heading, subject(flow.local).value, flow.local.profileUrl)}
815
- ${card(provider.name, externalName(flow.external), externalLink(flow.external), externalIdGroups(flow.external) ?? externalId(flow.external))}
836
+ ${externalCard(provider.name, flow.external)}
816
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>` : ''}
817
838
  ${found ? `<p>${escape(found)}</p>` : ''}
818
839
  <p class="fine">${escape(service.siteOf(flow.local))} receives the result. Verified via ${escape(options.verifierName)}.</p>
@@ -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;
@@ -1,4 +1,4 @@
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;
@@ -65,6 +65,18 @@ export interface ServiceOptions {
65
65
  * is on the site's domain or a subdomain of it. Unset reads nothing.
66
66
  */
67
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>;
68
80
  now?: () => number;
69
81
  }
70
82
  export declare class VerilyService {
@@ -296,6 +308,37 @@ export declare class VerilyService {
296
308
  private ended;
297
309
  /** Every read of a connection goes through here, so a record in an older shape is upgraded. */
298
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;
299
342
  /**
300
343
  * The methods a reader is shown. An additional method whose proof has gone unread no
301
344
  * longer shows anything, so it is left out until it reads again; the main one is