@bkazemi/verily 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -10
- package/dist/browser/badge.d.ts +4 -1
- package/dist/browser/badge.js +7 -4
- package/dist/browser/connect-dialog.js +10 -6
- package/dist/browser/evidence-dialog.d.ts +17 -3
- package/dist/browser/evidence-dialog.js +279 -29
- package/dist/browser/index.d.ts +7 -1
- package/dist/browser/index.js +52 -64
- package/dist/core/index.d.ts +95 -4
- package/dist/core/index.js +59 -5
- package/dist/core/routes.d.ts +57 -0
- package/dist/core/routes.js +52 -0
- package/dist/server/domain.d.ts +76 -10
- package/dist/server/domain.js +140 -51
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +148 -34
- package/dist/server/openpgp.d.ts +4 -0
- package/dist/server/openpgp.js +10 -1
- package/dist/server/pgp.d.ts +2 -0
- package/dist/server/pgp.js +1 -1
- package/dist/server/service.d.ts +87 -2
- package/dist/server/service.js +248 -25
- package/dist/server/style.d.ts +1 -1
- package/dist/server/style.js +40 -0
- package/dist/site/index.js +2 -2
- package/dist/verily.js +371 -95
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/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.
|
|
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. |
|
|
226
|
-
| `wellKnownProvider()` | serving a text file at a fixed path on their domain, then entering the domain. |
|
|
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 `/
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/dist/browser/badge.d.ts
CHANGED
|
@@ -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
|
package/dist/browser/badge.js
CHANGED
|
@@ -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 =
|
|
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:
|
|
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:
|
|
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
|
|
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;
|