@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.
- package/README.md +42 -27
- package/dist/browser/badge.js +16 -5
- package/dist/browser/connect-dialog.d.ts +21 -2
- package/dist/browser/connect-dialog.js +28 -13
- package/dist/browser/evidence-dialog.d.ts +19 -1
- package/dist/browser/evidence-dialog.js +163 -16
- package/dist/browser/index.d.ts +10 -1
- package/dist/browser/index.js +57 -13
- package/dist/browser/logo.d.ts +2 -0
- package/dist/browser/logo.js +2 -0
- package/dist/browser/mark.d.ts +2 -0
- package/dist/browser/mark.js +6 -0
- package/dist/browser/signed.d.ts +4 -0
- package/dist/browser/signed.js +23 -6
- package/dist/core/index.d.ts +76 -4
- package/dist/core/index.js +14 -0
- package/dist/core/signed.d.ts +20 -5
- package/dist/core/signed.js +5 -2
- package/dist/server/copy.d.ts +6 -0
- package/dist/server/copy.js +20 -0
- package/dist/server/discord.js +3 -1
- package/dist/server/github.js +4 -2
- package/dist/server/index.js +134 -50
- package/dist/server/link.js +24 -4
- package/dist/server/service.d.ts +92 -6
- package/dist/server/service.js +362 -19
- package/dist/server/youtube.js +3 -1
- package/dist/site/index.d.ts +2 -1
- package/dist/verily.js +212 -37
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/server/service.js
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
267
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
510
|
-
*
|
|
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,
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
/**
|
package/dist/server/youtube.js
CHANGED
|
@@ -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,
|
package/dist/site/index.d.ts
CHANGED
|
@@ -61,7 +61,8 @@ export interface SiteSubject {
|
|
|
61
61
|
export interface SiteResult {
|
|
62
62
|
site: string;
|
|
63
63
|
id: string;
|
|
64
|
-
|
|
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;
|