@bkazemi/verily 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -145,7 +145,7 @@ import { init } from '@bkazemi/verily'; // also registers <verily-badge>
145
145
  const client = init({ backendUrl: '/api/verily' });
146
146
  ```
147
147
 
148
- Without one, serve `node_modules/@bkazemi/verily/dist/verily.js` yourself, or load it from a CDN such as `https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.4.0/dist/verily.js`. The script defines a global `Verily`.
148
+ Without one, serve `node_modules/@bkazemi/verily/dist/verily.js` yourself, or load it from a CDN such as `https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.6.0/dist/verily.js`. The script defines a global `Verily`.
149
149
 
150
150
  Then place the badge wherever the account appears:
151
151
 
@@ -184,6 +184,8 @@ Add `stacked` to name the accounts in the badge itself, one to a row:
184
184
 
185
185
  It lists up to four verified accounts in the order they were connected, then counts the rest as `+2 more`. It is still one badge that opens the one set of details. It is as tall as its rows, so give it a line of its own. With one verified account it is the ordinary badge. `mountBadges` and `presentConnections` take the same choices as `{ stacked: true }` and `{ peek: false }`.
186
186
 
187
+ In the details of several accounts, each account is a short card: its name, its status and who verified it. How it was shown and its dates sit behind a Details toggle on the card, and retired accounts are folded into a group at the foot. Add `details-open` to the badge, or pass `{ detailsOpen: true }`, to start every card open where your readers come for the proof. A reader can still open or shut each one. A single account always shows everything.
188
+
187
189
  The badge refreshes every 30 seconds, and only ever reads public evidence. The client can also start and end connections, and draw a badge into an element of your own:
188
190
 
189
191
  ```js
@@ -222,8 +224,8 @@ import {
222
224
  | `githubGistProvider()` | publishing a public gist containing a line Verily gives them. | None. |
223
225
  | `linkProvider()` | adding a `rel="me"` link to their profile on a page they control. | The local account needs a `profileUrl`. |
224
226
  | `githubLinkProvider()` | putting their profile URL in the website field of their GitHub profile, then entering their GitHub username. | The local account needs a `profileUrl`. |
225
- | `dnsProvider()` | adding a TXT record to their domain's DNS, then entering the domain. | The local account needs a `profileUrl`. |
226
- | `wellKnownProvider()` | serving a text file at a fixed path on their domain, then entering the domain. | The local account needs a `profileUrl`. |
227
+ | `dnsProvider()` | adding a TXT record to their domain's DNS, then entering the domain. | None. |
228
+ | `wellKnownProvider()` | serving a text file at a fixed path on their domain, then entering the domain. | None. |
227
229
  | `pgpProvider()` | signing a line Verily gives them with their OpenPGP key, then pasting it and the key. | None. |
228
230
  | `emailProvider()` | pressing a button, or entering a code, that Verily mails to their address. | A function that sends one message. |
229
231
 
@@ -243,7 +245,7 @@ Proving the same account a second way adds that proof to the existing record ins
243
245
 
244
246
  **Link-backs.** Many people already have one, since GitHub and Mastodon mark profile links `rel="me"`. By default the page may be on any public host, and the account is named by the page's address. This mode needs Node, because Verily checks every connection it makes to stop the page's address from pointing inside your network. Pass `hosts` to read only certain hosts (required on Cloudflare Workers), and `profile` to name the account by handle, as `githubLinkProvider()` does for github.com. If you pass your own `fetch`, it replaces that network check, so it must enforce the same rule itself. Only real `<a>` and `<link>` elements in HTML pages, or a `Link:` header, count; [`src/server/link.ts`](src/server/link.ts) has the exact rules.
245
247
 
246
- **Domains.** `dnsProvider()` proves a domain that serves no page of its own, or only forwards elsewhere. The user adds a TXT record named `_verily` whose value is their `profileUrl`, and enters the domain. Verily looks up `_verily.<domain>` through `https://dns.google/resolve`, a fixed resolver, so it runs on Cloudflare Workers, and the evidence links to that lookup for a reader to repeat. `wellKnownProvider()` reads `https://<domain>/.well-known/verily.txt` instead and looks for the `profileUrl` on a line of its own. It fetches a host the user names, so it follows the link-back's rules: Node, or `hosts`. Both name the account by the exact domain entered, and say nothing about its subdomains. List both and a domain proved one way picks up the other if it is already there.
248
+ **Domains.** `dnsProvider()` proves a domain that serves no page of its own, or only forwards elsewhere. The user adds a TXT record named `_verily` whose value is their `profileUrl`, and enters the domain. Verily looks up `_verily.<domain>` through `https://dns.google/resolve`, a fixed resolver, so it runs on Cloudflare Workers, and the evidence links to that lookup for a reader to repeat. `wellKnownProvider()` reads `https://<domain>/.well-known/verily.txt` instead and looks for the `profileUrl` on a line of its own. It fetches a host the user names, so it follows the link-back's rules: Node, or `hosts`. Both name the account by the exact domain entered, and say nothing about its subdomains. Where the resolver validated the DNS answer with DNSSEC, the proof records it and the evidence says so; a domain that does not sign its zone is proved all the same, on the resolver's own lookup. `recheck()` updates the flag at each read. A local account with no `profileUrl` has no address to publish, so the user is given a random value, `verily-proof=…`, instead. It names neither the account nor your site, since DNS is public, and a renewal asks for the same value, so the record is added once. List both and a domain proved one way picks up the other if it is already there.
247
249
 
248
250
  **Your own method.** A provider is an object, and [`src/core/index.ts`](src/core/index.ts) documents each shape. An artifact provider may also set `field` and `input` to name what it asks for, `resolve` to turn something short such as a username into the address to read, and `known` to say where to look for an account already on record, which is what lets a proof be found without asking. A sign-in provider's `authorizationUrl` is passed `account` on a renewal or visibility change, so it can ask for that account by name.
249
251
 
@@ -295,13 +297,13 @@ A static site only needs the script and the badge markup; the backend runs somew
295
297
 
296
298
  1. Clone this repository, run `npm ci`, and copy `wrangler.example.jsonc` to `wrangler.jsonc`. Fill in your account, origin and owner details.
297
299
  2. Run `wrangler secret put` for `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET` and `OWNER_KEY`, then `npm run cloudflare:deploy`.
298
- 3. On the backend's site, sign in with `OWNER_KEY`, verify, and approve a **public** connection. Its id is listed at `/api/verily/mine`.
300
+ 3. On the backend's site, sign in with `OWNER_KEY`, verify, and approve a **public** connection. Its id is listed at `/mine`.
299
301
  4. Add the script and the badge to your site:
300
302
 
301
303
  ```html
302
- <script src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.4.0/dist/verily.js" defer></script>
304
+ <script src="https://cdn.jsdelivr.net/npm/@bkazemi/verily@0.6.0/dist/verily.js" defer></script>
303
305
  <verily-badge
304
- backend-url="https://your-backend.example/api/verily"
306
+ backend-url="https://your-backend.example"
305
307
  connection-id="CONNECTION_ID"
306
308
  ></verily-badge>
307
309
  ```
@@ -355,7 +357,7 @@ The authorize and return URLs the instance's operator registers for you are `<pr
355
357
  ```html
356
358
  <script src="/assets/verily.js" defer></script>
357
359
  <verily-connect
358
- backend-url="https://verily.example/api/verily"
360
+ backend-url="https://verily.example"
359
361
  handoff-url="/api/verily/handoff"
360
362
  ></verily-connect>
361
363
  ```
@@ -367,7 +369,7 @@ Once a user has a link, show their badge in place of the pill, and give it the s
367
369
  ```html
368
370
  <verily-badge
369
371
  connections="[...the records, as JSON...]"
370
- backend-url="https://verily.example/api/verily"
372
+ backend-url="https://verily.example"
371
373
  handoff-url="/api/verily/handoff"
372
374
  ></verily-badge>
373
375
  ```
@@ -468,6 +470,43 @@ A signed record proves what the verifier claimed and when (`issuedAt`). It does
468
470
 
469
471
  The Cloudflare Worker signs by default, with a key it makes and stores itself. Set the `SIGNING_KEY` secret to supply your own, or `SIGNING` to `off` to sign nothing.
470
472
 
473
+ ## What DNS says of the verifier
474
+
475
+ Two TXT records let a reader check the verifier against DNS instead of taking its word. Both sit at `_verily.<domain>`, the name the domain method uses, and neither is required.
476
+
477
+ | Record | Published on | Says |
478
+ | --------------------------------------- | --------------------- | ------------------------------------------------------- |
479
+ | `verily-key=<fingerprint>` | the verifier's domain | this OpenPGP key signs the verifier's records. |
480
+ | `verily-verifier=<the verifier's host>` | a site's domain | this verifier speaks for the site's accounts and pages. |
481
+
482
+ **The signing key.** A signed record is checked against keys the verifier's own server lists, so a reader has nowhere else to confirm them and nothing once the server is gone. Publish one `verily-key=` record for each key, current and retired, on the host in `baseUrl`. `GET <baseUrl>/keys` returns the exact records under `dns`. A reader then confirms the key against the domain, for as long as the domain is kept:
483
+
484
+ ```ts
485
+ import { dnsKeys, signedBy, verifySigned } from '@bkazemi/verily';
486
+
487
+ const record = await verifySigned(file, keys); // keys saved with the file, or from /keys
488
+ const named = await dnsKeys(new URL(record.evidenceUrl).hostname);
489
+
490
+ named.ids.includes(await signedBy(file)); // the verifier's domain names the key that signed it
491
+ named.dnssec; // whether the resolver validated that answer
492
+ ```
493
+
494
+ **The site's verifier.** A record's local side is the verifier's statement of what the site told it. On the site's own pages that is enough, since the site put the badge there. On a shared evidence link or a saved signed record, nothing shows the site chose this verifier. A site whose verifier is on another domain publishes `verily-verifier=<host>` on the domain of its `profileUrl`, with the port if `baseUrl` has one. `dnsVerifiers(domain)` reads it back. A verifier on the site's own domain, or a subdomain of it, needs no record and none is looked for.
495
+
496
+ Set `dns: true` and the instance reads both through `https://dns.google/resolve`, the resolver the domain method asks:
497
+
498
+ ```ts
499
+ createVerily({
500
+ // ...
501
+ dns: true, // or { fetch, timeoutMs }
502
+ });
503
+ ```
504
+
505
+ - The evidence page and `<baseUrl>/check` say the signing key is named in the verifier's DNS, with a link to the lookup, once it is. The answer is kept for ten minutes.
506
+ - When a link is approved, the site's domain is looked up. If it names this verifier, the record's local side carries the lookup as `artifactUrl` and the record as `expect`, the evidence reads "Stated by … whose DNS names this verifier" and links to the lookup, and the signed record includes it. `recheck()` reads each site again every `recheckMs`, one lookup for all of a site's records. A record loses the proof when the site stops naming the verifier, or when the lookup has gone unread for `freshnessMs`; its status is unaffected either way.
507
+
508
+ The Cloudflare Worker sets `dns: true`.
509
+
471
510
  ## Operations
472
511
 
473
512
  Verification lasts 30 days, flows 10 minutes, and sharing links 7 days. Change them with `validityMs`, `flowTtlMs` and `shareTtlMs`. Evidence is never cached.
@@ -489,7 +528,7 @@ setInterval(
489
528
  ```
490
529
 
491
530
  - **`prune()`** deletes expired flows, and deletes expired or revoked records after 90 days. A retired record is kept until it is removed.
492
- - **`recheck()`** re-reads gists and link-backs that are due, up to 5 per run (its `budget` argument). Each proof is re-read every 24 hours (`recheckMs`) and stays current for 7 days after its last successful read (`freshnessMs`); after that it shows as unconfirmed until a read succeeds. A failed read changes nothing. For PGP, it asks the keyserver whether the key has been revoked, and revokes the connection if so.
531
+ - **`recheck()`** re-reads gists, link-backs, DNS records and files that are due, up to 5 per run (its `budget` argument). Each proof is re-read every 24 hours (`recheckMs`) and stays current for 7 days after its last successful read (`freshnessMs`); after that it shows as unconfirmed until a read succeeds. A failed read changes nothing. For PGP, it asks the keyserver whether the key has been revoked, and revokes the connection if so.
493
532
 
494
533
  **If you never call `recheck()`, set `freshnessMs: Infinity`.** Otherwise connections proved by gist, link-back or PGP lapse after a week.
495
534
 
@@ -15,17 +15,20 @@ export declare function renderBadgeMessage(element: HTMLElement, message: string
15
15
  * `more` is how many other accounts of the same subject stand behind the one shown, said
16
16
  * as a count beside it. `linked` is whether the record has a page of its own a reader can
17
17
  * open: an unlisted one has none, so its pill is a button and never a link to nowhere.
18
+ * `href` is that page, where it is not the record's own: a pill standing for several
19
+ * accounts links to all of them.
18
20
  *
19
21
  * `rows` are those of the other accounts to name, in order. Stacked, each has a row of
20
22
  * its own in the pill, which is then as tall as its rows. Otherwise the pill stays short
21
23
  * and, with `peek`, names them in a panel that opens beside it while a pointer rests on it
22
24
  * or the keyboard is on it. Either way the accounts past the rows are a count.
23
25
  */
24
- export declare function renderBadge(element: HTMLElement, evidence: Evidence, { more, linked, rows, peek, }?: {
26
+ export declare function renderBadge(element: HTMLElement, evidence: Evidence, { more, linked, rows, peek, href, }?: {
25
27
  more?: number;
26
28
  linked?: boolean;
27
29
  rows?: Evidence[];
28
30
  peek?: boolean;
31
+ href?: string;
29
32
  }): HTMLElement | null;
30
33
  /**
31
34
  * Shuts a host's panel and lets go of what it was listening for. For a host leaving the
@@ -155,11 +155,12 @@ export function badgeShown(element) {
155
155
  return intact(element) && shown.has(element);
156
156
  }
157
157
  /** Everything the pill draws: equal keys mean an identical pill. */
158
- function renderKey(evidence, current, label, more, linked, rows, peek) {
158
+ function renderKey(evidence, current, label, more, linked, rows, peek, href) {
159
159
  return JSON.stringify([
160
160
  more,
161
161
  linked,
162
162
  peek,
163
+ href,
163
164
  rows.map((row) => [row.provider, row.providerName, externalName(row.external)]),
164
165
  evidence.provider,
165
166
  evidence.providerName,
@@ -220,13 +221,15 @@ export function renderBadgeMessage(element, message) {
220
221
  * `more` is how many other accounts of the same subject stand behind the one shown, said
221
222
  * as a count beside it. `linked` is whether the record has a page of its own a reader can
222
223
  * open: an unlisted one has none, so its pill is a button and never a link to nowhere.
224
+ * `href` is that page, where it is not the record's own: a pill standing for several
225
+ * accounts links to all of them.
223
226
  *
224
227
  * `rows` are those of the other accounts to name, in order. Stacked, each has a row of
225
228
  * its own in the pill, which is then as tall as its rows. Otherwise the pill stays short
226
229
  * and, with `peek`, names them in a panel that opens beside it while a pointer rests on it
227
230
  * or the keyboard is on it. Either way the accounts past the rows are a count.
228
231
  */
229
- export function renderBadge(element, evidence, { more = 0, linked = true, rows = [], peek = false, } = {}) {
232
+ export function renderBadge(element, evidence, { more = 0, linked = true, rows = [], peek = false, href = evidence.evidenceUrl, } = {}) {
230
233
  const provider = evidence.providerName;
231
234
  const current = evidence.status === 'verified' && evidence.expiresAt > Date.now();
232
235
  const retired = evidence.status === 'retired';
@@ -238,7 +241,7 @@ export function renderBadge(element, evidence, { more = 0, linked = true, rows =
238
241
  ? 'revoked'
239
242
  : 'expired';
240
243
  const label = statusLabel(evidence, Date.now());
241
- const key = renderKey(evidence, current, label, more, linked, rows, peek);
244
+ const key = renderKey(evidence, current, label, more, linked, rows, peek, href);
242
245
  if (intact(element) && shown.get(element) === key)
243
246
  return null;
244
247
  const handle = externalName(evidence.external);
@@ -250,7 +253,7 @@ export function renderBadge(element, evidence, { more = 0, linked = true, rows =
250
253
  // is: in the page's text colour, and not as a verification that failed.
251
254
  paintMark(mark, current ? 'current' : retired ? 'pending' : 'inactive');
252
255
  if (linked) {
253
- badge.href = evidence.evidenceUrl;
256
+ badge.href = href;
254
257
  badge.rel = 'noreferrer';
255
258
  }
256
259
  else
@@ -1,5 +1,5 @@
1
- import { externalId, externalLink, externalName } from '../core/index.js';
2
- import { accountCard, linkMark, node, outward, stampLink, styles } from './evidence-dialog.js';
1
+ import { externalId, externalIdGroups, externalLink, externalName } from '../core/index.js';
2
+ import { accountCard, backMark, closeMark, linkMark, node, outward, stampLink, styles, } from './evidence-dialog.js';
3
3
  import { providerMark } from './provider-mark.js';
4
4
  import { version } from '../version.js';
5
5
  const connectStyles = `
@@ -14,13 +14,15 @@ const connectStyles = `
14
14
  /* The block scrolls and its frame does not, so the copy button stays in its corner. */
15
15
  .code { position: relative; margin: 10px 0; }
16
16
  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; }
17
- .code .action { position: absolute; top: 6px; right: 6px; padding: 3px 8px; font-size: 11px; }
17
+ .code .action { position: absolute; top: 9px; right: 9px; padding: 3px 8px; font-size: 11px; }
18
18
  label { display: grid; gap: 4px; margin-top: 12px; font-weight: 600; }
19
19
  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; }
20
20
  textarea { font-family: ui-monospace, monospace; font-size: 12px; resize: vertical; }
21
21
  fieldset { margin: 16px 0 0; padding: 10px 14px; border: 1px solid #e0e6df; border-radius: 10px; }
22
22
  legend { padding: 0 4px; color: #6b786f; font-size: 11px; font-weight: 550; }
23
23
  fieldset label { display: flex; align-items: baseline; gap: 8px; margin-top: 4px; font-weight: 500; }
24
+ /* A radio has no baseline to speak of, so it is set in the middle of the label's first line. */
25
+ fieldset input { flex-shrink: 0; align-self: flex-start; width: 13px; height: 13px; font: inherit; margin-top: 4px; margin-top: calc((1lh - 13px) / 2); margin-bottom: 0; }
24
26
  .error { color: #9b2c33; }
25
27
  `;
26
28
  /** Puts a copy control on a block the holder must reproduce exactly, as the flow page does. */
@@ -110,7 +112,7 @@ renew) {
110
112
  root.adoptedStyleSheets = [sheet];
111
113
  const dialog = node('dialog');
112
114
  const heading = node('h2', 'Verify an account');
113
- const close = node('button', '×');
115
+ const close = node('button', '', 'icon');
114
116
  const header = node('header');
115
117
  const stamp = node('footer');
116
118
  const content = node('div', 'Loading…', 'steps');
@@ -118,6 +120,7 @@ renew) {
118
120
  dialog.setAttribute('aria-labelledby', heading.id);
119
121
  close.type = 'button';
120
122
  close.setAttribute('aria-label', 'Close');
123
+ close.append(closeMark());
121
124
  content.setAttribute('aria-live', 'polite');
122
125
  stamp.append(stampLink(), node('span', version));
123
126
  header.append(heading, close);
@@ -127,9 +130,10 @@ renew) {
127
130
  openDialogs.set(opener, dialog);
128
131
  let returning = false;
129
132
  if (back) {
130
- const before = node('button', '‹');
133
+ const before = node('button', '', 'icon');
131
134
  before.type = 'button';
132
135
  before.setAttribute('aria-label', 'Back');
136
+ before.append(backMark());
133
137
  header.className = 'backed';
134
138
  header.replaceChildren(before, heading, close);
135
139
  before.onclick = () => {
@@ -433,7 +437,7 @@ renew) {
433
437
  const logo = providerMark(flow.provider.id, flow.provider.method);
434
438
  const external = accountCard(logo
435
439
  ? [logo, document.createTextNode(flow.provider.name)]
436
- : [document.createTextNode(flow.provider.name)], externalName(flow.external), externalId(flow.external), externalLink(flow.external));
440
+ : [document.createTextNode(flow.provider.name)], externalName(flow.external), externalIdGroups(flow.external) ?? externalId(flow.external), externalLink(flow.external));
437
441
  const parts = [subjectCard(flow.local), linkMark(), external];
438
442
  const choice = node('fieldset');
439
443
  const visibilityText = {
@@ -1,7 +1,7 @@
1
1
  import { type Evidence } from '../core/index.js';
2
2
  /** The logotype as a link to the project, as both dialogs carry it in their footer. */
3
3
  export declare function stampLink(): HTMLAnchorElement;
4
- 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 /* In the corner the heading leaves empty, apart from everything that was checked. */\n .listing { position: absolute; top: 12px; right: 14px; padding: 1px 8px; border: 1px solid #e0e6df; border-radius: 999px; color: #6b786f; font-size: 11px; font-weight: 550; }\n .listing.preferred { border-color: #245f43; color: #245f43; }\n /* Quiet, as its pill is: the surface and the name step back, and the controls do not. */\n .account.retired { background: #f6f8f5; }\n .account.retired > a, .account.retired > strong { color: #6b786f; }\n .account.retired .mark { opacity: .45; }\n .group { margin: 20px 0 0; color: #6b786f; font-size: 11px; font-weight: 550; }\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; flex-wrap: wrap; 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: 20px 0 -8px; color: #9aa9a0; font-size: 11px; }\n footer a { display: flex; color: inherit; }\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";
4
+ 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 .icon { display: grid; place-items: center; padding: 0; }\n .icon svg { width: 16px; height: 16px; }\n .title { display: flex; align-items: center; gap: 6px; min-width: 0; }\n .all { display: grid; place-items: center; width: 28px; height: 28px; border-radius: 8px; color: #52645a; }\n .all:hover { background: #f2f5f1; }\n .all svg { width: 16px; height: 16px; }\n a { color: #245f43; text-underline-offset: 3px; overflow-wrap: anywhere; }\n a:focus-visible, button:focus-visible, summary:focus-visible { outline: 2px solid #357ce5; outline-offset: 3px; }\n /* An element given its own display would otherwise go on showing while hidden. */\n [hidden] { display: none !important; }\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: -1.5px; }\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 /* A fingerprint in fours: it breaks between groups, and copies as one unbroken word. */\n .fingerprint { font-family: ui-monospace, monospace; text-wrap: balance; }\n .fingerprint span { display: inline-block; margin-right: .5ch; }\n .method { margin-top: 8px; }\n /* In the corner the heading leaves empty, apart from everything that was checked. */\n .listing { position: absolute; top: 12px; right: 14px; padding: 1px 8px; border: 1px solid #e0e6df; border-radius: 999px; color: #6b786f; font-size: 11px; font-weight: 550; }\n .listing.preferred { border-color: #245f43; color: #245f43; }\n /* Quiet, as its pill is: the surface and the name step back, and the controls do not. */\n .account.retired { background: #f6f8f5; }\n .account.retired > a, .account.retired > strong { color: #6b786f; }\n .account.retired .mark { opacity: .45; }\n .group { margin: 20px 0 0; color: #6b786f; font-size: 11px; font-weight: 550; }\n summary.group { display: flex; align-items: center; gap: 4px; width: fit-content; border-radius: 4px; list-style: none; cursor: pointer; }\n summary::-webkit-details-marker { display: none; }\n .fold { display: flex; align-items: center; gap: 4px; width: auto; height: auto; padding: 2px 8px 2px 4px; margin-left: -4px; border: 0; background: none; font: 550 12px/1.6 system-ui, sans-serif; }\n .fold svg, summary svg { width: 12px; height: 12px; }\n .fold[aria-expanded=\"true\"] svg, details[open] > summary svg { transform: rotate(90deg); }\n @media (prefers-reduced-motion: no-preference) { .fold svg, summary svg { transition: transform .15s; } }\n /* The fold and what the holder may do share a line, until a question needs the whole of it. */\n .bar { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; margin-top: 10px; }\n .account .bar .row { margin: 0 0 0 auto; }\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; flex-wrap: wrap; margin-top: 12px; }\n .account .action { padding: 5px 10px; font-size: 12px; }\n .account .action.dots { display: grid; place-items: center; align-self: stretch; width: 30px; padding: 0; }\n .dots svg { width: 16px; height: 16px; }\n /* Placed under its button each time it opens, above the dialog and clipped by none of it. */\n .menu { position: fixed; inset: auto; margin: 0; min-width: 170px; padding: 6px; border: 1px solid #dce2de; border-radius: 10px; background: #fff; color: #23312b; box-shadow: 0 12px 40px #10201930; }\n .menu:popover-open { display: grid; }\n .account .menu .action { border: 0; border-radius: 6px; padding: 6px 10px; font-weight: 550; }\n .menu .danger { color: #b3261e; }\n .menu hr { width: 100%; margin: 4px 0; border: 0; border-top: 1px solid #e5e9e3; }\n footer { display: flex; align-items: center; justify-content: start; gap: 6px; margin: 20px 0 -8px; color: #9aa9a0; font-size: 11px; }\n footer a { display: flex; color: inherit; }\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";
5
5
  export declare function node<K extends keyof HTMLElementTagNameMap>(tag: K, text?: string, className?: string): HTMLElementTagNameMap[K];
6
6
  /**
7
7
  * Points an anchor out of the dialog. Every link here opens in a new tab: the dialog is
@@ -9,6 +9,15 @@ export declare function node<K extends keyof HTMLElementTagNameMap>(tag: K, text
9
9
  * take the reader off the page the pill was on. `noreferrer` keeps the opener unreachable.
10
10
  */
11
11
  export declare function outward(anchor: HTMLAnchorElement, url: string): HTMLAnchorElement;
12
+ /**
13
+ * Where the verifier lists every public record of a record's subject. A record whose
14
+ * address is not the usual one is left linking to itself.
15
+ */
16
+ export declare function subjectUrl(e: Evidence): string;
17
+ /** A cross drawn in a square, so it sits in the middle of its button in any font. */
18
+ export declare const closeMark: () => SVGSVGElement;
19
+ /** Points back the way the reader came, drawn for the same reason the cross is. */
20
+ export declare const backMark: () => SVGSVGElement;
12
21
  /** Joins the two cards: the link itself, drawn rather than described. */
13
22
  export declare function linkMark(): SVGSVGElement;
14
23
  /**
@@ -16,7 +25,7 @@ export declare function linkMark(): SVGSVGElement;
16
25
  * `extra` carries evidence belonging to that side alone: a provider authenticates and
17
26
  * expires on its own terms, and a second provider on the same subject would differ.
18
27
  */
19
- export declare function accountCard(heading: Node[], name: string, reference: string | undefined, url?: string, ...extra: HTMLElement[]): HTMLElement;
28
+ export declare function accountCard(heading: Node[], name: string, reference: string | string[] | undefined, url?: string, ...extra: HTMLElement[]): HTMLElement;
20
29
  /**
21
30
  * A record as a card shows it. Where the card speaks for several records of one account,
22
31
  * `links` names every one not yet revoked, whether or not it is shown, so removing the
@@ -74,4 +83,9 @@ arrange?: (records: Evidence[]) => Account[],
74
83
  * Given where whoever is looking holds these accounts: each then has its renewal and
75
84
  * removal under it, and the dialog offers to connect another.
76
85
  */
77
- manage?: Manage): void;
86
+ manage?: Manage,
87
+ /**
88
+ * Whether the cards of several accounts start with their details open. They start shut
89
+ * unless the page asked otherwise, and a reader may turn each either way from there.
90
+ */
91
+ detailsOpen?: boolean): void;