@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,7 +1,8 @@
1
+ import { accountsOf } from '../core/index.js';
1
2
  import { routePath } from '../core/routes.js';
2
3
  import { badgeShown, quietBadge, renderBadge, renderBadgeMessage, renderBadgePending, renderConnectPill, } from './badge.js';
3
4
  import { openConnectDialog, } from './connect-dialog.js';
4
- import { openEvidenceDialog } from './evidence-dialog.js';
5
+ import { openEvidenceDialog, subjectUrl, } from './evidence-dialog.js';
5
6
  import { drawSignedWith, standing, watchSigned } from './signed.js';
6
7
  /**
7
8
  * What each host's dialog opens on and how it reads again, kept apart from the pill: the
@@ -91,15 +92,17 @@ function details(element) {
91
92
  throw error;
92
93
  }
93
94
  }
94
- }, latestGroup.get(element), accounts, managers.get(element));
95
+ }, latestGroup.get(element), accounts, managers.get(element), openHosts.has(element));
95
96
  }
96
97
  }
97
98
  /**
98
- * Hosts whose pill is stacked, and hosts whose pill opens no panel, where the page asked
99
- * for either. Kept here so every later refresh of the host draws it the same way.
99
+ * Hosts whose pill is stacked, hosts whose pill opens no panel, and hosts whose dialog
100
+ * starts with its details open, where the page asked for any of them. Kept here so every
101
+ * later refresh of the host draws it the same way.
100
102
  */
101
103
  const stackedHosts = new WeakSet();
102
104
  const quietHosts = new WeakSet();
105
+ const openHosts = new WeakSet();
103
106
  /**
104
107
  * How many accounts a pill names before it counts the rest. A stacked pill pays for each
105
108
  * row in the page's height. A panel floats over the page and pays nothing, but it cannot
@@ -108,7 +111,11 @@ const quietHosts = new WeakSet();
108
111
  const stackedAccounts = 4;
109
112
  const peekedAccounts = 8;
110
113
  /** Records how a host's pill is arranged, from what the page last said. */
111
- function arrange(element, { stacked, peek }) {
114
+ function arrange(element, { stacked, peek, detailsOpen }) {
115
+ if (detailsOpen)
116
+ openHosts.add(element);
117
+ else
118
+ openHosts.delete(element);
112
119
  if (stacked)
113
120
  stackedHosts.add(element);
114
121
  else
@@ -133,45 +140,16 @@ const live = (evidence) => evidence.status === 'verified' && evidence.expiresAt
133
140
  * Each entry names every record of the account not yet revoked, shown or not, so removing
134
141
  * the account removes them all. One left behind, lapsed or unconfirmed, could be confirmed
135
142
  * again later and bring the account back without its holder approving anything.
136
- *
137
- * A record from a backend older than `connectedAt` is placed by when it was approved.
138
143
  */
139
144
  function accounts(records) {
140
- const when = (e) => e.connectedAt ?? e.approvedAt;
141
- const byAccount = new Map();
142
- for (const record of records) {
143
- const key = JSON.stringify([record.provider, record.external.id]);
144
- byAccount.set(key, [...(byAccount.get(key) ?? []), record]);
145
- }
146
- return [...byAccount.values()]
147
- .map((held) => {
148
- const sorted = [...held].sort((a, b) => when(a) - when(b));
149
- const verified = sorted.filter(live);
150
- const base = verified[0] ?? sorted.reduce((a, b) => (b.approvedAt > a.approvedAt ? b : a));
151
- const external = [...base.attestations.external];
152
- for (const other of verified.slice(1))
153
- for (const attestation of other.attestations.external)
154
- if (!external.some((shown) => shown.method === attestation.method))
155
- external.push(attestation);
156
- return {
157
- ...base,
158
- connectedAt: when(sorted[0]),
159
- attestations: { ...base.attestations, external },
160
- links: sorted.filter((e) => e.status !== 'revoked').map((e) => e.id),
161
- // Every record the card speaks for, so its signature mark answers for all of them.
162
- sources: verified.length ? verified : [base],
163
- };
164
- })
165
- .sort((a, b) => place(a) - place(b) || a.connectedAt - b.connectedAt);
166
- }
167
- /**
168
- * Where an account is listed among a subject's others, before the order they were
169
- * connected in. This is all there is to preferring an account: there is no separate pin.
170
- */
171
- function place(account) {
172
- if (account.status === 'retired')
173
- return 3;
174
- return account.mark === 'preferred' ? 0 : account.mark === 'unused' ? 2 : 1;
145
+ return accountsOf(records, Date.now()).map(({ lead, held, verified, external, connectedAt }) => ({
146
+ ...lead,
147
+ connectedAt,
148
+ attestations: { ...lead.attestations, external },
149
+ links: held.filter((e) => e.status !== 'revoked').map((e) => e.id),
150
+ // Every record the card speaks for, so its signature mark answers for all of them.
151
+ sources: verified.length ? verified : [lead],
152
+ }));
175
153
  }
176
154
  /**
177
155
  * One subject's accounts as a single pill: the first of them, then how many more stand
@@ -198,6 +176,8 @@ function presentGroup(element, records, load) {
198
176
  const badge = renderBadge(element, lead, {
199
177
  more: Math.max(verified.length - 1, 0),
200
178
  linked: lead.visibility === 'public',
179
+ // A modified click follows the link, and must reach what a plain one shows: every account.
180
+ ...(ordered.length > 1 ? { href: subjectUrl(lead) } : {}),
201
181
  rows: stacked
202
182
  ? verified.slice(1, stackedAccounts)
203
183
  : peek
@@ -669,6 +649,9 @@ function validEvidence(value) {
669
649
  ['label', 'reference'].every((k) => typeof value.local[k] === 'string') &&
670
650
  ['id', 'handle', 'profileUrl'].every((k) => typeof value.external[k] === 'string') &&
671
651
  (value.local.profileUrl === undefined || typeof value.local.profileUrl === 'string') &&
652
+ (value.external.createdAt === undefined ||
653
+ (typeof value.external.createdAt === 'number' &&
654
+ Number.isFinite(value.external.createdAt))) &&
672
655
  // A signed record lives beside the record it was made from and nowhere else, since the
673
656
  // keys it is checked against are read from where the record lives.
674
657
  (value.signedUrl === undefined || value.signedUrl === `${value.evidenceUrl}?format=signed`) &&
@@ -729,6 +712,7 @@ if (typeof customElements !== 'undefined' && !customElements.get('verily-badge')
729
712
  'connect',
730
713
  'stacked',
731
714
  'peek',
715
+ 'details-open',
732
716
  ];
733
717
  handed;
734
718
  /**
@@ -788,6 +772,7 @@ if (typeof customElements !== 'undefined' && !customElements.get('verily-badge')
788
772
  const arrangement = {
789
773
  stacked: this.getAttribute('stacked') !== null,
790
774
  peek: this.getAttribute('peek') !== 'off',
775
+ detailsOpen: this.getAttribute('details-open') !== null,
791
776
  };
792
777
  // The holder's own badge, where the page says so: its dialog connects another
793
778
  // account, renews one and removes one, through the backend named. The host hears
@@ -45,6 +45,12 @@ export interface ExternalAccount {
45
45
  handle: string;
46
46
  /** Where a reader goes to see the subject. For a mailbox, its `mailto:` address. */
47
47
  profileUrl: string;
48
+ /**
49
+ * When the account was created, in milliseconds, as reported by the provider. It lets a
50
+ * reader tell an old account from one opened just to be linked. Only sign-in providers
51
+ * set it. Once a record has it, later proofs by other methods keep it.
52
+ */
53
+ createdAt?: number;
48
54
  }
49
55
  /**
50
56
  * How control of one side was shown. A provider is who an account belongs to; a method is
@@ -96,6 +102,13 @@ export interface Attestation {
96
102
  * is reconfirmed like any other.
97
103
  */
98
104
  confirmedAt: number;
105
+ /** When `recheck()` last tried to read the proof, whether or not the read worked. */
106
+ readAt?: number;
107
+ /**
108
+ * When a recheck first read the proof and found it missing. The proof does not count
109
+ * while this is set. Cleared by the next read that finds it.
110
+ */
111
+ absentAt?: number;
99
112
  }
100
113
  /** Each side of a link is attested separately, by different parties under different methods. */
101
114
  export interface Attestations {
@@ -137,9 +150,44 @@ export interface Connection {
137
150
  * last proof: never renewed by another method, never reread, never verified.
138
151
  */
139
152
  retiredAt?: number;
153
+ /** The state last delivered to `onChange`. Absent until the first delivery. */
154
+ reported?: Reported;
155
+ /**
156
+ * When the last delivery to `onChange` started, while it has not been confirmed. Set when
157
+ * a delivery starts and cleared when it succeeds, so a record with it set is delivered
158
+ * again even if its state matches `reported`.
159
+ */
160
+ reportTriedAt?: number;
161
+ /** The revision given to the last delivery. Each delivery of the record adds one. */
162
+ reportRevision?: number;
163
+ }
164
+ /** The parts of a record that `onChange` reports a change in. */
165
+ export interface Reported {
166
+ status: Status;
167
+ visibility: Visibility;
168
+ expiresAt: number;
169
+ mark?: Mark;
170
+ }
171
+ /** One record whose status, visibility, expiry or mark changed, as `onChange` receives it. */
172
+ export interface Change {
173
+ /** The record as it stands now. */
174
+ evidence: Evidence;
175
+ /** The subject the record belongs to, with its private id. */
176
+ local: LocalAccount;
177
+ /** What the last confirmed delivery reported. Absent until one is confirmed. */
178
+ previous?: Reported;
179
+ /**
180
+ * Counts the deliveries of this record, starting at 1. A higher revision always carries
181
+ * a state read later, across processes too. Keep the last one applied for each record
182
+ * and ignore a change whose revision is not higher: a slow or repeated delivery can
183
+ * arrive after a newer one.
184
+ */
185
+ revision: number;
140
186
  }
141
187
  export interface Flow {
142
188
  id: string;
189
+ /** Saved DNS setup; grants no authority to finish a flow. */
190
+ dnsDraftId?: string;
143
191
  stateHash: string;
144
192
  bindingHash: string;
145
193
  verifier?: string;
@@ -155,7 +203,8 @@ export interface Flow {
155
203
  reason?: string;
156
204
  /**
157
205
  * The string an artifact must contain. Public by design: the holder publishes it. It is
158
- * unguessable and per-flow, so an artifact made for one flow cannot complete another.
206
+ * normally unguessable and per-flow. DNS setup can retain the same value across
207
+ * fresh flows for the same subject and operation while propagation is pending.
159
208
  */
160
209
  expect?: string;
161
210
  /** Whether `expect` was minted for a method that had nothing of the subject's to state. */
@@ -269,7 +318,16 @@ export interface SiteRead {
269
318
  /** Whether that read got an answer. One that did not is asked again sooner. */
270
319
  answered: boolean;
271
320
  }
321
+ /** DNS setup retained across short-lived, authenticated flows for seven days. */
322
+ export interface DnsDraft {
323
+ id: string;
324
+ flowId: string;
325
+ expect: string;
326
+ artifact?: string;
327
+ expiresAt: number;
328
+ }
272
329
  export interface Records {
330
+ dnsDrafts: DnsDraft;
273
331
  connections: Connection;
274
332
  flows: Flow;
275
333
  shares: Share;
@@ -349,8 +407,9 @@ export interface ArtifactProvider {
349
407
  * Told to the holder verbatim. Must name what they are publishing and where. Returned in
350
408
  * pieces so a renderer can set a command as one: a command is copied character for
351
409
  * character, and prose that reflows is prose a holder cannot safely copy.
410
+ * `artifact` is the last submitted or suggested location, when one is known.
352
411
  */
353
- instructions(expect: string): Instruction[];
412
+ instructions(expect: string, artifact?: string): Instruction[];
354
413
  /**
355
414
  * What the holder is asked to hand back, where the method takes something a holder knows
356
415
  * by a better name than "an address". `input` says how to ask for it: `text` where it
@@ -451,6 +510,14 @@ export interface CodeProvider {
451
510
  */
452
511
  export declare class Refused extends Error {
453
512
  }
513
+ /**
514
+ * A proof that was read and is not there: the page, file, gist or DNS answer came back and
515
+ * the expected value was missing. Thrown by `verify` or `prove`. On a recheck it makes the
516
+ * proof unconfirmed at once. Any other error means the read itself failed, and the proof
517
+ * keeps counting until `freshnessMs` runs out.
518
+ */
519
+ export declare class Absent extends Refused {
520
+ }
454
521
  /**
455
522
  * A piece of what the holder is told: a paragraph, a paragraph with links in it, or
456
523
  * something they run or publish as is.
@@ -525,6 +592,10 @@ export * from './routes.js';
525
592
  * telling anyone, so a confirmation is a heartbeat rather than a permanent fact.
526
593
  */
527
594
  export declare const freshnessMs: number;
595
+ /** Whether a record has reached its deadline, including the exact expiry instant. */
596
+ export declare function expired(record: {
597
+ expiresAt: number;
598
+ }, now: number): boolean;
528
599
  /**
529
600
  * `freshness` bounds how stale a published proof may be before it stops counting. A
530
601
  * backend that never rechecks must leave it unbounded, since an unread proof going stale
@@ -547,6 +618,29 @@ export declare function lastProved(evidence: Pick<Evidence, 'authenticatedAt' |
547
618
  * that has run out since reads as expired whatever status it was issued with.
548
619
  */
549
620
  export declare function statusLabel(evidence: Pick<Evidence, 'status' | 'expiresAt'>, now: number): string;
621
+ /**
622
+ * Where an account is listed among a subject's others, before the order they were
623
+ * connected in. This is all there is to preferring an account: there is no separate pin.
624
+ */
625
+ export declare function place(account: Pick<Evidence, 'status' | 'mark'>): number;
626
+ /**
627
+ * One subject's records as its accounts, in the order they are listed everywhere: the
628
+ * preferred one, then the rest as first connected, then those unused, then those retired.
629
+ * Records are not accounts: the same account can stand on several. `held` is every record
630
+ * of the account, earliest first, and `verified` those of them that stand now. `lead`
631
+ * speaks for the account: the earliest that stands, or else the latest approved, which
632
+ * says what became of it. `external` is how the account was shown: the lead's methods,
633
+ * then any other its standing records add.
634
+ *
635
+ * A record from a backend older than `connectedAt` is placed by when it was approved.
636
+ */
637
+ export declare function accountsOf(records: Evidence[], now: number): {
638
+ lead: Evidence;
639
+ held: Evidence[];
640
+ verified: Evidence[];
641
+ external: Attestations['external'];
642
+ connectedAt: number;
643
+ }[];
550
644
  /**
551
645
  * What to call a link's local side, and what to write as its value. When the site itself is
552
646
  * what was linked there is no subject on it to name, so the site is the value rather than a
@@ -584,6 +678,16 @@ export declare function attestationLabel(method: string, names: {
584
678
  * already all of it: a mailbox's address and a domain are each their own id.
585
679
  */
586
680
  export declare function externalId(external: ExternalAccount): string | undefined;
681
+ /**
682
+ * A key's fingerprint in groups of four, as gpg prints it, so a renderer can break the line
683
+ * between groups. Nothing for any other subject, whose id is written as it stands.
684
+ */
685
+ export declare function externalIdGroups(external: ExternalAccount): string[] | undefined;
686
+ /**
687
+ * The text for an account's creation date, such as "Joined 2014-03-02", or undefined if
688
+ * the date is unknown. `day` formats the date, to the day.
689
+ */
690
+ export declare function externalSince(external: ExternalAccount, day: (time: number) => string): string | undefined;
587
691
  /**
588
692
  * Where a subject's name may link, or nothing where it has no page of its own. A mailbox's
589
693
  * address is `mailto:`, which opens a message to it and shows a reader nothing.
@@ -4,6 +4,14 @@
4
4
  */
5
5
  export class Refused extends Error {
6
6
  }
7
+ /**
8
+ * A proof that was read and is not there: the page, file, gist or DNS answer came back and
9
+ * the expected value was missing. Thrown by `verify` or `prove`. On a recheck it makes the
10
+ * proof unconfirmed at once. Any other error means the read itself failed, and the proof
11
+ * keeps counting until `freshnessMs` runs out.
12
+ */
13
+ export class Absent extends Refused {
14
+ }
7
15
  /** Only an artifact provider is holder-paced, and only it needs a url handed back. */
8
16
  export function isArtifactProvider(provider) {
9
17
  return 'verify' in provider;
@@ -28,6 +36,10 @@ export * from './routes.js';
28
36
  * telling anyone, so a confirmation is a heartbeat rather than a permanent fact.
29
37
  */
30
38
  export const freshnessMs = 7 * 86400000;
39
+ /** Whether a record has reached its deadline, including the exact expiry instant. */
40
+ export function expired(record, now) {
41
+ return record.expiresAt <= now;
42
+ }
31
43
  /**
32
44
  * `freshness` bounds how stale a published proof may be before it stops counting. A
33
45
  * backend that never rechecks must leave it unbounded, since an unread proof going stale
@@ -39,7 +51,7 @@ export function status(connection, now, freshness = freshnessMs) {
39
51
  // Frozen at its last proof, so neither its expiry nor its freshness is read again.
40
52
  if (connection.retiredAt !== undefined)
41
53
  return 'retired';
42
- if (now >= connection.expiresAt)
54
+ if (expired(connection, now))
43
55
  return 'expired';
44
56
  const main = connection.attestations?.external[0];
45
57
  if (main && !fresh(main, now, freshness))
@@ -52,6 +64,9 @@ export function status(connection, now, freshness = freshnessMs) {
52
64
  * reverses the moment it reads again.
53
65
  */
54
66
  export function fresh(attestation, now, freshness = freshnessMs) {
67
+ // Read and found missing, which is not the same as not having been read lately.
68
+ if (attestation.absentAt !== undefined)
69
+ return false;
55
70
  return (!attestation.artifactUrl ||
56
71
  attestation.hosted === true ||
57
72
  freshness === Infinity ||
@@ -75,10 +90,51 @@ export function statusLabel(evidence, now) {
75
90
  // Before expiry: a retired record's approval has long run out, and that is not its state.
76
91
  if (evidence.status === 'retired')
77
92
  return 'Retired';
78
- if (evidence.status === 'expired' || evidence.expiresAt <= now)
93
+ if (evidence.status === 'expired' || expired(evidence, now))
79
94
  return 'Expired';
80
95
  return evidence.status === 'unconfirmed' ? 'Unconfirmed' : 'Verified';
81
96
  }
97
+ /**
98
+ * Where an account is listed among a subject's others, before the order they were
99
+ * connected in. This is all there is to preferring an account: there is no separate pin.
100
+ */
101
+ export function place(account) {
102
+ if (account.status === 'retired')
103
+ return 3;
104
+ return account.mark === 'preferred' ? 0 : account.mark === 'unused' ? 2 : 1;
105
+ }
106
+ /**
107
+ * One subject's records as its accounts, in the order they are listed everywhere: the
108
+ * preferred one, then the rest as first connected, then those unused, then those retired.
109
+ * Records are not accounts: the same account can stand on several. `held` is every record
110
+ * of the account, earliest first, and `verified` those of them that stand now. `lead`
111
+ * speaks for the account: the earliest that stands, or else the latest approved, which
112
+ * says what became of it. `external` is how the account was shown: the lead's methods,
113
+ * then any other its standing records add.
114
+ *
115
+ * A record from a backend older than `connectedAt` is placed by when it was approved.
116
+ */
117
+ export function accountsOf(records, now) {
118
+ const when = (e) => e.connectedAt ?? e.approvedAt;
119
+ const byAccount = new Map();
120
+ for (const record of records) {
121
+ const key = JSON.stringify([record.provider, record.external.id]);
122
+ byAccount.set(key, [...(byAccount.get(key) ?? []), record]);
123
+ }
124
+ return [...byAccount.values()]
125
+ .map((unsorted) => {
126
+ const held = [...unsorted].sort((a, b) => when(a) - when(b));
127
+ const verified = held.filter((e) => e.status === 'verified' && e.expiresAt > now);
128
+ const lead = verified[0] ?? held.reduce((a, b) => (b.approvedAt > a.approvedAt ? b : a));
129
+ const external = [...lead.attestations.external];
130
+ for (const other of verified.slice(1))
131
+ for (const attestation of other.attestations.external)
132
+ if (!external.some((shown) => shown.method === attestation.method))
133
+ external.push(attestation);
134
+ return { lead, held, verified, external, connectedAt: when(held[0]) };
135
+ })
136
+ .sort((a, b) => place(a.lead) - place(b.lead) || a.connectedAt - b.connectedAt);
137
+ }
82
138
  /**
83
139
  * What to call a link's local side, and what to write as its value. When the site itself is
84
140
  * what was linked there is no subject on it to name, so the site is the value rather than a
@@ -140,6 +196,22 @@ export function attestationLabel(method, names, proof = {}) {
140
196
  export function externalId(external) {
141
197
  return external.kind === 'mailbox' || external.kind === 'domain' ? undefined : external.id;
142
198
  }
199
+ /**
200
+ * A key's fingerprint in groups of four, as gpg prints it, so a renderer can break the line
201
+ * between groups. Nothing for any other subject, whose id is written as it stands.
202
+ */
203
+ export function externalIdGroups(external) {
204
+ return external.kind === 'key' ? (external.id.match(/.{1,4}/g) ?? undefined) : undefined;
205
+ }
206
+ /**
207
+ * The text for an account's creation date, such as "Joined 2014-03-02", or undefined if
208
+ * the date is unknown. `day` formats the date, to the day.
209
+ */
210
+ export function externalSince(external, day) {
211
+ return typeof external.createdAt === 'number' && Number.isFinite(external.createdAt)
212
+ ? `Joined ${day(external.createdAt)}`
213
+ : undefined;
214
+ }
143
215
  /**
144
216
  * Where a subject's name may link, or nothing where it has no page of its own. A mailbox's
145
217
  * address is `mailto:`, which opens a message to it and shows a reader nothing.
@@ -62,6 +62,9 @@ export function discordProvider(options) {
62
62
  id: user.id,
63
63
  handle: user.username,
64
64
  profileUrl: `https://discord.com/users/${user.id}`,
65
+ // A Discord id encodes its creation time: the bits above the low 22 are
66
+ // milliseconds since 2015-01-01 UTC.
67
+ createdAt: Number(BigInt(user.id) >> 22n) + 1420070400000,
65
68
  };
66
69
  },
67
70
  };
@@ -1,5 +1,5 @@
1
1
  import { MIMEType } from 'node:util';
2
- import { Refused, } from '../core/index.js';
2
+ import { Absent, Refused, } from '../core/index.js';
3
3
  import { discard, readBounded } from './body.js';
4
4
  import { transport } from './public-fetch.js';
5
5
  /**
@@ -47,18 +47,39 @@ export function dnsProvider(options = {}) {
47
47
  const domain = looked(artifact);
48
48
  const { values, dnssec } = await published(domain, options);
49
49
  if (!values.includes(expect))
50
- throw new Refused(`No TXT record at ${label}.${domain} has this value`);
50
+ throw new Absent(`No TXT record at ${label}.${domain} has this value`);
51
51
  return { account: account(domain), ...(dnssec ? { dnssec } : {}) };
52
52
  };
53
53
  return {
54
54
  ...shared(options),
55
55
  method: 'dns',
56
- instructions: (expect) => [
57
- 'Add a TXT record to your domain’s DNS with this name and value, then enter the domain below.',
58
- { code: label },
59
- { code: expect },
60
- 'A new record can take a few minutes to be seen.',
61
- ],
56
+ instructions(expect, artifact) {
57
+ let domain = artifact ? named(artifact) : undefined;
58
+ if (!domain && artifact) {
59
+ try {
60
+ domain = looked(artifact);
61
+ }
62
+ catch {
63
+ /* An invalid input is never a command. */
64
+ }
65
+ }
66
+ const record = domain ? `${label}.${domain}` : `${label}.YOUR-DOMAIN`;
67
+ return [
68
+ domain
69
+ ? 'Add a TXT record with this name and value.'
70
+ : 'Add a TXT record with this name and value. Replace YOUR-DOMAIN with the exact domain you enter below.',
71
+ { code: record },
72
+ 'If your DNS host adds the domain automatically, enter only _verily for the root domain. For a subdomain such as www.example.com in the example.com zone, enter _verily.www.',
73
+ { code: expect },
74
+ domain
75
+ ? `Proves control of ${domain} only. Parent domains and subdomains must be verified separately.`
76
+ : 'Proves control of the exact domain you enter. Parent domains and subdomains must be verified separately.',
77
+ 'DNS changes can take up to 72 hours to appear. Check the published record with:',
78
+ { code: `dig TXT ${record} +short` },
79
+ ...(domain ? [[{ text: 'Look up this TXT record', href: lookup(domain) }]] : []),
80
+ 'Keep the TXT record in place after verification so Verily can check it again.',
81
+ ];
82
+ },
62
83
  resolve(artifact) {
63
84
  const domain = named(artifact);
64
85
  return domain ? lookup(domain) : artifact;
@@ -223,6 +244,9 @@ export function wellKnownProvider(options = {}) {
223
244
  // The holder named this domain, so a redirect would take the check somewhere else.
224
245
  if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400))
225
246
  throw new Refused('File redirects, and a redirect is not followed');
247
+ // The file is gone. Any other failure is a failed read.
248
+ if (response.status === 404 || response.status === 410)
249
+ throw new Absent('File unavailable');
226
250
  if (!response.ok)
227
251
  throw new Refused('File unavailable');
228
252
  if (mediaType(response.headers.get('content-type')) !== 'text/plain')
@@ -232,8 +256,11 @@ export function wellKnownProvider(options = {}) {
232
256
  // The last line of a file cut short may be the start of some longer address.
233
257
  if (truncated)
234
258
  lines.pop();
235
- if (!lines.some((line) => line.trim() === expect))
236
- throw new Refused(truncated ? 'File is too large to read' : 'File has no line with this value');
259
+ if (!lines.some((line) => line.trim() === expect)) {
260
+ if (truncated)
261
+ throw new Refused('File is too large to read');
262
+ throw new Absent('File has no line with this value');
263
+ }
237
264
  }
238
265
  finally {
239
266
  await discard(response);
@@ -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
  };