@bkazemi/verily 0.2.0 → 0.3.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,5 @@
1
1
  import type { Evidence } from '../core/index.js';
2
+ import { type Listing } from './evidence-dialog.js';
2
3
  export type { Evidence } from '../core/index.js';
3
4
  export interface Result {
4
5
  outcome: 'complete' | 'cancelled' | 'failed';
@@ -29,7 +30,10 @@ export declare function init({ backendUrl, handoffUrl }: {
29
30
  * Opens the connect dialog over the current page. Resolves when it closes, with how
30
31
  * the last attempt in it ended. Call from a click: a sign-in method opens a window.
31
32
  */
32
- openConnect(opener?: HTMLElement, back?: () => void, provider?: string): Promise<Result>;
33
+ openConnect(opener?: HTMLElement, back?: () => void, provider?: string, renew?: {
34
+ id: string;
35
+ method?: string;
36
+ }): Promise<Result>;
33
37
  /**
34
38
  * Draws the pill that opens the connect dialog. The host receives a `verily-result`
35
39
  * event, whose detail is the Result, whenever the dialog closes on a new connection.
@@ -54,6 +58,11 @@ export declare function init({ backendUrl, handoffUrl }: {
54
58
  connectionIds: string[];
55
59
  } & Arrangement): Promise<void>;
56
60
  disconnect(id: string): Promise<unknown>;
61
+ /**
62
+ * Says how the holder would have an account listed, or retires it. Names one record
63
+ * and acts on the account: every record of it the holder has.
64
+ */
65
+ mark(id: string, as: Listing): Promise<unknown>;
57
66
  issueShare: (id: string) => Promise<unknown>;
58
67
  revokeShare: (id: string) => Promise<unknown>;
59
68
  };
@@ -119,9 +119,11 @@ function arrange(element, { stacked, peek }) {
119
119
  }
120
120
  const live = (evidence) => evidence.status === 'verified' && evidence.expiresAt > Date.now();
121
121
  /**
122
- * A subject's accounts, one entry for each, in the order they were connected, earliest
123
- * first. Records are not accounts: the same account can stand on several, shown a second
124
- * way, or revoked once and connected again. Those are one account and get one card.
122
+ * A subject's accounts, one entry for each. The one its holder prefers comes first, then
123
+ * the rest in the order they were connected, earliest first, then those the holder no
124
+ * longer uses, and last those retired. Records are not accounts: the same account can
125
+ * stand on several, shown a second way, or revoked once and connected again. Those are one
126
+ * account and get one card.
125
127
  *
126
128
  * Where any of an account's records is verified now, the earliest of those speaks for it,
127
129
  * and the ways the others show it are listed beneath as other methods. Its lapsed
@@ -159,14 +161,26 @@ function accounts(records) {
159
161
  sources: verified.length ? verified : [base],
160
162
  };
161
163
  })
162
- .sort((a, b) => a.connectedAt - b.connectedAt);
164
+ .sort((a, b) => place(a) - place(b) || a.connectedAt - b.connectedAt);
163
165
  }
164
166
  /**
165
- * One subject's accounts as a single pill: the first connected, then how many more stand
166
- * behind it. The order is the order they were connected in, which a renewal never changes.
167
- * Only accounts verified now are counted, and one of those leads when the first connected
168
- * has lapsed, so the pill never puts a lapsed account forward while a good one sits behind
169
- * a number. The lapsed ones are all still there in the dialog, each in its place.
167
+ * Where an account is listed among a subject's others, before the order they were
168
+ * connected in. This is all there is to preferring an account: there is no separate pin.
169
+ */
170
+ function place(account) {
171
+ if (account.status === 'retired')
172
+ return 3;
173
+ return account.mark === 'preferred' ? 0 : account.mark === 'unused' ? 2 : 1;
174
+ }
175
+ /**
176
+ * One subject's accounts as a single pill: the first of them, then how many more stand
177
+ * behind it. The order is the holder's preferred account, then the order the rest were
178
+ * connected in, which a renewal never changes, then the ones no longer used.
179
+ * Only accounts verified now are counted, and one of those leads when the first has
180
+ * lapsed, so the pill never puts a lapsed account forward while a good one sits behind
181
+ * a number. The lapsed ones are all still there in the dialog, each in its place. A
182
+ * preferred account that has lapsed keeps its mark, and leads again once renewed. A
183
+ * retired account is never counted, and leads only where nothing else is shown.
170
184
  *
171
185
  * `load` gives the records afresh when the dialog opens and as it stays open.
172
186
  */
@@ -317,7 +331,9 @@ export function init({ backendUrl, handoffUrl }) {
317
331
  safeUrl(data.local.profileUrl);
318
332
  return data;
319
333
  },
320
- start: async (provider, method) => flowView(await ask('/sessions', { kind: 'connect', provider, method })),
334
+ start: async (provider, method, renew) => flowView(await ask('/sessions', renew
335
+ ? { kind: 'renew', connectionId: renew, provider, method }
336
+ : { kind: 'connect', provider, method })),
321
337
  read: async (id) => flowView(await ask(`/flows/${encodeURIComponent(id)}?format=json`, undefined, id)),
322
338
  submit: async (id, artifact) => flowView(await ask(`/flows/${encodeURIComponent(id)}/submit`, { artifact }, id)),
323
339
  async approve(id, visibility, cancel) {
@@ -362,12 +378,14 @@ export function init({ backendUrl, handoffUrl }) {
362
378
  * Opens the connect dialog over the current page. Resolves when it closes, with how
363
379
  * the last attempt in it ended. Call from a click: a sign-in method opens a window.
364
380
  */
365
- openConnect(opener = document.body, back, provider) {
381
+ openConnect(opener = document.body, back, provider,
382
+ /** A record of the holder's to renew, in place of connecting: see `openConnectDialog`. */
383
+ renew) {
366
384
  if (remote && !handoffUrl)
367
385
  throw new Error('A backend on another origin requires a handoff URL');
368
386
  // Each opening is vouched for afresh, for whoever is signed in to this site now.
369
387
  session = undefined;
370
- return openConnectDialog(opener, flows, back, provider);
388
+ return openConnectDialog(opener, flows, back, provider, renew);
371
389
  },
372
390
  /**
373
391
  * Draws the pill that opens the connect dialog. The host receives a `verily-result`
@@ -530,6 +548,15 @@ export function init({ backendUrl, handoffUrl }) {
530
548
  session = undefined;
531
549
  return ask(`/connections/${encodeURIComponent(id)}/disconnect`, {});
532
550
  },
551
+ /**
552
+ * Says how the holder would have an account listed, or retires it. Names one record
553
+ * and acts on the account: every record of it the holder has.
554
+ */
555
+ mark(id, as) {
556
+ if (remote)
557
+ session = undefined;
558
+ return ask(`/connections/${encodeURIComponent(id)}/mark`, { as });
559
+ },
533
560
  issueShare: (id) => request(`/connections/${encodeURIComponent(id)}/share`, {}),
534
561
  revokeShare: (id) => request(`/connections/${encodeURIComponent(id)}/share-revoke`, {}),
535
562
  };
@@ -645,7 +672,12 @@ function validEvidence(value) {
645
672
  ['account', 'key', 'page', 'mailbox'].includes(String(value.external.kind))) &&
646
673
  (value.revokedAt === undefined ||
647
674
  (typeof value.revokedAt === 'number' && Number.isFinite(value.revokedAt))) &&
648
- ['verified', 'unconfirmed', 'expired', 'revoked'].includes(String(value.status)) &&
675
+ ['verified', 'unconfirmed', 'expired', 'retired', 'revoked'].includes(String(value.status)) &&
676
+ // A retired record stands on its dates, so one that does not say when is not one.
677
+ (value.status === 'retired'
678
+ ? typeof value.retiredAt === 'number' && Number.isFinite(value.retiredAt)
679
+ : value.retiredAt === undefined) &&
680
+ (value.mark === undefined || ['preferred', 'unused'].includes(String(value.mark))) &&
649
681
  ['public', 'unlisted'].includes(String(value.visibility)) &&
650
682
  (value.connectedAt === undefined ||
651
683
  (typeof value.connectedAt === 'number' && Number.isFinite(value.connectedAt))) &&
@@ -766,6 +798,14 @@ if (typeof customElements !== 'undefined' && !customElements.get('verily-badge')
766
798
  changed(result);
767
799
  });
768
800
  },
801
+ renew: (id, provider, method) => {
802
+ void client
803
+ .openConnect(this, () => details(this), provider, { id, method })
804
+ .then((result) => {
805
+ if (result.outcome === 'complete')
806
+ changed(result);
807
+ });
808
+ },
769
809
  // One at a time, each told as it goes: if a later one fails, the host still
770
810
  // hears of those that went and reads its records again.
771
811
  remove: async (ids) => {
@@ -774,6 +814,10 @@ if (typeof customElements !== 'undefined' && !customElements.get('verily-badge')
774
814
  changed({ outcome: 'removed', connectionId: id });
775
815
  }
776
816
  },
817
+ mark: async (id, as) => {
818
+ await client.mark(id, as);
819
+ changed({ outcome: 'marked', connectionId: id, as });
820
+ },
777
821
  });
778
822
  }
779
823
  else
@@ -1,2 +1,4 @@
1
+ /** Where the logotype under a dialog leads: the project, for a reader asking what drew this. */
2
+ export declare const projectUrl = "https://github.com/bkazemi/verily";
1
3
  /** The logotype, drawn as nodes: nothing in the badge sets innerHTML. */
2
4
  export declare function verilyLogo(): SVGSVGElement;
@@ -1,4 +1,6 @@
1
1
  import { logoPaths, logoViewBox } from '../logo.js';
2
+ /** Where the logotype under a dialog leads: the project, for a reader asking what drew this. */
3
+ export const projectUrl = 'https://github.com/bkazemi/verily';
2
4
  /** The logotype, drawn as nodes: nothing in the badge sets innerHTML. */
3
5
  export function verilyLogo() {
4
6
  const namespace = 'http://www.w3.org/2000/svg';
@@ -15,5 +15,7 @@ export declare function paintMark(svg: SVGSVGElement, tone: MarkTone): void;
15
15
  export declare function verificationMark(tone?: MarkTone): SVGSVGElement;
16
16
  /** The clock on an expired record: a face, and hands at three o'clock. */
17
17
  export declare const clockMark: () => SVGSVGElement;
18
+ /** The box on a retired record: a lid, what it holds, and a slot to take it down by. */
19
+ export declare const boxMark: () => SVGSVGElement;
18
20
  /** The dash on a record that is revoked or cannot be shown. */
19
21
  export declare const dashMark: () => SVGSVGElement;
@@ -73,5 +73,11 @@ export const clockMark = () => statusMark('clock', [
73
73
  ['circle', { cx: '8', cy: '8', r: '6.2' }],
74
74
  ['path', { d: 'M8 4.6V8h2.6' }],
75
75
  ]);
76
+ /** The box on a retired record: a lid, what it holds, and a slot to take it down by. */
77
+ export const boxMark = () => statusMark('box', [
78
+ ['path', { d: 'M2.6 3.8h10.8v2.6H2.6z' }],
79
+ ['path', { d: 'M3.7 6.4v5.8h8.6V6.4' }],
80
+ ['path', { d: 'M6.6 9h2.8' }],
81
+ ]);
76
82
  /** The dash on a record that is revoked or cannot be shown. */
77
83
  export const dashMark = () => statusMark('dash', [['path', { d: 'M4.4 8h7.2' }]]);
@@ -28,6 +28,10 @@ export declare function signature(evidence: Evidence): Promise<Signature>;
28
28
  * it, with only its present state taken from the record as read, so what stands beside the
29
29
  * signature mark is what was signed. One whose signed record failed is not shown as verified: the
30
30
  * verifier's own signature does not bear it out, so it reads as unconfirmed.
31
+ *
32
+ * The holder's mark is taken from the record as read too, each time, and never from the
33
+ * signed record kept: a mark would otherwise vanish the moment its record checked, and one
34
+ * changed since would go on being shown as it was.
31
35
  */
32
36
  export declare function standing<T extends Evidence>(evidence: T): T;
33
37
  /**
@@ -27,9 +27,12 @@ function canonical(value) {
27
27
  .map(([key, field]) => `${JSON.stringify(key)}:${canonical(field)}`)
28
28
  .join(',')}}`;
29
29
  }
30
- /** What a record claims, apart from whether it stands now: all a signed record can bear out. */
30
+ /**
31
+ * What a record claims, apart from whether it stands now: all a signed record can bear out.
32
+ * The holder's mark is no claim of the verifier's and is in no signed record.
33
+ */
31
34
  function claims(record) {
32
- const { status: _status, revokedAt: _revokedAt, signedUrl: _signedUrl, links: _links, type: _type, version: _version, issuedAt: _issuedAt, ...rest } = record;
35
+ const { status: _status, revokedAt: _revokedAt, signedUrl: _signedUrl, mark: _mark, links: _links, type: _type, version: _version, issuedAt: _issuedAt, ...rest } = record;
33
36
  return canonical(rest);
34
37
  }
35
38
  /**
@@ -124,13 +127,20 @@ async function check(evidence) {
124
127
  document.provider !== evidence.provider ||
125
128
  document.external.id !== evidence.external.id)
126
129
  return { state: 'invalid' };
130
+ // A retired record is signed as a version of its own, and a record that stands as the
131
+ // other. One served for the other is the record caught between the two, and no verdict.
132
+ if ((document.version === 2) !== (evidence.status === 'retired'))
133
+ return { state: 'unchecked', why: 'unreadable' };
127
134
  // The record as it was signed. Whether it stands now is no part of a signed record, so
128
- // that alone is kept from the record as it was read.
129
- const { type: _type, version: _version, issuedAt: _issuedAt, ...said } = document;
135
+ // that is kept from the record as it was read, and so is the holder's mark, which is in
136
+ // no signed record: whatever a document says of either is left behind.
137
+ const { type: _type, version: _version, issuedAt: _issuedAt, status: _status, retiredAt: _retiredAt, mark: _mark, ...said } = document;
130
138
  const record = {
131
139
  ...said,
132
140
  status: evidence.status,
133
141
  ...(evidence.revokedAt === undefined ? {} : { revokedAt: evidence.revokedAt }),
142
+ ...(document.version === 2 ? { retiredAt: document.retiredAt } : {}),
143
+ ...(evidence.mark === undefined ? {} : { mark: evidence.mark }),
134
144
  signedUrl: evidence.signedUrl,
135
145
  };
136
146
  // Signed, but not something a page may draw: it is shown as read, with no mark.
@@ -178,18 +188,25 @@ export function signature(evidence) {
178
188
  * it, with only its present state taken from the record as read, so what stands beside the
179
189
  * signature mark is what was signed. One whose signed record failed is not shown as verified: the
180
190
  * verifier's own signature does not bear it out, so it reads as unconfirmed.
191
+ *
192
+ * The holder's mark is taken from the record as read too, each time, and never from the
193
+ * signed record kept: a mark would otherwise vanish the moment its record checked, and one
194
+ * changed since would go on being shown as it was.
181
195
  */
182
196
  export function standing(evidence) {
183
197
  if (evidence.signedUrl === undefined)
184
198
  return evidence;
185
199
  const key = shown(evidence);
186
200
  const signed = signedAs.get(key);
187
- if (signed)
201
+ if (signed) {
202
+ const { mark: _mark, ...said } = signed;
188
203
  return {
189
- ...signed,
204
+ ...said,
190
205
  status: evidence.status,
191
206
  ...(evidence.revokedAt === undefined ? {} : { revokedAt: evidence.revokedAt }),
207
+ ...(evidence.mark === undefined ? {} : { mark: evidence.mark }),
192
208
  };
209
+ }
193
210
  return evidence.status === 'verified' && failures.has(key)
194
211
  ? { ...evidence, status: 'unconfirmed' }
195
212
  : evidence;
@@ -2,9 +2,16 @@ export type Visibility = 'public' | 'unlisted';
2
2
  /**
3
3
  * `unconfirmed` is a published proof that has not been read lately. It is not disproved,
4
4
  * and turns back to `verified` the moment the proof reads again. `expired` is an approval
5
- * that ran out, which only the holder renewing it undoes.
5
+ * that ran out, which only the holder renewing it undoes. `retired` is an account its
6
+ * holder said is finished: the record is kept as it stood at its last proof and never
7
+ * reads as verified again, unless that proof is made afresh.
6
8
  */
7
- export type Status = 'verified' | 'unconfirmed' | 'expired' | 'revoked';
9
+ export type Status = 'verified' | 'unconfirmed' | 'expired' | 'retired' | 'revoked';
10
+ /**
11
+ * How the holder would have an account listed: the one to lead with, or one they still
12
+ * control and no longer use. The holder's own statement, and no part of what was verified.
13
+ */
14
+ export type Mark = 'preferred' | 'unused';
8
15
  /** What a site links: one of its accounts, a single page, or the site itself. */
9
16
  export type LocalKind = 'account' | 'page' | 'site';
10
17
  /** id is private and must never be reassigned. reference is durable and public-safe. */
@@ -106,6 +113,13 @@ export interface Connection {
106
113
  attestations?: Attestations;
107
114
  /** The proof itself, for a method whose artifact this backend publishes rather than reads. */
108
115
  proof?: string;
116
+ /** Set and cleared by the holder. Absent is an ordinary current account. */
117
+ mark?: Mark;
118
+ /**
119
+ * When the holder said the account is finished. From then the record is frozen at its
120
+ * last proof: never renewed by another method, never reread, never verified.
121
+ */
122
+ retiredAt?: number;
109
123
  }
110
124
  export interface Flow {
111
125
  id: string;
@@ -426,10 +440,17 @@ export interface Evidence {
426
440
  visibilityApprovedAt: number;
427
441
  expiresAt: number;
428
442
  revokedAt?: number;
443
+ /**
444
+ * What the holder says of the account, and when they said it was finished. Both are the
445
+ * holder's statements, beside the attestations and never among them: nothing checked them.
446
+ */
447
+ mark?: Mark;
448
+ retiredAt?: number;
429
449
  evidenceUrl: string;
430
450
  /**
431
451
  * Where this record can be had signed, on a verifier that signs. Only a public record
432
- * that stands has a signed form: once saved, a signed record cannot be taken back.
452
+ * that stands, or one retired, has a signed form: once saved, a signed record cannot be
453
+ * taken back.
433
454
  */
434
455
  signedUrl?: string;
435
456
  }
@@ -452,6 +473,11 @@ export declare function status(connection: Connection, now: number, freshness?:
452
473
  * reverses the moment it reads again.
453
474
  */
454
475
  export declare function fresh(attestation: Attestation, now: number, freshness?: number): boolean;
476
+ /**
477
+ * The date a retired record stands on: when its account was last proved. A sign-in was
478
+ * proved when it happened, and a published proof when it was last read.
479
+ */
480
+ export declare function lastProved(evidence: Pick<Evidence, 'authenticatedAt' | 'attestations'>): number;
455
481
  /**
456
482
  * The word for a record's state. Evidence can be read after it was issued, so an approval
457
483
  * that has run out since reads as expired whatever status it was issued with.
@@ -35,6 +35,9 @@ export const freshnessMs = 7 * 86400000;
35
35
  export function status(connection, now, freshness = freshnessMs) {
36
36
  if (connection.revokedAt !== undefined)
37
37
  return 'revoked';
38
+ // Frozen at its last proof, so neither its expiry nor its freshness is read again.
39
+ if (connection.retiredAt !== undefined)
40
+ return 'retired';
38
41
  if (now >= connection.expiresAt)
39
42
  return 'expired';
40
43
  const main = connection.attestations?.external[0];
@@ -53,6 +56,14 @@ export function fresh(attestation, now, freshness = freshnessMs) {
53
56
  freshness === Infinity ||
54
57
  now < attestation.confirmedAt + freshness);
55
58
  }
59
+ /**
60
+ * The date a retired record stands on: when its account was last proved. A sign-in was
61
+ * proved when it happened, and a published proof when it was last read.
62
+ */
63
+ export function lastProved(evidence) {
64
+ const main = evidence.attestations.external[0];
65
+ return main.artifactUrl ? main.confirmedAt : evidence.authenticatedAt;
66
+ }
56
67
  /**
57
68
  * The word for a record's state. Evidence can be read after it was issued, so an approval
58
69
  * that has run out since reads as expired whatever status it was issued with.
@@ -60,6 +71,9 @@ export function fresh(attestation, now, freshness = freshnessMs) {
60
71
  export function statusLabel(evidence, now) {
61
72
  if (evidence.status === 'revoked')
62
73
  return 'Revoked';
74
+ // Before expiry: a retired record's approval has long run out, and that is not its state.
75
+ if (evidence.status === 'retired')
76
+ return 'Retired';
63
77
  if (evidence.status === 'expired' || evidence.expiresAt <= now)
64
78
  return 'Expired';
65
79
  return evidence.status === 'unconfirmed' ? 'Unconfirmed' : 'Verified';
@@ -11,15 +11,29 @@ export interface VerifierKey {
11
11
  publicKey: string;
12
12
  }
13
13
  /**
14
- * What a signature covers: the record as it stood at `issuedAt`. Whether it still stands
15
- * is a live fact no signature holds, so its status is left out and `evidenceUrl` is where
16
- * to ask.
14
+ * What either version says of a record. The holder's mark is left out: it is about how
15
+ * their accounts are listed, and no part of what was verified.
17
16
  */
18
- export type SignedDocument = Omit<Evidence, 'status' | 'revokedAt' | 'signedUrl'> & {
17
+ type Signable = Omit<Evidence, 'status' | 'revokedAt' | 'signedUrl' | 'mark' | 'retiredAt'> & {
19
18
  type: 'verily-evidence';
20
- version: 1;
21
19
  issuedAt: number;
22
20
  };
21
+ /**
22
+ * What a signature covers: the record as it stood at `issuedAt`. Whether it still stands
23
+ * is a live fact no signature holds, so version 1 leaves its status out, means the record
24
+ * stood when it was signed, and gives `evidenceUrl` as where to ask.
25
+ *
26
+ * Version 2 is a retired record, which did not stand when it was signed and says so. It is
27
+ * a version of its own because a reader of version 1 ignores fields it does not know, and
28
+ * would read a retired record signed as version 1 as one that stood.
29
+ */
30
+ export type SignedDocument = (Signable & {
31
+ version: 1;
32
+ }) | (Signable & {
33
+ version: 2;
34
+ status: 'retired';
35
+ retiredAt: number;
36
+ });
23
37
  /**
24
38
  * A record that can be checked away from the verifier that served it: the document as
25
39
  * text under an OpenPGP cleartext signature, which `gpg --verify` reads as it is.
@@ -47,3 +61,4 @@ export declare function signedBy(signed: unknown): Promise<string | undefined>;
47
61
  * of it, so the fingerprint a record names is the one a list holds.
48
62
  */
49
63
  export declare function verifySigned(signed: unknown, keys: VerifierKey[]): Promise<SignedDocument | undefined>;
64
+ export {};
@@ -100,8 +100,11 @@ export async function verifySigned(signed, keys) {
100
100
  await signatures[0].verified;
101
101
  const document = JSON.parse(data);
102
102
  return document?.type === 'verily-evidence' &&
103
- document.version === 1 &&
104
- typeof document.issuedAt === 'number'
103
+ typeof document.issuedAt === 'number' &&
104
+ (document.version === 1 ||
105
+ (document.version === 2 &&
106
+ document.status === 'retired' &&
107
+ typeof document.retiredAt === 'number'))
105
108
  ? document
106
109
  : undefined;
107
110
  }
@@ -1,4 +1,4 @@
1
- import { attestationLabel, externalLink, externalName, isArtifactProvider, isCodeProvider, isRedirectProvider, localSide, proofTitle, providerMethod, statusLabel, signedBy, } from '../core/index.js';
1
+ import { attestationLabel, 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';
@@ -245,20 +245,32 @@ function evidencePage(e, base, report, keyId) {
245
245
  card(names.provider, externalName(e.external), externalLink(e.external),
246
246
  // A mailbox's address is its name already, so there is no second identifier.
247
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
- ]) +
248
+ times(e.status === 'retired'
249
+ ? [
250
+ ['Approved', e.approvedAt],
251
+ // Two rows, always, so a reader sees any gap between them: an account retired
252
+ // weeks after it was last proved went unproved for those weeks.
253
+ ['Last verified', lastProved(e)],
254
+ ['Retired', e.retiredAt],
255
+ ['Sharing link expires', e.linkExpiresAt],
256
+ ]
257
+ : [
258
+ ['Approved', e.approvedAt],
259
+ ['Authenticated', e.authenticatedAt],
260
+ [
261
+ current ? 'Valid until' : 'Expired on',
262
+ e.status === 'revoked' ? undefined : e.expiresAt,
263
+ ],
264
+ ['Revoked on', e.revokedAt],
265
+ // Only a method that publishes an artifact drifts; a sign-in does not go stale.
266
+ [
267
+ 'Last checked',
268
+ e.attestations.external[0].artifactUrl
269
+ ? e.attestations.external[0].confirmedAt
270
+ : undefined,
271
+ ],
272
+ ['Sharing link expires', e.linkExpiresAt],
273
+ ]) +
262
274
  `${e.linkExpiresAt ? '<p class="fine">Anyone with this link can view and forward it.</p>' : ''}
263
275
  ${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
276
  <p class="fine">This connection does not establish legal identity, trustworthiness, content authorship, or permanent ownership.</p>
@@ -275,14 +287,23 @@ const checkForm = '<form method="post"><label>Signed record <textarea name="reco
275
287
  function checkedPage(d, keyId, base) {
276
288
  const names = { site: d.siteName, provider: d.providerName };
277
289
  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>` +
290
+ // A retired record did not stand when it was signed, and its heading says so first.
291
+ const retired = d.version === 2;
292
+ 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
293
  card(local.heading, local.value, d.local.profileUrl, undefined, attestationNote(d.attestations.local, names)) +
280
294
  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
- ]) +
295
+ times(retired
296
+ ? [
297
+ ['Signed', d.issuedAt],
298
+ ['Approved', d.approvedAt],
299
+ ['Last verified', lastProved(d)],
300
+ ['Retired', d.retiredAt],
301
+ ]
302
+ : [
303
+ ['Signed', d.issuedAt],
304
+ ['Approved', d.approvedAt],
305
+ ['Valid until', d.expiresAt],
306
+ ]) +
286
307
  `<p class="fine">Signing key <a href="${escape(base)}/keys.asc">${escape(keyId)}</a>.</p>
287
308
  <p class="fine"><a href="${escape(safeUrl(d.evidenceUrl))}">See whether this connection still stands</a></p>`);
288
309
  }
@@ -374,13 +395,24 @@ export function createVerily(options) {
374
395
  /**
375
396
  * The methods a page offers. A flow on an existing record stays in its namespace, so
376
397
  * 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.
398
+ * external side refuses a standing proof, which anybody can hand back. A retired record
399
+ * is renewed by the method it was first shown by, so that one is offered alone.
378
400
  */
379
401
  async function offer(kind, id, user, requested) {
380
402
  let namespace = requested ?? undefined;
381
- if (user && id)
382
- namespace = (await service.read(id, user)).provider;
403
+ let only;
404
+ if (user && id) {
405
+ const held = await service.read(id, user);
406
+ namespace = held.provider;
407
+ if (held.status === 'retired') {
408
+ // Who can read a retired record is frozen with the rest of it.
409
+ if (kind === 'visibility')
410
+ throw new Unavailable();
411
+ only = held.attestations.external[0].method;
412
+ }
413
+ }
383
414
  const offered = (user ? permitted(user) : service.providers).filter((p) => (namespace === undefined || p.id === namespace) &&
415
+ (only === undefined || providerMethod(p) === only) &&
384
416
  !(['revoke', 'share-revoke'].includes(kind) && isArtifactProvider(p) && p.expect));
385
417
  if (!offered.length)
386
418
  throw new Unavailable();
@@ -428,8 +460,8 @@ export function createVerily(options) {
428
460
  profileUrl: user.profileUrl,
429
461
  });
430
462
  /**
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.
463
+ * A connect or renew flow as the in-page dialog draws it: the same steps the flow pages
464
+ * walk through, as data, so the holder never has to leave the page they started on.
433
465
  */
434
466
  async function flowView(flow, authorizationUrl) {
435
467
  const provider = service.providerOf(flow);
@@ -472,10 +504,10 @@ export function createVerily(options) {
472
504
  view.connectionId = flow.resultId;
473
505
  return view;
474
506
  }
475
- /** A flow the dialog may read: one this holder started, to connect an account. */
507
+ /** A flow the dialog may read: one this holder started, to connect an account or renew one. */
476
508
  async function ownFlow(request, id) {
477
509
  const flow = await service.flow(id, binding(request));
478
- if (flow.kind !== 'connect' || (await local(request)).id !== flow.local?.id)
510
+ if (!['connect', 'renew'].includes(flow.kind) || (await local(request)).id !== flow.local?.id)
479
511
  throw new Unavailable();
480
512
  return flow;
481
513
  }
@@ -775,7 +807,7 @@ export function createVerily(options) {
775
807
  }), await context(request, kind),
776
808
  // Posted from this origin, which the check on every POST has already held it to.
777
809
  true);
778
- if (asJson && kind === 'connect')
810
+ if (asJson && ['connect', 'renew'].includes(kind))
779
811
  return json(await flowView(await service.flow(flow.flowId, flow.binding), flow.authorizationUrl), flowCookie(flow.binding));
780
812
  return redirect(entry(flow), flowCookie(flow.binding));
781
813
  }
@@ -815,15 +847,21 @@ export function createVerily(options) {
815
847
  return json(id ? { outcome: 'complete', connectionId: id } : { outcome: 'cancelled' });
816
848
  return await ended(await service.flow(flow.id, binding(request)));
817
849
  }
818
- const management = path.match(/^\/connections\/([^/]+)\/(disconnect|share|share-revoke|visibility)$/);
850
+ const management = path.match(/^\/connections\/([^/]+)\/(disconnect|mark|share|share-revoke|visibility)$/);
819
851
  if (management) {
820
852
  const user = await local(request), id = management[1];
821
853
  if (management[2] === 'disconnect') {
822
854
  await service.revoke(id, user);
823
855
  return json({ ok: true });
824
856
  }
857
+ // Acts on the account the record names: every record of it this holder has.
858
+ if (management[2] === 'mark') {
859
+ await service.mark(id, user, data.as);
860
+ return json({ ok: true });
861
+ }
825
862
  if (management[2] === 'visibility') {
826
- await service.read(id, user);
863
+ if ((await service.read(id, user)).status === 'retired')
864
+ throw new Unavailable();
827
865
  return json({ url: `${service.baseUrl}/visibility/${id}` });
828
866
  }
829
867
  return json((await service.share(id, user, management[2] === 'share-revoke')) ?? { ok: true });