@bkazemi/verily 0.4.0 → 0.6.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 +49 -10
- package/dist/browser/badge.d.ts +4 -1
- package/dist/browser/badge.js +7 -4
- package/dist/browser/connect-dialog.js +10 -6
- package/dist/browser/evidence-dialog.d.ts +17 -3
- package/dist/browser/evidence-dialog.js +279 -29
- package/dist/browser/index.d.ts +7 -1
- package/dist/browser/index.js +52 -64
- package/dist/core/index.d.ts +95 -4
- package/dist/core/index.js +59 -5
- package/dist/core/routes.d.ts +57 -0
- package/dist/core/routes.js +52 -0
- package/dist/server/domain.d.ts +76 -10
- package/dist/server/domain.js +140 -51
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +148 -34
- package/dist/server/openpgp.d.ts +4 -0
- package/dist/server/openpgp.js +10 -1
- package/dist/server/pgp.d.ts +2 -0
- package/dist/server/pgp.js +1 -1
- package/dist/server/service.d.ts +87 -2
- package/dist/server/service.js +248 -25
- package/dist/server/style.d.ts +1 -1
- package/dist/server/style.js +40 -0
- package/dist/site/index.js +2 -2
- package/dist/verily.js +371 -95
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/browser/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { accountsOf } from '../core/index.js';
|
|
2
|
+
import { routePath } from '../core/routes.js';
|
|
1
3
|
import { badgeShown, quietBadge, renderBadge, renderBadgeMessage, renderBadgePending, renderConnectPill, } from './badge.js';
|
|
2
4
|
import { openConnectDialog, } from './connect-dialog.js';
|
|
3
|
-
import { openEvidenceDialog } from './evidence-dialog.js';
|
|
5
|
+
import { openEvidenceDialog, subjectUrl, } from './evidence-dialog.js';
|
|
4
6
|
import { drawSignedWith, standing, watchSigned } from './signed.js';
|
|
5
7
|
/**
|
|
6
8
|
* What each host's dialog opens on and how it reads again, kept apart from the pill: the
|
|
@@ -90,15 +92,17 @@ function details(element) {
|
|
|
90
92
|
throw error;
|
|
91
93
|
}
|
|
92
94
|
}
|
|
93
|
-
}, latestGroup.get(element), accounts, managers.get(element));
|
|
95
|
+
}, latestGroup.get(element), accounts, managers.get(element), openHosts.has(element));
|
|
94
96
|
}
|
|
95
97
|
}
|
|
96
98
|
/**
|
|
97
|
-
* Hosts whose pill is stacked,
|
|
98
|
-
*
|
|
99
|
+
* Hosts whose pill is stacked, hosts whose pill opens no panel, and hosts whose dialog
|
|
100
|
+
* starts with its details open, where the page asked for any of them. Kept here so every
|
|
101
|
+
* later refresh of the host draws it the same way.
|
|
99
102
|
*/
|
|
100
103
|
const stackedHosts = new WeakSet();
|
|
101
104
|
const quietHosts = new WeakSet();
|
|
105
|
+
const openHosts = new WeakSet();
|
|
102
106
|
/**
|
|
103
107
|
* How many accounts a pill names before it counts the rest. A stacked pill pays for each
|
|
104
108
|
* row in the page's height. A panel floats over the page and pays nothing, but it cannot
|
|
@@ -107,7 +111,11 @@ const quietHosts = new WeakSet();
|
|
|
107
111
|
const stackedAccounts = 4;
|
|
108
112
|
const peekedAccounts = 8;
|
|
109
113
|
/** Records how a host's pill is arranged, from what the page last said. */
|
|
110
|
-
function arrange(element, { stacked, peek }) {
|
|
114
|
+
function arrange(element, { stacked, peek, detailsOpen }) {
|
|
115
|
+
if (detailsOpen)
|
|
116
|
+
openHosts.add(element);
|
|
117
|
+
else
|
|
118
|
+
openHosts.delete(element);
|
|
111
119
|
if (stacked)
|
|
112
120
|
stackedHosts.add(element);
|
|
113
121
|
else
|
|
@@ -132,45 +140,16 @@ const live = (evidence) => evidence.status === 'verified' && evidence.expiresAt
|
|
|
132
140
|
* Each entry names every record of the account not yet revoked, shown or not, so removing
|
|
133
141
|
* the account removes them all. One left behind, lapsed or unconfirmed, could be confirmed
|
|
134
142
|
* again later and bring the account back without its holder approving anything.
|
|
135
|
-
*
|
|
136
|
-
* A record from a backend older than `connectedAt` is placed by when it was approved.
|
|
137
143
|
*/
|
|
138
144
|
function accounts(records) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
const sorted = [...held].sort((a, b) => when(a) - when(b));
|
|
148
|
-
const verified = sorted.filter(live);
|
|
149
|
-
const base = verified[0] ?? sorted.reduce((a, b) => (b.approvedAt > a.approvedAt ? b : a));
|
|
150
|
-
const external = [...base.attestations.external];
|
|
151
|
-
for (const other of verified.slice(1))
|
|
152
|
-
for (const attestation of other.attestations.external)
|
|
153
|
-
if (!external.some((shown) => shown.method === attestation.method))
|
|
154
|
-
external.push(attestation);
|
|
155
|
-
return {
|
|
156
|
-
...base,
|
|
157
|
-
connectedAt: when(sorted[0]),
|
|
158
|
-
attestations: { ...base.attestations, external },
|
|
159
|
-
links: sorted.filter((e) => e.status !== 'revoked').map((e) => e.id),
|
|
160
|
-
// Every record the card speaks for, so its signature mark answers for all of them.
|
|
161
|
-
sources: verified.length ? verified : [base],
|
|
162
|
-
};
|
|
163
|
-
})
|
|
164
|
-
.sort((a, b) => place(a) - place(b) || a.connectedAt - b.connectedAt);
|
|
165
|
-
}
|
|
166
|
-
/**
|
|
167
|
-
* Where an account is listed among a subject's others, before the order they were
|
|
168
|
-
* connected in. This is all there is to preferring an account: there is no separate pin.
|
|
169
|
-
*/
|
|
170
|
-
function place(account) {
|
|
171
|
-
if (account.status === 'retired')
|
|
172
|
-
return 3;
|
|
173
|
-
return account.mark === 'preferred' ? 0 : account.mark === 'unused' ? 2 : 1;
|
|
145
|
+
return accountsOf(records, Date.now()).map(({ lead, held, verified, external, connectedAt }) => ({
|
|
146
|
+
...lead,
|
|
147
|
+
connectedAt,
|
|
148
|
+
attestations: { ...lead.attestations, external },
|
|
149
|
+
links: held.filter((e) => e.status !== 'revoked').map((e) => e.id),
|
|
150
|
+
// Every record the card speaks for, so its signature mark answers for all of them.
|
|
151
|
+
sources: verified.length ? verified : [lead],
|
|
152
|
+
}));
|
|
174
153
|
}
|
|
175
154
|
/**
|
|
176
155
|
* One subject's accounts as a single pill: the first of them, then how many more stand
|
|
@@ -197,6 +176,8 @@ function presentGroup(element, records, load) {
|
|
|
197
176
|
const badge = renderBadge(element, lead, {
|
|
198
177
|
more: Math.max(verified.length - 1, 0),
|
|
199
178
|
linked: lead.visibility === 'public',
|
|
179
|
+
// A modified click follows the link, and must reach what a plain one shows: every account.
|
|
180
|
+
...(ordered.length > 1 ? { href: subjectUrl(lead) } : {}),
|
|
200
181
|
rows: stacked
|
|
201
182
|
? verified.slice(1, stackedAccounts)
|
|
202
183
|
: peek
|
|
@@ -249,7 +230,10 @@ export function init({ backendUrl, handoffUrl }) {
|
|
|
249
230
|
const base = new URL(backendUrl, location.href);
|
|
250
231
|
if (!['https:', 'http:'].includes(base.protocol))
|
|
251
232
|
throw new Error('Invalid backend URL');
|
|
252
|
-
|
|
233
|
+
// Where the backend's routes begin, with no slash to end it: a backend mounted at the
|
|
234
|
+
// root of its origin has a path of `/` however it is written, and its routes are not
|
|
235
|
+
// under `//`.
|
|
236
|
+
const root = base.href.replace(/\/$/, '');
|
|
253
237
|
/**
|
|
254
238
|
* A backend on another origin: an instance this site is registered with. Its cookies
|
|
255
239
|
* are another site's on this page, so the dialog carries what they would have. The
|
|
@@ -268,7 +252,7 @@ export function init({ backendUrl, handoffUrl }) {
|
|
|
268
252
|
const vouched = handed.ok ? await handed.json() : undefined;
|
|
269
253
|
if (!record(vouched) || typeof vouched.token !== 'string')
|
|
270
254
|
throw new Error('Verily request unavailable');
|
|
271
|
-
const traded = await fetch(`${
|
|
255
|
+
const traded = await fetch(`${root}/site/session`, {
|
|
272
256
|
method: 'POST',
|
|
273
257
|
credentials: 'omit',
|
|
274
258
|
cache: 'no-store',
|
|
@@ -285,11 +269,12 @@ export function init({ backendUrl, handoffUrl }) {
|
|
|
285
269
|
if (!remote)
|
|
286
270
|
return request(path, data);
|
|
287
271
|
const binding = flow && bindings.get(flow);
|
|
288
|
-
const response = await fetch(`${
|
|
272
|
+
const response = await fetch(`${root}${path}`, {
|
|
289
273
|
credentials: 'omit',
|
|
290
274
|
cache: 'no-store',
|
|
291
275
|
method: data ? 'POST' : 'GET',
|
|
292
276
|
headers: {
|
|
277
|
+
Accept: 'application/json',
|
|
293
278
|
Authorization: `Bearer ${await (session ??= openSession())}`,
|
|
294
279
|
...(binding ? { 'X-Verily-Flow': binding } : {}),
|
|
295
280
|
...(data ? { 'Content-Type': 'application/json' } : {}),
|
|
@@ -306,16 +291,16 @@ export function init({ backendUrl, handoffUrl }) {
|
|
|
306
291
|
return answer;
|
|
307
292
|
}
|
|
308
293
|
async function request(path, data) {
|
|
309
|
-
const response = await fetch(`${
|
|
294
|
+
const response = await fetch(`${root}${path}`, {
|
|
310
295
|
credentials: 'same-origin',
|
|
311
296
|
cache: 'no-store',
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
297
|
+
// Says what is wanted back, whatever is sent. A header any page may send to another
|
|
298
|
+
// origin without asking first, so a badge on a static site reads as it did.
|
|
299
|
+
headers: {
|
|
300
|
+
Accept: 'application/json',
|
|
301
|
+
...(data ? { 'Content-Type': 'application/json' } : {}),
|
|
302
|
+
},
|
|
303
|
+
...(data ? { method: 'POST', body: JSON.stringify(data) } : {}),
|
|
319
304
|
});
|
|
320
305
|
if (!response.ok)
|
|
321
306
|
throw new Error('Verily request unavailable');
|
|
@@ -324,20 +309,20 @@ export function init({ backendUrl, handoffUrl }) {
|
|
|
324
309
|
/** The connect flow as the dialog drives it, every answer checked before it is drawn. */
|
|
325
310
|
const flows = {
|
|
326
311
|
async methods() {
|
|
327
|
-
const data = await ask('
|
|
312
|
+
const data = await ask(routePath('methods'));
|
|
328
313
|
if (!record(data) || !record(data.local) || !Array.isArray(data.methods))
|
|
329
314
|
throw new Error('Invalid methods response');
|
|
330
315
|
if (typeof data.local.profileUrl === 'string')
|
|
331
316
|
safeUrl(data.local.profileUrl);
|
|
332
317
|
return data;
|
|
333
318
|
},
|
|
334
|
-
start: async (provider, method, renew) => flowView(await ask('
|
|
319
|
+
start: async (provider, method, renew) => flowView(await ask(routePath('start'), renew
|
|
335
320
|
? { kind: 'renew', connectionId: renew, provider, method }
|
|
336
321
|
: { kind: 'connect', provider, method })),
|
|
337
|
-
read: async (id) => flowView(await ask(
|
|
338
|
-
submit: async (id, artifact) => flowView(await ask(
|
|
322
|
+
read: async (id) => flowView(await ask(routePath('flow', id), undefined, id)),
|
|
323
|
+
submit: async (id, artifact) => flowView(await ask(routePath('submit', id), { artifact }, id)),
|
|
339
324
|
async approve(id, visibility, cancel) {
|
|
340
|
-
const data = await ask(
|
|
325
|
+
const data = await ask(routePath('approve', id), { action: cancel ? 'cancel' : 'approve', visibility }, id);
|
|
341
326
|
if (!record(data))
|
|
342
327
|
throw new Error('Invalid approval response');
|
|
343
328
|
return data.outcome === 'complete' && typeof data.connectionId === 'string'
|
|
@@ -368,7 +353,7 @@ export function init({ backendUrl, handoffUrl }) {
|
|
|
368
353
|
}, base.origin);
|
|
369
354
|
};
|
|
370
355
|
window.addEventListener('message', receive);
|
|
371
|
-
popup.location.href = `${
|
|
356
|
+
popup.location.href = `${root}/site/enter`;
|
|
372
357
|
},
|
|
373
358
|
}
|
|
374
359
|
: {}),
|
|
@@ -396,7 +381,7 @@ export function init({ backendUrl, handoffUrl }) {
|
|
|
396
381
|
pill.setAttribute('aria-haspopup', 'dialog');
|
|
397
382
|
pill.onclick = async () => {
|
|
398
383
|
if (typeof HTMLDialogElement === 'undefined' && !remote) {
|
|
399
|
-
location.href = `${
|
|
384
|
+
location.href = `${root}/verify`;
|
|
400
385
|
return;
|
|
401
386
|
}
|
|
402
387
|
const result = await client.openConnect(element);
|
|
@@ -546,7 +531,7 @@ export function init({ backendUrl, handoffUrl }) {
|
|
|
546
531
|
// On another origin this is vouched for afresh, as each opening of the dialog is.
|
|
547
532
|
if (remote)
|
|
548
533
|
session = undefined;
|
|
549
|
-
return ask(
|
|
534
|
+
return ask(routePath('disconnect', id), {});
|
|
550
535
|
},
|
|
551
536
|
/**
|
|
552
537
|
* Says how the holder would have an account listed, or retires it. Names one record
|
|
@@ -555,10 +540,10 @@ export function init({ backendUrl, handoffUrl }) {
|
|
|
555
540
|
mark(id, as) {
|
|
556
541
|
if (remote)
|
|
557
542
|
session = undefined;
|
|
558
|
-
return ask(
|
|
543
|
+
return ask(routePath('mark', id), { as });
|
|
559
544
|
},
|
|
560
|
-
issueShare: (id) => request(
|
|
561
|
-
revokeShare: (id) => request(
|
|
545
|
+
issueShare: (id) => request(routePath('share', id), {}),
|
|
546
|
+
revokeShare: (id) => request(routePath('shareRevoke', id), {}),
|
|
562
547
|
};
|
|
563
548
|
return client;
|
|
564
549
|
}
|
|
@@ -643,6 +628,7 @@ function validAttestation(attestation) {
|
|
|
643
628
|
typeof attestation.confirmedAt === 'number' &&
|
|
644
629
|
Number.isFinite(attestation.confirmedAt) &&
|
|
645
630
|
(attestation.expect === undefined || typeof attestation.expect === 'string') &&
|
|
631
|
+
(attestation.dnssec === undefined || typeof attestation.dnssec === 'boolean') &&
|
|
646
632
|
// Rendered as a link later, so only http(s) may ever reach an href.
|
|
647
633
|
(attestation.artifactUrl === undefined || httpUrl(attestation.artifactUrl)));
|
|
648
634
|
}
|
|
@@ -723,6 +709,7 @@ if (typeof customElements !== 'undefined' && !customElements.get('verily-badge')
|
|
|
723
709
|
'connect',
|
|
724
710
|
'stacked',
|
|
725
711
|
'peek',
|
|
712
|
+
'details-open',
|
|
726
713
|
];
|
|
727
714
|
handed;
|
|
728
715
|
/**
|
|
@@ -782,6 +769,7 @@ if (typeof customElements !== 'undefined' && !customElements.get('verily-badge')
|
|
|
782
769
|
const arrangement = {
|
|
783
770
|
stacked: this.getAttribute('stacked') !== null,
|
|
784
771
|
peek: this.getAttribute('peek') !== 'off',
|
|
772
|
+
detailsOpen: this.getAttribute('details-open') !== null,
|
|
785
773
|
};
|
|
786
774
|
// The holder's own badge, where the page says so: its dialog connects another
|
|
787
775
|
// account, renews one and removes one, through the backend named. The host hears
|
package/dist/core/index.d.ts
CHANGED
|
@@ -76,9 +76,24 @@ export interface Attestation {
|
|
|
76
76
|
hosted?: boolean;
|
|
77
77
|
/** What a reader should expect to find at artifactUrl, such as a challenge token. */
|
|
78
78
|
expect?: string;
|
|
79
|
+
/**
|
|
80
|
+
* Set where the method publishes something of the subject's, as its address, and this
|
|
81
|
+
* subject had nothing for it to publish: `expect` is then a string minted for the holder
|
|
82
|
+
* and names nobody. Such a proof holds whatever the subject's address becomes, where one
|
|
83
|
+
* that published an address is a proof of that address alone.
|
|
84
|
+
*/
|
|
85
|
+
minted?: boolean;
|
|
86
|
+
/**
|
|
87
|
+
* Set where the proof was read from DNS and the resolver said it had validated the answer
|
|
88
|
+
* with DNSSEC. Without it the answer is only as good as the resolver's own lookup, which
|
|
89
|
+
* somebody on the path between it and the domain's name servers could have answered.
|
|
90
|
+
*/
|
|
91
|
+
dnssec?: boolean;
|
|
79
92
|
/**
|
|
80
93
|
* When this side was last confirmed. For `declared` and `oauth` that is the moment it
|
|
81
94
|
* was established; artifact methods drift out of date and are reconfirmed on a schedule.
|
|
95
|
+
* A `declared` side that carries a proof is the site's own DNS naming this verifier, and
|
|
96
|
+
* is reconfirmed like any other.
|
|
82
97
|
*/
|
|
83
98
|
confirmedAt: number;
|
|
84
99
|
}
|
|
@@ -143,6 +158,15 @@ export interface Flow {
|
|
|
143
158
|
* unguessable and per-flow, so an artifact made for one flow cannot complete another.
|
|
144
159
|
*/
|
|
145
160
|
expect?: string;
|
|
161
|
+
/** Whether `expect` was minted for a method that had nothing of the subject's to state. */
|
|
162
|
+
minted?: boolean;
|
|
163
|
+
/** Whether the proof was read from a DNS answer the resolver validated with DNSSEC. */
|
|
164
|
+
dnssec?: boolean;
|
|
165
|
+
/**
|
|
166
|
+
* How the local side is recorded once the flow is approved, where the site's own DNS was
|
|
167
|
+
* found to name this verifier. Absent means the site's word alone.
|
|
168
|
+
*/
|
|
169
|
+
stated?: Attestation;
|
|
146
170
|
/**
|
|
147
171
|
* What the holder handed back: an address to read, or the proof itself. On a flow still
|
|
148
172
|
* waiting it is what they last handed back and had refused, kept so they can put it right.
|
|
@@ -232,12 +256,26 @@ export interface Limit {
|
|
|
232
256
|
count: number;
|
|
233
257
|
expiresAt: number;
|
|
234
258
|
}
|
|
259
|
+
/**
|
|
260
|
+
* When a site's DNS was last asked whether it names this verifier, by the maintenance run
|
|
261
|
+
* and nothing else. It is what orders the sites in a run, so it is kept for as long as the
|
|
262
|
+
* site has a record here and never expires by itself: an order that could lapse would
|
|
263
|
+
* start again from the same few sites.
|
|
264
|
+
*/
|
|
265
|
+
export interface SiteRead {
|
|
266
|
+
/** The site's domain. */
|
|
267
|
+
id: string;
|
|
268
|
+
readAt: number;
|
|
269
|
+
/** Whether that read got an answer. One that did not is asked again sooner. */
|
|
270
|
+
answered: boolean;
|
|
271
|
+
}
|
|
235
272
|
export interface Records {
|
|
236
273
|
connections: Connection;
|
|
237
274
|
flows: Flow;
|
|
238
275
|
shares: Share;
|
|
239
276
|
audit: Audit;
|
|
240
277
|
limits: Limit;
|
|
278
|
+
sites: SiteRead;
|
|
241
279
|
}
|
|
242
280
|
export interface Transaction {
|
|
243
281
|
get<K extends keyof Records>(kind: K, id: string): Promise<Records[K] | undefined>;
|
|
@@ -288,9 +326,18 @@ export interface ArtifactProvider {
|
|
|
288
326
|
* says the far side points at this exact subject, so the subject's own address is the
|
|
289
327
|
* whole of what is proved and a per-flow token could not be part of it. Absent means the
|
|
290
328
|
* backend mints an unguessable string instead, which is what a method wants when any
|
|
291
|
-
* fresh artifact will do.
|
|
329
|
+
* fresh artifact will do. So does returning nothing, for a subject that gives the method
|
|
330
|
+
* nothing to state: one with no address has no address to point at. Throw when the
|
|
331
|
+
* subject cannot be proved this way at all.
|
|
292
332
|
*/
|
|
293
|
-
expect?(local: LocalAccount): string;
|
|
333
|
+
expect?(local: LocalAccount): string | undefined;
|
|
334
|
+
/**
|
|
335
|
+
* Words the string the backend mints, given its unguessable part. The default names the
|
|
336
|
+
* site, so the holder can see what they are agreeing to before they publish. A method
|
|
337
|
+
* that publishes where anyone can look without being told where, as DNS is, may not want
|
|
338
|
+
* the site said there at all.
|
|
339
|
+
*/
|
|
340
|
+
mint?(secret: string): string;
|
|
294
341
|
/**
|
|
295
342
|
* Where the proof lives. `location` means the holder publishes it somewhere only they
|
|
296
343
|
* can write and hands back an address, so the address is half of what is proved.
|
|
@@ -333,6 +380,14 @@ export interface ArtifactProvider {
|
|
|
333
380
|
artifact: string;
|
|
334
381
|
expect: string;
|
|
335
382
|
}): Promise<ExternalAccount>;
|
|
383
|
+
/**
|
|
384
|
+
* The same check, for a method that learns more than whose the proof is. Where a method
|
|
385
|
+
* has one, this is what the backend calls, and `verify` gives the account it found.
|
|
386
|
+
*/
|
|
387
|
+
prove?(input: {
|
|
388
|
+
artifact: string;
|
|
389
|
+
expect: string;
|
|
390
|
+
}): Promise<Proved>;
|
|
336
391
|
/**
|
|
337
392
|
* Whether the holder has since withdrawn the identity itself, wherever such a statement
|
|
338
393
|
* is published. This revokes the connection, so it must be something the holder stated
|
|
@@ -340,6 +395,12 @@ export interface ArtifactProvider {
|
|
|
340
395
|
*/
|
|
341
396
|
withdrawn?(account: ExternalAccount, artifact: string): Promise<boolean>;
|
|
342
397
|
}
|
|
398
|
+
/** Whose a proof is, with what the method learned of how far the read can be trusted. */
|
|
399
|
+
export interface Proved {
|
|
400
|
+
account: ExternalAccount;
|
|
401
|
+
/** Whether the DNS answer the proof was read from was validated with DNSSEC. */
|
|
402
|
+
dnssec?: boolean;
|
|
403
|
+
}
|
|
343
404
|
/**
|
|
344
405
|
* Proves control by sending a message somewhere only the holder can read and having them
|
|
345
406
|
* answer it: by pressing the link it carries, or by reading its code back. It is
|
|
@@ -457,6 +518,7 @@ export interface Evidence {
|
|
|
457
518
|
signedUrl?: string;
|
|
458
519
|
}
|
|
459
520
|
export * from './signed.js';
|
|
521
|
+
export * from './routes.js';
|
|
460
522
|
/**
|
|
461
523
|
* How long a published proof stays good without being read again. An artifact method is
|
|
462
524
|
* only true while the artifact is still there, and the holder can delete it without
|
|
@@ -485,6 +547,29 @@ export declare function lastProved(evidence: Pick<Evidence, 'authenticatedAt' |
|
|
|
485
547
|
* that has run out since reads as expired whatever status it was issued with.
|
|
486
548
|
*/
|
|
487
549
|
export declare function statusLabel(evidence: Pick<Evidence, 'status' | 'expiresAt'>, now: number): string;
|
|
550
|
+
/**
|
|
551
|
+
* Where an account is listed among a subject's others, before the order they were
|
|
552
|
+
* connected in. This is all there is to preferring an account: there is no separate pin.
|
|
553
|
+
*/
|
|
554
|
+
export declare function place(account: Pick<Evidence, 'status' | 'mark'>): number;
|
|
555
|
+
/**
|
|
556
|
+
* One subject's records as its accounts, in the order they are listed everywhere: the
|
|
557
|
+
* preferred one, then the rest as first connected, then those unused, then those retired.
|
|
558
|
+
* Records are not accounts: the same account can stand on several. `held` is every record
|
|
559
|
+
* of the account, earliest first, and `verified` those of them that stand now. `lead`
|
|
560
|
+
* speaks for the account: the earliest that stands, or else the latest approved, which
|
|
561
|
+
* says what became of it. `external` is how the account was shown: the lead's methods,
|
|
562
|
+
* then any other its standing records add.
|
|
563
|
+
*
|
|
564
|
+
* A record from a backend older than `connectedAt` is placed by when it was approved.
|
|
565
|
+
*/
|
|
566
|
+
export declare function accountsOf(records: Evidence[], now: number): {
|
|
567
|
+
lead: Evidence;
|
|
568
|
+
held: Evidence[];
|
|
569
|
+
verified: Evidence[];
|
|
570
|
+
external: Attestations['external'];
|
|
571
|
+
connectedAt: number;
|
|
572
|
+
}[];
|
|
488
573
|
/**
|
|
489
574
|
* What to call a link's local side, and what to write as its value. When the site itself is
|
|
490
575
|
* what was linked there is no subject on it to name, so the site is the value rather than a
|
|
@@ -510,17 +595,23 @@ export declare function externalName(external: ExternalAccount): string;
|
|
|
510
595
|
* judgement, which is the reason for publishing it rather than a verdict about it.
|
|
511
596
|
*
|
|
512
597
|
* An unrecognised method returns nothing, so a renderer omits the line instead of
|
|
513
|
-
* describing a proof it does not understand.
|
|
598
|
+
* describing a proof it does not understand. Given the attestation, the words also say
|
|
599
|
+
* what its proof adds: that a DNS answer was validated, or that a site's DNS was read.
|
|
514
600
|
*/
|
|
515
601
|
export declare function attestationLabel(method: string, names: {
|
|
516
602
|
site: string;
|
|
517
603
|
provider: string;
|
|
518
|
-
}): string | undefined;
|
|
604
|
+
}, proof?: Pick<Attestation, 'artifactUrl' | 'dnssec'>): string | undefined;
|
|
519
605
|
/**
|
|
520
606
|
* A subject's second identifier, shown beneath its name, or nothing where the name is
|
|
521
607
|
* already all of it: a mailbox's address and a domain are each their own id.
|
|
522
608
|
*/
|
|
523
609
|
export declare function externalId(external: ExternalAccount): string | undefined;
|
|
610
|
+
/**
|
|
611
|
+
* A key's fingerprint in groups of four, as gpg prints it, so a renderer can break the line
|
|
612
|
+
* between groups. Nothing for any other subject, whose id is written as it stands.
|
|
613
|
+
*/
|
|
614
|
+
export declare function externalIdGroups(external: ExternalAccount): string[] | undefined;
|
|
524
615
|
/**
|
|
525
616
|
* Where a subject's name may link, or nothing where it has no page of its own. A mailbox's
|
|
526
617
|
* address is `mailto:`, which opens a message to it and shows a reader nothing.
|
package/dist/core/index.js
CHANGED
|
@@ -21,6 +21,7 @@ export function providerMethod(provider) {
|
|
|
21
21
|
return provider.method ?? 'oauth';
|
|
22
22
|
}
|
|
23
23
|
export * from './signed.js';
|
|
24
|
+
export * from './routes.js';
|
|
24
25
|
/**
|
|
25
26
|
* How long a published proof stays good without being read again. An artifact method is
|
|
26
27
|
* only true while the artifact is still there, and the holder can delete it without
|
|
@@ -78,6 +79,47 @@ export function statusLabel(evidence, now) {
|
|
|
78
79
|
return 'Expired';
|
|
79
80
|
return evidence.status === 'unconfirmed' ? 'Unconfirmed' : 'Verified';
|
|
80
81
|
}
|
|
82
|
+
/**
|
|
83
|
+
* Where an account is listed among a subject's others, before the order they were
|
|
84
|
+
* connected in. This is all there is to preferring an account: there is no separate pin.
|
|
85
|
+
*/
|
|
86
|
+
export function place(account) {
|
|
87
|
+
if (account.status === 'retired')
|
|
88
|
+
return 3;
|
|
89
|
+
return account.mark === 'preferred' ? 0 : account.mark === 'unused' ? 2 : 1;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* One subject's records as its accounts, in the order they are listed everywhere: the
|
|
93
|
+
* preferred one, then the rest as first connected, then those unused, then those retired.
|
|
94
|
+
* Records are not accounts: the same account can stand on several. `held` is every record
|
|
95
|
+
* of the account, earliest first, and `verified` those of them that stand now. `lead`
|
|
96
|
+
* speaks for the account: the earliest that stands, or else the latest approved, which
|
|
97
|
+
* says what became of it. `external` is how the account was shown: the lead's methods,
|
|
98
|
+
* then any other its standing records add.
|
|
99
|
+
*
|
|
100
|
+
* A record from a backend older than `connectedAt` is placed by when it was approved.
|
|
101
|
+
*/
|
|
102
|
+
export function accountsOf(records, now) {
|
|
103
|
+
const when = (e) => e.connectedAt ?? e.approvedAt;
|
|
104
|
+
const byAccount = new Map();
|
|
105
|
+
for (const record of records) {
|
|
106
|
+
const key = JSON.stringify([record.provider, record.external.id]);
|
|
107
|
+
byAccount.set(key, [...(byAccount.get(key) ?? []), record]);
|
|
108
|
+
}
|
|
109
|
+
return [...byAccount.values()]
|
|
110
|
+
.map((unsorted) => {
|
|
111
|
+
const held = [...unsorted].sort((a, b) => when(a) - when(b));
|
|
112
|
+
const verified = held.filter((e) => e.status === 'verified' && e.expiresAt > now);
|
|
113
|
+
const lead = verified[0] ?? held.reduce((a, b) => (b.approvedAt > a.approvedAt ? b : a));
|
|
114
|
+
const external = [...lead.attestations.external];
|
|
115
|
+
for (const other of verified.slice(1))
|
|
116
|
+
for (const attestation of other.attestations.external)
|
|
117
|
+
if (!external.some((shown) => shown.method === attestation.method))
|
|
118
|
+
external.push(attestation);
|
|
119
|
+
return { lead, held, verified, external, connectedAt: when(held[0]) };
|
|
120
|
+
})
|
|
121
|
+
.sort((a, b) => place(a.lead) - place(b.lead) || a.connectedAt - b.connectedAt);
|
|
122
|
+
}
|
|
81
123
|
/**
|
|
82
124
|
* What to call a link's local side, and what to write as its value. When the site itself is
|
|
83
125
|
* what was linked there is no subject on it to name, so the site is the value rather than a
|
|
@@ -113,16 +155,21 @@ export function externalName(external) {
|
|
|
113
155
|
* judgement, which is the reason for publishing it rather than a verdict about it.
|
|
114
156
|
*
|
|
115
157
|
* An unrecognised method returns nothing, so a renderer omits the line instead of
|
|
116
|
-
* describing a proof it does not understand.
|
|
158
|
+
* describing a proof it does not understand. Given the attestation, the words also say
|
|
159
|
+
* what its proof adds: that a DNS answer was validated, or that a site's DNS was read.
|
|
117
160
|
*/
|
|
118
|
-
export function attestationLabel(method, names) {
|
|
161
|
+
export function attestationLabel(method, names, proof = {}) {
|
|
162
|
+
const dnssec = proof.dnssec ? ', validated by DNSSEC' : '';
|
|
119
163
|
return {
|
|
120
|
-
|
|
164
|
+
// A site's word carries a proof only where its own DNS was read naming this verifier.
|
|
165
|
+
declared: proof.artifactUrl
|
|
166
|
+
? `Stated by ${names.site}, whose DNS names this verifier${dnssec}`
|
|
167
|
+
: `Stated by ${names.site}`,
|
|
121
168
|
oauth: `Signed in with ${names.provider}`,
|
|
122
169
|
gist: `Published a proof on ${names.provider}`,
|
|
123
170
|
backlink: `Linked back to ${names.site}`,
|
|
124
|
-
dns: `
|
|
125
|
-
wellknown:
|
|
171
|
+
dns: `Published a proof in its DNS${dnssec}`,
|
|
172
|
+
wellknown: 'Published a proof in a file it serves',
|
|
126
173
|
signature: 'Proved with a signature',
|
|
127
174
|
code: 'Entered a code sent to this address',
|
|
128
175
|
}[method];
|
|
@@ -134,6 +181,13 @@ export function attestationLabel(method, names) {
|
|
|
134
181
|
export function externalId(external) {
|
|
135
182
|
return external.kind === 'mailbox' || external.kind === 'domain' ? undefined : external.id;
|
|
136
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* A key's fingerprint in groups of four, as gpg prints it, so a renderer can break the line
|
|
186
|
+
* between groups. Nothing for any other subject, whose id is written as it stands.
|
|
187
|
+
*/
|
|
188
|
+
export function externalIdGroups(external) {
|
|
189
|
+
return external.kind === 'key' ? (external.id.match(/.{1,4}/g) ?? undefined) : undefined;
|
|
190
|
+
}
|
|
137
191
|
/**
|
|
138
192
|
* Where a subject's name may link, or nothing where it has no page of its own. A mailbox's
|
|
139
193
|
* address is `mailto:`, which opens a message to it and shows a reader nothing.
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export declare const routes: {
|
|
2
|
+
readonly methods: {
|
|
3
|
+
readonly method: "GET";
|
|
4
|
+
readonly path: "/methods";
|
|
5
|
+
};
|
|
6
|
+
readonly start: {
|
|
7
|
+
readonly method: "POST";
|
|
8
|
+
readonly path: "/sessions";
|
|
9
|
+
};
|
|
10
|
+
readonly flow: {
|
|
11
|
+
readonly method: "GET";
|
|
12
|
+
readonly path: "/flows/:id";
|
|
13
|
+
readonly format: "json";
|
|
14
|
+
};
|
|
15
|
+
readonly submit: {
|
|
16
|
+
readonly method: "POST";
|
|
17
|
+
readonly path: "/flows/:id/submit";
|
|
18
|
+
};
|
|
19
|
+
readonly approve: {
|
|
20
|
+
readonly method: "POST";
|
|
21
|
+
readonly path: "/flows/:id/approve";
|
|
22
|
+
};
|
|
23
|
+
readonly disconnect: {
|
|
24
|
+
readonly method: "POST";
|
|
25
|
+
readonly path: "/connections/:id/disconnect";
|
|
26
|
+
readonly manages: true;
|
|
27
|
+
};
|
|
28
|
+
readonly mark: {
|
|
29
|
+
readonly method: "POST";
|
|
30
|
+
readonly path: "/connections/:id/mark";
|
|
31
|
+
readonly manages: true;
|
|
32
|
+
};
|
|
33
|
+
readonly share: {
|
|
34
|
+
readonly method: "POST";
|
|
35
|
+
readonly path: "/connections/:id/share";
|
|
36
|
+
readonly manages: true;
|
|
37
|
+
readonly dialog: false;
|
|
38
|
+
};
|
|
39
|
+
readonly shareRevoke: {
|
|
40
|
+
readonly method: "POST";
|
|
41
|
+
readonly path: "/connections/:id/share-revoke";
|
|
42
|
+
readonly manages: true;
|
|
43
|
+
readonly dialog: false;
|
|
44
|
+
};
|
|
45
|
+
};
|
|
46
|
+
export type RouteName = keyof typeof routes;
|
|
47
|
+
/** The address of a route, for the record or flow it acts on where it names one. */
|
|
48
|
+
export declare function routePath(name: RouteName, id?: string): string;
|
|
49
|
+
/** The route a request is for, by its method and its path beneath the mount. */
|
|
50
|
+
export declare function routeOf(method: string, path: string): RouteName | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* The route a dialog's request is for, or nothing where it is not one a dialog makes: a
|
|
53
|
+
* route no dialog calls, or a page asked for without the query that makes it JSON.
|
|
54
|
+
*/
|
|
55
|
+
export declare function dialogRoute(method: string, path: string, query: URLSearchParams): RouteName | undefined;
|
|
56
|
+
/** Whether any dialog route is at a path, whatever the method: what a preflight asks about. */
|
|
57
|
+
export declare function dialogPath(path: string): boolean;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const routes = {
|
|
2
|
+
methods: { method: 'GET', path: '/methods' },
|
|
3
|
+
start: { method: 'POST', path: '/sessions' },
|
|
4
|
+
flow: { method: 'GET', path: '/flows/:id', format: 'json' },
|
|
5
|
+
submit: { method: 'POST', path: '/flows/:id/submit' },
|
|
6
|
+
approve: { method: 'POST', path: '/flows/:id/approve' },
|
|
7
|
+
disconnect: { method: 'POST', path: '/connections/:id/disconnect', manages: true },
|
|
8
|
+
mark: { method: 'POST', path: '/connections/:id/mark', manages: true },
|
|
9
|
+
share: { method: 'POST', path: '/connections/:id/share', manages: true, dialog: false },
|
|
10
|
+
shareRevoke: {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
path: '/connections/:id/share-revoke',
|
|
13
|
+
manages: true,
|
|
14
|
+
dialog: false,
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
const names = Object.keys(routes);
|
|
18
|
+
/** Whether a path is a route's, with any one segment where the route has `:id`. */
|
|
19
|
+
function matches(route, path) {
|
|
20
|
+
const want = route.path.split('/');
|
|
21
|
+
const got = path.split('/');
|
|
22
|
+
return (want.length === got.length &&
|
|
23
|
+
want.every((part, at) => (part === ':id' ? got[at] !== '' : part === got[at])));
|
|
24
|
+
}
|
|
25
|
+
/** The address of a route, for the record or flow it acts on where it names one. */
|
|
26
|
+
export function routePath(name, id) {
|
|
27
|
+
const route = routes[name];
|
|
28
|
+
const path = route.path.replace(':id', encodeURIComponent(id ?? ''));
|
|
29
|
+
return route.format ? `${path}?format=${route.format}` : path;
|
|
30
|
+
}
|
|
31
|
+
/** The route a request is for, by its method and its path beneath the mount. */
|
|
32
|
+
export function routeOf(method, path) {
|
|
33
|
+
return names.find((name) => routes[name].method === method && matches(routes[name], path));
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The route a dialog's request is for, or nothing where it is not one a dialog makes: a
|
|
37
|
+
* route no dialog calls, or a page asked for without the query that makes it JSON.
|
|
38
|
+
*/
|
|
39
|
+
export function dialogRoute(method, path, query) {
|
|
40
|
+
const name = routeOf(method, path);
|
|
41
|
+
const route = name && routes[name];
|
|
42
|
+
if (!route || route.dialog === false)
|
|
43
|
+
return undefined;
|
|
44
|
+
return !route.format || query.get('format') === route.format ? name : undefined;
|
|
45
|
+
}
|
|
46
|
+
/** Whether any dialog route is at a path, whatever the method: what a preflight asks about. */
|
|
47
|
+
export function dialogPath(path) {
|
|
48
|
+
return names.some((name) => {
|
|
49
|
+
const route = routes[name];
|
|
50
|
+
return route.dialog !== false && matches(route, path);
|
|
51
|
+
});
|
|
52
|
+
}
|