@bkazemi/verily 0.2.0 → 0.4.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) {
@@ -184,6 +191,9 @@ export class VerilyService {
184
191
  connection.revokedAt !== undefined ||
185
192
  (['visibility', 'renew'].includes(kind) && connection.local.id !== local?.id))
186
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();
187
197
  subject = connection.local;
188
198
  if (['renew', 'visibility'].includes(kind))
189
199
  account = connection.external;
@@ -250,6 +260,7 @@ export class VerilyService {
250
260
  async known(tx, local, provider) {
251
261
  const offers = (await tx.list('connections'))
252
262
  .filter((c) => c.revokedAt === undefined &&
263
+ c.retiredAt === undefined &&
253
264
  c.local.id === local.id &&
254
265
  c.provider === provider.id &&
255
266
  (c.attestations?.external[0].method ?? 'oauth') !== provider.method)
@@ -291,6 +302,10 @@ export class VerilyService {
291
302
  * Removing a link from the external side takes a method whose proof is fresh. A standing
292
303
  * proof, such as a link back, is there for anyone to point at: handing one back shows
293
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.
294
309
  */
295
310
  chosen(kind, choice, connection) {
296
311
  let provider;
@@ -304,6 +319,8 @@ export class VerilyService {
304
319
  provider = this.resolve(choice.provider, choice.method);
305
320
  if (connection && provider.id !== connection.provider)
306
321
  throw new Unavailable();
322
+ if (kind === 'renew' && connection && !revives(connection, provider))
323
+ throw new Unavailable();
307
324
  if (['revoke', 'share-revoke'].includes(kind) &&
308
325
  isArtifactProvider(provider) &&
309
326
  provider.expect)
@@ -750,6 +767,7 @@ export class VerilyService {
750
767
  throw new Unavailable();
751
768
  // The id exists before the record does, because a hosted proof is addressed by it.
752
769
  const connectionId = secret();
770
+ const mark = await this.accountMark(tx, flow.local, provider.id, flow.external);
753
771
  connection = {
754
772
  id: connectionId,
755
773
  local: flow.local,
@@ -766,6 +784,8 @@ export class VerilyService {
766
784
  external: [this.attestation(flow, provider, connectionId)],
767
785
  },
768
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 } : {}),
769
789
  };
770
790
  }
771
791
  else {
@@ -783,7 +803,20 @@ export class VerilyService {
783
803
  // subject snapshot refreshes because the holder just approved what it shows.
784
804
  if (flow.local.id !== existing.local.id)
785
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();
786
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
+ }
787
820
  }
788
821
  else if (flow.kind === 'revoke') {
789
822
  connection.revokedAt = this.now();
@@ -792,6 +825,8 @@ export class VerilyService {
792
825
  else if (flow.kind === 'visibility') {
793
826
  if (allowed && !allowed.includes(visibility))
794
827
  throw new Unavailable();
828
+ if (connection.retiredAt !== undefined)
829
+ throw new Unavailable();
795
830
  connection.visibility = visibility;
796
831
  connection.visibilityApprovedAt = this.now();
797
832
  }
@@ -839,9 +874,11 @@ export class VerilyService {
839
874
  external: [{ by: 'provider', method: 'oauth', confirmedAt: connection.authenticatedAt }],
840
875
  };
841
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();
842
879
  return {
843
880
  ...connection.attestations,
844
- external: [main, ...rest.filter((a) => fresh(a, this.now(), this.freshness))],
881
+ external: [main, ...rest.filter((a) => fresh(a, at, this.freshness))],
845
882
  };
846
883
  }
847
884
  evidence(connection) {
@@ -865,10 +902,15 @@ export class VerilyService {
865
902
  visibilityApprovedAt: connection.visibilityApprovedAt,
866
903
  expiresAt: connection.expiresAt,
867
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 } : {}),
868
910
  evidenceUrl,
869
911
  ...(this.options.signingKey !== undefined &&
870
912
  connection.visibility === 'public' &&
871
- standing === 'verified'
913
+ ['verified', 'retired'].includes(standing)
872
914
  ? { signedUrl: `${evidenceUrl}?format=signed` }
873
915
  : {}),
874
916
  };
@@ -877,12 +919,14 @@ export class VerilyService {
877
919
  * The live record a connect flow reproves by another method, if there is one: the same
878
920
  * subject linked to the same account on the same provider, first shown some other way.
879
921
  * The same method again is a second record, as it always was; renewing is how that
880
- * 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.
881
924
  */
882
925
  async joins(tx, flow) {
883
926
  const provider = this.providerOf(flow);
884
927
  return (await tx.list('connections'))
885
928
  .filter((c) => c.revokedAt === undefined &&
929
+ c.retiredAt === undefined &&
886
930
  c.local.id === flow.local.id &&
887
931
  c.provider === provider.id &&
888
932
  (c.attestations?.external[0].method ?? 'oauth') !== providerMethod(provider) &&
@@ -1046,6 +1090,84 @@ export class VerilyService {
1046
1090
  await this.audit(tx, id, 'revoke', 'local');
1047
1091
  });
1048
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
+ }
1049
1171
  async share(id, local, revoke = false) {
1050
1172
  const token = secret();
1051
1173
  return this.transaction(async (tx) => {
@@ -1103,6 +1225,8 @@ export class VerilyService {
1103
1225
  * `budget` bounds the reads per run, since providers rate-limit and the alarm this runs
1104
1226
  * on is shared. Oldest first, so nothing starves however many connections are waiting.
1105
1227
  *
1228
+ * A retired record is never asked about: its proof is no longer expected to be there.
1229
+ *
1106
1230
  * Returns how many were confirmed. A connection revoked here is not one of them.
1107
1231
  */
1108
1232
  async recheck(budget = 5) {
@@ -1111,7 +1235,10 @@ export class VerilyService {
1111
1235
  const interval = this.options.recheckMs ?? 86400000;
1112
1236
  const now = this.now();
1113
1237
  const due = await this.transaction(async (tx) => (await tx.list('connections'))
1114
- .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)
1115
1242
  .flatMap((connection) => connection
1116
1243
  .attestations.external.map((attestation) => ({
1117
1244
  connection,
@@ -1166,8 +1293,12 @@ export class VerilyService {
1166
1293
  }
1167
1294
  return this.transaction(async (tx) => {
1168
1295
  const current = await tx.get('connections', connection.id);
1169
- // It may have been revoked or reproved while the fetch was in flight.
1170
- 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)
1171
1302
  return 0;
1172
1303
  const [first, ...rest] = current.attestations.external;
1173
1304
  const held = main ? first : rest.find((a) => a.method === method);
@@ -1197,7 +1328,11 @@ export class VerilyService {
1197
1328
  }),
1198
1329
  ]);
1199
1330
  }
1200
- /** 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
+ */
1201
1336
  async prune(retentionMs = 90 * 86400000) {
1202
1337
  if (!Number.isSafeInteger(retentionMs) || retentionMs < 0)
1203
1338
  throw new Error('Invalid retention');
@@ -1211,7 +1346,9 @@ export class VerilyService {
1211
1346
  await tx.delete('limits', limit.id);
1212
1347
  const audit = await tx.list('audit');
1213
1348
  for (const connection of await tx.list('connections')) {
1214
- if ((connection.revokedAt ?? connection.expiresAt) + retentionMs <= now) {
1349
+ const ended = connection.revokedAt ??
1350
+ (connection.retiredAt === undefined ? connection.expiresAt : Infinity);
1351
+ if (ended + retentionMs <= now) {
1215
1352
  await tx.delete('connections', connection.id);
1216
1353
  await tx.delete('shares', connection.id);
1217
1354
  }
@@ -1262,6 +1399,15 @@ function connected(connection) {
1262
1399
  return (connection.connectedAt ??
1263
1400
  Math.min(connection.approvedAt, connection.authenticatedAt, connection.visibilityApprovedAt, ...(connection.attestations?.external.map((a) => a.confirmedAt) ?? [])));
1264
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
+ }
1265
1411
  /**
1266
1412
  * Whether two methods named the same account. Where both carry an id their provider
1267
1413
  * issued, that id settles it. A method that learns only an address, as a link back does,
@@ -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;