@bkazemi/verily 0.1.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.
@@ -72,15 +72,22 @@ export class VerilyService {
72
72
  * A public record that stands, signed as of now. Signed when asked for, not when
73
73
  * approved, so a signed record says the record stood on the day it was taken and none can be
74
74
  * made once it is removed.
75
+ *
76
+ * A retired record is signed too, as what it is: the durable form of the claim that the
77
+ * account was the holder's when it was last proved. It did not stand when it was signed,
78
+ * so it is a version of its own that says so, which a reader of the first refuses.
75
79
  */
76
80
  async signed(id) {
77
81
  const current = await this.signer();
78
82
  if (!current)
79
83
  throw new Unavailable();
80
- const { status, revokedAt: _revokedAt, signedUrl: _signedUrl, ...record } = await this.read(id);
84
+ const { status, revokedAt: _revokedAt, signedUrl: _signedUrl, mark: _mark, retiredAt, ...record } = await this.read(id);
85
+ const document = { type: 'verily-evidence', issuedAt: this.now(), ...record };
86
+ if (status === 'retired')
87
+ return current.sign({ ...document, version: 2, status, retiredAt: retiredAt });
81
88
  if (status !== 'verified')
82
89
  throw new Unavailable();
83
- return current.sign({ type: 'verily-evidence', version: 1, issuedAt: this.now(), ...record });
90
+ return current.sign({ ...document, version: 1 });
84
91
  }
85
92
  /** What a signed record says, if this instance signed it. */
86
93
  async checked(signed) {
@@ -135,7 +142,13 @@ export class VerilyService {
135
142
  ? this.providers[0]
136
143
  : this.resolve(flow.provider, flow.method);
137
144
  }
138
- async start(local, connectionId, kind = 'connect', choice = {}, context) {
145
+ async start(local, connectionId, kind = 'connect', choice = {}, context,
146
+ /**
147
+ * Whether the caller has shown the holder asked for this themselves, as a form posted
148
+ * from their own page shows and following a link does not. Without it the flow reads
149
+ * nothing at its start and ends on an approval, however little there is to approve.
150
+ */
151
+ attended = false) {
139
152
  if (kind === 'connect' && !local)
140
153
  throw new Unavailable();
141
154
  if (context !== undefined &&
@@ -157,10 +170,15 @@ export class VerilyService {
157
170
  connectionId,
158
171
  phase: 'pending',
159
172
  expiresAt: this.now() + (this.options.flowTtlMs ?? 600000),
173
+ ...(attended ? { attended } : {}),
160
174
  };
161
175
  // Removal from the external side answers to nobody local, so nothing local rides on it.
162
176
  if (context && !['revoke', 'share-revoke'].includes(kind))
163
177
  flow.context = { ...context };
178
+ // The account a sign-in is asked for by name. Only where the local holder of the record
179
+ // started the flow: removal from the external side is started by anyone, and is told
180
+ // nothing about the account it would have to show.
181
+ let account;
164
182
  const provider = await this.transaction(async (tx) => {
165
183
  // Whose link this is. A flow against an existing connection may carry no local
166
184
  // subject of its own, and the record is the authority on it in any case.
@@ -173,7 +191,12 @@ export class VerilyService {
173
191
  connection.revokedAt !== undefined ||
174
192
  (['visibility', 'renew'].includes(kind) && connection.local.id !== local?.id))
175
193
  throw new Unavailable();
194
+ // Who can read a retired record is frozen with the rest of it.
195
+ if (kind === 'visibility' && connection.retiredAt !== undefined)
196
+ throw new Unavailable();
176
197
  subject = connection.local;
198
+ if (['renew', 'visibility'].includes(kind))
199
+ account = connection.external;
177
200
  }
178
201
  const provider = this.chosen(kind, choice, connection);
179
202
  flow.provider = provider.id;
@@ -189,10 +212,23 @@ export class VerilyService {
189
212
  flow.expect = artifact.expect
190
213
  ? artifact.expect(subject)
191
214
  : `Verily proof for ${this.siteOf(subject)}: ${secret()}`;
215
+ // These kinds are all started by the local holder, whose own record this is. Removal
216
+ // from the external side is started by anyone, and is told nothing.
217
+ if (artifact?.known && ['connect', 'renew', 'visibility'].includes(kind)) {
218
+ const suggested = connection
219
+ ? artifact.known(connection.external)
220
+ : await this.known(tx, subject, artifact);
221
+ if (suggested)
222
+ flow.suggested = suggested;
223
+ }
192
224
  await tx.put('flows', flow.id, flow);
193
225
  return provider;
194
226
  });
195
227
  const artifact = isArtifactProvider(provider) ? provider : undefined;
228
+ // Where a record already says whose account this is, there is nothing to ask the
229
+ // holder: the proof is either there to be read or it is not.
230
+ if (artifact && flow.suggested && attended)
231
+ await this.offered(flow.id, binding, artifact, flow.expect, flow.suggested);
196
232
  // A redirect provider hands the holder to its own site; an artifact provider tells
197
233
  // them what to publish and waits for them to say where they put it; a code provider
198
234
  // has nothing to say until the holder names the address to send to.
@@ -211,10 +247,54 @@ export class VerilyService {
211
247
  state,
212
248
  challenge: hash(verifier),
213
249
  redirectUri: `${this.baseUrl}/callback`,
250
+ ...(account ? { account } : {}),
214
251
  }),
215
252
  }
216
253
  : { flowId: flow.id, binding };
217
254
  }
255
+ /**
256
+ * What a new flow can offer the holder to hand back: the one account this subject has
257
+ * already shown some other way, which is the record the flow would join. With several,
258
+ * which one they mean is theirs to say.
259
+ */
260
+ async known(tx, local, provider) {
261
+ const offers = (await tx.list('connections'))
262
+ .filter((c) => c.revokedAt === undefined &&
263
+ c.retiredAt === undefined &&
264
+ c.local.id === local.id &&
265
+ c.provider === provider.id &&
266
+ (c.attestations?.external[0].method ?? 'oauth') !== provider.method)
267
+ .map((c) => provider.known(c.external))
268
+ .filter((offer) => offer !== undefined);
269
+ return new Set(offers.map((offer) => offer.toLowerCase())).size === 1 ? offers[0] : undefined;
270
+ }
271
+ /**
272
+ * Reads the proof at the account a record already names, before the holder is asked for
273
+ * anything. Found, the flow goes straight to approval, as if they had handed it back.
274
+ * Not found is no failure: they have not been told what to publish yet, so the flow
275
+ * stays where it was and tells them.
276
+ */
277
+ async offered(id, binding, provider, expect, suggested) {
278
+ try {
279
+ const handed = provider.resolve?.(suggested) ?? suggested;
280
+ const external = await this.deadline(provider.verify({ artifact: handed, expect }));
281
+ await this.transaction(async (tx) => {
282
+ const flow = await this.bound(tx, id, binding);
283
+ flow.phase = 'exchanging';
284
+ await tx.put('flows', id, flow);
285
+ });
286
+ await this.established(id, binding, external, handed);
287
+ }
288
+ catch {
289
+ await this.transaction(async (tx) => {
290
+ const flow = await tx.get('flows', id);
291
+ if (flow?.phase === 'exchanging') {
292
+ flow.phase = 'pending';
293
+ await tx.put('flows', id, flow);
294
+ }
295
+ });
296
+ }
297
+ }
218
298
  /**
219
299
  * Which method a flow runs. A flow on an existing record stays in that record's
220
300
  * namespace, and without a choice it uses the method the record was first shown by.
@@ -222,6 +302,10 @@ export class VerilyService {
222
302
  * Removing a link from the external side takes a method whose proof is fresh. A standing
223
303
  * proof, such as a link back, is there for anyone to point at: handing one back shows
224
304
  * the link exists, not that whoever handed it back holds the account.
305
+ *
306
+ * A retired record is renewed by the method it was first shown by and no other. Another
307
+ * would be listed beneath a main proof nobody has read since, and the record would come
308
+ * back unconfirmed.
225
309
  */
226
310
  chosen(kind, choice, connection) {
227
311
  let provider;
@@ -235,6 +319,8 @@ export class VerilyService {
235
319
  provider = this.resolve(choice.provider, choice.method);
236
320
  if (connection && provider.id !== connection.provider)
237
321
  throw new Unavailable();
322
+ if (kind === 'renew' && connection && !revives(connection, provider))
323
+ throw new Unavailable();
238
324
  if (['revoke', 'share-revoke'].includes(kind) &&
239
325
  isArtifactProvider(provider) &&
240
326
  provider.expect)
@@ -263,14 +349,40 @@ export class VerilyService {
263
349
  return { provider, expect: flow.expect };
264
350
  });
265
351
  try {
266
- const external = await provider.verify({ artifact, expect });
267
- await this.established(id, binding, external, artifact);
352
+ // Kept as the address that was read, never as the shorthand that named it: it is
353
+ // where a reader is sent, and what is read again later.
354
+ const handed = provider.resolve?.(artifact) ?? artifact;
355
+ const external = await provider.verify({ artifact: handed, expect });
356
+ await this.established(id, binding, external, handed);
268
357
  }
269
358
  catch (error) {
270
- await this.failed(id, error);
359
+ await this.refused(id, error, artifact);
271
360
  }
272
361
  return id;
273
362
  }
363
+ /**
364
+ * Takes a refused proof as something the holder can put right, and leaves the flow
365
+ * waiting for it. What they must publish is minted per flow, so a flow ended here would
366
+ * have them publish or sign all over again for a mistyped address. The tries are counted
367
+ * all the same: each one is a fetch somewhere the holder chose.
368
+ */
369
+ async refused(id, error, artifact) {
370
+ await this.transaction(async (tx) => {
371
+ const flow = await tx.get('flows', id);
372
+ if (flow?.phase !== 'exchanging')
373
+ return;
374
+ flow.tries = (flow.tries ?? 0) + 1;
375
+ // Only a `Refused` reason is kept: any other error may describe this backend.
376
+ flow.reason = error instanceof Refused ? error.message : undefined;
377
+ if (flow.tries >= artifactAttempts)
378
+ await this.ended(tx, flow, 'failed');
379
+ else {
380
+ flow.phase = 'pending';
381
+ flow.artifact = artifact;
382
+ }
383
+ await tx.put('flows', id, flow);
384
+ });
385
+ }
274
386
  /**
275
387
  * One step of a mailed code. The first answer is an address: a code is minted, sent
276
388
  * there, and only its hash kept. The second is the code, which the holder could only
@@ -484,7 +596,9 @@ export class VerilyService {
484
596
  // A mailbox has no page to point at, so its own address stands in for one.
485
597
  new URL(external.profileUrl).protocol !== (external.kind === 'mailbox' ? 'mailto:' : 'https:'))
486
598
  throw new Unavailable();
487
- await this.transaction(async (tx) => {
599
+ // The flow as it stands once this account is accepted for it, or a refusal. Asked
600
+ // twice, because what it is checked against can change while proofs are being read.
601
+ const accepted = async (tx) => {
488
602
  const flow = binding === undefined ? await tx.get('flows', id) : await this.bound(tx, id, binding);
489
603
  if (flow?.phase !== 'exchanging')
490
604
  throw new Unavailable();
@@ -499,15 +613,91 @@ export class VerilyService {
499
613
  flow.local = connection.local;
500
614
  }
501
615
  flow.external = external;
616
+ return flow;
617
+ };
618
+ // The account is named now, so a proof that needs nothing but its name can be read
619
+ // without the holder choosing that method or handing anything back. Read while the
620
+ // flow is still exchanging: approval is shown once, and must show all it will record.
621
+ const shown = await this.transaction(accepted);
622
+ const standing = ['connect', 'renew'].includes(shown.kind) ? await this.standing(shown) : [];
623
+ await this.transaction(async (tx) => {
624
+ const flow = await accepted(tx);
502
625
  flow.artifact = artifact;
626
+ flow.reason = undefined;
503
627
  flow.authenticatedAt = this.now();
628
+ if (standing.length)
629
+ flow.standing = standing;
504
630
  flow.phase = 'approval';
505
- await tx.put('flows', id, flow);
631
+ // Nothing for the holder to decide, so nothing to ask them. The visibility passed is
632
+ // never read: both of these keep the record's own.
633
+ if (await this.settled(tx, flow))
634
+ await this.conclude(tx, flow, 'unlisted');
635
+ else
636
+ await tx.put('flows', id, flow);
506
637
  });
507
638
  }
508
639
  /**
509
- * A failed check leaves the flow dead rather than retryable in place. Only a `Refused`
510
- * reason is kept: any other error may describe this backend rather than the proof.
640
+ * The proofs already standing for the account a flow just showed, by the other methods
641
+ * configured for its provider that can tell where to look from the account alone. One
642
+ * that is not there, or cannot be read, is simply not found: the holder asked for none
643
+ * of them, so none of them can fail the flow.
644
+ */
645
+ async standing(flow) {
646
+ const shownBy = this.providerOf(flow);
647
+ const found = [];
648
+ for (const provider of this.providers) {
649
+ if (!isArtifactProvider(provider) ||
650
+ provider.id !== shownBy.id ||
651
+ provider.method === providerMethod(shownBy) ||
652
+ provider.artifact !== 'location' ||
653
+ !provider.expect ||
654
+ !provider.known)
655
+ continue;
656
+ try {
657
+ const expect = provider.expect(flow.local);
658
+ const known = provider.known(flow.external);
659
+ if (!known)
660
+ continue;
661
+ const handed = provider.resolve?.(known) ?? known;
662
+ const external = await this.deadline(provider.verify({ artifact: handed, expect }));
663
+ if (!sameAccount(flow.external, external))
664
+ continue;
665
+ found.push({
666
+ by: 'provider',
667
+ method: provider.method,
668
+ confirmedAt: this.now(),
669
+ artifactUrl: handed,
670
+ expect,
671
+ });
672
+ }
673
+ catch {
674
+ continue;
675
+ }
676
+ }
677
+ return found;
678
+ }
679
+ /** Adds the proofs a flow found standing beneath the method the record was first shown by. */
680
+ recordStanding(connection, flow) {
681
+ if (!flow.standing?.length || !connection.attestations)
682
+ return;
683
+ const [main, ...rest] = connection.attestations.external;
684
+ for (const attestation of flow.standing) {
685
+ // The main method is reread against the id it named, so it is only ever replaced
686
+ // by a flow that ran it.
687
+ if (attestation.method === main.method)
688
+ continue;
689
+ const at = rest.findIndex((a) => a.method === attestation.method);
690
+ if (at < 0)
691
+ rest.push(attestation);
692
+ else
693
+ rest[at] = attestation;
694
+ }
695
+ connection.attestations = { ...connection.attestations, external: [main, ...rest] };
696
+ }
697
+ /**
698
+ * A sign-in or a mailed code that fails leaves the flow dead: there is nothing in it to
699
+ * put right, and starting again costs the holder nothing. Only a `Refused` reason is
700
+ * kept: any other error may describe this backend rather than the proof.
511
701
  */
512
702
  async failed(id, error) {
513
703
  await this.transaction(async (tx) => {
@@ -542,6 +732,26 @@ export class VerilyService {
542
732
  await tx.put('flows', id, flow);
543
733
  return undefined;
544
734
  }
735
+ return this.conclude(tx, flow, visibility, allowed);
736
+ });
737
+ }
738
+ /**
739
+ * Whether approving a flow would decide anything. A renewal, or another way of showing
740
+ * an account the subject is already linked to, changes neither who is linked nor who
741
+ * can read it: the holder asked for exactly this and the account has been checked
742
+ * against the record, so it is recorded without asking them to say so again.
743
+ */
744
+ async settled(tx, flow) {
745
+ // A flow anyone could have sent the holder's browser into is theirs only once they
746
+ // approve it, and the approval is a form that only their own page can post.
747
+ if (!flow.attended)
748
+ return false;
749
+ return flow.kind === 'renew' || (flow.kind === 'connect' && !!(await this.joins(tx, flow)));
750
+ }
751
+ /** Records what an approved flow does, and ends it. The caller has checked who approves. */
752
+ async conclude(tx, flow, visibility, allowed) {
753
+ const id = flow.id;
754
+ {
545
755
  let connection;
546
756
  const provider = this.providerOf(flow);
547
757
  const joined = flow.kind === 'connect' ? await this.joins(tx, flow) : undefined;
@@ -557,6 +767,7 @@ export class VerilyService {
557
767
  throw new Unavailable();
558
768
  // The id exists before the record does, because a hosted proof is addressed by it.
559
769
  const connectionId = secret();
770
+ const mark = await this.accountMark(tx, flow.local, provider.id, flow.external);
560
771
  connection = {
561
772
  id: connectionId,
562
773
  local: flow.local,
@@ -573,6 +784,8 @@ export class VerilyService {
573
784
  external: [this.attestation(flow, provider, connectionId)],
574
785
  },
575
786
  proof: hosted(provider) ? flow.artifact : undefined,
787
+ // The account's other records say how the holder has it listed, and so does this.
788
+ ...(mark ? { mark } : {}),
576
789
  };
577
790
  }
578
791
  else {
@@ -590,7 +803,20 @@ export class VerilyService {
590
803
  // subject snapshot refreshes because the holder just approved what it shows.
591
804
  if (flow.local.id !== existing.local.id)
592
805
  throw new Unavailable();
806
+ // Asked again here: the record may have been retired since the flow started.
807
+ if (!revives(existing, provider))
808
+ throw new Unavailable();
593
809
  this.recordMethod(connection, flow, provider);
810
+ if (connection.retiredAt !== undefined) {
811
+ // Live again on the strength of the proof just made and nothing older. A record
812
+ // made for the account while this one sat retired may have been marked since,
813
+ // and this one would otherwise be drawn in its place and hide that choice.
814
+ const mark = await this.accountMark(tx, connection.local, connection.provider, connection.external, connection.id);
815
+ delete connection.retiredAt;
816
+ delete connection.mark;
817
+ if (mark)
818
+ connection.mark = mark;
819
+ }
594
820
  }
595
821
  else if (flow.kind === 'revoke') {
596
822
  connection.revokedAt = this.now();
@@ -599,17 +825,21 @@ export class VerilyService {
599
825
  else if (flow.kind === 'visibility') {
600
826
  if (allowed && !allowed.includes(visibility))
601
827
  throw new Unavailable();
828
+ if (connection.retiredAt !== undefined)
829
+ throw new Unavailable();
602
830
  connection.visibility = visibility;
603
831
  connection.visibilityApprovedAt = this.now();
604
832
  }
605
833
  }
834
+ if (['connect', 'renew'].includes(flow.kind))
835
+ this.recordStanding(connection, flow);
606
836
  await tx.put('connections', connection.id, connection);
607
837
  await this.audit(tx, connection.id, flow.kind, ['revoke', 'share-revoke'].includes(flow.kind) ? 'external' : 'local', id, connection.visibility);
608
838
  flow.resultId = connection.id;
609
839
  await this.ended(tx, flow, 'complete', connection);
610
840
  await tx.put('flows', id, flow);
611
841
  return connection.id;
612
- });
842
+ }
613
843
  }
614
844
  /**
615
845
  * Ends a flow and records how, inside the transaction that ends it: the connection as it
@@ -644,9 +874,11 @@ export class VerilyService {
644
874
  external: [{ by: 'provider', method: 'oauth', confirmedAt: connection.authenticatedAt }],
645
875
  };
646
876
  const [main, ...rest] = connection.attestations.external;
877
+ // A retired record is as it stood when it was retired, and nothing is read after that.
878
+ const at = connection.retiredAt ?? this.now();
647
879
  return {
648
880
  ...connection.attestations,
649
- external: [main, ...rest.filter((a) => fresh(a, this.now(), this.freshness))],
881
+ external: [main, ...rest.filter((a) => fresh(a, at, this.freshness))],
650
882
  };
651
883
  }
652
884
  evidence(connection) {
@@ -670,10 +902,15 @@ export class VerilyService {
670
902
  visibilityApprovedAt: connection.visibilityApprovedAt,
671
903
  expiresAt: connection.expiresAt,
672
904
  revokedAt: connection.revokedAt,
905
+ // How an account is listed says nothing once its record is removed or retired.
906
+ ...(connection.mark && !['revoked', 'retired'].includes(standing)
907
+ ? { mark: connection.mark }
908
+ : {}),
909
+ ...(standing === 'retired' ? { retiredAt: connection.retiredAt } : {}),
673
910
  evidenceUrl,
674
911
  ...(this.options.signingKey !== undefined &&
675
912
  connection.visibility === 'public' &&
676
- standing === 'verified'
913
+ ['verified', 'retired'].includes(standing)
677
914
  ? { signedUrl: `${evidenceUrl}?format=signed` }
678
915
  : {}),
679
916
  };
@@ -682,12 +919,14 @@ export class VerilyService {
682
919
  * The live record a connect flow reproves by another method, if there is one: the same
683
920
  * subject linked to the same account on the same provider, first shown some other way.
684
921
  * The same method again is a second record, as it always was; renewing is how that
685
- * one is extended.
922
+ * one is extended. A retired record is never joined: it is frozen, so proving its account
923
+ * another way makes a new record and leaves that one as history behind it.
686
924
  */
687
925
  async joins(tx, flow) {
688
926
  const provider = this.providerOf(flow);
689
927
  return (await tx.list('connections'))
690
928
  .filter((c) => c.revokedAt === undefined &&
929
+ c.retiredAt === undefined &&
691
930
  c.local.id === flow.local.id &&
692
931
  c.provider === provider.id &&
693
932
  (c.attestations?.external[0].method ?? 'oauth') !== providerMethod(provider) &&
@@ -851,6 +1090,84 @@ export class VerilyService {
851
1090
  await this.audit(tx, id, 'revoke', 'local');
852
1091
  });
853
1092
  }
1093
+ /**
1094
+ * The holder's records of one account that are not revoked. A mark belongs to an account,
1095
+ * and an account can stand on several records: shown a second way on a record of its own,
1096
+ * or removed once and connected again. They are told apart as a badge tells them, by
1097
+ * provider and the id the provider gave.
1098
+ */
1099
+ async account(tx, local, provider, external) {
1100
+ return (await tx.list('connections')).filter((c) => c.revokedAt === undefined &&
1101
+ c.local.id === local.id &&
1102
+ c.provider === provider &&
1103
+ c.external.id === external.id);
1104
+ }
1105
+ /**
1106
+ * The mark an account's records carry, for a record about to stand beside them: every
1107
+ * one neither revoked nor retired, lapsed ones included, since those hold a mark that
1108
+ * must survive their own renewal.
1109
+ */
1110
+ async accountMark(tx, local, provider, external, except) {
1111
+ return (await this.account(tx, local, provider, external)).find((c) => c.id !== except && c.retiredAt === undefined && c.mark !== undefined)?.mark;
1112
+ }
1113
+ /**
1114
+ * Marks an account as the holder would have it listed, or retires it. Names one record
1115
+ * and acts on the account, or a mark on one record could vanish behind a sibling, and
1116
+ * retiring one could leave the account reading as verified through another.
1117
+ *
1118
+ * No fresh proof is asked for. A mark never widens who can read a record or makes one
1119
+ * read as verified, and a holder retiring an account often can no longer prove it.
1120
+ */
1121
+ async mark(id, local, as) {
1122
+ if (!['preferred', 'current', 'unused', 'retired'].includes(as))
1123
+ throw new Unavailable();
1124
+ await this.transaction(async (tx) => {
1125
+ const named = await this.owned(tx, id, local);
1126
+ if (named.revokedAt !== undefined)
1127
+ throw new Unavailable();
1128
+ // A record already retired is left exactly as it is: its dates are what it stands on.
1129
+ const records = (await this.account(tx, local, named.provider, named.external)).filter((c) => c.retiredAt === undefined);
1130
+ if (as === 'retired') {
1131
+ // Whatever its status: a lapsed one left live could be read again later and bring
1132
+ // the account back. Nothing is revoked.
1133
+ for (const record of records) {
1134
+ record.retiredAt = this.now();
1135
+ delete record.mark;
1136
+ await tx.put('connections', record.id, record);
1137
+ await this.audit(tx, record.id, 'retire', 'local');
1138
+ }
1139
+ return;
1140
+ }
1141
+ // Only a retired account is left, and that is listed as retired and nothing else.
1142
+ if (!records.length)
1143
+ throw new Unavailable();
1144
+ const mark = as === 'current' ? undefined : as;
1145
+ for (const record of records) {
1146
+ if (record.mark === mark)
1147
+ continue;
1148
+ if (mark)
1149
+ record.mark = mark;
1150
+ else
1151
+ delete record.mark;
1152
+ await tx.put('connections', record.id, record);
1153
+ await this.audit(tx, record.id, `mark-${as}`, 'local');
1154
+ }
1155
+ if (as !== 'preferred')
1156
+ return;
1157
+ // One preferred account per holder: picking another moves the mark. Only that mark
1158
+ // is taken from the others, and what they say of being unused is left alone.
1159
+ for (const other of await tx.list('connections')) {
1160
+ if (other.local.id !== local.id ||
1161
+ other.revokedAt !== undefined ||
1162
+ other.mark !== 'preferred' ||
1163
+ records.some((record) => record.id === other.id))
1164
+ continue;
1165
+ delete other.mark;
1166
+ await tx.put('connections', other.id, other);
1167
+ await this.audit(tx, other.id, 'mark-current', 'local');
1168
+ }
1169
+ });
1170
+ }
854
1171
  async share(id, local, revoke = false) {
855
1172
  const token = secret();
856
1173
  return this.transaction(async (tx) => {
@@ -908,6 +1225,8 @@ export class VerilyService {
908
1225
  * `budget` bounds the reads per run, since providers rate-limit and the alarm this runs
909
1226
  * on is shared. Oldest first, so nothing starves however many connections are waiting.
910
1227
  *
1228
+ * A retired record is never asked about: its proof is no longer expected to be there.
1229
+ *
911
1230
  * Returns how many were confirmed. A connection revoked here is not one of them.
912
1231
  */
913
1232
  async recheck(budget = 5) {
@@ -916,7 +1235,10 @@ export class VerilyService {
916
1235
  const interval = this.options.recheckMs ?? 86400000;
917
1236
  const now = this.now();
918
1237
  const due = await this.transaction(async (tx) => (await tx.list('connections'))
919
- .filter((c) => c.revokedAt === undefined && c.expiresAt > now && c.attestations)
1238
+ .filter((c) => c.revokedAt === undefined &&
1239
+ c.retiredAt === undefined &&
1240
+ c.expiresAt > now &&
1241
+ c.attestations)
920
1242
  .flatMap((connection) => connection
921
1243
  .attestations.external.map((attestation) => ({
922
1244
  connection,
@@ -971,8 +1293,12 @@ export class VerilyService {
971
1293
  }
972
1294
  return this.transaction(async (tx) => {
973
1295
  const current = await tx.get('connections', connection.id);
974
- // It may have been revoked or reproved while the fetch was in flight.
975
- if (!current?.attestations || current.revokedAt !== undefined)
1296
+ // It may have been revoked or reproved while the fetch was in flight. Or retired, and
1297
+ // then an answer written now would move the proof date it was frozen with, or revoke
1298
+ // it on a withdrawal.
1299
+ if (!current?.attestations ||
1300
+ current.revokedAt !== undefined ||
1301
+ current.retiredAt !== undefined)
976
1302
  return 0;
977
1303
  const [first, ...rest] = current.attestations.external;
978
1304
  const held = main ? first : rest.find((a) => a.method === method);
@@ -1002,7 +1328,11 @@ export class VerilyService {
1002
1328
  }),
1003
1329
  ]);
1004
1330
  }
1005
- /** Run periodically. Pending secrets expire immediately; historical evidence defaults to 90 days. */
1331
+ /**
1332
+ * Run periodically. Pending secrets expire immediately; historical evidence defaults to 90
1333
+ * days. A retired record is history its holder asked to keep, so it stays for as long as
1334
+ * it is not revoked, and once revoked goes like any other.
1335
+ */
1006
1336
  async prune(retentionMs = 90 * 86400000) {
1007
1337
  if (!Number.isSafeInteger(retentionMs) || retentionMs < 0)
1008
1338
  throw new Error('Invalid retention');
@@ -1016,7 +1346,9 @@ export class VerilyService {
1016
1346
  await tx.delete('limits', limit.id);
1017
1347
  const audit = await tx.list('audit');
1018
1348
  for (const connection of await tx.list('connections')) {
1019
- if ((connection.revokedAt ?? connection.expiresAt) + retentionMs <= now) {
1349
+ const ended = connection.revokedAt ??
1350
+ (connection.retiredAt === undefined ? connection.expiresAt : Infinity);
1351
+ if (ended + retentionMs <= now) {
1020
1352
  await tx.delete('connections', connection.id);
1021
1353
  await tx.delete('shares', connection.id);
1022
1354
  }
@@ -1067,6 +1399,15 @@ function connected(connection) {
1067
1399
  return (connection.connectedAt ??
1068
1400
  Math.min(connection.approvedAt, connection.authenticatedAt, connection.visibilityApprovedAt, ...(connection.attestations?.external.map((a) => a.confirmedAt) ?? [])));
1069
1401
  }
1402
+ /**
1403
+ * Whether a renewal by this method may go ahead. Any may renew a live record. A retired one
1404
+ * is revived by the method it was first shown by alone, which replaces the proof the
1405
+ * record is judged by.
1406
+ */
1407
+ function revives(connection, provider) {
1408
+ return (connection.retiredAt === undefined ||
1409
+ (connection.attestations?.external[0].method ?? 'oauth') === providerMethod(provider));
1410
+ }
1070
1411
  /**
1071
1412
  * Whether two methods named the same account. Where both carry an id their provider
1072
1413
  * issued, that id settles it. A method that learns only an address, as a link back does,
@@ -1149,6 +1490,8 @@ function profile(url) {
1149
1490
  const dayMs = 86400000;
1150
1491
  /** How many wrong codes a flow takes before it is dead. */
1151
1492
  export const codeAttempts = 5;
1493
+ /** How many times a published proof may be refused before its flow is dead. */
1494
+ export const artifactAttempts = 5;
1152
1495
  /** Crockford's base 32: no I, L, O or U, so no letter is mistaken for a digit when typed. */
1153
1496
  const codeAlphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
1154
1497
  /**
@@ -15,7 +15,9 @@ export function youtubeProvider(options) {
15
15
  authorizationUrl({ state, challenge, redirectUri }) {
16
16
  const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
17
17
  // select_account lets a holder with several Google accounts, or a channel under a
18
- // brand account, pick the one they mean rather than whichever is signed in.
18
+ // brand account, pick the one they mean rather than whichever is signed in. A flow on
19
+ // a record asks too: a channel is not a Google account, so nothing here can name the
20
+ // one to use, and the wrong one signed in silently would be refused with no way round.
19
21
  url.search = new URLSearchParams({
20
22
  response_type: 'code',
21
23
  client_id: options.clientId,
@@ -61,7 +61,8 @@ export interface SiteSubject {
61
61
  export interface SiteResult {
62
62
  site: string;
63
63
  id: string;
64
- operation: 'connect' | 'renew' | 'visibility' | 'disconnect';
64
+ /** `mark` is an account listed another way or retired: read its records again to see how. */
65
+ operation: 'connect' | 'renew' | 'visibility' | 'disconnect' | 'mark';
65
66
  outcome: 'complete' | 'cancelled' | 'failed';
66
67
  /** Present when the outcome is complete. */
67
68
  connection?: string;