@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,541 @@
1
+ import { attestationLabel, externalLink, externalName, localSide, proofTitle, statusLabel, } from '../core/index.js';
2
+ import { markStyles, verificationMark } from './mark.js';
3
+ import { providerMark } from './provider-mark.js';
4
+ import { onSignatureResult, signature, standing } from './signed.js';
5
+ import { verilyLogo } from './logo.js';
6
+ import { version } from '../version.js';
7
+ const openDialogs = new WeakMap();
8
+ export const styles = `
9
+ * { box-sizing: border-box; }
10
+ dialog { width: min(460px, calc(100vw - 32px)); max-height: calc(100dvh - 40px); margin: auto; padding: 24px; border: 1px solid #dce2de; border-radius: 16px; background: #fff; color: #23312b; box-shadow: 0 24px 90px #10201935; font: 13px/1.6 system-ui, sans-serif; }
11
+ dialog::backdrop { background: #15271f66; }
12
+ header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }
13
+ h2 { margin: 0; font-size: 18px; font-weight: 650; letter-spacing: -.3px; }
14
+ button { width: 32px; height: 32px; border: 1px solid #dce2de; border-radius: 8px; background: #fff; color: #52645a; font: 20px system-ui, sans-serif; cursor: pointer; }
15
+ button:hover { background: #f2f5f1; }
16
+ a { color: #245f43; text-underline-offset: 3px; overflow-wrap: anywhere; }
17
+ a:focus-visible, button:focus-visible { outline: 2px solid #357ce5; outline-offset: 3px; }
18
+ .summary { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
19
+ .mark { width: 36px; height: 36px; flex-shrink: 0; }
20
+ .provider { width: 14px; height: 14px; }
21
+ .state { font-size: 14px; font-weight: 650; line-height: 1.4; }
22
+ .muted { color: #6b786f; font-size: 12px; }
23
+ .account { position: relative; }
24
+ .veiled > :not(.checking) { filter: blur(4px); opacity: .55; user-select: none; }
25
+ .checking { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 8px; color: #23312b; font-size: 13px; font-weight: 600; }
26
+ .spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .7s linear infinite; }
27
+ @keyframes spin { to { transform: rotate(360deg); } }
28
+ @media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }
29
+ .caution { display: inline-block; margin-left: 4px; color: #a15c00; cursor: help; vertical-align: middle; }
30
+ .caution svg { display: block; width: 12px; height: 12px; }
31
+ .bad { color: #b3261e; font-weight: 650; text-decoration: underline dotted; text-underline-offset: 3px; cursor: help; }
32
+ .account { padding: 14px 16px; border: 1px solid #e0e6df; border-radius: 10px; margin-top: 12px; }
33
+ .account h3 { display: flex; align-items: center; gap: 6px; margin: 0 0 3px; color: #6b786f; font-size: 11px; font-weight: 550; }
34
+ .account a, .account strong { font-weight: 650; font-size: 14px; }
35
+ /* The account's own name carries that weight; a link inside a line of prose does not. */
36
+ .summary a, .method a, .additional a { font: inherit; }
37
+ .reference { margin-top: 2px; }
38
+ .method { margin-top: 8px; }
39
+ .additional { margin-top: 2px; padding-left: 12px; }
40
+ .joiner { display: block; width: 20px; height: 20px; margin: 8px auto -4px; color: #90a096; }
41
+ dl { margin: 16px 0 0; padding-top: 12px; border-top: 1px solid #e5e9e3; display: grid; grid-template-columns: auto 1fr; gap: 5px 16px; font-size: 11px; }
42
+ dt { color: #6b786f; }
43
+ dd { margin: 0; text-align: right; overflow-wrap: anywhere; }
44
+ .explanation { margin-top: 16px; }
45
+ .action { width: auto; height: auto; padding: 9px 14px; font: 600 13px/1.3 system-ui, sans-serif; color: #23312b; text-align: left; }
46
+ .action.primary { border-color: #245f43; background: #245f43; color: #fff; }
47
+ .action.primary:hover { background: #1d4f37; }
48
+ .action:disabled { opacity: .6; cursor: progress; }
49
+ .action.danger:hover { border-color: #b3261e; background: #fdecea; color: #b3261e; }
50
+ .row { display: flex; justify-content: end; gap: 8px; margin-top: 16px; }
51
+ .account .row { align-items: center; justify-content: start; margin-top: 12px; }
52
+ .account .action { padding: 5px 10px; font-size: 12px; }
53
+ footer { display: flex; align-items: center; justify-content: start; gap: 6px; margin-top: 14px; color: #9aa9a0; font-size: 11px; }
54
+ .logo { display: block; height: 13px; }
55
+ ${markStyles}
56
+ `;
57
+ export function node(tag, text = '', className = '') {
58
+ const element = document.createElement(tag);
59
+ element.textContent = text;
60
+ element.className = className;
61
+ return element;
62
+ }
63
+ /**
64
+ * Points an anchor out of the dialog. Every link here opens in a new tab: the dialog is
65
+ * read against what it links to, and following one in place would close the dialog and
66
+ * take the reader off the page the pill was on. `noreferrer` keeps the opener unreachable.
67
+ */
68
+ export function outward(anchor, url) {
69
+ anchor.href = url;
70
+ anchor.rel = 'noreferrer';
71
+ anchor.target = '_blank';
72
+ return anchor;
73
+ }
74
+ /** Joins the two cards: the link itself, drawn rather than described. */
75
+ export function linkMark() {
76
+ const namespace = 'http://www.w3.org/2000/svg';
77
+ const svg = document.createElementNS(namespace, 'svg');
78
+ svg.setAttribute('viewBox', '0 0 24 24');
79
+ svg.setAttribute('fill', 'none');
80
+ svg.setAttribute('class', 'joiner');
81
+ svg.setAttribute('aria-hidden', 'true');
82
+ svg.setAttribute('focusable', 'false');
83
+ for (const d of [
84
+ 'M10.5 7.5 13 5a4.95 4.95 0 0 1 7 7l-2.5 2.5',
85
+ 'M13.5 16.5 11 19a4.95 4.95 0 0 1-7-7l2.5-2.5',
86
+ 'M9 15l6-6',
87
+ ]) {
88
+ const path = document.createElementNS(namespace, 'path');
89
+ path.setAttribute('d', d);
90
+ path.setAttribute('stroke', 'currentColor');
91
+ path.setAttribute('stroke-width', '2');
92
+ path.setAttribute('stroke-linecap', 'round');
93
+ svg.append(path);
94
+ }
95
+ return svg;
96
+ }
97
+ /**
98
+ * Names how one side was established, inside that side's card. A method that published a
99
+ * proof is itself the link to it, so a reader can check the claim without taking this
100
+ * backend's word for it, and several proofs on one card each say which one they open.
101
+ * The methods are named, never ranked: which ones convince is the reader's call.
102
+ * Additional methods sit beneath the main one, indented under it.
103
+ */
104
+ function attestationNote(attestation, names, additional = false) {
105
+ const label = attestationLabel(attestation.method, names);
106
+ if (!label)
107
+ return [];
108
+ const note = node('div', additional ? '+ ' : '', `muted ${additional ? 'additional' : 'method'}`);
109
+ // Evidence is rejected before it reaches here unless every artifact url is http(s).
110
+ if (!attestation.artifactUrl) {
111
+ note.append(document.createTextNode(label));
112
+ return [note];
113
+ }
114
+ const proof = outward(node('a', label), attestation.artifactUrl);
115
+ proof.title = proofTitle(attestation, moment);
116
+ note.append(proof);
117
+ return [note];
118
+ }
119
+ /** Seconds are noise on a record measured in days, and every time here reads the same way. */
120
+ function moment(time) {
121
+ return new Date(time).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
122
+ }
123
+ /**
124
+ * Both sides of a link render as the same card, so a reader can see it is a pair.
125
+ * `extra` carries evidence belonging to that side alone: a provider authenticates and
126
+ * expires on its own terms, and a second provider on the same subject would differ.
127
+ */
128
+ export function accountCard(heading, name, reference, url, ...extra) {
129
+ const card = node('section', '', 'account');
130
+ const title = node('h3');
131
+ const value = node(url ? 'a' : 'strong', name);
132
+ title.append(...heading);
133
+ if (value instanceof HTMLAnchorElement)
134
+ outward(value, url);
135
+ card.append(title, value);
136
+ if (reference)
137
+ card.append(node('div', reference, 'muted reference'));
138
+ card.append(...extra);
139
+ return card;
140
+ }
141
+ /**
142
+ * The links removing an account would remove. The record shown may itself be revoked while
143
+ * older ones of the account are not, and those must stay removable, so this is not read
144
+ * from the status on the card.
145
+ */
146
+ const removable = (evidence) => evidence.links ?? (evidence.status === 'revoked' ? [] : [evidence.id]);
147
+ /**
148
+ * Under a holder's own account: renewing it, which is showing the same account again, and
149
+ * removing it, which asks once more before it does. A removed link has nothing left to do.
150
+ */
151
+ function manageRow(evidence, manage) {
152
+ const row = node('div', '', 'row');
153
+ const act = (label, danger = false) => {
154
+ const control = node('button', label, danger ? 'action danger' : 'action');
155
+ control.type = 'button';
156
+ return control;
157
+ };
158
+ const offer = (note = '') => {
159
+ const renew = act('Renew');
160
+ const remove = act('Remove', true);
161
+ renew.onclick = () => manage.connect(evidence.provider);
162
+ remove.onclick = confirm;
163
+ row.replaceChildren(renew, remove, ...(note ? [node('span', note, 'muted')] : []));
164
+ };
165
+ const confirm = () => {
166
+ const cancel = act('Cancel');
167
+ const sure = act('Remove', true);
168
+ cancel.onclick = () => offer();
169
+ sure.onclick = async () => {
170
+ cancel.disabled = sure.disabled = true;
171
+ try {
172
+ await manage.remove(removable(evidence));
173
+ }
174
+ catch {
175
+ offer('That did not go through.');
176
+ }
177
+ };
178
+ row.replaceChildren(node('span',
179
+ // Removing stops new signed records. One already saved goes on checking.
180
+ evidence.signedUrl
181
+ ? 'Remove this link? Signed records already saved still check.'
182
+ : 'Remove this link?', 'muted'), cancel, sure);
183
+ cancel.focus();
184
+ };
185
+ offer();
186
+ return row;
187
+ }
188
+ /**
189
+ * Checks every record a card speaks for. A card may be drawn from several records of one
190
+ * account, and what it says of a signature answers for all of them.
191
+ */
192
+ function signatures(evidence) {
193
+ return Promise.all((evidence.sources ?? [evidence]).map((source) => source.signedUrl === undefined
194
+ ? Promise.resolve({ state: 'unchecked', why: 'unreadable' })
195
+ : signature(source)));
196
+ }
197
+ /**
198
+ * Says a record is signed, in a word beside its verifier. The word opens the verifier's
199
+ * own page for the record at what it says of the signature, where the signed record is to be
200
+ * had: nothing is downloaded from the card. The signed record is checked here against the
201
+ * verifier's keys: a tick once it has checked, and a warning in the word's place if it
202
+ * fails, the one thing on a card that is a warning.
203
+ */
204
+ function signedMark(evidence, later) {
205
+ const mark = node('span');
206
+ const link = outward(node('a', 'signed'), `${evidence.evidenceUrl}#signed`);
207
+ const see = 'See the signature (opens in a new tab)';
208
+ const settle = (results) => {
209
+ const first = results[0];
210
+ if (results.some((result) => result.state === 'invalid')) {
211
+ const warning = node('span', 'invalid signature', 'bad');
212
+ warning.setAttribute('role', 'alert');
213
+ // What it means, for a reader who has never met a signature, kept off the card.
214
+ warning.title = `This record does not match the signature ${evidence.verifierName} put on it, so it may have been altered. Do not rely on it.`;
215
+ mark.replaceChildren(document.createTextNode(' · '), warning);
216
+ return;
217
+ }
218
+ if (first?.state === 'valid' && results.every((result) => result.state === 'valid')) {
219
+ link.textContent = 'signed ✓';
220
+ // Names where the keys came from, which is what the tick answers for.
221
+ link.title = `Signature checked in this browser against the keys ${new URL(evidence.evidenceUrl).host} publishes (key ${first.keyId}). ${see}`;
222
+ mark.replaceChildren(document.createTextNode(' · '), link);
223
+ return;
224
+ }
225
+ // No verdict was reached. The record is signed and says so, with a warning beside the
226
+ // word in place of a tick, and why is said on hover. A check that only ran out of time
227
+ // or could not read what it needed is made again when the dialog next reads.
228
+ const why = results.find((result) => result.state === 'unchecked');
229
+ const reason = why?.state === 'unchecked' && why.why === 'timeout'
230
+ ? 'Signature check timed out, retrying.'
231
+ : `The signature could not be read${why ? ', retrying' : ''}.`;
232
+ const caution = node('span', '', 'caution');
233
+ caution.title = reason;
234
+ caution.setAttribute('role', 'img');
235
+ caution.setAttribute('aria-label', reason);
236
+ caution.append(cautionMark());
237
+ link.textContent = 'signed';
238
+ link.title = `${reason} ${see}`;
239
+ mark.replaceChildren(document.createTextNode(' · '), link, caution);
240
+ if (why)
241
+ later(() => void signatures(evidence).then(settle));
242
+ };
243
+ // Nothing is said until the check comes back: the card is veiled until then.
244
+ void signatures(evidence).then(settle);
245
+ return mark;
246
+ }
247
+ /** A small warning triangle, drawn so it looks the same wherever the dialog is shown. */
248
+ function cautionMark() {
249
+ const namespace = 'http://www.w3.org/2000/svg';
250
+ const svg = document.createElementNS(namespace, 'svg');
251
+ svg.setAttribute('viewBox', '0 0 16 16');
252
+ svg.setAttribute('fill', 'none');
253
+ svg.setAttribute('aria-hidden', 'true');
254
+ svg.setAttribute('focusable', 'false');
255
+ for (const d of ['M8 2.2 14.3 13.3H1.7Z', 'M8 6.6v3.2', 'M8 11.6v.1']) {
256
+ const path = document.createElementNS(namespace, 'path');
257
+ path.setAttribute('d', d);
258
+ path.setAttribute('stroke', 'currentColor');
259
+ path.setAttribute('stroke-width', '1.5');
260
+ path.setAttribute('stroke-linecap', 'round');
261
+ path.setAttribute('stroke-linejoin', 'round');
262
+ svg.append(path);
263
+ }
264
+ return svg;
265
+ }
266
+ /**
267
+ * One linked account: its state, how it was shown and when, on a card of its own. A
268
+ * subject with several accounts gets one of these each, since a provider authenticates and
269
+ * expires on its own terms and none of that may be read across to another's card.
270
+ */
271
+ function externalCard(evidence, manage, later = () => { }) {
272
+ const current = evidence.status === 'verified' && evidence.expiresAt > Date.now();
273
+ const status = statusLabel(evidence, Date.now());
274
+ const provider = evidence.providerName;
275
+ const summary = node('div', '', 'summary');
276
+ const copy = node('div');
277
+ // The verifier is named and reachable: this record on the verifier's own domain is
278
+ // what a reader checks the claim against, and a plain click on the pill opens this
279
+ // dialog rather than that page, so the way there belongs here.
280
+ const attribution = node('div', 'via: ', 'muted');
281
+ // An unlisted record has no page a reader could open, so the verifier is named and
282
+ // not linked: a link there would lead to a page that refuses them.
283
+ if (evidence.visibility === 'public') {
284
+ const verifier = outward(node('a', evidence.verifierName), evidence.evidenceUrl);
285
+ verifier.title = `View this record at ${evidence.verifierName} (opens in a new tab)`;
286
+ attribution.append(verifier);
287
+ }
288
+ else
289
+ attribution.append(document.createTextNode(evidence.verifierName));
290
+ if (evidence.signedUrl)
291
+ attribution.append(signedMark(evidence, later));
292
+ copy.append(node('div', status, 'state'), attribution);
293
+ summary.append(verificationMark(current ? 'current' : 'inactive'), copy);
294
+ const dates = node('dl');
295
+ const dateRows = [['Approved', evidence.approvedAt]];
296
+ if (evidence.status === 'revoked') {
297
+ if (evidence.revokedAt !== undefined)
298
+ dateRows.push(['Revoked on', evidence.revokedAt]);
299
+ }
300
+ else {
301
+ // A proof that has gone unread has not reached its expiry, so it still reads forward.
302
+ dateRows.push([
303
+ evidence.expiresAt > Date.now() ? 'Valid until' : 'Expired on',
304
+ evidence.expiresAt,
305
+ ]);
306
+ }
307
+ // When an artifact was last read belongs with the other times, not inside a sentence.
308
+ // Only artifact methods drift: a sign-in is established once and does not go stale.
309
+ const main = evidence.attestations.external[0];
310
+ if (main.artifactUrl)
311
+ dateRows.push(['Last checked', main.confirmedAt]);
312
+ for (const [label, time] of dateRows) {
313
+ dates.append(node('dt', label), node('dd', moment(time)));
314
+ }
315
+ const names = { site: evidence.siteName, provider };
316
+ const logo = providerMark(evidence.provider, evidence.attestations.external[0].method);
317
+ const card = accountCard(
318
+ // The mark and the name, or just the name. A mark that falls back to writing the name
319
+ // would print it twice here, since this heading writes it either way.
320
+ logo ? [logo, document.createTextNode(provider)] : [document.createTextNode(provider)], externalName(evidence.external),
321
+ // A mailbox's address is its name already, so there is no second identifier.
322
+ evidence.external.kind === 'mailbox' ? undefined : evidence.external.id, externalLink(evidence.external),
323
+ // Status first, then how it was shown, then when. The proof explains the state
324
+ // above it, so it cannot sit before that state has been given.
325
+ summary, ...attestationNote(main, names), ...evidence.attestations.external.slice(1).flatMap((a) => attestationNote(a, names, true)), dates, ...(manage && removable(evidence).length ? [manageRow(evidence, manage)] : []));
326
+ // A signed record is not read, or acted on, before its signature has been checked: until
327
+ // the check comes back the whole card is veiled and out of reach, with a word over it
328
+ // saying why. A record handed over altered is then never read as it was handed over.
329
+ if (evidence.signedUrl) {
330
+ const veiled = [...card.children];
331
+ const checking = node('div', '', 'checking');
332
+ checking.setAttribute('role', 'status');
333
+ checking.append(node('span', '', 'spinner'), document.createTextNode('Checking signature…'));
334
+ card.className = 'account veiled';
335
+ for (const part of veiled)
336
+ part.inert = true;
337
+ card.append(checking);
338
+ void signatures(evidence).then(() => {
339
+ card.className = 'account';
340
+ for (const part of veiled)
341
+ part.inert = false;
342
+ checking.remove();
343
+ });
344
+ }
345
+ return card;
346
+ }
347
+ /** Under the holder's accounts: the way to connect another, ahead of the closing note. */
348
+ function addRow(manage) {
349
+ const add = node('button', 'Add account', 'action');
350
+ const row = node('div', '', 'row');
351
+ add.type = 'button';
352
+ add.onclick = () => manage.connect();
353
+ row.append(add);
354
+ return row;
355
+ }
356
+ /**
357
+ * The subject once, then each account linked to it on its own card, in the order they
358
+ * were connected. Every record given is of the one subject, which the caller has checked.
359
+ */
360
+ function render(content, records, manage, later) {
361
+ const [first] = records;
362
+ // Each card says what it is. Without that the pair is two unlabelled boxes.
363
+ const local = localSide(first.local, first.siteName);
364
+ const localCard = accountCard([document.createTextNode(local.heading)], local.value,
365
+ // The heading names the site and the label names the subject, so the site's own
366
+ // reference adds a third line saying the same thing. The provider id on the other
367
+ // card stays: that one is the provider's identifier, not the site's own wording.
368
+ undefined, first.local.profileUrl, ...attestationNote(first.attestations.local, {
369
+ site: first.siteName,
370
+ provider: first.providerName,
371
+ }));
372
+ content.replaceChildren(localCard, linkMark(), ...records.map((record) => externalCard(record, manage, later)), ...(manage ? [addRow(manage)] : []),
373
+ // Nothing below the cards may name a provider. Approval, method and dates belong to
374
+ // the card they came from, and a second provider on this subject gets its own card.
375
+ node('p', 'Verification does not guarantee legal identity, trustworthiness, or permanent ownership.', 'muted explanation'));
376
+ }
377
+ /**
378
+ * Fetch fresh, permitted evidence; native dialog supplies focus containment and Escape
379
+ * dismissal. `opened` is the record the opener already holds: the dialog is drawn from it
380
+ * before it is shown, so it appears at the size it will keep rather than growing into its
381
+ * own contents, and the check that follows either leaves it alone or replaces it.
382
+ */
383
+ export function openEvidenceDialog(opener, load, opened,
384
+ /**
385
+ * Turns the records held into the cards drawn, each time they are drawn. The dialog
386
+ * keeps the records as they were read and arranges them afresh at every deadline: which
387
+ * record speaks for an account depends on which are still good, and that changes with
388
+ * the clock, not only with what is read.
389
+ */
390
+ arrange = (records) => records,
391
+ /**
392
+ * Given where whoever is looking holds these accounts: each then has its renewal and
393
+ * removal under it, and the dialog offers to connect another.
394
+ */
395
+ manage) {
396
+ const existing = openDialogs.get(opener);
397
+ if (existing?.open) {
398
+ existing.focus();
399
+ return;
400
+ }
401
+ const host = document.createElement('div');
402
+ const root = host.attachShadow({ mode: 'open' });
403
+ const sheet = new CSSStyleSheet();
404
+ sheet.replaceSync(styles);
405
+ root.adoptedStyleSheets = [sheet];
406
+ const dialog = node('dialog');
407
+ const heading = node('h2', 'Verification details');
408
+ const close = node('button', '×');
409
+ const header = node('header');
410
+ // What drew the record, under it. The record is about the pair, not about us.
411
+ const stamp = node('footer');
412
+ const content = node('div', 'Checking verification…');
413
+ heading.id = 'verily-dialog-title';
414
+ dialog.setAttribute('aria-labelledby', heading.id);
415
+ close.type = 'button';
416
+ close.setAttribute('aria-label', 'Close verification details');
417
+ content.setAttribute('aria-live', 'polite');
418
+ stamp.append(verilyLogo(), node('span', version));
419
+ header.append(heading, close);
420
+ dialog.append(header, content, stamp);
421
+ /** Links removed from this dialog, shown as removed before the page hands over new records. */
422
+ const removed = new Map();
423
+ let held;
424
+ const actions = manage && {
425
+ // The other dialog opens over this one before this one goes, so the page behind is
426
+ // never uncovered between the two.
427
+ connect(provider) {
428
+ manage.connect(provider);
429
+ dialog.close();
430
+ },
431
+ async remove(ids) {
432
+ await manage.remove(ids);
433
+ for (const id of ids)
434
+ removed.set(id, Date.now());
435
+ if (held && dialog.open)
436
+ draw(held);
437
+ },
438
+ };
439
+ root.append(dialog);
440
+ document.body.append(host);
441
+ openDialogs.set(opener, dialog);
442
+ let refreshing = false;
443
+ let expiryTimer;
444
+ let drawn;
445
+ /** What the cards on screen have asked to try again at the next read. */
446
+ const retries = new Set();
447
+ /** Redraws only for a record that reads differently from the one already on screen. */
448
+ const draw = (given) => {
449
+ held = given;
450
+ const records = arrange([given]
451
+ .flat()
452
+ .map((e) => removed.has(e.id) && e.status !== 'revoked'
453
+ ? { ...e, status: 'revoked', revokedAt: removed.get(e.id) }
454
+ : standing(e)));
455
+ const key = JSON.stringify(records.map((e) => [e, statusLabel(e, Date.now())]));
456
+ if (key === drawn)
457
+ return;
458
+ drawn = key;
459
+ // The cards are new, and what the old ones were waiting to try again went with them.
460
+ retries.clear();
461
+ render(content, records, actions, (retry) => retries.add(retry));
462
+ };
463
+ /**
464
+ * Redraws at the moment the next record runs out, where that comes before the next check
465
+ * would notice, and then waits on the one after. Each account reaches its own deadline,
466
+ * so with several the first must not be the only one that is watched.
467
+ */
468
+ const watch = (given) => {
469
+ clearTimeout(expiryTimer);
470
+ const remaining = Math.min(...[given]
471
+ .flat()
472
+ .filter((e) => e.status === 'verified' && e.expiresAt > Date.now())
473
+ .map((e) => e.expiresAt - Date.now()));
474
+ if (remaining > 30000)
475
+ return;
476
+ expiryTimer = setTimeout(() => {
477
+ if (!dialog.open)
478
+ return;
479
+ draw(given);
480
+ watch(given);
481
+ }, remaining);
482
+ };
483
+ const refresh = async () => {
484
+ if (refreshing)
485
+ return;
486
+ refreshing = true;
487
+ try {
488
+ const evidence = await load();
489
+ if (!dialog.open)
490
+ return;
491
+ draw(evidence);
492
+ watch(evidence);
493
+ }
494
+ catch {
495
+ clearTimeout(expiryTimer);
496
+ drawn = undefined;
497
+ if (dialog.open)
498
+ content.replaceChildren(node('p', 'Verification unavailable. Please close this dialog and try again.'));
499
+ }
500
+ finally {
501
+ refreshing = false;
502
+ }
503
+ };
504
+ const interval = setInterval(() => {
505
+ // A signature that could not be checked last time is checked again with each read.
506
+ for (const retry of [...retries]) {
507
+ retries.delete(retry);
508
+ retry();
509
+ }
510
+ void refresh();
511
+ }, 30000);
512
+ // A check that comes back while the dialog is open may change what a card is drawn from.
513
+ const unwatch = onSignatureResult(() => {
514
+ if (held && dialog.open)
515
+ draw(held);
516
+ });
517
+ close.addEventListener('click', () => dialog.close());
518
+ dialog.addEventListener('click', (event) => {
519
+ const bounds = dialog.getBoundingClientRect();
520
+ if (event.target === dialog &&
521
+ (event.clientX < bounds.left ||
522
+ event.clientX > bounds.right ||
523
+ event.clientY < bounds.top ||
524
+ event.clientY > bounds.bottom))
525
+ dialog.close();
526
+ });
527
+ dialog.addEventListener('close', () => {
528
+ clearInterval(interval);
529
+ clearTimeout(expiryTimer);
530
+ unwatch();
531
+ openDialogs.delete(opener);
532
+ host.remove();
533
+ opener.firstElementChild?.shadowRoot
534
+ ?.querySelector('a, button, [tabindex]')
535
+ ?.focus();
536
+ }, { once: true });
537
+ if (opened && [opened].flat().length)
538
+ draw(opened);
539
+ dialog.showModal();
540
+ void refresh();
541
+ }
@@ -0,0 +1,59 @@
1
+ import type { Evidence } from '../core/index.js';
2
+ export type { Evidence } from '../core/index.js';
3
+ export interface Result {
4
+ outcome: 'complete' | 'cancelled' | 'failed';
5
+ connectionId?: string;
6
+ }
7
+ /** How a pill of several accounts names them, where the page that placed it says. */
8
+ export interface Arrangement {
9
+ /** Every account on a row of its own, in a pill as tall as its rows. */
10
+ stacked?: boolean;
11
+ /**
12
+ * Whether a short pill opens a panel naming its accounts while a pointer rests on it or
13
+ * the keyboard is on it. On unless this is false. A stacked pill has nothing to open.
14
+ */
15
+ peek?: boolean;
16
+ }
17
+ /**
18
+ * Presents records the page already holds, without asking any backend for them. This is
19
+ * how a site shows links only it can read: its own backend read them with its key and put
20
+ * them in the page for the readers it chose. Nothing here can check them again, so the
21
+ * page is what vouches for them, and an unlisted one is drawn with no link to follow.
22
+ */
23
+ export declare function presentConnections(element: HTMLElement, records: unknown, arrangement?: Arrangement): void;
24
+ export declare function init({ backendUrl, handoffUrl }: {
25
+ backendUrl: string;
26
+ handoffUrl?: string;
27
+ }): {
28
+ /**
29
+ * Opens the connect dialog over the current page. Resolves when it closes, with how
30
+ * the last attempt in it ended. Call from a click: a sign-in method opens a window.
31
+ */
32
+ openConnect(opener?: HTMLElement, back?: () => void, provider?: string): Promise<Result>;
33
+ /**
34
+ * Draws the pill that opens the connect dialog. The host receives a `verily-result`
35
+ * event, whose detail is the Result, whenever the dialog closes on a new connection.
36
+ */
37
+ mountConnect(element: HTMLElement): void;
38
+ connect({ provider }: {
39
+ provider: string;
40
+ }): Promise<Result>;
41
+ /** The site's current public connections, so an embed need not hardcode ids. */
42
+ listPublished(): Promise<Evidence[]>;
43
+ getConnection(id: string): Promise<Evidence>;
44
+ mountBadge(element: HTMLElement, { connectionId, evidence }: {
45
+ connectionId: string;
46
+ evidence?: Evidence;
47
+ }): Promise<void>;
48
+ /**
49
+ * Several of one subject's public connections as a single pill. One that cannot be
50
+ * read is left out, since the rest are still true without it; with none left, or with
51
+ * records of more than one subject, the pill says it is unavailable.
52
+ */
53
+ mountBadges(element: HTMLElement, { connectionIds, ...arrangement }: {
54
+ connectionIds: string[];
55
+ } & Arrangement): Promise<void>;
56
+ disconnect(id: string): Promise<unknown>;
57
+ issueShare: (id: string) => Promise<unknown>;
58
+ revokeShare: (id: string) => Promise<unknown>;
59
+ };