@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,514 @@
1
+ import { externalLink, externalName } from '../core/index.js';
2
+ import { accountCard, linkMark, node, outward, styles } from './evidence-dialog.js';
3
+ import { providerMark } from './provider-mark.js';
4
+ import { verilyLogo } from './logo.js';
5
+ import { version } from '../version.js';
6
+ const connectStyles = `
7
+ ${styles}
8
+ .steps > p:first-child { margin-top: 0; }
9
+ .choices { display: grid; gap: 8px; margin-top: 16px; }
10
+ /* With a way back beside it, the heading sits by that and not in the middle. */
11
+ header.backed h2 { margin-right: auto; }
12
+ .choices .action { display: flex; align-items: center; gap: 8px; }
13
+ .choices .more { margin-left: auto; color: #6b786f; font-weight: 500; font-size: 12px; }
14
+ .row.start { justify-content: start; }
15
+ /* The block scrolls and its frame does not, so the copy button stays in its corner. */
16
+ .code { position: relative; margin: 10px 0; }
17
+ pre { margin: 0; padding: 10px 64px 10px 12px; border: 1px solid #e0e6df; border-radius: 8px; background: #f6f8f5; font: 12px/1.5 ui-monospace, monospace; white-space: pre; overflow-x: auto; }
18
+ .code .action { position: absolute; top: 6px; right: 6px; padding: 3px 8px; font-size: 11px; }
19
+ label { display: grid; gap: 4px; margin-top: 12px; font-weight: 600; }
20
+ input[type=url], input[type=email], input[type=tel], input[type=text], textarea { width: 100%; padding: 8px 10px; border: 1px solid #cfd8d2; border-radius: 8px; font: 13px/1.4 system-ui, sans-serif; color: inherit; background: #fff; }
21
+ textarea { font-family: ui-monospace, monospace; font-size: 12px; resize: vertical; }
22
+ fieldset { margin: 16px 0 0; padding: 10px 14px; border: 1px solid #e0e6df; border-radius: 10px; }
23
+ legend { padding: 0 4px; color: #6b786f; font-size: 11px; font-weight: 550; }
24
+ fieldset label { display: flex; align-items: baseline; gap: 8px; margin-top: 4px; font-weight: 500; }
25
+ .error { color: #9b2c33; }
26
+ `;
27
+ /** Puts a copy control on a block the holder must reproduce exactly, as the flow page does. */
28
+ function codeBlock(code) {
29
+ const block = node('div', '', 'code');
30
+ const scroller = node('pre');
31
+ const text = node('code', code);
32
+ const copy = node('button', 'Copy', 'action');
33
+ copy.type = 'button';
34
+ copy.setAttribute('aria-live', 'polite');
35
+ copy.addEventListener('click', async () => {
36
+ try {
37
+ await navigator.clipboard.writeText(code);
38
+ copy.textContent = 'Copied';
39
+ }
40
+ catch {
41
+ // No clipboard: select it, one keystroke from copied, rather than claim it worked.
42
+ const range = document.createRange();
43
+ range.selectNodeContents(text);
44
+ getSelection()?.removeAllRanges();
45
+ getSelection()?.addRange(range);
46
+ copy.textContent = 'Selected';
47
+ }
48
+ setTimeout(() => (copy.textContent = 'Copy'), 2000);
49
+ });
50
+ scroller.append(text);
51
+ block.append(scroller, copy);
52
+ return block;
53
+ }
54
+ /** A paragraph with links in it. A link that is not http(s) is written as its text alone. */
55
+ function paragraph(pieces) {
56
+ const element = node('p');
57
+ for (const piece of pieces) {
58
+ if (typeof piece === 'string')
59
+ element.append(document.createTextNode(piece));
60
+ else if (httpUrl(piece.href))
61
+ element.append(outward(node('a', piece.text), piece.href));
62
+ else
63
+ element.append(document.createTextNode(piece.text));
64
+ }
65
+ return element;
66
+ }
67
+ function httpUrl(value) {
68
+ try {
69
+ return ['http:', 'https:'].includes(new URL(value).protocol);
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
75
+ function button(label, primary = false) {
76
+ const element = node('button', label, primary ? 'action primary' : 'action');
77
+ element.type = 'button';
78
+ return element;
79
+ }
80
+ const openDialogs = new WeakMap();
81
+ /**
82
+ * Walks the holder through connecting an account without leaving the page: pick a method,
83
+ * prove it, approve it. A sign-in method still needs the provider's own page, so that one
84
+ * step runs in a small window, and the dialog picks the flow back up once it returns.
85
+ * Resolves when the dialog closes, with how the last flow ended.
86
+ */
87
+ export function openConnectDialog(opener, api,
88
+ /**
89
+ * Given when this dialog was opened from another, which it stands in place of: the
90
+ * header then has a way back, and `back` is called once this dialog has closed by it.
91
+ */
92
+ back,
93
+ /** The provider to start on, where the holder already said which account they mean. */
94
+ provider) {
95
+ const existing = openDialogs.get(opener);
96
+ if (existing?.open) {
97
+ existing.focus();
98
+ return Promise.resolve({ outcome: 'cancelled' });
99
+ }
100
+ const host = document.createElement('div');
101
+ const root = host.attachShadow({ mode: 'open' });
102
+ const sheet = new CSSStyleSheet();
103
+ sheet.replaceSync(connectStyles);
104
+ root.adoptedStyleSheets = [sheet];
105
+ const dialog = node('dialog');
106
+ const heading = node('h2', 'Verify an account');
107
+ const close = node('button', '×');
108
+ const header = node('header');
109
+ const stamp = node('footer');
110
+ const content = node('div', 'Loading…', 'steps');
111
+ heading.id = 'verily-connect-title';
112
+ dialog.setAttribute('aria-labelledby', heading.id);
113
+ close.type = 'button';
114
+ close.setAttribute('aria-label', 'Close');
115
+ content.setAttribute('aria-live', 'polite');
116
+ stamp.append(verilyLogo(), node('span', version));
117
+ header.append(heading, close);
118
+ dialog.append(header, content, stamp);
119
+ root.append(dialog);
120
+ document.body.append(host);
121
+ openDialogs.set(opener, dialog);
122
+ let returning = false;
123
+ if (back) {
124
+ const before = node('button', '‹');
125
+ before.type = 'button';
126
+ before.setAttribute('aria-label', 'Back');
127
+ header.className = 'backed';
128
+ header.replaceChildren(before, heading, close);
129
+ before.onclick = () => {
130
+ returning = true;
131
+ dialog.close();
132
+ };
133
+ }
134
+ let outcome = { outcome: 'cancelled' };
135
+ let methods;
136
+ let group = provider;
137
+ let popup = null;
138
+ let poll;
139
+ /** An approval sent and not yet answered: the dialog's result waits for it. */
140
+ let approving;
141
+ /**
142
+ * Which step is on screen. Anything asked for on an earlier step is ignored when it
143
+ * answers, so an abandoned flow can never draw over the one that replaced it.
144
+ */
145
+ let step = 0;
146
+ /** Leaves the current step, and stops watching a sign-in window. */
147
+ const leave = () => {
148
+ step++;
149
+ clearInterval(poll);
150
+ poll = undefined;
151
+ };
152
+ /** Leaves the current step, and shuts a sign-in window if one is still up. */
153
+ const settle = () => {
154
+ leave();
155
+ popup?.close();
156
+ popup = null;
157
+ };
158
+ /** Whether the step that asked for something is still the one on screen. */
159
+ const current = (asked) => asked === step && dialog.open;
160
+ const failure = (message) => {
161
+ settle();
162
+ const again = button('Try again', true);
163
+ again.onclick = () => void choose();
164
+ content.replaceChildren(node('p', message, 'error'), row(again));
165
+ };
166
+ const row = (...buttons) => {
167
+ const element = node('div', '', 'row');
168
+ element.append(...buttons);
169
+ return element;
170
+ };
171
+ /**
172
+ * Runs one request, holding the button that asked for it until it answers, and hands
173
+ * the answer on only if the holder is still on the step that asked.
174
+ */
175
+ const busy = async (control, request, then) => {
176
+ const asked = step;
177
+ control.disabled = true;
178
+ try {
179
+ const value = await request();
180
+ if (current(asked))
181
+ then(value);
182
+ }
183
+ catch {
184
+ if (current(asked))
185
+ failure('That did not go through. Please try again.');
186
+ }
187
+ finally {
188
+ control.disabled = false;
189
+ }
190
+ };
191
+ const subjectCard = (subject) => accountCard([document.createTextNode(subject.heading)], subject.value, undefined, subject.profileUrl);
192
+ /**
193
+ * The methods on offer. A provider shown more than one way is one button for that
194
+ * provider, which opens onto its own methods; the rest stand on their own. `group` is
195
+ * remembered, so backing out of a method returns to the list it was picked from.
196
+ */
197
+ async function choose() {
198
+ settle();
199
+ const asked = step;
200
+ try {
201
+ methods ??= await api.methods();
202
+ }
203
+ catch {
204
+ if (!current(asked))
205
+ return;
206
+ content.replaceChildren(node('p', 'Sign in to this site to verify an account, then try again.', 'error'));
207
+ return;
208
+ }
209
+ if (!current(asked))
210
+ return;
211
+ const all = methods.methods;
212
+ // A provider asked for that this holder is not offered leaves every option open.
213
+ if (group !== undefined && !all.some((m) => m.provider === group))
214
+ group = undefined;
215
+ const count = (provider) => all.filter((m) => m.provider === provider).length;
216
+ const list = node('div', '', 'choices');
217
+ const drawn = new Set();
218
+ for (const choice of all) {
219
+ if (group !== undefined && choice.provider !== group)
220
+ continue;
221
+ if (group === undefined && count(choice.provider) > 1) {
222
+ if (drawn.has(choice.provider))
223
+ continue;
224
+ drawn.add(choice.provider);
225
+ const open = labelled(choice.provider, choice.name);
226
+ open.append(node('span', `${count(choice.provider)} ways ›`, 'more'));
227
+ open.setAttribute('aria-label', `${choice.name}: ${count(choice.provider)} ways`);
228
+ open.onclick = () => {
229
+ group = choice.provider;
230
+ void choose();
231
+ };
232
+ list.append(open);
233
+ continue;
234
+ }
235
+ const control = labelled(choice.provider, choice.action, choice.method);
236
+ control.onclick = () => {
237
+ // Opened in the click itself, or a browser would block it; pointed at the
238
+ // provider once the flow exists.
239
+ if (choice.method === 'oauth') {
240
+ popup = window.open('about:blank', '_blank', 'popup,width=600,height=750');
241
+ if (!popup) {
242
+ failure('Allow pop-ups for this site to sign in, then try again.');
243
+ return;
244
+ }
245
+ }
246
+ void busy(control, () => api.start(choice.provider, choice.method), show);
247
+ };
248
+ list.append(control);
249
+ }
250
+ const name = all.find((m) => m.provider === group)?.name;
251
+ const parts = [
252
+ subjectCard(methods.local),
253
+ node('p', name
254
+ ? `Choose how to show you control your ${name} account.`
255
+ : 'Choose how to show you control the other account.', 'explanation'),
256
+ list,
257
+ ];
258
+ if (group !== undefined) {
259
+ const back = button('‹ All options');
260
+ back.onclick = () => {
261
+ group = undefined;
262
+ void choose();
263
+ };
264
+ parts.push(Object.assign(row(back), { className: 'row start' }));
265
+ }
266
+ content.replaceChildren(...parts);
267
+ list.querySelector('button')?.focus();
268
+ }
269
+ /** A choice button: the provider's mark where it has one, then what the button does. */
270
+ function labelled(provider, text, method) {
271
+ const control = button('');
272
+ const logo = providerMark(provider, method);
273
+ if (logo)
274
+ control.append(logo);
275
+ control.append(node('span', text));
276
+ return control;
277
+ }
278
+ /** Draws whichever step the flow is at. */
279
+ function show(flow) {
280
+ if (flow.phase === 'pending' && flow.authorizationUrl) {
281
+ leave();
282
+ return signIn(flow);
283
+ }
284
+ settle();
285
+ if (flow.phase === 'pending' && flow.instructions)
286
+ return publish(flow);
287
+ if (flow.phase === 'pending' && flow.code)
288
+ return mailed(flow);
289
+ if (flow.phase === 'approval')
290
+ return approve(flow);
291
+ if (flow.phase === 'complete') {
292
+ outcome = { outcome: 'complete', connectionId: flow.connectionId };
293
+ return done();
294
+ }
295
+ if (flow.phase === 'cancelled')
296
+ return void choose();
297
+ failure(flow.reason ? `Verification failed: ${flow.reason}.` : 'Verification failed.');
298
+ }
299
+ /**
300
+ * Sends the sign-in window to the provider and watches the flow until it comes back.
301
+ * The flow itself says when it has, so no message from the window is needed or trusted.
302
+ */
303
+ function signIn(flow) {
304
+ if (!popup || popup.closed)
305
+ return failure('The sign-in window was closed.');
306
+ if (api.enter)
307
+ api.enter(popup, flow);
308
+ else
309
+ popup.location.href = flow.authorizationUrl;
310
+ const cancel = button('Cancel');
311
+ cancel.onclick = () => void choose();
312
+ content.replaceChildren(node('p', `Continue in the ${flow.provider.name} window that opened.`), row(cancel));
313
+ const asked = step;
314
+ poll = setInterval(async () => {
315
+ const closed = !popup || popup.closed;
316
+ try {
317
+ const now = await api.read(flow.id);
318
+ if (!current(asked))
319
+ return;
320
+ if (!['pending', 'exchanging'].includes(now.phase))
321
+ show(now);
322
+ else if (closed)
323
+ void choose();
324
+ }
325
+ catch {
326
+ if (current(asked) && closed)
327
+ failure('Verification failed.');
328
+ }
329
+ }, 1000);
330
+ }
331
+ function publish(flow) {
332
+ const input = flow.artifact === 'document'
333
+ ? node('textarea')
334
+ : Object.assign(node('input'), { type: 'url' });
335
+ input.name = 'artifact';
336
+ input.required = true;
337
+ if (input instanceof HTMLTextAreaElement)
338
+ input.rows = 10;
339
+ const label = node('label', flow.field ?? 'Your proof');
340
+ label.append(input);
341
+ const check = button('Check my proof', true);
342
+ const back = button('Back');
343
+ back.onclick = () => void choose();
344
+ check.onclick = () => {
345
+ if (!input.value.trim()) {
346
+ input.focus();
347
+ return;
348
+ }
349
+ void busy(check, () => api.submit(flow.id, input.value), show);
350
+ };
351
+ content.replaceChildren(...flow.instructions.map((part) => typeof part === 'string'
352
+ ? node('p', part)
353
+ : Array.isArray(part)
354
+ ? paragraph(part)
355
+ : codeBlock(part.code)), label, row(back, check), ...(flow.note ? [node('p', flow.note, 'muted')] : []));
356
+ }
357
+ /**
358
+ * A mailed code, in two steps on the one flow: the holder names an address, then enters
359
+ * the code that arrived there. Both go back the way a proof does.
360
+ */
361
+ function mailed(flow) {
362
+ const { field, sentTo, wrong, triesLeft } = flow.code;
363
+ const sent = sentTo !== undefined;
364
+ const input = Object.assign(node('input'), {
365
+ type: sent ? 'text' : flow.code.input,
366
+ name: 'artifact',
367
+ required: true,
368
+ autocomplete: sent ? 'one-time-code' : flow.code.input,
369
+ });
370
+ if (sent) {
371
+ input.autocapitalize = 'characters';
372
+ input.spellcheck = false;
373
+ }
374
+ const label = node('label', sent ? 'Your code' : field);
375
+ label.append(input);
376
+ const check = button(sent ? 'Check my code' : `Send the ${flow.provider.name.toLowerCase()}`, true);
377
+ const back = button('Back');
378
+ back.onclick = () => void choose();
379
+ const send = () => {
380
+ // No form is submitted here, so the browser's own check of the field is asked for:
381
+ // an address it would refuse in a form is refused the same way, in its own words.
382
+ if (!input.value.trim() || !input.checkValidity()) {
383
+ input.reportValidity();
384
+ input.focus();
385
+ return;
386
+ }
387
+ void busy(check, () => api.submit(flow.id, input.value), show);
388
+ };
389
+ check.onclick = send;
390
+ input.addEventListener('keydown', (event) => {
391
+ if (event.key === 'Enter')
392
+ send();
393
+ });
394
+ const parts = [];
395
+ if (sent)
396
+ parts.push(node('p', `A message was sent to ${sentTo}. Press the button in it, or enter its code here.`));
397
+ if (sent && wrong)
398
+ parts.push(node('p', `That code did not match. ${triesLeft === 1 ? 'One try is' : `${triesLeft} tries are`} left.`, 'error'));
399
+ content.replaceChildren(...parts, label, row(back, check), ...(!sent && flow.note ? [node('p', flow.note, 'muted')] : []));
400
+ input.focus();
401
+ if (!sent)
402
+ return;
403
+ // The button in the message is pressed somewhere else, so only the flow can say it
404
+ // was. A code half typed is left alone: nothing is redrawn until the step moves on.
405
+ const asked = step;
406
+ poll = setInterval(async () => {
407
+ try {
408
+ const now = await api.read(flow.id);
409
+ if (current(asked) && !['pending', 'exchanging'].includes(now.phase))
410
+ show(now);
411
+ }
412
+ catch {
413
+ // A read that fails says nothing about the flow, which the next one may.
414
+ }
415
+ }, 2000);
416
+ }
417
+ function approve(flow) {
418
+ const logo = providerMark(flow.provider.id, flow.provider.method);
419
+ const external = accountCard(logo
420
+ ? [logo, document.createTextNode(flow.provider.name)]
421
+ : [document.createTextNode(flow.provider.name)], externalName(flow.external), flow.external.kind === 'mailbox' ? undefined : flow.external.id, externalLink(flow.external));
422
+ const parts = [subjectCard(flow.local), linkMark(), external];
423
+ const choice = node('fieldset');
424
+ const visibilityText = {
425
+ unlisted: 'Unlisted: only people you share a link with can view it',
426
+ public: 'Public: anyone can view both sides of this link' +
427
+ (flow.signedNote ? `. ${flow.signedNote}` : ''),
428
+ };
429
+ // The backend refuses any other, so the dialog neither offers one nor sends one.
430
+ const only = flow.visibilities?.length === 1 ? flow.visibilities[0] : undefined;
431
+ if (flow.joined)
432
+ parts.push(node('p', `This account is already linked here. Confirming adds this method to that connection, and it stays ${flow.joined.visibility}.`));
433
+ else if (only)
434
+ // Nothing to choose, so the one visibility there is gets said, not offered.
435
+ parts.push(node('p', visibilityText[only]));
436
+ else {
437
+ choice.append(node('legend', 'Evidence visibility'));
438
+ for (const value of ['unlisted', 'public']) {
439
+ const text = visibilityText[value];
440
+ const option = node('label');
441
+ const radio = Object.assign(node('input'), { type: 'radio', name: 'visibility', value });
442
+ radio.checked = value === 'unlisted';
443
+ option.append(radio, document.createTextNode(text));
444
+ choice.append(option);
445
+ }
446
+ parts.push(choice);
447
+ }
448
+ if (methods)
449
+ parts.push(node('p', `${methods.siteName} receives the result. Verified via ${methods.verifierName}.`, 'muted'));
450
+ const confirm = button(flow.joined ? 'Add to connection' : 'Confirm connection', true);
451
+ const cancel = button('Cancel');
452
+ /** One decision at a time: a second one sent beside the first could answer for it. */
453
+ let deciding = false;
454
+ const decide = async (control, cancelled) => {
455
+ if (deciding)
456
+ return;
457
+ deciding = true;
458
+ const other = control === confirm ? cancel : confirm;
459
+ const picked = choice.querySelector('input:checked');
460
+ const visibility = only ?? (picked?.value === 'public' ? 'public' : 'unlisted');
461
+ other.disabled = true;
462
+ // The outcome is taken as soon as the server answers, even if the dialog has closed
463
+ // in the meantime: a connection recorded after the holder looked away is still one.
464
+ const request = api.approve(flow.id, visibility, cancelled).then((result) => {
465
+ if (result.outcome === 'complete')
466
+ outcome = result;
467
+ return result;
468
+ });
469
+ approving = request;
470
+ try {
471
+ await busy(control, () => request, (result) => (result.outcome === 'complete' ? done() : void choose()));
472
+ }
473
+ finally {
474
+ deciding = false;
475
+ other.disabled = false;
476
+ }
477
+ };
478
+ confirm.onclick = () => void decide(confirm, false);
479
+ cancel.onclick = () => void decide(cancel, true);
480
+ content.replaceChildren(...parts, row(cancel, confirm));
481
+ }
482
+ function done() {
483
+ settle();
484
+ const finish = button('Done', true);
485
+ finish.onclick = () => dialog.close();
486
+ content.replaceChildren(node('p', 'The connection is recorded.', 'state'), row(finish));
487
+ finish.focus();
488
+ }
489
+ close.addEventListener('click', () => dialog.close());
490
+ dialog.addEventListener('click', (event) => {
491
+ const bounds = dialog.getBoundingClientRect();
492
+ if (event.target === dialog &&
493
+ (event.clientX < bounds.left ||
494
+ event.clientX > bounds.right ||
495
+ event.clientY < bounds.top ||
496
+ event.clientY > bounds.bottom))
497
+ dialog.close();
498
+ });
499
+ const closed = new Promise((resolve) => dialog.addEventListener('close', () => {
500
+ settle();
501
+ openDialogs.delete(opener);
502
+ host.remove();
503
+ opener.firstElementChild?.shadowRoot?.querySelector('button')?.focus();
504
+ if (returning)
505
+ back();
506
+ // Closed with an approval still out: its answer decides the result.
507
+ void Promise.resolve(approving)
508
+ .catch(() => { })
509
+ .then(() => resolve(outcome));
510
+ }, { once: true }));
511
+ dialog.showModal();
512
+ void choose();
513
+ return closed;
514
+ }
@@ -0,0 +1,59 @@
1
+ import { type Evidence } from '../core/index.js';
2
+ export declare const styles = "\n * { box-sizing: border-box; }\n 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; }\n dialog::backdrop { background: #15271f66; }\n header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 20px; }\n h2 { margin: 0; font-size: 18px; font-weight: 650; letter-spacing: -.3px; }\n button { width: 32px; height: 32px; border: 1px solid #dce2de; border-radius: 8px; background: #fff; color: #52645a; font: 20px system-ui, sans-serif; cursor: pointer; }\n button:hover { background: #f2f5f1; }\n a { color: #245f43; text-underline-offset: 3px; overflow-wrap: anywhere; }\n a:focus-visible, button:focus-visible { outline: 2px solid #357ce5; outline-offset: 3px; }\n .summary { display: flex; align-items: center; gap: 8px; margin-top: 16px; }\n .mark { width: 36px; height: 36px; flex-shrink: 0; }\n .provider { width: 14px; height: 14px; }\n .state { font-size: 14px; font-weight: 650; line-height: 1.4; }\n .muted { color: #6b786f; font-size: 12px; }\n .account { position: relative; }\n .veiled > :not(.checking) { filter: blur(4px); opacity: .55; user-select: none; }\n .checking { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; gap: 8px; color: #23312b; font-size: 13px; font-weight: 600; }\n .spinner { width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: spin .7s linear infinite; }\n @keyframes spin { to { transform: rotate(360deg); } }\n @media (prefers-reduced-motion: reduce) { .spinner { animation: none; } }\n .caution { display: inline-block; margin-left: 4px; color: #a15c00; cursor: help; vertical-align: middle; }\n .caution svg { display: block; width: 12px; height: 12px; }\n .bad { color: #b3261e; font-weight: 650; text-decoration: underline dotted; text-underline-offset: 3px; cursor: help; }\n .account { padding: 14px 16px; border: 1px solid #e0e6df; border-radius: 10px; margin-top: 12px; }\n .account h3 { display: flex; align-items: center; gap: 6px; margin: 0 0 3px; color: #6b786f; font-size: 11px; font-weight: 550; }\n .account a, .account strong { font-weight: 650; font-size: 14px; }\n /* The account's own name carries that weight; a link inside a line of prose does not. */\n .summary a, .method a, .additional a { font: inherit; }\n .reference { margin-top: 2px; }\n .method { margin-top: 8px; }\n .additional { margin-top: 2px; padding-left: 12px; }\n .joiner { display: block; width: 20px; height: 20px; margin: 8px auto -4px; color: #90a096; }\n 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; }\n dt { color: #6b786f; }\n dd { margin: 0; text-align: right; overflow-wrap: anywhere; }\n .explanation { margin-top: 16px; }\n .action { width: auto; height: auto; padding: 9px 14px; font: 600 13px/1.3 system-ui, sans-serif; color: #23312b; text-align: left; }\n .action.primary { border-color: #245f43; background: #245f43; color: #fff; }\n .action.primary:hover { background: #1d4f37; }\n .action:disabled { opacity: .6; cursor: progress; }\n .action.danger:hover { border-color: #b3261e; background: #fdecea; color: #b3261e; }\n .row { display: flex; justify-content: end; gap: 8px; margin-top: 16px; }\n .account .row { align-items: center; justify-content: start; margin-top: 12px; }\n .account .action { padding: 5px 10px; font-size: 12px; }\n footer { display: flex; align-items: center; justify-content: start; gap: 6px; margin-top: 14px; color: #9aa9a0; font-size: 11px; }\n .logo { display: block; height: 13px; }\n \n @media (prefers-reduced-motion: no-preference) {\n .mark.current path:last-child { animation: verily-draw .8s cubic-bezier(.65, 0, .35, 1) .1s backwards; }\n }\n @keyframes verily-draw { from { stroke-dasharray: 0 176; } to { stroke-dasharray: 108 176; } }\n\n";
3
+ export declare function node<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, className?: string): HTMLElementTagNameMap[K];
4
+ /**
5
+ * Points an anchor out of the dialog. Every link here opens in a new tab: the dialog is
6
+ * read against what it links to, and following one in place would close the dialog and
7
+ * take the reader off the page the pill was on. `noreferrer` keeps the opener unreachable.
8
+ */
9
+ export declare function outward(anchor: HTMLAnchorElement, url: string): HTMLAnchorElement;
10
+ /** Joins the two cards: the link itself, drawn rather than described. */
11
+ export declare function linkMark(): SVGSVGElement;
12
+ /**
13
+ * Both sides of a link render as the same card, so a reader can see it is a pair.
14
+ * `extra` carries evidence belonging to that side alone: a provider authenticates and
15
+ * expires on its own terms, and a second provider on the same subject would differ.
16
+ */
17
+ export declare function accountCard(heading: Node[], name: string, reference: string | undefined, url?: string, ...extra: HTMLElement[]): HTMLElement;
18
+ /**
19
+ * A record as a card shows it. Where the card speaks for several records of one account,
20
+ * `links` names every one not yet revoked, whether or not it is shown, so removing the
21
+ * account removes them all and none is left to take the card up again.
22
+ */
23
+ export type Account = Evidence & {
24
+ links?: string[];
25
+ /**
26
+ * The records as they were read, where a card is drawn from more than one: what it says
27
+ * is put together from all of them, and each has a signed record of its own.
28
+ */
29
+ sources?: Evidence[];
30
+ };
31
+ /**
32
+ * What the holder of the accounts shown may do from the dialog, where the page that drew
33
+ * the badge said its reader is that holder.
34
+ */
35
+ export interface Manage {
36
+ /** Opens what connects an account, in this dialog's place. Given a provider, starts on it. */
37
+ connect(provider?: string): void;
38
+ /** Removes the links named. Rejects if any could not be removed. */
39
+ remove(ids: string[]): Promise<void>;
40
+ }
41
+ /**
42
+ * Fetch fresh, permitted evidence; native dialog supplies focus containment and Escape
43
+ * dismissal. `opened` is the record the opener already holds: the dialog is drawn from it
44
+ * before it is shown, so it appears at the size it will keep rather than growing into its
45
+ * own contents, and the check that follows either leaves it alone or replaces it.
46
+ */
47
+ export declare function openEvidenceDialog(opener: HTMLElement, load: () => Promise<Evidence | Evidence[]>, opened?: Evidence | Evidence[],
48
+ /**
49
+ * Turns the records held into the cards drawn, each time they are drawn. The dialog
50
+ * keeps the records as they were read and arranges them afresh at every deadline: which
51
+ * record speaks for an account depends on which are still good, and that changes with
52
+ * the clock, not only with what is read.
53
+ */
54
+ arrange?: (records: Evidence[]) => Account[],
55
+ /**
56
+ * Given where whoever is looking holds these accounts: each then has its renewal and
57
+ * removal under it, and the dialog offers to connect another.
58
+ */
59
+ manage?: Manage): void;