@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,809 @@
1
+ import { badgeShown, quietBadge, renderBadge, renderBadgeMessage, renderBadgePending, renderConnectPill, } from './badge.js';
2
+ import { openConnectDialog, } from './connect-dialog.js';
3
+ import { openEvidenceDialog } from './evidence-dialog.js';
4
+ import { drawSignedWith, standing, watchSigned } from './signed.js';
5
+ /**
6
+ * What each host's dialog opens on and how it reads again, kept apart from the pill: the
7
+ * most recent records read for the host, and the way to read them afresh. Both are set on
8
+ * every presentation whether or not the pill changed, because new records often leave the
9
+ * pill looking the same, and a renewal moves dates the pill never shows. The click handler
10
+ * holds neither. It looks them up when clicked, so it is the same handler however the pill
11
+ * came to be drawn and cannot fall behind what the host was last given.
12
+ */
13
+ const latestGroup = new WeakMap();
14
+ const loaders = new WeakMap();
15
+ /**
16
+ * What a host's dialog lets its reader do with the accounts shown, for a host whose page
17
+ * said its reader holds them: the holder's own badge, on a page only they are shown.
18
+ * Looked up when the dialog opens.
19
+ */
20
+ const managers = new WeakMap();
21
+ /** Where a host's records are no longer to be shown: an open dialog says so on its next read. */
22
+ const unavailable = async () => {
23
+ throw new Error('Unavailable');
24
+ };
25
+ /**
26
+ * How many times each host has been given something to present. A read that takes time
27
+ * can finish after the host has moved on: to other records, to none, or to the same ones
28
+ * asked for again. What it brings back then is about a host that no longer exists, so
29
+ * whoever started it checks it is still the latest before touching the host, whether it
30
+ * came back with records or with a failure.
31
+ */
32
+ const generations = new WeakMap();
33
+ /** Starts a presentation, and returns whether it is still the host's latest. */
34
+ function begin(element) {
35
+ const mine = (generations.get(element) ?? 0) + 1;
36
+ generations.set(element, mine);
37
+ return () => generations.get(element) === mine;
38
+ }
39
+ /** Stops a host's dialog showing what the host no longer presents, from its next read on. */
40
+ function forget(element) {
41
+ begin(element);
42
+ latestGroup.delete(element);
43
+ loaders.set(element, unavailable);
44
+ }
45
+ /** Takes a host's records away: its pill says so now, and an open dialog when it next reads. */
46
+ function withdraw(element) {
47
+ forget(element);
48
+ renderBadgeMessage(element, 'Unavailable');
49
+ }
50
+ /**
51
+ * Makes a drawn pill open the dialog. A modified click is left alone, so it follows the
52
+ * pill's own link where it has one. Assigned, not added: the pill is kept across renders,
53
+ * so a listener per render would stack up on the same element.
54
+ */
55
+ function opens(element, badge) {
56
+ badge.setAttribute('aria-haspopup', 'dialog');
57
+ badge.onclick = (event) => {
58
+ if (event.button !== 0 ||
59
+ event.ctrlKey ||
60
+ event.metaKey ||
61
+ event.shiftKey ||
62
+ event.altKey ||
63
+ typeof HTMLDialogElement === 'undefined')
64
+ return;
65
+ event.preventDefault();
66
+ details(element);
67
+ };
68
+ }
69
+ /** Opens a host's dialog on the records it holds now. */
70
+ function details(element) {
71
+ {
72
+ // The dialog opens on what the host holds, at full size, and the read that follows
73
+ // either leaves it alone or replaces it. It is given the records themselves and groups
74
+ // them into accounts each time it draws, so an account whose leading record runs out
75
+ // is taken up at that moment by another of its records that is still good.
76
+ openEvidenceDialog(element,
77
+ // Looked up at each read, so an open dialog follows a loader replaced under it. A
78
+ // read the host moved on from while it was out is not shown: the host's loader by
79
+ // then is asked instead, until one comes back to a host that has not changed.
80
+ async () => {
81
+ for (;;) {
82
+ const before = generations.get(element);
83
+ try {
84
+ const records = await (loaders.get(element) ?? unavailable)();
85
+ if (generations.get(element) === before)
86
+ return records;
87
+ }
88
+ catch (error) {
89
+ if (generations.get(element) === before)
90
+ throw error;
91
+ }
92
+ }
93
+ }, latestGroup.get(element), accounts, managers.get(element));
94
+ }
95
+ }
96
+ /**
97
+ * Hosts whose pill is stacked, and hosts whose pill opens no panel, where the page asked
98
+ * for either. Kept here so every later refresh of the host draws it the same way.
99
+ */
100
+ const stackedHosts = new WeakSet();
101
+ const quietHosts = new WeakSet();
102
+ /**
103
+ * How many accounts a pill names before it counts the rest. A stacked pill pays for each
104
+ * row in the page's height. A panel floats over the page and pays nothing, but it cannot
105
+ * be scrolled, so it stops while it still fits a small window.
106
+ */
107
+ const stackedAccounts = 4;
108
+ const peekedAccounts = 8;
109
+ /** Records how a host's pill is arranged, from what the page last said. */
110
+ function arrange(element, { stacked, peek }) {
111
+ if (stacked)
112
+ stackedHosts.add(element);
113
+ else
114
+ stackedHosts.delete(element);
115
+ if (peek === false)
116
+ quietHosts.add(element);
117
+ else
118
+ quietHosts.delete(element);
119
+ }
120
+ const live = (evidence) => evidence.status === 'verified' && evidence.expiresAt > Date.now();
121
+ /**
122
+ * A subject's accounts, one entry for each, in the order they were connected, earliest
123
+ * first. Records are not accounts: the same account can stand on several, shown a second
124
+ * way, or revoked once and connected again. Those are one account and get one card.
125
+ *
126
+ * Where any of an account's records is verified now, the earliest of those speaks for it,
127
+ * and the ways the others show it are listed beneath as other methods. Its lapsed
128
+ * records are left out, having been replaced. Where none is verified, the latest one says
129
+ * what became of it. Either way the account keeps the place of its first connection.
130
+ * Each entry names every record of the account not yet revoked, shown or not, so removing
131
+ * the account removes them all. One left behind, lapsed or unconfirmed, could be confirmed
132
+ * again later and bring the account back without its holder approving anything.
133
+ *
134
+ * A record from a backend older than `connectedAt` is placed by when it was approved.
135
+ */
136
+ function accounts(records) {
137
+ const when = (e) => e.connectedAt ?? e.approvedAt;
138
+ const byAccount = new Map();
139
+ for (const record of records) {
140
+ const key = JSON.stringify([record.provider, record.external.id]);
141
+ byAccount.set(key, [...(byAccount.get(key) ?? []), record]);
142
+ }
143
+ return [...byAccount.values()]
144
+ .map((held) => {
145
+ const sorted = [...held].sort((a, b) => when(a) - when(b));
146
+ const verified = sorted.filter(live);
147
+ const base = verified[0] ?? sorted.reduce((a, b) => (b.approvedAt > a.approvedAt ? b : a));
148
+ const external = [...base.attestations.external];
149
+ for (const other of verified.slice(1))
150
+ for (const attestation of other.attestations.external)
151
+ if (!external.some((shown) => shown.method === attestation.method))
152
+ external.push(attestation);
153
+ return {
154
+ ...base,
155
+ connectedAt: when(sorted[0]),
156
+ attestations: { ...base.attestations, external },
157
+ links: sorted.filter((e) => e.status !== 'revoked').map((e) => e.id),
158
+ // Every record the card speaks for, so its signature mark answers for all of them.
159
+ sources: verified.length ? verified : [base],
160
+ };
161
+ })
162
+ .sort((a, b) => a.connectedAt - b.connectedAt);
163
+ }
164
+ /**
165
+ * One subject's accounts as a single pill: the first connected, then how many more stand
166
+ * behind it. The order is the order they were connected in, which a renewal never changes.
167
+ * Only accounts verified now are counted, and one of those leads when the first connected
168
+ * has lapsed, so the pill never puts a lapsed account forward while a good one sits behind
169
+ * a number. The lapsed ones are all still there in the dialog, each in its place.
170
+ *
171
+ * `load` gives the records afresh when the dialog opens and as it stays open.
172
+ */
173
+ function presentGroup(element, records, load) {
174
+ const ordered = accounts(records.map(standing));
175
+ const verified = ordered.filter(live);
176
+ const lead = verified[0] ?? ordered[0];
177
+ latestGroup.set(element, records);
178
+ loaders.set(element, load);
179
+ // The accounts the count stands for are named too, as far as there is room: in rows of
180
+ // the pill where it is stacked, and otherwise in the panel a short pill opens.
181
+ const stacked = stackedHosts.has(element);
182
+ const peek = !stacked && !quietHosts.has(element);
183
+ const badge = renderBadge(element, lead, {
184
+ more: Math.max(verified.length - 1, 0),
185
+ linked: lead.visibility === 'public',
186
+ rows: stacked
187
+ ? verified.slice(1, stackedAccounts)
188
+ : peek
189
+ ? verified.slice(1, peekedAccounts)
190
+ : [],
191
+ peek,
192
+ });
193
+ // Null means the pill on screen is unchanged. Its handler stands, and reads the above.
194
+ if (badge)
195
+ opens(element, badge);
196
+ // A check that comes back may change what the pill is drawn from: the signed record's own
197
+ // words once it checks, and a record no longer verified if it fails.
198
+ watchSigned(records, () => {
199
+ if (latestGroup.get(element) === records)
200
+ presentGroup(element, records, load);
201
+ });
202
+ }
203
+ /**
204
+ * The records as one subject's, each safe to draw, or nothing. Every link in them must be
205
+ * one a page may follow, and they must all describe the same subject: the dialog names
206
+ * the subject once, above every account, and may not put one subject's name over
207
+ * another's.
208
+ */
209
+ function oneSubject(records) {
210
+ if (!Array.isArray(records) || !records.length || !records.every(validEvidence))
211
+ return undefined;
212
+ const [first] = records;
213
+ return records.every((e) => readable(e) && e.siteName === first.siteName && e.local.reference === first.local.reference)
214
+ ? records
215
+ : undefined;
216
+ }
217
+ /**
218
+ * Presents records the page already holds, without asking any backend for them. This is
219
+ * how a site shows links only it can read: its own backend read them with its key and put
220
+ * them in the page for the readers it chose. Nothing here can check them again, so the
221
+ * page is what vouches for them, and an unlisted one is drawn with no link to follow.
222
+ */
223
+ export function presentConnections(element, records, arrangement = {}) {
224
+ const subject = oneSubject(records);
225
+ arrange(element, arrangement);
226
+ // Nothing is awaited here, but a read still out for this host must not land on top.
227
+ begin(element);
228
+ if (!subject) {
229
+ withdraw(element);
230
+ return;
231
+ }
232
+ presentGroup(element, subject, async () => subject);
233
+ }
234
+ export function init({ backendUrl, handoffUrl }) {
235
+ const base = new URL(backendUrl, location.href);
236
+ if (!['https:', 'http:'].includes(base.protocol))
237
+ throw new Error('Invalid backend URL');
238
+ base.pathname = base.pathname.replace(/\/$/, '');
239
+ /**
240
+ * A backend on another origin: an instance this site is registered with. Its cookies
241
+ * are another site's on this page, so the dialog carries what they would have. The
242
+ * session comes from trading a handoff, which this site's own backend signs for its
243
+ * signed-in user at `handoffUrl`, and each flow's binding comes back in a header.
244
+ */
245
+ const remote = base.origin !== location.origin;
246
+ const bindings = new Map();
247
+ let session;
248
+ async function openSession() {
249
+ const handed = await fetch(new URL(handoffUrl, location.href), {
250
+ method: 'POST',
251
+ credentials: 'same-origin',
252
+ cache: 'no-store',
253
+ });
254
+ const vouched = handed.ok ? await handed.json() : undefined;
255
+ if (!record(vouched) || typeof vouched.token !== 'string')
256
+ throw new Error('Verily request unavailable');
257
+ const traded = await fetch(`${base.href}/site/session`, {
258
+ method: 'POST',
259
+ credentials: 'omit',
260
+ cache: 'no-store',
261
+ headers: { 'Content-Type': 'application/json' },
262
+ body: JSON.stringify({ token: vouched.token }),
263
+ });
264
+ const opened = traded.ok ? await traded.json() : undefined;
265
+ if (!record(opened) || typeof opened.session !== 'string')
266
+ throw new Error('Verily request unavailable');
267
+ return opened.session;
268
+ }
269
+ /** One of the dialog's requests. `flow` names the flow whose binding goes with it. */
270
+ async function ask(path, data, flow) {
271
+ if (!remote)
272
+ return request(path, data);
273
+ const binding = flow && bindings.get(flow);
274
+ const response = await fetch(`${base.href}${path}`, {
275
+ credentials: 'omit',
276
+ cache: 'no-store',
277
+ method: data ? 'POST' : 'GET',
278
+ headers: {
279
+ Authorization: `Bearer ${await (session ??= openSession())}`,
280
+ ...(binding ? { 'X-Verily-Flow': binding } : {}),
281
+ ...(data ? { 'Content-Type': 'application/json' } : {}),
282
+ },
283
+ ...(data ? { body: JSON.stringify(data) } : {}),
284
+ });
285
+ if (!response.ok)
286
+ throw new Error('Verily request unavailable');
287
+ const answer = await response.json();
288
+ const bound = response.headers.get('X-Verily-Flow');
289
+ // Only starting a flow sets a binding, and the answer names the flow it is for.
290
+ if (bound && record(answer) && typeof answer.id === 'string')
291
+ bindings.set(answer.id, bound);
292
+ return answer;
293
+ }
294
+ async function request(path, data) {
295
+ const response = await fetch(`${base.href}${path}`, {
296
+ credentials: 'same-origin',
297
+ cache: 'no-store',
298
+ ...(data
299
+ ? {
300
+ method: 'POST',
301
+ headers: { 'Content-Type': 'application/json' },
302
+ body: JSON.stringify(data),
303
+ }
304
+ : {}),
305
+ });
306
+ if (!response.ok)
307
+ throw new Error('Verily request unavailable');
308
+ return response.json();
309
+ }
310
+ /** The connect flow as the dialog drives it, every answer checked before it is drawn. */
311
+ const flows = {
312
+ async methods() {
313
+ const data = await ask('/methods');
314
+ if (!record(data) || !record(data.local) || !Array.isArray(data.methods))
315
+ throw new Error('Invalid methods response');
316
+ if (typeof data.local.profileUrl === 'string')
317
+ safeUrl(data.local.profileUrl);
318
+ return data;
319
+ },
320
+ start: async (provider, method) => flowView(await ask('/sessions', { kind: 'connect', provider, method })),
321
+ read: async (id) => flowView(await ask(`/flows/${encodeURIComponent(id)}?format=json`, undefined, id)),
322
+ submit: async (id, artifact) => flowView(await ask(`/flows/${encodeURIComponent(id)}/submit`, { artifact }, id)),
323
+ async approve(id, visibility, cancel) {
324
+ const data = await ask(`/flows/${encodeURIComponent(id)}/approve`, { action: cancel ? 'cancel' : 'approve', visibility }, id);
325
+ if (!record(data))
326
+ throw new Error('Invalid approval response');
327
+ return data.outcome === 'complete' && typeof data.connectionId === 'string'
328
+ ? { outcome: 'complete', connectionId: data.connectionId }
329
+ : { outcome: 'cancelled' };
330
+ },
331
+ /**
332
+ * With the backend on another origin, the sign-in window opens on the backend first.
333
+ * The page there asks for the flow's binding and keeps it as a cookie of its own
334
+ * origin, which is where the provider's callback looks for it, and then goes on to the
335
+ * provider. The binding is sent to the backend's origin alone.
336
+ */
337
+ ...(remote
338
+ ? {
339
+ enter(popup, flow) {
340
+ const receive = (event) => {
341
+ if (event.source !== popup ||
342
+ event.origin !== base.origin ||
343
+ !record(event.data) ||
344
+ event.data.type !== 'verily-enter')
345
+ return;
346
+ window.removeEventListener('message', receive);
347
+ popup.postMessage({
348
+ type: 'verily-enter',
349
+ flow: flow.id,
350
+ binding: bindings.get(flow.id),
351
+ url: flow.authorizationUrl,
352
+ }, base.origin);
353
+ };
354
+ window.addEventListener('message', receive);
355
+ popup.location.href = `${base.href}/site/enter`;
356
+ },
357
+ }
358
+ : {}),
359
+ };
360
+ const client = {
361
+ /**
362
+ * Opens the connect dialog over the current page. Resolves when it closes, with how
363
+ * the last attempt in it ended. Call from a click: a sign-in method opens a window.
364
+ */
365
+ openConnect(opener = document.body, back, provider) {
366
+ if (remote && !handoffUrl)
367
+ throw new Error('A backend on another origin requires a handoff URL');
368
+ // Each opening is vouched for afresh, for whoever is signed in to this site now.
369
+ session = undefined;
370
+ return openConnectDialog(opener, flows, back, provider);
371
+ },
372
+ /**
373
+ * Draws the pill that opens the connect dialog. The host receives a `verily-result`
374
+ * event, whose detail is the Result, whenever the dialog closes on a new connection.
375
+ */
376
+ mountConnect(element) {
377
+ const pill = renderConnectPill(element);
378
+ pill.setAttribute('aria-haspopup', 'dialog');
379
+ pill.onclick = async () => {
380
+ if (typeof HTMLDialogElement === 'undefined' && !remote) {
381
+ location.href = `${base.href}/verify`;
382
+ return;
383
+ }
384
+ const result = await client.openConnect(element);
385
+ if (result.outcome === 'complete')
386
+ element.dispatchEvent(new CustomEvent('verily-result', { detail: result, bubbles: true, composed: true }));
387
+ };
388
+ },
389
+ async connect({ provider }) {
390
+ if (base.origin !== location.origin)
391
+ throw new Error('Management requires a same-origin backend');
392
+ const popup = window.open('about:blank', '_blank', 'popup,width=600,height=750');
393
+ if (!popup)
394
+ throw new Error('Allow popups to verify');
395
+ try {
396
+ const data = await request('/connect', { provider });
397
+ if (!record(data) ||
398
+ typeof data.url !== 'string' ||
399
+ new URL(data.url).origin !== base.origin)
400
+ throw new Error('Invalid flow URL');
401
+ return await new Promise((resolve) => {
402
+ const finish = (result) => {
403
+ clearInterval(timer);
404
+ clearTimeout(timeout);
405
+ window.removeEventListener('message', receive);
406
+ popup.close();
407
+ resolve(result);
408
+ };
409
+ const receive = (event) => {
410
+ if (event.origin !== base.origin ||
411
+ event.source !== popup ||
412
+ !record(event.data) ||
413
+ event.data.type !== 'verily-result')
414
+ return;
415
+ const value = event.data;
416
+ if (value.outcome === 'complete' && typeof value.connectionId === 'string')
417
+ finish({ outcome: 'complete', connectionId: value.connectionId });
418
+ else
419
+ finish({ outcome: value.outcome === 'cancelled' ? 'cancelled' : 'failed' });
420
+ };
421
+ const timer = setInterval(() => {
422
+ if (popup.closed)
423
+ finish({ outcome: 'cancelled' });
424
+ }, 500);
425
+ const timeout = setTimeout(() => finish({ outcome: 'failed' }), 11 * 60000);
426
+ window.addEventListener('message', receive);
427
+ popup.location.href = data.url;
428
+ });
429
+ }
430
+ catch (error) {
431
+ popup.close();
432
+ throw error;
433
+ }
434
+ },
435
+ /** The site's current public connections, so an embed need not hardcode ids. */
436
+ async listPublished() {
437
+ const data = await request('/published');
438
+ if (!Array.isArray(data) || !data.every(validEvidence))
439
+ throw new Error('Invalid evidence response');
440
+ return data;
441
+ },
442
+ async getConnection(id) {
443
+ const data = await request(`/connections/${encodeURIComponent(id)}?format=json`);
444
+ if (!validEvidence(data))
445
+ throw new Error('Invalid evidence response');
446
+ return data;
447
+ },
448
+ async mountBadge(element, { connectionId, evidence }) {
449
+ // Evidence already in hand renders at once. Otherwise a host with nothing to show
450
+ // gets the waiting pill, whose frame and mark the finished badge keeps; a host that
451
+ // already shows a badge keeps it up until the check answers, so a periodic refresh
452
+ // never blinks the pill through an interim state.
453
+ if (!evidence && !badgeShown(element))
454
+ renderBadgePending(element);
455
+ const current = begin(element);
456
+ try {
457
+ const e = evidence ?? (await client.getConnection(connectionId));
458
+ if (!current())
459
+ return;
460
+ if (!validEvidence(e))
461
+ throw new Error('Invalid evidence response');
462
+ if (e.visibility !== 'public')
463
+ throw new Error('Unavailable');
464
+ // Validate both links before rendering any provider details.
465
+ externalUrl(e.external);
466
+ safeUrl(e.evidenceUrl);
467
+ // What the dialog draws has one more link in it than the pill, so it opens on the
468
+ // record only if that one is good too, and otherwise on what it reads.
469
+ latestGroup.set(element, readable(e) ? [e] : []);
470
+ loaders.set(element, async () => {
471
+ const fresh = await client.getConnection(e.id);
472
+ if (fresh.visibility !== 'public' || !readable(fresh))
473
+ throw new Error('Unavailable');
474
+ return [fresh];
475
+ });
476
+ const draw = () => {
477
+ const badge = renderBadge(element, standing(e));
478
+ // Null means the pill on screen is unchanged. Its handler stands, and reads the above.
479
+ if (badge)
480
+ opens(element, badge);
481
+ };
482
+ draw();
483
+ // Drawn again from the signed record once it checks, and as unverified if it fails.
484
+ watchSigned([e], () => {
485
+ if (current())
486
+ draw();
487
+ });
488
+ }
489
+ catch {
490
+ if (current())
491
+ withdraw(element);
492
+ }
493
+ },
494
+ /**
495
+ * Several of one subject's public connections as a single pill. One that cannot be
496
+ * read is left out, since the rest are still true without it; with none left, or with
497
+ * records of more than one subject, the pill says it is unavailable.
498
+ */
499
+ async mountBadges(element, { connectionIds, ...arrangement }) {
500
+ const ids = [...new Set(connectionIds)];
501
+ arrange(element, arrangement);
502
+ if (ids.length === 1)
503
+ return client.mountBadge(element, { connectionId: ids[0] });
504
+ if (!badgeShown(element))
505
+ renderBadgePending(element);
506
+ const read = async () => {
507
+ const answers = await Promise.allSettled(ids.map((id) => client.getConnection(id)));
508
+ const records = oneSubject(answers
509
+ .filter((a) => a.status === 'fulfilled')
510
+ .map((a) => a.value)
511
+ .filter((e) => e.visibility === 'public'));
512
+ if (!records)
513
+ throw new Error('Unavailable');
514
+ return records;
515
+ };
516
+ const current = begin(element);
517
+ try {
518
+ const records = await read();
519
+ if (current())
520
+ presentGroup(element, records, read);
521
+ }
522
+ catch {
523
+ if (current())
524
+ withdraw(element);
525
+ }
526
+ },
527
+ disconnect(id) {
528
+ // On another origin this is vouched for afresh, as each opening of the dialog is.
529
+ if (remote)
530
+ session = undefined;
531
+ return ask(`/connections/${encodeURIComponent(id)}/disconnect`, {});
532
+ },
533
+ issueShare: (id) => request(`/connections/${encodeURIComponent(id)}/share`, {}),
534
+ revokeShare: (id) => request(`/connections/${encodeURIComponent(id)}/share-revoke`, {}),
535
+ };
536
+ return client;
537
+ }
538
+ // What a signed record says is drawn in a record's place, so it is held to the same rules.
539
+ drawSignedWith((value) => validEvidence(value) && readable(value) !== undefined);
540
+ function record(value) {
541
+ return value !== null && typeof value === 'object';
542
+ }
543
+ /**
544
+ * The record as the dialog would draw it, or nothing if any link in it may not be
545
+ * rendered. The pill checks the two links it shows itself; the dialog shows one more.
546
+ */
547
+ function readable(evidence) {
548
+ try {
549
+ safeUrl(evidence.evidenceUrl);
550
+ externalUrl(evidence.external);
551
+ if (evidence.local.profileUrl)
552
+ safeUrl(evidence.local.profileUrl);
553
+ if (evidence.signedUrl)
554
+ safeUrl(evidence.signedUrl);
555
+ return evidence;
556
+ }
557
+ catch {
558
+ return undefined;
559
+ }
560
+ }
561
+ /** A flow step, refused unless every address in it may be followed or drawn as a link. */
562
+ function flowView(value) {
563
+ if (!record(value) ||
564
+ typeof value.id !== 'string' ||
565
+ typeof value.phase !== 'string' ||
566
+ !record(value.provider))
567
+ throw new Error('Invalid flow response');
568
+ if (value.authorizationUrl !== undefined)
569
+ safeUrl(String(value.authorizationUrl));
570
+ if (value.phase === 'approval') {
571
+ if (!record(value.local) || !record(value.external))
572
+ throw new Error('Invalid flow response');
573
+ externalUrl(value.external);
574
+ if (value.local.profileUrl !== undefined)
575
+ safeUrl(String(value.local.profileUrl));
576
+ if (value.signedNote !== undefined && typeof value.signedNote !== 'string')
577
+ throw new Error('Invalid flow response');
578
+ }
579
+ return value;
580
+ }
581
+ /**
582
+ * An external subject's address, refused unless it may be drawn. A mailbox has no page and
583
+ * is never linked, so its address need only be the `mailto:` it says it is.
584
+ */
585
+ function externalUrl(external) {
586
+ const address = String(external.profileUrl);
587
+ if (external.kind !== 'mailbox')
588
+ return safeUrl(address);
589
+ if (new URL(address).protocol !== 'mailto:')
590
+ throw new Error('Invalid URL');
591
+ return address;
592
+ }
593
+ function safeUrl(value) {
594
+ const url = new URL(value);
595
+ if (!['http:', 'https:'].includes(url.protocol))
596
+ throw new Error('Invalid URL');
597
+ return url.href;
598
+ }
599
+ /**
600
+ * Both sides must be described, because a half-filled record would let a renderer imply a
601
+ * method for a side that never reported one.
602
+ */
603
+ function validAttestations(value) {
604
+ if (!record(value))
605
+ return false;
606
+ return (validAttestation(value.local) &&
607
+ Array.isArray(value.external) &&
608
+ value.external.length > 0 &&
609
+ value.external.every(validAttestation));
610
+ }
611
+ function validAttestation(attestation) {
612
+ if (!record(attestation))
613
+ return false;
614
+ return (['backend', 'provider'].includes(String(attestation.by)) &&
615
+ typeof attestation.method === 'string' &&
616
+ typeof attestation.confirmedAt === 'number' &&
617
+ Number.isFinite(attestation.confirmedAt) &&
618
+ (attestation.expect === undefined || typeof attestation.expect === 'string') &&
619
+ // Rendered as a link later, so only http(s) may ever reach an href.
620
+ (attestation.artifactUrl === undefined || httpUrl(attestation.artifactUrl)));
621
+ }
622
+ function httpUrl(value) {
623
+ if (typeof value !== 'string')
624
+ return false;
625
+ try {
626
+ return ['http:', 'https:'].includes(new URL(value).protocol);
627
+ }
628
+ catch {
629
+ return false;
630
+ }
631
+ }
632
+ function validEvidence(value) {
633
+ if (!record(value) || !record(value.local) || !record(value.external))
634
+ return false;
635
+ return (['id', 'provider', 'providerName', 'siteName', 'verifierName', 'evidenceUrl'].every((k) => typeof value[k] === 'string') &&
636
+ ['label', 'reference'].every((k) => typeof value.local[k] === 'string') &&
637
+ ['id', 'handle', 'profileUrl'].every((k) => typeof value.external[k] === 'string') &&
638
+ (value.local.profileUrl === undefined || typeof value.local.profileUrl === 'string') &&
639
+ // A signed record lives beside the record it was made from and nowhere else, since the
640
+ // keys it is checked against are read from where the record lives.
641
+ (value.signedUrl === undefined || value.signedUrl === `${value.evidenceUrl}?format=signed`) &&
642
+ validAttestations(value.attestations) &&
643
+ (value.local.kind === undefined || typeof value.local.kind === 'string') &&
644
+ (value.external.kind === undefined ||
645
+ ['account', 'key', 'page', 'mailbox'].includes(String(value.external.kind))) &&
646
+ (value.revokedAt === undefined ||
647
+ (typeof value.revokedAt === 'number' && Number.isFinite(value.revokedAt))) &&
648
+ ['verified', 'unconfirmed', 'expired', 'revoked'].includes(String(value.status)) &&
649
+ ['public', 'unlisted'].includes(String(value.visibility)) &&
650
+ (value.connectedAt === undefined ||
651
+ (typeof value.connectedAt === 'number' && Number.isFinite(value.connectedAt))) &&
652
+ ['authenticatedAt', 'approvedAt', 'expiresAt'].every((k) => typeof value[k] === 'number' && Number.isFinite(value[k])));
653
+ }
654
+ if (typeof customElements !== 'undefined' && !customElements.get('verily-connect')) {
655
+ /**
656
+ * The pill a signed-in holder clicks to connect an account, in a dialog over the page.
657
+ * A site registered with an instance on another origin names it in `backend-url` and
658
+ * adds `handoff-url`, its own endpoint that vouches for its signed-in user.
659
+ */
660
+ customElements.define('verily-connect', class extends HTMLElement {
661
+ static observedAttributes = ['backend-url', 'handoff-url'];
662
+ connectedCallback() {
663
+ this.present();
664
+ }
665
+ attributeChangedCallback() {
666
+ if (this.isConnected)
667
+ this.present();
668
+ }
669
+ present() {
670
+ const backendUrl = this.getAttribute('backend-url');
671
+ if (backendUrl)
672
+ init({
673
+ backendUrl,
674
+ handoffUrl: this.getAttribute('handoff-url') ?? undefined,
675
+ }).mountConnect(this);
676
+ }
677
+ });
678
+ }
679
+ if (typeof customElements !== 'undefined' && !customElements.get('verily-badge')) {
680
+ customElements.define('verily-badge', class extends HTMLElement {
681
+ /**
682
+ * A badge may be placed before its connection is known: it then waits, showing the
683
+ * pill's own frame, until `connection-id` names what it presents.
684
+ */
685
+ static observedAttributes = [
686
+ 'backend-url',
687
+ 'connection-id',
688
+ 'connection-ids',
689
+ 'connections',
690
+ 'handoff-url',
691
+ 'connect',
692
+ 'stacked',
693
+ 'peek',
694
+ ];
695
+ handed;
696
+ /**
697
+ * Records the page already holds, drawn as given and never fetched: one subject's
698
+ * accounts, as the site's own backend read them. Also settable as JSON in the
699
+ * `connections` attribute, for a page rendered on the server.
700
+ */
701
+ get connections() {
702
+ return this.handed;
703
+ }
704
+ set connections(records) {
705
+ this.handed = records;
706
+ this.present();
707
+ }
708
+ timer;
709
+ queued = false;
710
+ connectedCallback() {
711
+ // A page may set `connections` before this script has run. That lands on the
712
+ // element itself and hides the setter above from then on, so it is taken off and
713
+ // given to the setter, as it would have been had the script come first.
714
+ if (Object.hasOwn(this, 'connections')) {
715
+ const early = this.connections;
716
+ delete this.connections;
717
+ this.handed = early;
718
+ }
719
+ this.present();
720
+ this.timer = setInterval(() => this.refresh(), 30000);
721
+ }
722
+ disconnectedCallback() {
723
+ clearInterval(this.timer);
724
+ quietBadge(this);
725
+ }
726
+ attributeChangedCallback() {
727
+ this.present();
728
+ }
729
+ /**
730
+ * Deferred by a microtask, so an embed that sets the backend, the connection and the
731
+ * evidence one after another is drawn once, from all three, before anything paints.
732
+ */
733
+ present() {
734
+ if (this.queued)
735
+ return;
736
+ this.queued = true;
737
+ queueMicrotask(() => {
738
+ this.queued = false;
739
+ if (!this.isConnected)
740
+ return;
741
+ const seed = this.evidence;
742
+ this.evidence = undefined;
743
+ this.refresh(validEvidence(seed) && seed.visibility === 'public' ? seed : undefined);
744
+ });
745
+ }
746
+ refresh(evidence) {
747
+ const backendUrl = this.getAttribute('backend-url'), connectionId = this.getAttribute('connection-id');
748
+ const written = this.getAttribute('connections');
749
+ const handoffUrl = this.getAttribute('handoff-url');
750
+ const arrangement = {
751
+ stacked: this.getAttribute('stacked') !== null,
752
+ peek: this.getAttribute('peek') !== 'off',
753
+ };
754
+ // The holder's own badge, where the page says so: its dialog connects another
755
+ // account, renews one and removes one, through the backend named. The host hears
756
+ // of each change in a `verily-result` event, to read its records again.
757
+ if (backendUrl && (handoffUrl !== null || this.getAttribute('connect') !== null)) {
758
+ const client = init({ backendUrl, handoffUrl: handoffUrl ?? undefined });
759
+ const changed = (detail) => this.dispatchEvent(new CustomEvent('verily-result', { detail, bubbles: true, composed: true }));
760
+ managers.set(this, {
761
+ connect: (provider) => {
762
+ void client
763
+ .openConnect(this, () => details(this), provider)
764
+ .then((result) => {
765
+ if (result.outcome === 'complete')
766
+ changed(result);
767
+ });
768
+ },
769
+ // One at a time, each told as it goes: if a later one fails, the host still
770
+ // hears of those that went and reads its records again.
771
+ remove: async (ids) => {
772
+ for (const id of ids) {
773
+ await client.disconnect(id);
774
+ changed({ outcome: 'removed', connectionId: id });
775
+ }
776
+ },
777
+ });
778
+ }
779
+ else
780
+ managers.delete(this);
781
+ if (this.handed !== undefined || written !== null) {
782
+ let records = this.handed;
783
+ if (records === undefined)
784
+ try {
785
+ records = JSON.parse(written);
786
+ }
787
+ catch {
788
+ records = undefined;
789
+ }
790
+ presentConnections(this, records, arrangement);
791
+ return;
792
+ }
793
+ const ids = (this.getAttribute('connection-ids') ?? '').split(/\s+/).filter(Boolean);
794
+ if (ids.length) {
795
+ if (backendUrl)
796
+ void init({ backendUrl }).mountBadges(this, { connectionIds: ids, ...arrangement });
797
+ return;
798
+ }
799
+ if (!connectionId) {
800
+ // Nothing is named any more, so nothing it named before is to be shown either.
801
+ forget(this);
802
+ renderBadgePending(this);
803
+ return;
804
+ }
805
+ if (backendUrl)
806
+ void init({ backendUrl }).mountBadge(this, { connectionId, evidence });
807
+ }
808
+ });
809
+ }