@bkazemi/verily 0.1.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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +503 -0
  3. package/dist/browser/badge.d.ts +39 -0
  4. package/dist/browser/badge.js +440 -0
  5. package/dist/browser/connect-dialog.d.ts +83 -0
  6. package/dist/browser/connect-dialog.js +514 -0
  7. package/dist/browser/evidence-dialog.d.ts +59 -0
  8. package/dist/browser/evidence-dialog.js +541 -0
  9. package/dist/browser/index.d.ts +59 -0
  10. package/dist/browser/index.js +809 -0
  11. package/dist/browser/logo.d.ts +2 -0
  12. package/dist/browser/logo.js +17 -0
  13. package/dist/browser/mark.d.ts +19 -0
  14. package/dist/browser/mark.js +77 -0
  15. package/dist/browser/provider-mark.d.ts +6 -0
  16. package/dist/browser/provider-mark.js +99 -0
  17. package/dist/browser/signed.d.ts +40 -0
  18. package/dist/browser/signed.js +219 -0
  19. package/dist/browser.d.ts +3 -0
  20. package/dist/browser.js +3 -0
  21. package/dist/core/index.d.ts +454 -0
  22. package/dist/core/index.js +128 -0
  23. package/dist/core/signed.d.ts +49 -0
  24. package/dist/core/signed.js +113 -0
  25. package/dist/index.d.ts +5 -0
  26. package/dist/index.js +5 -0
  27. package/dist/logo.d.ts +21 -0
  28. package/dist/logo.js +38 -0
  29. package/dist/postgres/index.d.ts +12 -0
  30. package/dist/postgres/index.js +76 -0
  31. package/dist/server/addresses.d.ts +2 -0
  32. package/dist/server/addresses.js +61 -0
  33. package/dist/server/body.d.ts +11 -0
  34. package/dist/server/body.js +49 -0
  35. package/dist/server/copy.d.ts +8 -0
  36. package/dist/server/copy.js +42 -0
  37. package/dist/server/discord.d.ts +7 -0
  38. package/dist/server/discord.js +69 -0
  39. package/dist/server/email-logo.d.ts +11 -0
  40. package/dist/server/email-logo.js +122 -0
  41. package/dist/server/email.d.ts +46 -0
  42. package/dist/server/email.js +145 -0
  43. package/dist/server/escape.d.ts +2 -0
  44. package/dist/server/escape.js +2 -0
  45. package/dist/server/github-gist.d.ts +11 -0
  46. package/dist/server/github-gist.js +68 -0
  47. package/dist/server/github.d.ts +7 -0
  48. package/dist/server/github.js +65 -0
  49. package/dist/server/index.d.ts +70 -0
  50. package/dist/server/index.js +862 -0
  51. package/dist/server/link.d.ts +64 -0
  52. package/dist/server/link.js +458 -0
  53. package/dist/server/openpgp.d.ts +49 -0
  54. package/dist/server/openpgp.js +158 -0
  55. package/dist/server/pgp.d.ts +29 -0
  56. package/dist/server/pgp.js +198 -0
  57. package/dist/server/public-fetch.d.ts +23 -0
  58. package/dist/server/public-fetch.js +62 -0
  59. package/dist/server/resend.d.ts +13 -0
  60. package/dist/server/resend.js +37 -0
  61. package/dist/server/service.d.ts +311 -0
  62. package/dist/server/service.js +1178 -0
  63. package/dist/server/signing.d.ts +16 -0
  64. package/dist/server/signing.js +56 -0
  65. package/dist/server/style.d.ts +17 -0
  66. package/dist/server/style.js +244 -0
  67. package/dist/server/wkd.d.ts +7 -0
  68. package/dist/server/wkd.js +65 -0
  69. package/dist/server/youtube.d.ts +11 -0
  70. package/dist/server/youtube.js +99 -0
  71. package/dist/site/index.d.ts +139 -0
  72. package/dist/site/index.js +239 -0
  73. package/dist/verily.js +11907 -0
  74. package/dist/version.d.ts +10 -0
  75. package/dist/version.js +10 -0
  76. package/docs/licenses/gpl-3.0.txt +232 -0
  77. package/docs/licenses/octicons.txt +21 -0
  78. package/docs/licenses/openpgp.txt +172 -0
  79. package/docs/licenses/rubik.txt +93 -0
  80. package/docs/licenses/simple-icons.txt +8 -0
  81. package/package.json +86 -0
@@ -0,0 +1,1178 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { fresh, freshnessMs, isArtifactProvider, isCodeProvider, isRedirectProvider, providerMethod, Refused, status, verifySigned, } from '../core/index.js';
3
+ import { signer } from './signing.js';
4
+ export const secret = () => randomBytes(32).toString('base64url');
5
+ export const hash = (value) => createHash('sha256').update(value).digest('base64url');
6
+ export class Unavailable extends Error {
7
+ constructor() {
8
+ super('Unavailable');
9
+ }
10
+ }
11
+ export class VerilyService {
12
+ options;
13
+ now;
14
+ baseUrl;
15
+ providers;
16
+ constructor(options) {
17
+ this.options = options;
18
+ this.now = options.now ?? Date.now;
19
+ this.baseUrl = options.baseUrl.replace(/\/$/, '');
20
+ this.providers = options.providers;
21
+ // A flow names its method by provider id and method, so that pair must say which.
22
+ const keys = this.providers.map((p) => `${p.id} ${providerMethod(p)}`);
23
+ if (!keys.length || new Set(keys).size !== keys.length)
24
+ throw new Error('Configure each provider method once');
25
+ const url = new URL(this.baseUrl);
26
+ if (url.search ||
27
+ url.hash ||
28
+ url.username ||
29
+ url.password ||
30
+ (url.protocol !== 'https:' &&
31
+ !(url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname))))
32
+ throw new Error('Use HTTPS (HTTP allowed on localhost)');
33
+ for (const duration of [
34
+ options.validityMs,
35
+ options.flowTtlMs,
36
+ options.shareTtlMs,
37
+ options.recheckMs,
38
+ options.recheckTimeoutMs,
39
+ ]) {
40
+ if (duration !== undefined && (!Number.isSafeInteger(duration) || duration <= 0))
41
+ throw new Error('Invalid duration');
42
+ }
43
+ if (options.freshnessMs !== undefined &&
44
+ options.freshnessMs !== Infinity &&
45
+ (!Number.isSafeInteger(options.freshnessMs) || options.freshnessMs <= 0))
46
+ throw new Error('Invalid duration');
47
+ if (this.freshness <= (options.recheckMs ?? 86400000))
48
+ throw new Error('freshnessMs must exceed recheckMs');
49
+ for (const limit of Object.values(this.sendLimits))
50
+ if (limit !== Infinity && (!Number.isSafeInteger(limit) || limit <= 0))
51
+ throw new Error('Invalid send limit');
52
+ }
53
+ signing;
54
+ /** The key this instance signs with, read once. */
55
+ signer() {
56
+ const { signingKey } = this.options;
57
+ if (signingKey === undefined)
58
+ return undefined;
59
+ this.signing ??= Promise.resolve(typeof signingKey === 'string' ? signingKey : signingKey()).then(signer);
60
+ return this.signing;
61
+ }
62
+ /** Every key a record of this instance may be signed by: the current one first. */
63
+ async keys() {
64
+ const current = await this.signer();
65
+ return [...(current ? [current.key] : []), ...(this.options.retiredKeys ?? [])];
66
+ }
67
+ /** The same keys as one armored text, which is what `gpg --import` takes. */
68
+ async armoredKeys() {
69
+ return (await this.keys()).map((key) => key.publicKey.trim()).join('\n\n') + '\n';
70
+ }
71
+ /**
72
+ * A public record that stands, signed as of now. Signed when asked for, not when
73
+ * approved, so a signed record says the record stood on the day it was taken and none can be
74
+ * made once it is removed.
75
+ */
76
+ async signed(id) {
77
+ const current = await this.signer();
78
+ if (!current)
79
+ throw new Unavailable();
80
+ const { status, revokedAt: _revokedAt, signedUrl: _signedUrl, ...record } = await this.read(id);
81
+ if (status !== 'verified')
82
+ throw new Unavailable();
83
+ return current.sign({ type: 'verily-evidence', version: 1, issuedAt: this.now(), ...record });
84
+ }
85
+ /** What a signed record says, if this instance signed it. */
86
+ async checked(signed) {
87
+ return verifySigned(signed, await this.keys());
88
+ }
89
+ get sendLimits() {
90
+ return {
91
+ day: this.options.sendLimits?.day ?? 100,
92
+ address: this.options.sendLimits?.address ?? 5,
93
+ };
94
+ }
95
+ /** A proof is only as fresh as the schedule that reads it, so both live together. */
96
+ get freshness() {
97
+ return this.options.freshnessMs ?? freshnessMs;
98
+ }
99
+ validateLocal(local) {
100
+ if (![local.id, local.label, local.reference].every((v) => typeof v === 'string' && v.length > 0 && v.length <= 500))
101
+ throw new Error('Invalid local subject adapter result');
102
+ if (local.kind !== undefined && !['account', 'page', 'site'].includes(local.kind))
103
+ throw new Error('Invalid local subject kind');
104
+ if (local.siteName !== undefined &&
105
+ (typeof local.siteName !== 'string' || !local.siteName || local.siteName.length > 500))
106
+ throw new Error('Invalid local subject site name');
107
+ if (local.profileUrl) {
108
+ const url = new URL(local.profileUrl);
109
+ if (!['https:', 'http:'].includes(url.protocol) ||
110
+ url.username ||
111
+ url.password ||
112
+ !this.options.profileOrigins.includes(url.origin))
113
+ throw new Error('Unregistered profile origin');
114
+ }
115
+ return structuredClone(local);
116
+ }
117
+ /** The site a subject belongs to: its own name where it has one, else this instance's. */
118
+ siteOf(local) {
119
+ return local.siteName ?? this.options.siteName;
120
+ }
121
+ /**
122
+ * The configured method a caller asked for: a provider id and, where that provider is
123
+ * shown more than one way, which way. Nothing asked for means the first configured.
124
+ */
125
+ resolve(provider, method) {
126
+ const found = this.providers.find((p) => (provider === undefined || p.id === provider) &&
127
+ (method === undefined || providerMethod(p) === method));
128
+ if (!found)
129
+ throw new Unavailable();
130
+ return found;
131
+ }
132
+ /** The method a flow was started with. A flow from before methods were recorded ran the first. */
133
+ providerOf(flow) {
134
+ return flow.provider === undefined
135
+ ? this.providers[0]
136
+ : this.resolve(flow.provider, flow.method);
137
+ }
138
+ async start(local, connectionId, kind = 'connect', choice = {}, context) {
139
+ if (kind === 'connect' && !local)
140
+ throw new Unavailable();
141
+ if (context !== undefined &&
142
+ (typeof context !== 'object' ||
143
+ Object.values(context).some((v) => typeof v !== 'string' || v.length > 500)))
144
+ throw new Error('Invalid flow context');
145
+ if (local)
146
+ local = this.validateLocal(local);
147
+ if (kind !== 'connect' && !connectionId)
148
+ throw new Unavailable();
149
+ const state = secret(), binding = secret(), verifier = secret();
150
+ const flow = {
151
+ id: hash(state),
152
+ stateHash: hash(state),
153
+ bindingHash: hash(binding),
154
+ verifier,
155
+ kind,
156
+ local,
157
+ connectionId,
158
+ phase: 'pending',
159
+ expiresAt: this.now() + (this.options.flowTtlMs ?? 600000),
160
+ };
161
+ // Removal from the external side answers to nobody local, so nothing local rides on it.
162
+ if (context && !['revoke', 'share-revoke'].includes(kind))
163
+ flow.context = { ...context };
164
+ const provider = await this.transaction(async (tx) => {
165
+ // Whose link this is. A flow against an existing connection may carry no local
166
+ // subject of its own, and the record is the authority on it in any case.
167
+ let subject = local;
168
+ let connection;
169
+ if (kind !== 'connect') {
170
+ connection = await tx.get('connections', connectionId);
171
+ // No account disclosure at entry; only the matching provider account can inspect later.
172
+ if (!connection ||
173
+ connection.revokedAt !== undefined ||
174
+ (['visibility', 'renew'].includes(kind) && connection.local.id !== local?.id))
175
+ throw new Unavailable();
176
+ subject = connection.local;
177
+ }
178
+ const provider = this.chosen(kind, choice, connection);
179
+ flow.provider = provider.id;
180
+ flow.method = providerMethod(provider);
181
+ const artifact = isArtifactProvider(provider) ? provider : undefined;
182
+ // Unguessable and per-flow, so an artifact published for one flow cannot complete
183
+ // another, and naming the site means the holder can see what they are agreeing to
184
+ // before they publish anything. A method that points back at the subject has no such
185
+ // freedom: the subject's address is the whole claim, so that method states it here
186
+ // and gives up per-flow uniqueness for a standing link that is read again on a
187
+ // schedule instead.
188
+ if (artifact)
189
+ flow.expect = artifact.expect
190
+ ? artifact.expect(subject)
191
+ : `Verily proof for ${this.siteOf(subject)}: ${secret()}`;
192
+ await tx.put('flows', flow.id, flow);
193
+ return provider;
194
+ });
195
+ const artifact = isArtifactProvider(provider) ? provider : undefined;
196
+ // A redirect provider hands the holder to its own site; an artifact provider tells
197
+ // them what to publish and waits for them to say where they put it; a code provider
198
+ // has nothing to say until the holder names the address to send to.
199
+ return artifact
200
+ ? {
201
+ flowId: flow.id,
202
+ binding,
203
+ expect: flow.expect,
204
+ instructions: artifact.instructions(flow.expect),
205
+ }
206
+ : isRedirectProvider(provider)
207
+ ? {
208
+ flowId: flow.id,
209
+ binding,
210
+ authorizationUrl: provider.authorizationUrl({
211
+ state,
212
+ challenge: hash(verifier),
213
+ redirectUri: `${this.baseUrl}/callback`,
214
+ }),
215
+ }
216
+ : { flowId: flow.id, binding };
217
+ }
218
+ /**
219
+ * Which method a flow runs. A flow on an existing record stays in that record's
220
+ * namespace, and without a choice it uses the method the record was first shown by.
221
+ *
222
+ * Removing a link from the external side takes a method whose proof is fresh. A standing
223
+ * proof, such as a link back, is there for anyone to point at: handing one back shows
224
+ * the link exists, not that whoever handed it back holds the account.
225
+ */
226
+ chosen(kind, choice, connection) {
227
+ let provider;
228
+ if (choice.provider === undefined && choice.method === undefined && connection) {
229
+ const main = connection.attestations?.external[0].method ?? 'oauth';
230
+ provider =
231
+ this.providers.find((p) => p.id === connection.provider && providerMethod(p) === main) ??
232
+ this.resolve(connection.provider);
233
+ }
234
+ else
235
+ provider = this.resolve(choice.provider, choice.method);
236
+ if (connection && provider.id !== connection.provider)
237
+ throw new Unavailable();
238
+ if (['revoke', 'share-revoke'].includes(kind) &&
239
+ isArtifactProvider(provider) &&
240
+ provider.expect)
241
+ throw new Unavailable();
242
+ return provider;
243
+ }
244
+ /**
245
+ * Accepts what the holder hands back: the address they published the flow's string at,
246
+ * or the proof itself. Both are holder-supplied, so the provider decides what counts.
247
+ * A code provider is handed two things in turn, the address to send to and then the
248
+ * code that arrived there.
249
+ */
250
+ async submit(id, binding, artifact) {
251
+ const asked = this.providerOf(await this.flow(id, binding));
252
+ if (isCodeProvider(asked)) {
253
+ await this.answer(id, binding, asked, artifact);
254
+ return id;
255
+ }
256
+ const { provider, expect } = await this.transaction(async (tx) => {
257
+ const flow = await this.bound(tx, id, binding);
258
+ const provider = this.providerOf(flow);
259
+ if (flow.phase !== 'pending' || !flow.expect || !isArtifactProvider(provider))
260
+ throw new Unavailable();
261
+ flow.phase = 'exchanging';
262
+ await tx.put('flows', id, flow);
263
+ return { provider, expect: flow.expect };
264
+ });
265
+ try {
266
+ const external = await provider.verify({ artifact, expect });
267
+ await this.established(id, binding, external, artifact);
268
+ }
269
+ catch (error) {
270
+ await this.failed(id, error);
271
+ }
272
+ return id;
273
+ }
274
+ /**
275
+ * One step of a mailed code. The first answer is an address: a code is minted, sent
276
+ * there, and only its hash kept. The second is the code, which the holder could only
277
+ * have read at that address. The code is the whole proof and short enough to type, so
278
+ * the guesses are counted and a flow that runs out of them is dead.
279
+ */
280
+ async answer(id, binding, provider, answer) {
281
+ const step = await this.transaction(async (tx) => {
282
+ const flow = await this.bound(tx, id, binding);
283
+ if (flow.phase !== 'pending')
284
+ throw new Unavailable();
285
+ if (!flow.sent) {
286
+ // The record is the authority on whose link this is, as it is when a flow starts.
287
+ const subject = flow.connectionId
288
+ ? (await tx.get('connections', flow.connectionId))?.local
289
+ : flow.local;
290
+ if (!subject)
291
+ throw new Unavailable();
292
+ // Held here while the message is on its way, so the address is named only once.
293
+ flow.phase = 'exchanging';
294
+ await tx.put('flows', id, flow);
295
+ // Removal from the external side is started by anybody who knows the record's id,
296
+ // and the message goes wherever they say. Which site the record belongs to is not
297
+ // theirs to read until the matching account is shown, so that message names this
298
+ // installation, which says nothing about any one record.
299
+ const siteName = ['revoke', 'share-revoke'].includes(flow.kind)
300
+ ? this.options.verifierName
301
+ : this.siteOf(subject);
302
+ return { send: { siteName, expiresAt: flow.expiresAt } };
303
+ }
304
+ if (flow.sent.codeHash === codeHash(id, answer)) {
305
+ const { account } = flow.sent;
306
+ delete flow.sent;
307
+ flow.phase = 'exchanging';
308
+ await tx.put('flows', id, flow);
309
+ return { account };
310
+ }
311
+ flow.sent.attempts += 1;
312
+ if (flow.sent.attempts >= codeAttempts) {
313
+ flow.reason = 'Too many wrong codes';
314
+ await this.ended(tx, flow, 'failed');
315
+ }
316
+ await tx.put('flows', id, flow);
317
+ return {};
318
+ });
319
+ try {
320
+ if (step.account)
321
+ await this.established(id, binding, step.account);
322
+ if (!step.send)
323
+ return;
324
+ const account = provider.account(answer);
325
+ if (!(await this.spend(provider, account)))
326
+ throw new Refused('Too many messages have been sent today, so try again tomorrow');
327
+ const code = mintCode();
328
+ const token = secret();
329
+ await provider.deliver({
330
+ account,
331
+ link: `${this.baseUrl}/confirm/${id}?token=${token}`,
332
+ code,
333
+ ...step.send,
334
+ });
335
+ await this.transaction(async (tx) => {
336
+ const flow = await this.bound(tx, id, binding);
337
+ if (flow.phase !== 'exchanging')
338
+ throw new Unavailable();
339
+ flow.sent = {
340
+ account,
341
+ codeHash: codeHash(id, code),
342
+ linkHash: hash(token),
343
+ attempts: 0,
344
+ };
345
+ flow.phase = 'pending';
346
+ await tx.put('flows', id, flow);
347
+ });
348
+ }
349
+ catch (error) {
350
+ await this.failed(id, error);
351
+ }
352
+ }
353
+ /**
354
+ * Counts one message against the day's ceiling and the address's, and says whether both
355
+ * had room. All or nothing, so a message refused by one spends nothing from the other.
356
+ * Counted before the message goes, so one that fails to send is still spent: a sender
357
+ * that is failing is not a reason to try it more often. The address is kept as a hash.
358
+ */
359
+ async spend(provider, account) {
360
+ const { day, address } = this.sendLimits;
361
+ const buckets = [
362
+ { id: `send/${provider.id}/day`, limit: day },
363
+ { id: `send/${provider.id}/to/${hash(account.id)}`, limit: address },
364
+ ].filter(({ limit }) => limit !== Infinity);
365
+ return this.transaction(async (tx) => {
366
+ const now = this.now();
367
+ const next = [];
368
+ for (const { id, limit } of buckets) {
369
+ const held = await tx.get('limits', id);
370
+ const window = held && held.expiresAt > now ? held : { id, count: 0, expiresAt: now + dayMs };
371
+ if (window.count >= limit)
372
+ return false;
373
+ next.push({ ...window, count: window.count + 1 });
374
+ }
375
+ for (const window of next)
376
+ await tx.put('limits', window.id, window);
377
+ return true;
378
+ });
379
+ }
380
+ /**
381
+ * The flow a confirmation link belongs to, while the link is still good. The link is
382
+ * the whole credential and is used from wherever the holder reads their mail, so no
383
+ * binding is asked for: it proves the mailbox and nothing else, and what happens to the
384
+ * flow next is still up to the browser that started it.
385
+ */
386
+ async linked(tx, id, token) {
387
+ const flow = await tx.get('flows', id);
388
+ if (!flow ||
389
+ flow.expiresAt <= this.now() ||
390
+ flow.phase !== 'pending' ||
391
+ flow.sent?.linkHash !== hash(token))
392
+ throw new Unavailable();
393
+ return flow;
394
+ }
395
+ /**
396
+ * What a confirmation link would confirm, to show its holder before they press anything.
397
+ * The subject is named only for a new link, where the one who started the flow is the
398
+ * one who named it. A flow on an existing record is started by anybody, and its subject
399
+ * is nobody's to read until the matching account has been shown.
400
+ */
401
+ async confirming(id, token) {
402
+ return this.transaction(async (tx) => {
403
+ const flow = await this.linked(tx, id, token);
404
+ return {
405
+ kind: flow.kind,
406
+ provider: this.providerOf(flow),
407
+ account: flow.sent.account,
408
+ local: flow.kind === 'connect' ? flow.local : undefined,
409
+ };
410
+ });
411
+ }
412
+ /**
413
+ * Takes a pressed confirmation link as the proof a typed code would have been, and
414
+ * returns the flow as that left it. The link is spent either way.
415
+ */
416
+ async confirm(id, token) {
417
+ const account = await this.transaction(async (tx) => {
418
+ const flow = await this.linked(tx, id, token);
419
+ const { account } = flow.sent;
420
+ delete flow.sent;
421
+ flow.phase = 'exchanging';
422
+ await tx.put('flows', id, flow);
423
+ return account;
424
+ });
425
+ try {
426
+ await this.established(id, undefined, account);
427
+ }
428
+ catch (error) {
429
+ await this.failed(id, error);
430
+ }
431
+ return this.transaction(async (tx) => (await tx.get('flows', id)));
432
+ }
433
+ async bound(tx, id, binding) {
434
+ const flow = await tx.get('flows', id);
435
+ if (!flow || flow.bindingHash !== hash(binding) || flow.expiresAt <= this.now())
436
+ throw new Unavailable();
437
+ return flow;
438
+ }
439
+ async flow(id, binding) {
440
+ return this.transaction((tx) => this.bound(tx, id, binding));
441
+ }
442
+ async callback(state, binding, code) {
443
+ const id = hash(state);
444
+ const claimed = await this.transaction(async (tx) => {
445
+ const flow = await this.bound(tx, id, binding);
446
+ if (flow.phase !== 'pending' || !isRedirectProvider(this.providerOf(flow)))
447
+ throw new Unavailable();
448
+ if (code)
449
+ flow.phase = 'exchanging';
450
+ else
451
+ await this.ended(tx, flow, 'cancelled');
452
+ const verifier = flow.verifier;
453
+ delete flow.verifier;
454
+ await tx.put('flows', id, flow);
455
+ return { provider: this.providerOf(flow), verifier };
456
+ });
457
+ if (!code)
458
+ return id;
459
+ try {
460
+ const external = await claimed.provider.authenticate({
461
+ code,
462
+ verifier: claimed.verifier,
463
+ redirectUri: `${this.baseUrl}/callback`,
464
+ });
465
+ await this.established(id, binding, external);
466
+ }
467
+ catch (error) {
468
+ await this.failed(id, error);
469
+ }
470
+ return id;
471
+ }
472
+ /**
473
+ * The identity is established the same way whichever method produced it, so both paths
474
+ * land here: a provider result is never trusted for its shape, and a flow against an
475
+ * existing connection must still be the same account on the same provider. No binding
476
+ * means a confirmation link got here, which is held to the link and not to a browser.
477
+ */
478
+ async established(id, binding, external, artifact) {
479
+ if (typeof external.id !== 'string' ||
480
+ !external.id ||
481
+ typeof external.handle !== 'string' ||
482
+ !external.handle ||
483
+ typeof external.profileUrl !== 'string' ||
484
+ // A mailbox has no page to point at, so its own address stands in for one.
485
+ new URL(external.profileUrl).protocol !== (external.kind === 'mailbox' ? 'mailto:' : 'https:'))
486
+ throw new Unavailable();
487
+ await this.transaction(async (tx) => {
488
+ const flow = binding === undefined ? await tx.get('flows', id) : await this.bound(tx, id, binding);
489
+ if (flow?.phase !== 'exchanging')
490
+ throw new Unavailable();
491
+ if (flow.kind !== 'connect') {
492
+ const connection = await tx.get('connections', flow.connectionId);
493
+ if (!connection ||
494
+ connection.provider !== this.providerOf(flow).id ||
495
+ connection.revokedAt !== undefined)
496
+ throw new Unavailable();
497
+ if (!matches(flow.kind, connection.external, external))
498
+ throw new Refused('This is a different account from the one this connection links');
499
+ flow.local = connection.local;
500
+ }
501
+ flow.external = external;
502
+ flow.artifact = artifact;
503
+ flow.authenticatedAt = this.now();
504
+ flow.phase = 'approval';
505
+ await tx.put('flows', id, flow);
506
+ });
507
+ }
508
+ /**
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.
511
+ */
512
+ async failed(id, error) {
513
+ await this.transaction(async (tx) => {
514
+ const flow = await tx.get('flows', id);
515
+ if (flow?.phase === 'exchanging') {
516
+ flow.reason = error instanceof Refused ? error.message : undefined;
517
+ await this.ended(tx, flow, 'failed');
518
+ await tx.put('flows', id, flow);
519
+ }
520
+ });
521
+ }
522
+ async approve(id, binding, local, visibility, cancel = false,
523
+ /**
524
+ * The visibilities this subject may choose, where it may not choose either. Checked at
525
+ * the point the record's visibility is set, inside the transaction that decides whether
526
+ * this approval makes a record or joins one, so no change in between gets past it.
527
+ */
528
+ allowed) {
529
+ if (!['public', 'unlisted'].includes(visibility))
530
+ throw new Unavailable();
531
+ return this.transaction(async (tx) => {
532
+ const flow = await this.bound(tx, id, binding);
533
+ if (!['revoke', 'share-revoke'].includes(flow.kind) &&
534
+ (!local || local.id !== flow.local?.id))
535
+ throw new Unavailable();
536
+ if (flow.phase === 'complete')
537
+ return flow.resultId;
538
+ if (flow.phase !== 'approval')
539
+ throw new Unavailable();
540
+ if (cancel) {
541
+ await this.ended(tx, flow, 'cancelled');
542
+ await tx.put('flows', id, flow);
543
+ return undefined;
544
+ }
545
+ let connection;
546
+ const provider = this.providerOf(flow);
547
+ const joined = flow.kind === 'connect' ? await this.joins(tx, flow) : undefined;
548
+ if (joined) {
549
+ // Another way of showing an account this subject is already linked to. The record
550
+ // keeps its id, its visibility and the method it was first shown by; this one is
551
+ // added beneath it, and like a renewal it extends the record it just reproved.
552
+ connection = joined;
553
+ this.recordMethod(connection, flow, provider);
554
+ }
555
+ else if (flow.kind === 'connect') {
556
+ if (allowed && !allowed.includes(visibility))
557
+ throw new Unavailable();
558
+ // The id exists before the record does, because a hosted proof is addressed by it.
559
+ const connectionId = secret();
560
+ connection = {
561
+ id: connectionId,
562
+ local: flow.local,
563
+ external: flow.external,
564
+ provider: provider.id,
565
+ visibility,
566
+ visibilityApprovedAt: this.now(),
567
+ authenticatedAt: flow.authenticatedAt,
568
+ approvedAt: this.now(),
569
+ connectedAt: this.now(),
570
+ expiresAt: this.now() + (this.options.validityMs ?? 30 * 86400000),
571
+ attestations: {
572
+ local: this.declared(),
573
+ external: [this.attestation(flow, provider, connectionId)],
574
+ },
575
+ proof: hosted(provider) ? flow.artifact : undefined,
576
+ };
577
+ }
578
+ else {
579
+ const existing = await tx.get('connections', flow.connectionId);
580
+ if (!existing ||
581
+ existing.revokedAt !== undefined ||
582
+ !matches(flow.kind, existing.external, flow.external))
583
+ throw new Unavailable();
584
+ connection = existing;
585
+ if (flow.kind !== 'renew')
586
+ await this.invalidateShare(tx, connection.id);
587
+ if (flow.kind === 'renew') {
588
+ // Re-approval of the same pair extends the record rather than minting a new
589
+ // id, so embeds and evidence urls published earlier keep resolving. The
590
+ // subject snapshot refreshes because the holder just approved what it shows.
591
+ if (flow.local.id !== existing.local.id)
592
+ throw new Unavailable();
593
+ this.recordMethod(connection, flow, provider);
594
+ }
595
+ else if (flow.kind === 'revoke') {
596
+ connection.revokedAt = this.now();
597
+ connection.revocationReason = 'external';
598
+ }
599
+ else if (flow.kind === 'visibility') {
600
+ if (allowed && !allowed.includes(visibility))
601
+ throw new Unavailable();
602
+ connection.visibility = visibility;
603
+ connection.visibilityApprovedAt = this.now();
604
+ }
605
+ }
606
+ await tx.put('connections', connection.id, connection);
607
+ await this.audit(tx, connection.id, flow.kind, ['revoke', 'share-revoke'].includes(flow.kind) ? 'external' : 'local', id, connection.visibility);
608
+ flow.resultId = connection.id;
609
+ await this.ended(tx, flow, 'complete', connection);
610
+ await tx.put('flows', id, flow);
611
+ return connection.id;
612
+ });
613
+ }
614
+ /**
615
+ * Ends a flow and records how, inside the transaction that ends it: the connection as it
616
+ * stands now, since whatever later reports this result must not read a record that has
617
+ * changed since. The caller puts the flow.
618
+ */
619
+ async ended(tx, flow, phase, connection) {
620
+ connection ??= flow.connectionId ? await tx.get('connections', flow.connectionId) : undefined;
621
+ flow.phase = phase;
622
+ flow.result = {
623
+ kind: flow.kind,
624
+ phase,
625
+ ...(connection ? { connectionId: connection.id, visibility: connection.visibility } : {}),
626
+ finishedAt: this.now(),
627
+ };
628
+ }
629
+ /** Every read of a connection goes through here, so a record in an older shape is upgraded. */
630
+ transaction(work) {
631
+ return this.options.storage.transaction((tx) => work(upgraded(tx)));
632
+ }
633
+ /**
634
+ * The methods a reader is shown. An additional method whose proof has gone unread no
635
+ * longer shows anything, so it is left out until it reads again; the main one is
636
+ * never left out, because the record's status already says what became of it.
637
+ */
638
+ shown(connection) {
639
+ if (!connection.attestations)
640
+ // Records written before methods were stored still have a known method: the site
641
+ // declared its subject and the provider ran the redirect flow, the only one built.
642
+ return {
643
+ local: { by: 'backend', method: 'declared', confirmedAt: connection.approvedAt },
644
+ external: [{ by: 'provider', method: 'oauth', confirmedAt: connection.authenticatedAt }],
645
+ };
646
+ const [main, ...rest] = connection.attestations.external;
647
+ return {
648
+ ...connection.attestations,
649
+ external: [main, ...rest.filter((a) => fresh(a, this.now(), this.freshness))],
650
+ };
651
+ }
652
+ evidence(connection) {
653
+ const { id: _privateId, siteName: _siteName, ...local } = connection.local;
654
+ const standing = status(connection, this.now(), this.freshness);
655
+ const evidenceUrl = `${this.baseUrl}/connections/${connection.id}`;
656
+ return {
657
+ id: connection.id,
658
+ local,
659
+ external: connection.external,
660
+ provider: connection.provider,
661
+ providerName: this.providerName(connection.provider),
662
+ attestations: this.shown(connection),
663
+ siteName: this.siteOf(connection.local),
664
+ verifierName: this.options.verifierName,
665
+ visibility: connection.visibility,
666
+ status: standing,
667
+ connectedAt: connected(connection),
668
+ authenticatedAt: connection.authenticatedAt,
669
+ approvedAt: connection.approvedAt,
670
+ visibilityApprovedAt: connection.visibilityApprovedAt,
671
+ expiresAt: connection.expiresAt,
672
+ revokedAt: connection.revokedAt,
673
+ evidenceUrl,
674
+ ...(this.options.signingKey !== undefined &&
675
+ connection.visibility === 'public' &&
676
+ standing === 'verified'
677
+ ? { signedUrl: `${evidenceUrl}?format=signed` }
678
+ : {}),
679
+ };
680
+ }
681
+ /**
682
+ * The live record a connect flow reproves by another method, if there is one: the same
683
+ * subject linked to the same account on the same provider, first shown some other way.
684
+ * The same method again is a second record, as it always was; renewing is how that
685
+ * one is extended.
686
+ */
687
+ async joins(tx, flow) {
688
+ const provider = this.providerOf(flow);
689
+ return (await tx.list('connections'))
690
+ .filter((c) => c.revokedAt === undefined &&
691
+ c.local.id === flow.local.id &&
692
+ c.provider === provider.id &&
693
+ (c.attestations?.external[0].method ?? 'oauth') !== providerMethod(provider) &&
694
+ sameAccount(c.external, flow.external) &&
695
+ // A record first shown by a proof naming the subject's old address is a record
696
+ // of that address. Joining it would show its proof beside the new one.
697
+ (!c.attestations || this.names(c, c.attestations.external[0], flow.local)))
698
+ .sort((a, b) => b.approvedAt - a.approvedAt)[0];
699
+ }
700
+ /**
701
+ * The record a connect flow awaiting approval would add its method to, so the approval
702
+ * page can say so: the holder is not creating a link but reproving one, and the
703
+ * visibility already chosen for it stands.
704
+ */
705
+ async joining(flow) {
706
+ if (flow.kind !== 'connect' || flow.phase !== 'approval')
707
+ return undefined;
708
+ return this.transaction((tx) => this.joins(tx, flow));
709
+ }
710
+ /**
711
+ * Records that a flow just showed the record's account again. Shown by the method the
712
+ * record was first shown by, it replaces that one; shown another way, it takes that
713
+ * method's place after it, or joins the end. Either way the holder approved it and the
714
+ * site reasserted its subject, so the record is extended and its subject refreshed.
715
+ */
716
+ recordMethod(connection, flow, provider) {
717
+ const attestation = this.attestation(flow, provider, connection.id);
718
+ const [main, ...rest] = connection.attestations?.external ?? [
719
+ { by: 'provider', method: 'oauth', confirmedAt: connection.authenticatedAt },
720
+ ];
721
+ connection.local = flow.local;
722
+ connection.approvedAt = this.now();
723
+ connection.expiresAt = this.now() + (this.options.validityMs ?? 30 * 86400000);
724
+ connection.revocationReason = undefined;
725
+ if (attestation.method === main.method) {
726
+ connection.authenticatedAt = flow.authenticatedAt;
727
+ // The account is taken as this method just named it. A recheck asks the same method
728
+ // about the new proof and compares ids exactly, and an address read with different
729
+ // case names the same profile under a different id.
730
+ connection.external = flow.external;
731
+ connection.attestations = { local: this.declared(), external: [attestation, ...rest] };
732
+ }
733
+ else {
734
+ // A proof that named the subject's old address proves nothing about its new one,
735
+ // and rereading it would go on confirming a link to where the subject used to be.
736
+ const others = rest.filter((a) => this.names(connection, a, flow.local));
737
+ const at = others.findIndex((a) => a.method === attestation.method);
738
+ // A method already listed keeps its place: the order is the order each was first used.
739
+ if (at < 0)
740
+ others.push(attestation);
741
+ else
742
+ others[at] = attestation;
743
+ connection.attestations = { local: this.declared(), external: [main, ...others] };
744
+ }
745
+ if (hosted(provider))
746
+ connection.proof = flow.artifact;
747
+ }
748
+ /**
749
+ * Whether a proof still names the subject as it is now. A method that has the holder
750
+ * publish something derived from the subject, as a link back publishes its address,
751
+ * proved the subject as it was then; one whose proof is a token minted per flow names no
752
+ * subject, and holds whatever the subject's address becomes.
753
+ */
754
+ names(connection, attestation, local) {
755
+ const provider = this.providers.find((p) => p.id === connection.provider && providerMethod(p) === attestation.method);
756
+ if (attestation.expect === undefined || !provider || !isArtifactProvider(provider))
757
+ return true;
758
+ if (!provider.expect)
759
+ return true;
760
+ try {
761
+ return provider.expect(local) === attestation.expect;
762
+ }
763
+ catch {
764
+ return false;
765
+ }
766
+ }
767
+ /** The site is the only authority on its own namespace, so it declares the local subject. */
768
+ declared() {
769
+ return { by: 'backend', method: 'declared', confirmedAt: this.now() };
770
+ }
771
+ /** How the provider established the external account, by whatever method the flow ran. */
772
+ attestation(flow, provider, connectionId) {
773
+ const external = {
774
+ by: 'provider',
775
+ method: providerMethod(provider),
776
+ confirmedAt: flow.authenticatedAt,
777
+ };
778
+ // Only a published proof has somewhere for a reader to go, and it is kept with what
779
+ // they should find there so the same check can be run again later. Where that is
780
+ // depends on who holds it: an address the holder published, or this backend's own
781
+ // copy when the proof is a document that stands up wherever it is read.
782
+ if (flow.artifact && isArtifactProvider(provider)) {
783
+ const hosted = provider.artifact === 'document';
784
+ external.artifactUrl = hosted
785
+ ? `${this.baseUrl}/connections/${connectionId}/proof`
786
+ : flow.artifact;
787
+ external.expect = flow.expect;
788
+ if (hosted)
789
+ external.hosted = true;
790
+ }
791
+ return external;
792
+ }
793
+ /**
794
+ * The proof itself, for a method whose artifact this backend publishes. It is as public
795
+ * as the evidence it belongs to and no more: an unlisted record's proof is the holder's
796
+ * to hand out, exactly like the evidence page it is linked from.
797
+ */
798
+ async proof(id, local) {
799
+ return this.transaction(async (tx) => {
800
+ const connection = await tx.get('connections', id);
801
+ if (!connection?.proof ||
802
+ (connection.visibility !== 'public' && connection.local.id !== local?.id))
803
+ throw new Unavailable();
804
+ return connection.proof;
805
+ });
806
+ }
807
+ /** A record from a provider this instance no longer configures keeps its raw id. */
808
+ providerName(provider) {
809
+ return this.providers.find((p) => p.id === provider)?.name ?? provider;
810
+ }
811
+ async read(id, local) {
812
+ return this.transaction(async (tx) => {
813
+ const connection = await tx.get('connections', id);
814
+ if (!connection || (connection.visibility !== 'public' && connection.local.id !== local?.id))
815
+ throw new Unavailable();
816
+ return this.evidence(connection);
817
+ });
818
+ }
819
+ /**
820
+ * Every connection this site has published. Public evidence is already world-readable
821
+ * one id at a time; listing it lets an embed follow the current connections instead of
822
+ * hardcoding an id that dies whenever one is revoked and replaced. Unlisted records are
823
+ * never included: they must not appear in any directory listing.
824
+ */
825
+ async published() {
826
+ return this.transaction(async (tx) => (await tx.list('connections'))
827
+ .filter((c) => c.visibility === 'public')
828
+ .map((c) => this.evidence(c))
829
+ .filter((e) => e.status === 'verified'));
830
+ }
831
+ async mine(local) {
832
+ return this.transaction(async (tx) => (await tx.list('connections'))
833
+ .filter((c) => c.local.id === local.id)
834
+ .map((c) => this.evidence(c)));
835
+ }
836
+ async owned(tx, id, local) {
837
+ const connection = await tx.get('connections', id);
838
+ if (!connection || connection.local.id !== local.id)
839
+ throw new Unavailable();
840
+ return connection;
841
+ }
842
+ async revoke(id, local) {
843
+ await this.transaction(async (tx) => {
844
+ const connection = await this.owned(tx, id, local);
845
+ if (connection.revokedAt !== undefined)
846
+ return;
847
+ connection.revokedAt = this.now();
848
+ connection.revocationReason = 'local';
849
+ await tx.put('connections', id, connection);
850
+ await this.invalidateShare(tx, id);
851
+ await this.audit(tx, id, 'revoke', 'local');
852
+ });
853
+ }
854
+ async share(id, local, revoke = false) {
855
+ const token = secret();
856
+ return this.transaction(async (tx) => {
857
+ const connection = await this.owned(tx, id, local);
858
+ if (connection.visibility !== 'unlisted' || connection.revokedAt !== undefined)
859
+ throw new Unavailable();
860
+ await this.invalidateShare(tx, id);
861
+ if (revoke) {
862
+ await this.audit(tx, id, 'share-revoke', 'local');
863
+ return;
864
+ }
865
+ const expiresAt = this.now() + (this.options.shareTtlMs ?? 7 * 86400000);
866
+ await tx.put('shares', id, {
867
+ connectionId: id,
868
+ tokenHash: hash(token),
869
+ createdAt: this.now(),
870
+ expiresAt,
871
+ });
872
+ await this.audit(tx, id, 'share-issue', 'local');
873
+ return { url: `${this.baseUrl}/s/${token}`, expiresAt };
874
+ });
875
+ }
876
+ async shared(token) {
877
+ return this.transaction(async (tx) => {
878
+ const share = (await tx.list('shares')).find((s) => s.tokenHash === hash(token));
879
+ if (!share || share.revokedAt !== undefined || share.expiresAt <= this.now())
880
+ throw new Unavailable();
881
+ const connection = await tx.get('connections', share.connectionId);
882
+ if (!connection || connection.visibility !== 'unlisted' || connection.revokedAt !== undefined)
883
+ throw new Unavailable();
884
+ return {
885
+ ...this.evidence(connection),
886
+ evidenceUrl: `${this.baseUrl}/s/${token}`,
887
+ linkExpiresAt: share.expiresAt,
888
+ };
889
+ });
890
+ }
891
+ /**
892
+ * Asks again about proofs, because unlike a sign-in they can stop being true with nobody
893
+ * told. What that means depends on who holds the proof:
894
+ *
895
+ * A proof published elsewhere is reread. The holder deletes the gist and this record
896
+ * would otherwise go on claiming it, so continued silence ages the connection out.
897
+ *
898
+ * A proof this backend hosts cannot go missing, so there is nothing to reread. What can
899
+ * still change is the identity behind it, and a method that has somewhere to say so is
900
+ * asked. A holder who publishes a revocation for their key has withdrawn it, which is a
901
+ * revocation of the connection rather than a proof gone stale.
902
+ *
903
+ * A failed read writes nothing. One refusal is not evidence the proof is gone, and a
904
+ * provider being down must not revoke anyone; it is continued silence that ages a
905
+ * connection out through `status`, and a single later success undoes that. Revocation
906
+ * stays what it is, something a party chose.
907
+ *
908
+ * `budget` bounds the reads per run, since providers rate-limit and the alarm this runs
909
+ * on is shared. Oldest first, so nothing starves however many connections are waiting.
910
+ *
911
+ * Returns how many were confirmed. A connection revoked here is not one of them.
912
+ */
913
+ async recheck(budget = 5) {
914
+ if (budget <= 0)
915
+ return 0;
916
+ const interval = this.options.recheckMs ?? 86400000;
917
+ const now = this.now();
918
+ const due = await this.transaction(async (tx) => (await tx.list('connections'))
919
+ .filter((c) => c.revokedAt === undefined && c.expiresAt > now && c.attestations)
920
+ .flatMap((connection) => connection
921
+ .attestations.external.map((attestation) => ({
922
+ connection,
923
+ attestation,
924
+ provider: this.rereads(connection, attestation),
925
+ }))
926
+ .filter((item) => item.provider !== undefined && item.attestation.confirmedAt + interval <= now))
927
+ .sort((a, b) => a.attestation.confirmedAt - b.attestation.confirmedAt)
928
+ .slice(0, budget));
929
+ let confirmed = 0;
930
+ // Serially and outside any transaction: storage here serializes writes, so holding one
931
+ // open across a fetch would stall every other request behind the slowest provider.
932
+ for (const item of due)
933
+ confirmed += await this.reread(item);
934
+ return confirmed;
935
+ }
936
+ /**
937
+ * The configured method that can ask again about one attestation, or nothing when there
938
+ * is nothing to ask: no published proof, a method this instance no longer runs, or a
939
+ * hosted proof whose method has nowhere to hear of a withdrawal.
940
+ */
941
+ rereads(connection, attestation) {
942
+ if (!attestation.artifactUrl || !attestation.expect)
943
+ return undefined;
944
+ const provider = this.providers.find((p) => p.id === connection.provider && providerMethod(p) === attestation.method);
945
+ if (!provider || !isArtifactProvider(provider))
946
+ return undefined;
947
+ if (provider.artifact === 'location')
948
+ return attestation.hosted ? undefined : provider;
949
+ return provider.withdrawn && connection.proof ? provider : undefined;
950
+ }
951
+ /** Asks about one proof and records the answer. Returns 1 when it confirmed. */
952
+ async reread({ connection, attestation, provider }) {
953
+ const { artifactUrl, expect, method } = attestation;
954
+ const main = attestation === connection.attestations.external[0];
955
+ let withdrawn = false;
956
+ try {
957
+ if (provider.artifact === 'document')
958
+ withdrawn = await this.deadline(provider.withdrawn(connection.external, connection.proof));
959
+ else {
960
+ const external = await this.deadline(provider.verify({ artifact: artifactUrl, expect: expect }));
961
+ // The proof must still be the same holder's. An account that changed hands has
962
+ // not reproved anything, whatever is published at the old address.
963
+ if (main
964
+ ? external.id !== connection.external.id
965
+ : !sameAccount(connection.external, external))
966
+ return 0;
967
+ }
968
+ }
969
+ catch {
970
+ return 0;
971
+ }
972
+ return this.transaction(async (tx) => {
973
+ 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)
976
+ return 0;
977
+ const [first, ...rest] = current.attestations.external;
978
+ const held = main ? first : rest.find((a) => a.method === method);
979
+ if (held?.method !== method || held.artifactUrl !== artifactUrl)
980
+ return 0;
981
+ // The holder said this identity is no longer theirs, which is a choice, not decay.
982
+ if (withdrawn) {
983
+ current.revokedAt = this.now();
984
+ current.revocationReason = 'withdrawn';
985
+ await tx.put('connections', connection.id, current);
986
+ await this.invalidateShare(tx, connection.id);
987
+ await this.audit(tx, connection.id, 'revoke', 'external');
988
+ return 0;
989
+ }
990
+ held.confirmedAt = this.now();
991
+ await tx.put('connections', connection.id, current);
992
+ return 1;
993
+ });
994
+ }
995
+ /** A provider that never answers must not hold up the maintenance run behind it. */
996
+ deadline(work) {
997
+ let timer;
998
+ return Promise.race([
999
+ work.finally(() => clearTimeout(timer)),
1000
+ new Promise((_, reject) => {
1001
+ timer = setTimeout(() => reject(new Unavailable()), this.options.recheckTimeoutMs ?? 10000);
1002
+ }),
1003
+ ]);
1004
+ }
1005
+ /** Run periodically. Pending secrets expire immediately; historical evidence defaults to 90 days. */
1006
+ async prune(retentionMs = 90 * 86400000) {
1007
+ if (!Number.isSafeInteger(retentionMs) || retentionMs < 0)
1008
+ throw new Error('Invalid retention');
1009
+ await this.transaction(async (tx) => {
1010
+ const now = this.now();
1011
+ for (const flow of await tx.list('flows'))
1012
+ if (flow.expiresAt <= now)
1013
+ await tx.delete('flows', flow.id);
1014
+ for (const limit of await tx.list('limits'))
1015
+ if (limit.expiresAt <= now)
1016
+ await tx.delete('limits', limit.id);
1017
+ const audit = await tx.list('audit');
1018
+ for (const connection of await tx.list('connections')) {
1019
+ if ((connection.revokedAt ?? connection.expiresAt) + retentionMs <= now) {
1020
+ await tx.delete('connections', connection.id);
1021
+ await tx.delete('shares', connection.id);
1022
+ }
1023
+ else if (connection.connectedAt === undefined) {
1024
+ // A record from before its first connection was kept. The audit trail still has
1025
+ // the moment, for as long as it retains it, and that is the answer. Only past
1026
+ // that is the earliest time left on the record used, which is a guess: a sign-in
1027
+ // comes before the approval that makes the record, so it must not outrank the
1028
+ // event itself. Written once, here, so it stops moving.
1029
+ const made = audit
1030
+ .filter((event) => event.connectionId === connection.id && event.action === 'connect')
1031
+ .map((event) => event.at);
1032
+ connection.connectedAt = made.length ? Math.min(...made) : connected(connection);
1033
+ await tx.put('connections', connection.id, connection);
1034
+ }
1035
+ }
1036
+ for (const event of audit)
1037
+ if (event.at + retentionMs <= now)
1038
+ await tx.delete('audit', event.id);
1039
+ });
1040
+ }
1041
+ async invalidateShare(tx, id) {
1042
+ const share = await tx.get('shares', id);
1043
+ if (share) {
1044
+ share.revokedAt = this.now();
1045
+ await tx.put('shares', id, share);
1046
+ }
1047
+ }
1048
+ async audit(tx, connectionId, action, actor, flowId, visibility) {
1049
+ const id = secret();
1050
+ await tx.put('audit', id, {
1051
+ id,
1052
+ connectionId,
1053
+ flowId,
1054
+ action,
1055
+ actor,
1056
+ at: this.now(),
1057
+ visibility,
1058
+ });
1059
+ }
1060
+ }
1061
+ /**
1062
+ * When a record was first made. One written before that was kept is answered with the
1063
+ * earliest time it still carries: every one of them is at or after the first connection,
1064
+ * and each renewal moves them later, so the least of them is the closest.
1065
+ */
1066
+ function connected(connection) {
1067
+ return (connection.connectedAt ??
1068
+ Math.min(connection.approvedAt, connection.authenticatedAt, connection.visibilityApprovedAt, ...(connection.attestations?.external.map((a) => a.confirmedAt) ?? [])));
1069
+ }
1070
+ /**
1071
+ * Whether two methods named the same account. Where both carry an id their provider
1072
+ * issued, that id settles it. A method that learns only an address, as a link back does,
1073
+ * names the account by its profile, so against one of those the profiles are compared:
1074
+ * the account behind the number GitHub issued is the one at that address for as long as
1075
+ * the address is theirs. Only accounts compare this way; a page or a key is named by
1076
+ * nothing but itself.
1077
+ */
1078
+ function sameAccount(a, b) {
1079
+ if (a.id === b.id)
1080
+ return true;
1081
+ if (![a.kind, b.kind].every((kind) => kind === undefined || kind === 'account'))
1082
+ return false;
1083
+ if (a.id !== a.profileUrl && b.id !== b.profileUrl)
1084
+ return false;
1085
+ return profile(a.profileUrl) === profile(b.profileUrl);
1086
+ }
1087
+ /**
1088
+ * Wraps a transaction so records read through it are in the current shape. They are
1089
+ * rewritten in that shape the next time they are put.
1090
+ */
1091
+ function upgraded(tx) {
1092
+ return {
1093
+ get: async (kind, id) => upgrade(kind, await tx.get(kind, id)),
1094
+ put: (kind, id, value) => tx.put(kind, id, value),
1095
+ delete: (kind, id) => tx.delete(kind, id),
1096
+ list: async (kind) => (await tx.list(kind)).map((value) => upgrade(kind, value)),
1097
+ };
1098
+ }
1099
+ /** Methods stored under an earlier name. */
1100
+ const renamed = { attestation: 'gist' };
1101
+ /**
1102
+ * Brings one stored record up to date. Connections written before `external` became a
1103
+ * list held the main method there alone and the rest under `further`, and connections
1104
+ * and flows may name a method by an earlier name.
1105
+ */
1106
+ function upgrade(kind, value) {
1107
+ if (!value)
1108
+ return value;
1109
+ const method = (stored) => ({
1110
+ ...stored,
1111
+ method: renamed[stored.method] ?? stored.method,
1112
+ });
1113
+ if (kind === 'flows') {
1114
+ const flow = value;
1115
+ return (flow.method && renamed[flow.method] ? { ...flow, method: renamed[flow.method] } : flow);
1116
+ }
1117
+ if (kind !== 'connections')
1118
+ return value;
1119
+ const attestations = value.attestations;
1120
+ if (!attestations)
1121
+ return value;
1122
+ const external = Array.isArray(attestations.external)
1123
+ ? attestations.external
1124
+ : [attestations.external, ...(attestations.further ?? [])];
1125
+ return {
1126
+ ...value,
1127
+ attestations: {
1128
+ local: method(attestations.local),
1129
+ external: external.map(method),
1130
+ },
1131
+ };
1132
+ }
1133
+ /**
1134
+ * Whether a flow of this kind showed the record's account. Removal from the external side
1135
+ * is started and approved by nobody local, so it takes the provider-issued id alone: a
1136
+ * profile address can change hands, and its next owner must not be able to remove the
1137
+ * last one's record. The weaker match is only for flows the local holder approves.
1138
+ */
1139
+ function matches(kind, held, shown) {
1140
+ if (kind === 'revoke' || kind === 'share-revoke')
1141
+ return held.id === shown.id;
1142
+ return sameAccount(held, shown);
1143
+ }
1144
+ /** A profile address as it compares. Handles are compared without case, as providers issue them. */
1145
+ function profile(url) {
1146
+ return url.replace(/\/+$/, '').toLowerCase();
1147
+ }
1148
+ /** The window a send limit counts over. */
1149
+ const dayMs = 86400000;
1150
+ /** How many wrong codes a flow takes before it is dead. */
1151
+ export const codeAttempts = 5;
1152
+ /** Crockford's base 32: no I, L, O or U, so no letter is mistaken for a digit when typed. */
1153
+ const codeAlphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
1154
+ /**
1155
+ * A code short enough to type and long enough not to guess: forty bits, against the few
1156
+ * tries a flow allows. Written in two groups, since eight characters in a row are miscounted.
1157
+ */
1158
+ function mintCode() {
1159
+ const text = [...randomBytes(8)].map((byte) => codeAlphabet[byte & 31]).join('');
1160
+ return `${text.slice(0, 4)}-${text.slice(4)}`;
1161
+ }
1162
+ /**
1163
+ * A code as it is compared: bound to its flow, and read the way it was meant however it
1164
+ * was typed, in either case, with or without the hyphen, and with the letters the alphabet
1165
+ * leaves out taken for the digits they look like.
1166
+ */
1167
+ function codeHash(flowId, code) {
1168
+ const typed = code
1169
+ .toUpperCase()
1170
+ .replace(/[^0-9A-Z]/g, '')
1171
+ .replace(/O/g, '0')
1172
+ .replace(/[IL]/g, '1');
1173
+ return hash(`${flowId} ${typed}`);
1174
+ }
1175
+ /** Whether a method hands over a proof for this backend to publish. */
1176
+ function hosted(provider) {
1177
+ return isArtifactProvider(provider) && provider.artifact === 'document';
1178
+ }