@bkazemi/verily 0.9.4 → 0.10.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.
@@ -1,4 +1,4 @@
1
- import { accountsOf } from '../core/index.js';
1
+ import { accountsOf, parseMaxAge, withMaxAge } from '../core/index.js';
2
2
  import { routePath } from '../core/routes.js';
3
3
  import { badgeShown, quietBadge, renderBadge, renderBadgeMessage, renderBadgePending, renderConnectPill, } from './badge.js';
4
4
  import { openConnectDialog, } from './connect-dialog.js';
@@ -92,7 +92,7 @@ function details(element) {
92
92
  throw error;
93
93
  }
94
94
  }
95
- }, latestGroup.get(element), accounts, managers.get(element), openHosts.has(element));
95
+ }, latestGroup.get(element), (records) => accounts(aged(element, records)), managers.get(element), openHosts.has(element));
96
96
  }
97
97
  }
98
98
  /**
@@ -103,6 +103,24 @@ function details(element) {
103
103
  const stackedHosts = new WeakSet();
104
104
  const quietHosts = new WeakSet();
105
105
  const openHosts = new WeakSet();
106
+ /** The max age each host was given, where the page set one. */
107
+ const maxAges = new WeakMap();
108
+ /** Sets or clears a host's max age. */
109
+ function bound(element, maxAgeMs) {
110
+ if (maxAgeMs === undefined)
111
+ maxAges.delete(element);
112
+ else
113
+ maxAges.set(element, maxAgeMs);
114
+ }
115
+ /**
116
+ * The records as this host's max age has them read. Call it on records already resolved
117
+ * by `standing()`, since a signed record can carry different proof dates.
118
+ */
119
+ function aged(element, records) {
120
+ const maxAgeMs = maxAges.get(element);
121
+ const now = Date.now();
122
+ return records.map((e) => withMaxAge(e, maxAgeMs, now));
123
+ }
106
124
  /**
107
125
  * How many accounts a pill names before it counts the rest. A stacked pill pays for each
108
126
  * row in the page's height. A panel floats over the page and pays nothing, but it cannot
@@ -111,7 +129,8 @@ const openHosts = new WeakSet();
111
129
  const stackedAccounts = 4;
112
130
  const peekedAccounts = 8;
113
131
  /** Records how a host's pill is arranged, from what the page last said. */
114
- function arrange(element, { stacked, peek, detailsOpen }) {
132
+ function arrange(element, { stacked, peek, detailsOpen, maxAgeMs }) {
133
+ bound(element, maxAgeMs);
115
134
  if (detailsOpen)
116
135
  openHosts.add(element);
117
136
  else
@@ -164,7 +183,7 @@ function accounts(records) {
164
183
  * `load` gives the records afresh when the dialog opens and as it stays open.
165
184
  */
166
185
  function presentGroup(element, records, load) {
167
- const ordered = accounts(records.map(standing));
186
+ const ordered = accounts(aged(element, records.map(standing)));
168
187
  const verified = ordered.filter(live);
169
188
  const lead = verified[0] ?? ordered[0];
170
189
  latestGroup.set(element, records);
@@ -448,7 +467,8 @@ export function init({ backendUrl, handoffUrl }) {
448
467
  throw new Error('Invalid evidence response');
449
468
  return data;
450
469
  },
451
- async mountBadge(element, { connectionId, evidence }) {
470
+ async mountBadge(element, { connectionId, evidence, maxAgeMs, }) {
471
+ bound(element, maxAgeMs);
452
472
  // Evidence already in hand renders at once. Otherwise a host with nothing to show
453
473
  // gets the waiting pill, whose frame and mark the finished badge keeps; a host that
454
474
  // already shows a badge keeps it up until the check answers, so a periodic refresh
@@ -477,7 +497,7 @@ export function init({ backendUrl, handoffUrl }) {
477
497
  return [fresh];
478
498
  });
479
499
  const draw = () => {
480
- const badge = renderBadge(element, standing(e));
500
+ const badge = renderBadge(element, aged(element, [standing(e)])[0]);
481
501
  // Null means the pill on screen is unchanged. Its handler stands, and reads the above.
482
502
  if (badge)
483
503
  opens(element, badge);
@@ -503,7 +523,10 @@ export function init({ backendUrl, handoffUrl }) {
503
523
  const ids = [...new Set(connectionIds)];
504
524
  arrange(element, arrangement);
505
525
  if (ids.length === 1)
506
- return client.mountBadge(element, { connectionId: ids[0] });
526
+ return client.mountBadge(element, {
527
+ connectionId: ids[0],
528
+ maxAgeMs: arrangement.maxAgeMs,
529
+ });
507
530
  if (!badgeShown(element))
508
531
  renderBadgePending(element);
509
532
  const read = async () => {
@@ -713,6 +736,7 @@ if (typeof customElements !== 'undefined' && !customElements.get('verily-badge')
713
736
  'stacked',
714
737
  'peek',
715
738
  'details-open',
739
+ 'max-age',
716
740
  ];
717
741
  handed;
718
742
  /**
@@ -773,6 +797,7 @@ if (typeof customElements !== 'undefined' && !customElements.get('verily-badge')
773
797
  stacked: this.getAttribute('stacked') !== null,
774
798
  peek: this.getAttribute('peek') !== 'off',
775
799
  detailsOpen: this.getAttribute('details-open') !== null,
800
+ maxAgeMs: parseMaxAge(this.getAttribute('max-age')),
776
801
  };
777
802
  // The holder's own badge, where the page says so: its dialog connects another
778
803
  // account, renews one and removes one, through the backend named. The host hears
@@ -838,7 +863,11 @@ if (typeof customElements !== 'undefined' && !customElements.get('verily-badge')
838
863
  return;
839
864
  }
840
865
  if (backendUrl)
841
- void init({ backendUrl }).mountBadge(this, { connectionId, evidence });
866
+ void init({ backendUrl }).mountBadge(this, {
867
+ connectionId,
868
+ evidence,
869
+ maxAgeMs: arrangement.maxAgeMs,
870
+ });
842
871
  }
843
872
  });
844
873
  }
@@ -0,0 +1,2 @@
1
+ declare const _default: "@media (prefers-reduced-motion: no-preference) {\n .mark.current path:last-child {\n animation: verily-draw 0.8s cubic-bezier(0.65, 0, 0.35, 1) 0.1s backwards;\n }\n}\n@keyframes verily-draw {\n from {\n stroke-dasharray: 0 176;\n }\n to {\n stroke-dasharray: 108 176;\n }\n}\n";
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ // Generated from mark.css by scripts/css.mjs. Do not edit.
2
+ export default "@media (prefers-reduced-motion: no-preference) {\n .mark.current path:last-child {\n animation: verily-draw 0.8s cubic-bezier(0.65, 0, 0.35, 1) 0.1s backwards;\n }\n}\n@keyframes verily-draw {\n from {\n stroke-dasharray: 0 176;\n }\n to {\n stroke-dasharray: 108 176;\n }\n}\n";
@@ -4,7 +4,7 @@ export type { MarkTone };
4
4
  * The check draws itself in from its left end once the verification holds: when a pill's
5
5
  * mark first becomes `current`, and each time the dialog opens on a current one.
6
6
  */
7
- export declare const markStyles = "\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";
7
+ export declare const markStyles = "@media (prefers-reduced-motion: no-preference) {\n .mark.current path:last-child {\n animation: verily-draw 0.8s cubic-bezier(0.65, 0, 0.35, 1) 0.1s backwards;\n }\n}\n@keyframes verily-draw {\n from {\n stroke-dasharray: 0 176;\n }\n to {\n stroke-dasharray: 108 176;\n }\n}\n";
8
8
  /** Repaints a mark in place, so answering a check never replaces the drawing. */
9
9
  export declare function paintMark(svg: SVGSVGElement, tone: MarkTone): void;
10
10
  /** V2: the green check fills its section of the symmetric red V. */
@@ -1,14 +1,10 @@
1
1
  import { tones, verification } from '../marks.js';
2
+ import markCss from './mark.css.js';
2
3
  /**
3
4
  * The check draws itself in from its left end once the verification holds: when a pill's
4
5
  * mark first becomes `current`, and each time the dialog opens on a current one.
5
6
  */
6
- export const markStyles = `
7
- @media (prefers-reduced-motion: no-preference) {
8
- .mark.current path:last-child { animation: verily-draw .8s cubic-bezier(.65, 0, .35, 1) .1s backwards; }
9
- }
10
- @keyframes verily-draw { from { stroke-dasharray: 0 176; } to { stroke-dasharray: 108 176; } }
11
- `;
7
+ export const markStyles = markCss;
12
8
  /** Repaints a mark in place, so answering a check never replaces the drawing. */
13
9
  export function paintMark(svg, tone) {
14
10
  const colors = tones[tone];
@@ -615,6 +615,30 @@ export declare function fresh(attestation: Attestation, now: number, freshness?:
615
615
  * proved when it happened, and a published proof when it was last read.
616
616
  */
617
617
  export declare function lastProved(evidence: Pick<Evidence, 'authenticatedAt' | 'attestations'>): number;
618
+ /**
619
+ * Whether a sign-in or mailed code happened more than a day before the record was last
620
+ * approved. That only happens after a renewal by another method. Otherwise the two dates
621
+ * match and a card shows only the approval.
622
+ */
623
+ export declare function authenticatedEarlier(evidence: Pick<Evidence, 'authenticatedAt' | 'approvedAt'>): boolean;
624
+ /**
625
+ * Whether a record's account was last proved within `maxAgeMs` of `now`. A reader who
626
+ * wants recent proof sets its own bound: a sign-in counts once, a year ago or this morning,
627
+ * and only the reader knows how old is too old.
628
+ */
629
+ export declare function recent(evidence: Pick<Evidence, 'authenticatedAt' | 'attestations'>, maxAgeMs: number, now: number): boolean;
630
+ /**
631
+ * The record as a reader with a `maxAgeMs` bound sees it. A verified record last proved
632
+ * before the bound reads as unconfirmed: not disproved, only not proved recently enough.
633
+ * With no bound the record is returned as is.
634
+ */
635
+ export declare function withMaxAge<T extends Evidence>(evidence: T, maxAgeMs: number | undefined, now: number): T;
636
+ /**
637
+ * A max age written as a whole number with an optional unit: `s`, `m`, `h` or `d`. A bare
638
+ * number is seconds, as in HTTP and OpenID Connect. Returns milliseconds, or undefined
639
+ * for anything else.
640
+ */
641
+ export declare function parseMaxAge(text: string | null | undefined): number | undefined;
618
642
  /**
619
643
  * The word for a record's state. Evidence can be read after it was issued, so an approval
620
644
  * that has run out since reads as expired whatever status it was issued with.
@@ -80,6 +80,46 @@ export function lastProved(evidence) {
80
80
  const main = evidence.attestations.external[0];
81
81
  return main.artifactUrl ? main.confirmedAt : evidence.authenticatedAt;
82
82
  }
83
+ /**
84
+ * Whether a sign-in or mailed code happened more than a day before the record was last
85
+ * approved. That only happens after a renewal by another method. Otherwise the two dates
86
+ * match and a card shows only the approval.
87
+ */
88
+ export function authenticatedEarlier(evidence) {
89
+ return evidence.approvedAt - evidence.authenticatedAt > 86400000;
90
+ }
91
+ /**
92
+ * Whether a record's account was last proved within `maxAgeMs` of `now`. A reader who
93
+ * wants recent proof sets its own bound: a sign-in counts once, a year ago or this morning,
94
+ * and only the reader knows how old is too old.
95
+ */
96
+ export function recent(evidence, maxAgeMs, now) {
97
+ return now - lastProved(evidence) <= maxAgeMs;
98
+ }
99
+ /**
100
+ * The record as a reader with a `maxAgeMs` bound sees it. A verified record last proved
101
+ * before the bound reads as unconfirmed: not disproved, only not proved recently enough.
102
+ * With no bound the record is returned as is.
103
+ */
104
+ export function withMaxAge(evidence, maxAgeMs, now) {
105
+ return maxAgeMs !== undefined &&
106
+ evidence.status === 'verified' &&
107
+ !recent(evidence, maxAgeMs, now)
108
+ ? { ...evidence, status: 'unconfirmed' }
109
+ : evidence;
110
+ }
111
+ /**
112
+ * A max age written as a whole number with an optional unit: `s`, `m`, `h` or `d`. A bare
113
+ * number is seconds, as in HTTP and OpenID Connect. Returns milliseconds, or undefined
114
+ * for anything else.
115
+ */
116
+ export function parseMaxAge(text) {
117
+ const match = /^\s*(\d+)\s*([smhd]?)\s*$/.exec(text ?? '');
118
+ if (!match)
119
+ return undefined;
120
+ const unit = { '': 1000, s: 1000, m: 60000, h: 3600000, d: 86400000 }[match[2]];
121
+ return Number(match[1]) * unit;
122
+ }
83
123
  /**
84
124
  * The word for a record's state. Evidence can be read after it was issued, so an approval
85
125
  * that has run out since reads as expired whatever status it was issued with.
@@ -1,9 +1,13 @@
1
1
  #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
2
3
  import { serve } from './serve.js';
3
4
  import { init } from './init.js';
5
+ import { githubApp } from './github-app.js';
4
6
  const usage = `Usage:
5
7
  verily serve Run an instance on Node, from the environment.
6
- verily init cloudflare [dir] Write a Cloudflare project into dir.`;
8
+ verily init cloudflare [dir] Write a Cloudflare project into dir.
9
+ verily github-app <callback>... [--org <name>] [--name <name>]
10
+ Create a GitHub App for sign-in, up to 10 callbacks.`;
7
11
  /** Writes the starter, then says what to do with it. */
8
12
  async function start(directory) {
9
13
  await init(directory);
@@ -15,6 +19,29 @@ Next, in that directory:
15
19
  3. npx wrangler secret put OWNER_KEY
16
20
  4. npm run deploy`);
17
21
  }
22
+ /** Runs the manifest flow, then prints the app's credentials. */
23
+ async function createGithubApp(args) {
24
+ const { values, positionals } = parseArgs({
25
+ args,
26
+ allowPositionals: true,
27
+ options: { org: { type: 'string' }, name: { type: 'string' } },
28
+ });
29
+ const app = await githubApp({
30
+ callbacks: positionals,
31
+ org: values.org,
32
+ name: values.name,
33
+ onReady: (url) => console.log(`Open ${url} in a browser signed in to GitHub.`),
34
+ });
35
+ console.log(`Created ${app.htmlUrl}
36
+
37
+ GITHUB_CLIENT_ID=${app.clientId}
38
+ GITHUB_CLIENT_SECRET=${app.clientSecret}
39
+
40
+ Keep the secret on the server. GitHub shows it only this once.
41
+
42
+ To show a logo when people sign in, upload one under Display information at
43
+ ${app.htmlUrl}`);
44
+ }
18
45
  const [command, target, directory] = process.argv.slice(2);
19
46
  try {
20
47
  if (command === 'serve') {
@@ -25,6 +52,9 @@ try {
25
52
  else if (command === 'init' && target === 'cloudflare') {
26
53
  await start(directory ?? '.');
27
54
  }
55
+ else if (command === 'github-app') {
56
+ await createGithubApp(process.argv.slice(3));
57
+ }
28
58
  else {
29
59
  console.error(usage);
30
60
  process.exit(command === undefined || command === '--help' || command === '-h' ? 0 : 2);
@@ -0,0 +1,26 @@
1
+ /** What `githubApp()` gets back from GitHub once the app is created. */
2
+ export interface GithubAppCredentials {
3
+ clientId: string;
4
+ clientSecret: string;
5
+ /** The app's settings page on GitHub. */
6
+ htmlUrl: string;
7
+ }
8
+ /**
9
+ * Registers a GitHub App for sign-in through GitHub's manifest flow. A local server serves a
10
+ * form that posts the manifest to GitHub, the holder clicks Create there, and GitHub
11
+ * redirects back with a code that is exchanged for the app's client id and secret.
12
+ *
13
+ * The app is public, so any GitHub account can sign in with it, and asks for no
14
+ * permissions and no webhook.
15
+ */
16
+ export declare function githubApp(options: {
17
+ /** The callback URLs to register, at most 10. */
18
+ callbacks: string[];
19
+ /** The organization to own the app. Without one, the signed-in GitHub account owns it. */
20
+ org?: string;
21
+ /** The app's name, unique across GitHub. Defaults to one made from the first callback. */
22
+ name?: string;
23
+ fetch?: typeof fetch;
24
+ /** Called with the local address to open in a browser. */
25
+ onReady: (url: string) => void;
26
+ }): Promise<GithubAppCredentials>;
@@ -0,0 +1,94 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { createServer } from 'node:http';
3
+ import { escape } from '../server/escape.js';
4
+ /**
5
+ * Registers a GitHub App for sign-in through GitHub's manifest flow. A local server serves a
6
+ * form that posts the manifest to GitHub, the holder clicks Create there, and GitHub
7
+ * redirects back with a code that is exchanged for the app's client id and secret.
8
+ *
9
+ * The app is public, so any GitHub account can sign in with it, and asks for no
10
+ * permissions and no webhook.
11
+ */
12
+ export async function githubApp(options) {
13
+ const { callbacks, org } = options;
14
+ const request = options.fetch ?? fetch;
15
+ if (callbacks.length === 0)
16
+ throw new Error('Name at least one callback URL');
17
+ if (callbacks.length > 10)
18
+ throw new Error('GitHub allows at most 10 callback URLs');
19
+ for (const callback of callbacks)
20
+ if (!URL.canParse(callback) || !/^https?:$/.test(new URL(callback).protocol))
21
+ throw new Error(`${callback} is not an http or https URL`);
22
+ if (org !== undefined && !/^[a-z\d](?:[a-z\d-]*[a-z\d])?$/i.test(org))
23
+ throw new Error(`${org} is not a GitHub organization name`);
24
+ // GitHub caps app names at 34 characters.
25
+ const name = options.name ?? `Verily ${new URL(callbacks[0]).hostname}`.slice(0, 34);
26
+ const state = randomBytes(16).toString('base64url');
27
+ const server = createServer();
28
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
29
+ const local = `http://127.0.0.1:${server.address().port}`;
30
+ const manifest = {
31
+ name,
32
+ description: `Sign-in for Verily on ${new URL(callbacks[0]).hostname}.`,
33
+ url: new URL(callbacks[0]).origin,
34
+ redirect_url: `${local}/done`,
35
+ callback_urls: callbacks,
36
+ public: true,
37
+ hook_attributes: { url: new URL(callbacks[0]).origin, active: false },
38
+ default_permissions: {},
39
+ };
40
+ const target = new URL(org
41
+ ? `https://github.com/organizations/${org}/settings/apps/new`
42
+ : 'https://github.com/settings/apps/new');
43
+ target.searchParams.set('state', state);
44
+ const credentials = new Promise((resolve, reject) => {
45
+ server.on('request', (req, res) => {
46
+ const url = new URL(req.url ?? '/', local);
47
+ const reply = (status, title, text, body = '') => {
48
+ res.writeHead(status, { 'Content-Type': 'text/html; charset=utf-8' });
49
+ res.end(`<!doctype html><meta charset="utf-8"><title>${escape(title)}</title><p>${escape(text)}</p>${body}`);
50
+ };
51
+ if (url.pathname === '/') {
52
+ // The form submits itself, with a button for when scripts are off.
53
+ return reply(200, 'Create the GitHub App', 'Sending you to GitHub to create the app.', `<form method="post" action="${escape(target.href)}">` +
54
+ `<input type="hidden" name="manifest" value="${escape(JSON.stringify(manifest))}">` +
55
+ '<button>Continue to GitHub</button></form>' +
56
+ '<script>document.forms[0].submit()</script>');
57
+ }
58
+ if (url.pathname !== '/done')
59
+ return reply(404, 'Not found', 'Not found.');
60
+ const code = url.searchParams.get('code');
61
+ // A request without the state this run made did not come from GitHub's redirect.
62
+ if (url.searchParams.get('state') !== state || !code)
63
+ return reply(400, 'Wrong link', 'This link is not from this run. Nothing was created.');
64
+ void convert(code).then((result) => {
65
+ reply(200, 'GitHub App created', 'The app is created. Go back to the terminal.');
66
+ resolve(result);
67
+ }, (error) => {
68
+ reply(502, 'Failed', 'GitHub did not return the app. See the terminal.');
69
+ reject(error);
70
+ });
71
+ });
72
+ });
73
+ async function convert(code) {
74
+ const response = await request(`https://api.github.com/app-manifests/${encodeURIComponent(code)}/conversions`, {
75
+ method: 'POST',
76
+ headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'Verily-V0' },
77
+ signal: AbortSignal.timeout(15000),
78
+ });
79
+ const app = await response.json().catch(() => undefined);
80
+ if (!response.ok ||
81
+ !isRecord(app) ||
82
+ typeof app.client_id !== 'string' ||
83
+ typeof app.client_secret !== 'string' ||
84
+ typeof app.html_url !== 'string')
85
+ throw new Error(`GitHub did not return the app (HTTP ${response.status})`);
86
+ return { clientId: app.client_id, clientSecret: app.client_secret, htmlUrl: app.html_url };
87
+ }
88
+ options.onReady(`${local}/`);
89
+ // Closing waits for the last reply to be sent, then drops idle connections.
90
+ return await credentials.finally(() => server.close());
91
+ }
92
+ function isRecord(value) {
93
+ return typeof value === 'object' && value !== null;
94
+ }
@@ -907,8 +907,10 @@ class Server {
907
907
  html `<p class="evidence"><a href="${e.evidenceUrl}">Inspect evidence</a></p>`, html `<dl>${rows.map(([label, value]) => html `<dt>${label}</dt><dd>${value}</dd>`)}</dl>`, shown && html `<p class="fine">${shown}</p>`, !site &&
908
908
  e.visibility === 'public' &&
909
909
  html `
910
- <p class="fine">Embed on your site</p>
911
- <pre><code>${highlightHtml(embed)}</code></pre>
910
+ <details class="embed">
911
+ <summary class="fine">Embed on your site</summary>
912
+ <pre><code>${highlightHtml(embed)}</code></pre>
913
+ </details>
912
914
  `, html `
913
915
  ${(goes.length > 0 || listing) && html `<div class="actions">${goes}${listing}</div>`}
914
916
  ${ending}
@@ -20,7 +20,7 @@ const wrangler = `// Your instance's Cloudflare settings. Fill in account_id and
20
20
  "account_id": "YOUR_CLOUDFLARE_ACCOUNT_ID",
21
21
  "main": "worker.ts",
22
22
  "compatibility_date": "2026-07-01",
23
- "compatibility_flags": ["nodejs_compat"],
23
+ "compatibility_flags": ["nodejs_compat", "global_fetch_strictly_public"],
24
24
  // The library loads undici only on Node, so the Worker build gets a stand-in for it.
25
25
  "alias": { "undici": "@bkazemi/verily/instance/undici" },
26
26
 
@@ -1,4 +1,4 @@
1
- import { attestationLabel, externalName, isArtifactProvider, isCodeProvider, isRedirectProvider, lastProved, accountsOf, localSide, proofTitle, providerMethod, statusLabel, signedBy, } from '../core/index.js';
1
+ import { attestationLabel, externalName, isArtifactProvider, isCodeProvider, isRedirectProvider, authenticatedEarlier, lastProved, accountsOf, localSide, proofTitle, providerMethod, statusLabel, signedBy, } from '../core/index.js';
2
2
  import { artifactAttempts, codeAttempts, VerilyService, Unavailable, } from './service.js';
3
3
  import { copyScript, waitScript } from './copy.js';
4
4
  import { html } from './html.js';
@@ -400,7 +400,9 @@ function accountCard(e, external, now) {
400
400
  : [
401
401
  ['Approved', e.approvedAt],
402
402
  [e.expiresAt > now ? 'Valid until' : 'Expired on', e.expiresAt],
403
- ['Last checked', external[0].artifactUrl ? external[0].confirmedAt : undefined],
403
+ external[0].artifactUrl
404
+ ? ['Last checked', external[0].confirmedAt]
405
+ : ['Authenticated', authenticatedEarlier(e) ? e.authenticatedAt : undefined],
404
406
  ])}
405
407
  </div>
406
408
  `,
@@ -142,6 +142,8 @@ export declare class VerilyService {
142
142
  /** What a signed record says, if this instance signed it. */
143
143
  checked(signed: unknown): Promise<SignedDocument | undefined>;
144
144
  private get sendLimits();
145
+ /** How long an approval stands. A published proof that is still up extends it. */
146
+ private get validity();
145
147
  /** A proof is only as fresh as the schedule that reads it, so both live together. */
146
148
  private get freshness();
147
149
  validateLocal(local: LocalAccount): LocalAccount;
@@ -173,6 +173,10 @@ export class VerilyService {
173
173
  address: this.options.sendLimits?.address ?? 5,
174
174
  };
175
175
  }
176
+ /** How long an approval stands. A published proof that is still up extends it. */
177
+ get validity() {
178
+ return this.options.validityMs ?? 365 * 86400000;
179
+ }
176
180
  /** A proof is only as fresh as the schedule that reads it, so both live together. */
177
181
  get freshness() {
178
182
  return this.options.freshnessMs ?? freshnessMs;
@@ -925,7 +929,7 @@ export class VerilyService {
925
929
  authenticatedAt: flow.authenticatedAt,
926
930
  approvedAt: this.now(),
927
931
  connectedAt: this.now(),
928
- expiresAt: this.now() + (this.options.validityMs ?? 30 * 86400000),
932
+ expiresAt: this.now() + this.validity,
929
933
  attestations: {
930
934
  local: this.declared(flow),
931
935
  external: [this.attestation(flow, provider, connectionId)],
@@ -1229,7 +1233,7 @@ export class VerilyService {
1229
1233
  const created = createdAt === undefined ? {} : { createdAt };
1230
1234
  connection.local = flow.local;
1231
1235
  connection.approvedAt = this.now();
1232
- connection.expiresAt = this.now() + (this.options.validityMs ?? 30 * 86400000);
1236
+ connection.expiresAt = this.now() + this.validity;
1233
1237
  connection.revocationReason = undefined;
1234
1238
  if (attestation.method === main.method) {
1235
1239
  connection.authenticatedAt = flow.authenticatedAt;
@@ -1537,6 +1541,19 @@ export class VerilyService {
1537
1541
  const interval = this.options.recheckMs ?? 86400000;
1538
1542
  const now = this.now();
1539
1543
  const spent = new Map();
1544
+ // Validity counts from approval, so a longer term set since applies to running records.
1545
+ await this.transaction(async (tx) => {
1546
+ for (const connection of await tx.list('connections')) {
1547
+ const term = connection.approvedAt + this.validity;
1548
+ if (connection.revokedAt === undefined &&
1549
+ connection.retiredAt === undefined &&
1550
+ connection.expiresAt > now &&
1551
+ connection.expiresAt < term) {
1552
+ connection.expiresAt = term;
1553
+ await tx.put('connections', connection.id, connection);
1554
+ }
1555
+ }
1556
+ });
1540
1557
  // When a proof was last read, or last tried. One whose last read did not confirm it,
1541
1558
  // because the read failed or the proof was missing, is tried again four times as soon.
1542
1559
  const tried = (a) => a.readAt ?? a.confirmedAt;
@@ -1718,6 +1735,10 @@ export class VerilyService {
1718
1735
  }
1719
1736
  held.confirmedAt = started;
1720
1737
  delete held.absentAt;
1738
+ // A proof still up keeps its record running. It is moved only once half the term is
1739
+ // gone, so a daily read does not report a new expiry every time.
1740
+ if (current.expiresAt > started && current.expiresAt - started < this.validity / 2)
1741
+ current.expiresAt = started + this.validity;
1721
1742
  // A zone can start or stop being signed, so the flag is as the last read found it.
1722
1743
  if (dnssec)
1723
1744
  held.dnssec = true;
@@ -0,0 +1,2 @@
1
+ declare const _default: ":root {\n --ink: #23312b;\n --muted: #6b786f;\n --line: #dce2de;\n --surface: #fff;\n --raised: #f7f9f7;\n --accent: #245f43;\n --warn: #a4442b;\n --caution: #b07a12;\n --syntax-tag: #245f43;\n --syntax-attr: #8a5a0b;\n --syntax-string: #2b5f8a;\n color-scheme: light dark;\n accent-color: var(--accent);\n}\n\n@media (prefers-color-scheme: dark) {\n :root {\n --ink: #dde5e0;\n --muted: #96a39b;\n --line: #2f3b35;\n --surface: #161a18;\n --raised: #1d2220;\n --accent: #7fc4a2;\n --warn: #e59a82;\n --caution: #d9a843;\n --syntax-tag: #7fc4a2;\n --syntax-attr: #d9a843;\n --syntax-string: #8fbbe0;\n }\n}\n\n* {\n box-sizing: border-box;\n}\n\nbody {\n margin: 0;\n padding: 0 1rem 4rem;\n background: var(--surface);\n color: var(--ink);\n font:\n 16px/1.65 system-ui,\n -apple-system,\n 'Segoe UI',\n sans-serif;\n text-rendering: optimizeLegibility;\n}\n\nmain {\n max-width: 34rem;\n margin: 0 auto;\n padding-top: 3rem;\n}\n\n/*\n * The logotype, above the page's own heading, on its own dark plate. Sized so the word is\n * the height it was without the plate, whose margin makes the box that much taller. The\n * word is white in either scheme: the plate is what it is read against, not the page.\n */\n.logo {\n display: block;\n height: 2.5rem;\n margin-bottom: 1.5rem;\n}\n\nh1 {\n margin: 0 0 1.75rem;\n padding-bottom: 1rem;\n border-bottom: 1px solid var(--line);\n font-size: 1.5rem;\n font-weight: 620;\n letter-spacing: -0.02em;\n line-height: 1.25;\n}\n\n/*\n * A page holding one thing needs no rule under its heading. The rule divides a heading\n * from the record beneath it, and a page that only asks for a key has no record to divide.\n */\nmain.single h1 {\n margin-bottom: 1.25rem;\n padding-bottom: 0;\n border-bottom: none;\n}\n\n/*\n * A page holding one thing sits in the middle of the window rather than at its top,\n * where it would leave most of the window empty below it.\n */\nbody:has(main.single) {\n display: flex;\n min-height: 100vh;\n min-height: 100dvh;\n}\nmain.single {\n width: 100%;\n max-width: 24rem;\n margin: auto;\n padding-bottom: 3rem;\n}\n\nh2 {\n margin: 2rem 0 0.75rem;\n font-size: 1.125rem;\n font-weight: 620;\n letter-spacing: -0.01em;\n}\n\nh3 {\n margin: 0 0 0.5rem;\n font-size: 1rem;\n font-weight: 620;\n}\n\np {\n margin: 0 0 1rem;\n overflow-wrap: break-word;\n}\n\na {\n color: var(--accent);\n text-underline-offset: 3px;\n overflow-wrap: anywhere;\n}\n\na:hover {\n text-decoration-thickness: 2px;\n}\n\n/*\n * Something to be run or published exactly as written. Its own line breaks are kept and a\n * long line is soft-wrapped rather than cut off: a soft wrap is drawn, not inserted, so\n * what is copied is still character for character what was written. Selecting inside the\n * block takes all of it, since half a command is worse than none.\n */\npre {\n position: relative;\n margin: 0 0 1rem;\n /* Room along the top line for the copy button, which sits over the block rather than in it. */\n padding: 0.75rem 5.25rem 0.75rem 0.875rem;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--raised);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n user-select: all;\n}\n\ncode {\n font:\n 0.8125rem/1.6 ui-monospace,\n SFMono-Regular,\n Menlo,\n monospace;\n}\n\n/* Highlighted HTML, marked up by highlight.js. Brackets stay muted so the names read first. */\n.hljs-tag {\n color: var(--muted);\n}\n.hljs-name {\n color: var(--syntax-tag);\n}\n.hljs-attr {\n color: var(--syntax-attr);\n}\n.hljs-string {\n color: var(--syntax-string);\n}\n\n/* Made by a script, so it exists only where it works. Quiet: the block is the point. */\n.copy {\n position: absolute;\n top: 0.5rem;\n right: 0.5rem;\n padding: 0.1875rem 0.5rem;\n border: 1px solid var(--line);\n border-radius: 5px;\n background: var(--surface);\n color: var(--muted);\n font:\n 500 0.75rem/1.5 system-ui,\n sans-serif;\n user-select: none;\n}\n\n.copy:hover {\n color: var(--ink);\n border-color: var(--muted);\n}\n\n/*\n * The two sides of the link, which is what a reader came to read. Each fact about a side\n * gets its own line: what it is, what it is called, the identifier behind that, and how it\n * was shown. Everything else on the page qualifies the pair, so it is set quieter.\n */\n.side {\n margin-bottom: 0.75rem;\n padding: 0.875rem 1rem;\n border: 1px solid var(--line);\n border-radius: 8px;\n background: var(--raised);\n}\n\n/*\n * A subject's accounts are peers, so where there is room they sit two across. The page\n * that lists them is wider for it, and a narrow one keeps them in a single column.\n */\nmain:has(.accounts) {\n max-width: 54rem;\n}\n\n.accounts {\n display: grid;\n grid-template-columns: minmax(0, 1fr);\n gap: 0.75rem;\n margin-bottom: 0.75rem;\n}\n\n.accounts .side {\n position: relative;\n display: flex;\n flex-direction: column;\n margin-bottom: 0;\n}\n\n/* How the holder lists the account, in the card's corner and out of the lines below. */\n.listing {\n position: absolute;\n top: 0.75rem;\n right: 0.875rem;\n padding: 0.0625rem 0.5rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n color: var(--muted);\n font-size: 0.6875rem;\n font-weight: 550;\n}\n\n.listing.preferred {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* The times sit at the foot of the card, so they line up along a row of uneven cards. */\n.accounts .side dl {\n margin-top: auto;\n}\n.accounts .rest {\n display: flex;\n flex-direction: column;\n}\n.accounts .rest > p:last-of-type {\n margin-bottom: 0.625rem;\n}\n\n/*\n * Two across, with the subject one card wide and centred above them. Every card on the\n * page is then the same width.\n */\n@media (min-width: 48rem) {\n .accounts {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }\n /* Each card is three rows of the grid: who, its verdict, and the rest. A row is as tall\n as the taller card's part, so the verdicts and times of neighbouring cards line up. */\n .accounts > .side {\n display: grid;\n grid-row: span 3;\n grid-template-rows: subgrid;\n row-gap: 0;\n }\n .subject {\n width: calc(50% - 0.375rem);\n margin-inline: auto;\n text-align: center;\n }\n .subject .name {\n justify-content: center;\n }\n}\n\n/* Between a subject and its accounts on the page that lists them. */\n.joiner {\n display: block;\n width: 1.25rem;\n height: 1.25rem;\n margin: 0 auto 0.75rem;\n color: var(--muted);\n}\n\n.who {\n margin: 0 0 0.125rem;\n color: var(--muted);\n font-size: 0.75rem;\n font-weight: 550;\n letter-spacing: 0.02em;\n}\n\n/* A provider's mark before its name, at the height of the letters. */\n.who .provider {\n display: inline-block;\n width: 0.875rem;\n height: 0.875rem;\n margin-right: 0.375rem;\n vertical-align: -0.125rem;\n}\n\n/* Clipped 4px left of the text, which hides the date's dot when the date wraps to a new line. */\n.name {\n display: flex;\n flex-wrap: wrap;\n align-items: baseline;\n gap: 0 1rem;\n margin: 0 0 0 -4px;\n padding-left: 4px;\n overflow-x: clip;\n font-size: 1.0625rem;\n font-weight: 620;\n letter-spacing: -0.01em;\n}\n\n/* The account's creation date, after the name and a dot. It wraps under a long name. */\n.name .since {\n position: relative;\n color: var(--muted);\n font-size: 0.8125rem;\n font-weight: 400;\n letter-spacing: 0;\n white-space: nowrap;\n}\n\n/* The dot sits in the gap before the date. */\n.name .since::before {\n content: '\u00B7';\n position: absolute;\n left: -0.5rem;\n transform: translateX(-50%);\n}\n\n/* The name is the card's subject before it is a link, so the rule is drawn on hover. */\n.name a {\n text-decoration: none;\n}\n\n.name a:hover {\n text-decoration: underline;\n}\n\n.reference,\n.how {\n margin: 0;\n color: var(--muted);\n font-size: 0.8125rem;\n line-height: 1.5;\n}\n\n.identity + .how {\n margin-top: 0.5rem;\n}\n.fingerprint {\n font-family: ui-monospace, monospace;\n text-wrap: balance;\n}\n.fingerprint span {\n display: inline-block;\n margin-right: 0.5ch;\n}\n.how.additional {\n padding-left: 0.75rem;\n}\n/* The \"signed\" pill after the verifier's name, one size smaller than the line it sits on. */\n.how .signed-mark {\n display: inline-block;\n margin-left: 0.3125rem;\n padding: 0 0.375rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n font-size: 0.625rem;\n font-weight: 550;\n line-height: 1.3;\n text-decoration: none;\n vertical-align: 0.0625rem;\n}\n\n.fine {\n color: var(--muted);\n font-size: 0.8125rem;\n line-height: 1.55;\n margin-bottom: 0.625rem;\n}\n\n/* That the record is signed is a fact about it, so it is set as one and not as fine print. */\n.signed {\n font-size: 0.875rem;\n line-height: 1.55;\n margin-bottom: 0.875rem;\n}\n/* Arrived at from a badge's \"signed\" link, so the reader is shown where they landed. */\n.signed:target {\n margin-inline: -0.75rem;\n padding: 0.625rem 0.75rem;\n border-radius: 8px;\n background: var(--raised);\n}\n\n/* Every time on the record in one column, so they can be compared rather than hunted for. */\ndl {\n display: grid;\n grid-template-columns: auto 1fr;\n gap: 0.3rem 1.5rem;\n margin: 1.25rem 0;\n padding-top: 1rem;\n border-top: 1px solid var(--line);\n font-size: 0.8125rem;\n}\n\ndt {\n color: var(--muted);\n}\n\ndd {\n margin: 0;\n font-variant-numeric: tabular-nums;\n}\n\n/* Inside a card the rule above it is the card's own edge, so the list needs none. */\nsection dl {\n margin: 0.5rem 0 1rem;\n padding-top: 0;\n border-top: 0;\n}\n\n/* Right under the heading, the heading's own rule is the one above it. */\nh1 + dl {\n padding-top: 0;\n border-top: 0;\n}\n.side dl {\n margin: 0.625rem 0 0;\n padding-top: 0.625rem;\n}\n\n/* The links under a record, ruled off from the record and its disclaimer. */\nfooter {\n margin-top: 1.25rem;\n padding-top: 1rem;\n border-top: 1px solid var(--line);\n}\nfooter > :last-child {\n margin-bottom: 0;\n}\n\nsection {\n margin: 0 0 1.25rem;\n padding: 1.25rem 1.375rem;\n border: 1px solid var(--line);\n border-radius: 10px;\n}\n\nsection > :last-child {\n margin-bottom: 0;\n}\n\n/* What holds a control that cannot be undone. */\nsection.danger,\nsection.caution {\n margin-top: 2rem;\n}\nsection.danger {\n border-color: var(--warn);\n}\n\n/* What holds a control that breaks something for a while, but can be recovered from. */\nsection.caution {\n border-color: var(--caution);\n}\nsection > h2:first-child {\n margin-top: 0;\n}\n\nform {\n margin: 0 0 1.5rem;\n}\n\nlabel {\n display: block;\n margin: 0 0 0.75rem;\n color: var(--muted);\n font-size: 0.8125rem;\n}\n\ninput,\ntextarea {\n display: block;\n width: 100%;\n margin-top: 0.375rem;\n padding: 0.5rem 0.625rem;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--surface);\n color: var(--ink);\n font:\n 0.875rem/1.5 ui-monospace,\n SFMono-Regular,\n Menlo,\n monospace;\n}\n\n/* Height comes from the rows attribute, which differs per box. */\ntextarea {\n resize: vertical;\n}\n\ninput:focus-visible,\ntextarea:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 1px;\n}\n\nbutton {\n padding: 0.5rem 1rem;\n border: 1px solid var(--accent);\n border-radius: 6px;\n background: var(--accent);\n color: var(--surface);\n font:\n 500 0.9375rem/1.4 system-ui,\n -apple-system,\n 'Segoe UI',\n sans-serif;\n cursor: pointer;\n}\n\n/* A link that starts something, drawn as a button. */\na.button {\n display: inline-block;\n padding: 0.5rem 1rem;\n border: 1px solid var(--accent);\n border-radius: 6px;\n background: var(--accent);\n color: var(--surface);\n font-weight: 500;\n font-size: 0.9375rem;\n line-height: 1.4;\n text-decoration: none;\n}\n\nbutton + button {\n margin-left: 0.5rem;\n border-color: var(--line);\n background: none;\n color: var(--ink);\n}\n\n/* The other choice beside a page's one action, such as Cancel. */\na.button.secondary {\n border-color: var(--line);\n background: none;\n color: var(--ink);\n}\n\n/* A page's action and its alternative, side by side. */\n.choice {\n display: flex;\n flex-wrap: wrap;\n gap: 0.5rem;\n margin-bottom: 1.5rem;\n}\n.choice form {\n margin: 0;\n}\n\n/* Ways to verify: one full-width button per method, none filled over the others. */\n.choices {\n display: grid;\n gap: 0.5rem;\n margin-bottom: 1.5rem;\n}\n.choices form {\n margin: 0;\n}\n\n.choices button {\n width: 100%;\n text-align: left;\n border-color: var(--line);\n background: var(--surface);\n color: var(--ink);\n}\n\n.choices button:hover {\n border-color: var(--muted);\n}\n\n/* A provider's mark before a button's label, at the height of the letters. */\nbutton .provider {\n width: 1rem;\n height: 1rem;\n margin-right: 0.5rem;\n vertical-align: -0.1875rem;\n}\n\n/* A control that ends something. Filled where it is the page's one action. */\nbutton.danger {\n border-color: var(--warn);\n background: var(--warn);\n}\n\n/*\n * A connection on the settings page: the account's card, then what can be done with it.\n * Its state sits in the card's corner, on the line that names the method.\n */\n.connection {\n position: relative;\n margin-bottom: 1rem;\n}\n\n.state {\n position: absolute;\n top: 0.75rem;\n right: 0.875rem;\n display: flex;\n align-items: center;\n gap: 0.375rem;\n height: 1.625rem;\n padding: 0 0.75rem 0 0.5rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n background: var(--surface);\n color: var(--ink);\n font-size: 0.8125rem;\n font-weight: 550;\n}\n\n/*\n * The V the badge draws, in the tone that says how the record stands. A record that claims\n * nothing is drawn faint in the text colour, as the badge draws it.\n */\n.verdict {\n flex-shrink: 0;\n width: 1rem;\n height: 1rem;\n}\n.verdict.pending {\n opacity: 0.45;\n}\n.how .verdict {\n width: 0.875rem;\n height: 0.875rem;\n margin-right: 0.3125rem;\n vertical-align: -0.125rem;\n}\n/* Cropped to its strokes and taller than the heading's letters, centred beside them. */\nh1:has(.verdict) {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n}\nh1 .verdict {\n width: auto;\n height: 2.25rem;\n}\n\n/*\n * In a state pill the V is cropped to its strokes and fills the pill's height, so its arms\n * and its foot meet the border. The pill's left padding keeps the arms clear of its curve.\n */\n.state .verdict {\n width: auto;\n height: 100%;\n}\n\n/*\n * A pill's line includes room for descenders below the letters, which leaves its text\n * sitting high. Trimmed to the letters, the padding alone sets the space above and below.\n * In a state pill the word alone is trimmed, so it centres on the V by its letters.\n */\n@supports (text-box: trim-both cap alphabetic) {\n .listing {\n padding: 0.3125rem 0.5rem;\n text-box: trim-both cap alphabetic;\n }\n .state > span {\n text-box: trim-both cap alphabetic;\n }\n}\n\n/* Links that sit together on one line, wrapping where there is no room. */\n.links {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 0.5rem 1.25rem;\n font-size: 0.875rem;\n}\n\n/* Under the account's id, with the facts about it rather than the controls. */\n.evidence {\n margin: 0.375rem 0 0;\n font-size: 0.8125rem;\n}\n\n.connection dl {\n margin-bottom: 0.75rem;\n}\n.connection .side > :last-child {\n margin-bottom: 0;\n}\n.connection pre {\n margin: 0.75rem 0;\n background: var(--surface);\n}\n\n/* The embed snippet is copied once, so it stays closed until asked for. */\n.embed summary {\n width: fit-content;\n cursor: pointer;\n}\n.embed summary:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 1px;\n}\n.embed pre {\n margin-top: 0;\n}\n.connection .actions {\n margin-top: 0.875rem;\n}\n\n/* The buttons for one connection in a row, wrapping where there is no room. */\n.actions {\n display: flex;\n flex-wrap: wrap;\n gap: 0.5rem;\n}\n.actions form {\n display: contents;\n}\n\n.actions button,\n.actions a.button {\n margin: 0;\n padding: 0.3125rem 0.75rem;\n border-color: var(--line);\n background: var(--surface);\n color: var(--ink);\n font-size: 0.8125rem;\n}\n\n.actions button:hover,\n.actions a.button:hover {\n border-color: var(--muted);\n}\n\n/* The controls that cannot be undone, together at the foot of the card. */\n.ending {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 0.5rem;\n margin-top: 0.75rem;\n padding-top: 0.75rem;\n border-top: 1px solid var(--line);\n}\n.ending form {\n margin: 0;\n}\n\n.ending button.danger {\n padding: 0.3125rem 0.75rem;\n background: var(--surface);\n color: var(--warn);\n font-size: 0.8125rem;\n}\n\nfieldset {\n margin: 0 0 1.25rem;\n padding: 1rem 1.125rem 0.25rem;\n border: 1px solid var(--line);\n border-radius: 8px;\n}\n\nlegend {\n padding: 0 0.375rem;\n color: var(--muted);\n font-size: 0.8125rem;\n}\n\nfieldset label {\n display: flex;\n align-items: baseline;\n gap: 0.5rem;\n color: var(--ink);\n font-size: 0.9375rem;\n}\n\nfieldset input {\n width: auto;\n margin: 0;\n}\n\nfieldset p {\n margin: 0.25rem 0 0.875rem 1.5rem;\n color: var(--muted);\n font-size: 0.8125rem;\n}\n";
2
+ export default _default;
@@ -0,0 +1,2 @@
1
+ // Generated from style.css by scripts/css.mjs. Do not edit.
2
+ export default ":root {\n --ink: #23312b;\n --muted: #6b786f;\n --line: #dce2de;\n --surface: #fff;\n --raised: #f7f9f7;\n --accent: #245f43;\n --warn: #a4442b;\n --caution: #b07a12;\n --syntax-tag: #245f43;\n --syntax-attr: #8a5a0b;\n --syntax-string: #2b5f8a;\n color-scheme: light dark;\n accent-color: var(--accent);\n}\n\n@media (prefers-color-scheme: dark) {\n :root {\n --ink: #dde5e0;\n --muted: #96a39b;\n --line: #2f3b35;\n --surface: #161a18;\n --raised: #1d2220;\n --accent: #7fc4a2;\n --warn: #e59a82;\n --caution: #d9a843;\n --syntax-tag: #7fc4a2;\n --syntax-attr: #d9a843;\n --syntax-string: #8fbbe0;\n }\n}\n\n* {\n box-sizing: border-box;\n}\n\nbody {\n margin: 0;\n padding: 0 1rem 4rem;\n background: var(--surface);\n color: var(--ink);\n font:\n 16px/1.65 system-ui,\n -apple-system,\n 'Segoe UI',\n sans-serif;\n text-rendering: optimizeLegibility;\n}\n\nmain {\n max-width: 34rem;\n margin: 0 auto;\n padding-top: 3rem;\n}\n\n/*\n * The logotype, above the page's own heading, on its own dark plate. Sized so the word is\n * the height it was without the plate, whose margin makes the box that much taller. The\n * word is white in either scheme: the plate is what it is read against, not the page.\n */\n.logo {\n display: block;\n height: 2.5rem;\n margin-bottom: 1.5rem;\n}\n\nh1 {\n margin: 0 0 1.75rem;\n padding-bottom: 1rem;\n border-bottom: 1px solid var(--line);\n font-size: 1.5rem;\n font-weight: 620;\n letter-spacing: -0.02em;\n line-height: 1.25;\n}\n\n/*\n * A page holding one thing needs no rule under its heading. The rule divides a heading\n * from the record beneath it, and a page that only asks for a key has no record to divide.\n */\nmain.single h1 {\n margin-bottom: 1.25rem;\n padding-bottom: 0;\n border-bottom: none;\n}\n\n/*\n * A page holding one thing sits in the middle of the window rather than at its top,\n * where it would leave most of the window empty below it.\n */\nbody:has(main.single) {\n display: flex;\n min-height: 100vh;\n min-height: 100dvh;\n}\nmain.single {\n width: 100%;\n max-width: 24rem;\n margin: auto;\n padding-bottom: 3rem;\n}\n\nh2 {\n margin: 2rem 0 0.75rem;\n font-size: 1.125rem;\n font-weight: 620;\n letter-spacing: -0.01em;\n}\n\nh3 {\n margin: 0 0 0.5rem;\n font-size: 1rem;\n font-weight: 620;\n}\n\np {\n margin: 0 0 1rem;\n overflow-wrap: break-word;\n}\n\na {\n color: var(--accent);\n text-underline-offset: 3px;\n overflow-wrap: anywhere;\n}\n\na:hover {\n text-decoration-thickness: 2px;\n}\n\n/*\n * Something to be run or published exactly as written. Its own line breaks are kept and a\n * long line is soft-wrapped rather than cut off: a soft wrap is drawn, not inserted, so\n * what is copied is still character for character what was written. Selecting inside the\n * block takes all of it, since half a command is worse than none.\n */\npre {\n position: relative;\n margin: 0 0 1rem;\n /* Room along the top line for the copy button, which sits over the block rather than in it. */\n padding: 0.75rem 5.25rem 0.75rem 0.875rem;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--raised);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n user-select: all;\n}\n\ncode {\n font:\n 0.8125rem/1.6 ui-monospace,\n SFMono-Regular,\n Menlo,\n monospace;\n}\n\n/* Highlighted HTML, marked up by highlight.js. Brackets stay muted so the names read first. */\n.hljs-tag {\n color: var(--muted);\n}\n.hljs-name {\n color: var(--syntax-tag);\n}\n.hljs-attr {\n color: var(--syntax-attr);\n}\n.hljs-string {\n color: var(--syntax-string);\n}\n\n/* Made by a script, so it exists only where it works. Quiet: the block is the point. */\n.copy {\n position: absolute;\n top: 0.5rem;\n right: 0.5rem;\n padding: 0.1875rem 0.5rem;\n border: 1px solid var(--line);\n border-radius: 5px;\n background: var(--surface);\n color: var(--muted);\n font:\n 500 0.75rem/1.5 system-ui,\n sans-serif;\n user-select: none;\n}\n\n.copy:hover {\n color: var(--ink);\n border-color: var(--muted);\n}\n\n/*\n * The two sides of the link, which is what a reader came to read. Each fact about a side\n * gets its own line: what it is, what it is called, the identifier behind that, and how it\n * was shown. Everything else on the page qualifies the pair, so it is set quieter.\n */\n.side {\n margin-bottom: 0.75rem;\n padding: 0.875rem 1rem;\n border: 1px solid var(--line);\n border-radius: 8px;\n background: var(--raised);\n}\n\n/*\n * A subject's accounts are peers, so where there is room they sit two across. The page\n * that lists them is wider for it, and a narrow one keeps them in a single column.\n */\nmain:has(.accounts) {\n max-width: 54rem;\n}\n\n.accounts {\n display: grid;\n grid-template-columns: minmax(0, 1fr);\n gap: 0.75rem;\n margin-bottom: 0.75rem;\n}\n\n.accounts .side {\n position: relative;\n display: flex;\n flex-direction: column;\n margin-bottom: 0;\n}\n\n/* How the holder lists the account, in the card's corner and out of the lines below. */\n.listing {\n position: absolute;\n top: 0.75rem;\n right: 0.875rem;\n padding: 0.0625rem 0.5rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n color: var(--muted);\n font-size: 0.6875rem;\n font-weight: 550;\n}\n\n.listing.preferred {\n border-color: var(--accent);\n color: var(--accent);\n}\n\n/* The times sit at the foot of the card, so they line up along a row of uneven cards. */\n.accounts .side dl {\n margin-top: auto;\n}\n.accounts .rest {\n display: flex;\n flex-direction: column;\n}\n.accounts .rest > p:last-of-type {\n margin-bottom: 0.625rem;\n}\n\n/*\n * Two across, with the subject one card wide and centred above them. Every card on the\n * page is then the same width.\n */\n@media (min-width: 48rem) {\n .accounts {\n grid-template-columns: repeat(2, minmax(0, 1fr));\n }\n /* Each card is three rows of the grid: who, its verdict, and the rest. A row is as tall\n as the taller card's part, so the verdicts and times of neighbouring cards line up. */\n .accounts > .side {\n display: grid;\n grid-row: span 3;\n grid-template-rows: subgrid;\n row-gap: 0;\n }\n .subject {\n width: calc(50% - 0.375rem);\n margin-inline: auto;\n text-align: center;\n }\n .subject .name {\n justify-content: center;\n }\n}\n\n/* Between a subject and its accounts on the page that lists them. */\n.joiner {\n display: block;\n width: 1.25rem;\n height: 1.25rem;\n margin: 0 auto 0.75rem;\n color: var(--muted);\n}\n\n.who {\n margin: 0 0 0.125rem;\n color: var(--muted);\n font-size: 0.75rem;\n font-weight: 550;\n letter-spacing: 0.02em;\n}\n\n/* A provider's mark before its name, at the height of the letters. */\n.who .provider {\n display: inline-block;\n width: 0.875rem;\n height: 0.875rem;\n margin-right: 0.375rem;\n vertical-align: -0.125rem;\n}\n\n/* Clipped 4px left of the text, which hides the date's dot when the date wraps to a new line. */\n.name {\n display: flex;\n flex-wrap: wrap;\n align-items: baseline;\n gap: 0 1rem;\n margin: 0 0 0 -4px;\n padding-left: 4px;\n overflow-x: clip;\n font-size: 1.0625rem;\n font-weight: 620;\n letter-spacing: -0.01em;\n}\n\n/* The account's creation date, after the name and a dot. It wraps under a long name. */\n.name .since {\n position: relative;\n color: var(--muted);\n font-size: 0.8125rem;\n font-weight: 400;\n letter-spacing: 0;\n white-space: nowrap;\n}\n\n/* The dot sits in the gap before the date. */\n.name .since::before {\n content: '·';\n position: absolute;\n left: -0.5rem;\n transform: translateX(-50%);\n}\n\n/* The name is the card's subject before it is a link, so the rule is drawn on hover. */\n.name a {\n text-decoration: none;\n}\n\n.name a:hover {\n text-decoration: underline;\n}\n\n.reference,\n.how {\n margin: 0;\n color: var(--muted);\n font-size: 0.8125rem;\n line-height: 1.5;\n}\n\n.identity + .how {\n margin-top: 0.5rem;\n}\n.fingerprint {\n font-family: ui-monospace, monospace;\n text-wrap: balance;\n}\n.fingerprint span {\n display: inline-block;\n margin-right: 0.5ch;\n}\n.how.additional {\n padding-left: 0.75rem;\n}\n/* The \"signed\" pill after the verifier's name, one size smaller than the line it sits on. */\n.how .signed-mark {\n display: inline-block;\n margin-left: 0.3125rem;\n padding: 0 0.375rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n font-size: 0.625rem;\n font-weight: 550;\n line-height: 1.3;\n text-decoration: none;\n vertical-align: 0.0625rem;\n}\n\n.fine {\n color: var(--muted);\n font-size: 0.8125rem;\n line-height: 1.55;\n margin-bottom: 0.625rem;\n}\n\n/* That the record is signed is a fact about it, so it is set as one and not as fine print. */\n.signed {\n font-size: 0.875rem;\n line-height: 1.55;\n margin-bottom: 0.875rem;\n}\n/* Arrived at from a badge's \"signed\" link, so the reader is shown where they landed. */\n.signed:target {\n margin-inline: -0.75rem;\n padding: 0.625rem 0.75rem;\n border-radius: 8px;\n background: var(--raised);\n}\n\n/* Every time on the record in one column, so they can be compared rather than hunted for. */\ndl {\n display: grid;\n grid-template-columns: auto 1fr;\n gap: 0.3rem 1.5rem;\n margin: 1.25rem 0;\n padding-top: 1rem;\n border-top: 1px solid var(--line);\n font-size: 0.8125rem;\n}\n\ndt {\n color: var(--muted);\n}\n\ndd {\n margin: 0;\n font-variant-numeric: tabular-nums;\n}\n\n/* Inside a card the rule above it is the card's own edge, so the list needs none. */\nsection dl {\n margin: 0.5rem 0 1rem;\n padding-top: 0;\n border-top: 0;\n}\n\n/* Right under the heading, the heading's own rule is the one above it. */\nh1 + dl {\n padding-top: 0;\n border-top: 0;\n}\n.side dl {\n margin: 0.625rem 0 0;\n padding-top: 0.625rem;\n}\n\n/* The links under a record, ruled off from the record and its disclaimer. */\nfooter {\n margin-top: 1.25rem;\n padding-top: 1rem;\n border-top: 1px solid var(--line);\n}\nfooter > :last-child {\n margin-bottom: 0;\n}\n\nsection {\n margin: 0 0 1.25rem;\n padding: 1.25rem 1.375rem;\n border: 1px solid var(--line);\n border-radius: 10px;\n}\n\nsection > :last-child {\n margin-bottom: 0;\n}\n\n/* What holds a control that cannot be undone. */\nsection.danger,\nsection.caution {\n margin-top: 2rem;\n}\nsection.danger {\n border-color: var(--warn);\n}\n\n/* What holds a control that breaks something for a while, but can be recovered from. */\nsection.caution {\n border-color: var(--caution);\n}\nsection > h2:first-child {\n margin-top: 0;\n}\n\nform {\n margin: 0 0 1.5rem;\n}\n\nlabel {\n display: block;\n margin: 0 0 0.75rem;\n color: var(--muted);\n font-size: 0.8125rem;\n}\n\ninput,\ntextarea {\n display: block;\n width: 100%;\n margin-top: 0.375rem;\n padding: 0.5rem 0.625rem;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--surface);\n color: var(--ink);\n font:\n 0.875rem/1.5 ui-monospace,\n SFMono-Regular,\n Menlo,\n monospace;\n}\n\n/* Height comes from the rows attribute, which differs per box. */\ntextarea {\n resize: vertical;\n}\n\ninput:focus-visible,\ntextarea:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 1px;\n}\n\nbutton {\n padding: 0.5rem 1rem;\n border: 1px solid var(--accent);\n border-radius: 6px;\n background: var(--accent);\n color: var(--surface);\n font:\n 500 0.9375rem/1.4 system-ui,\n -apple-system,\n 'Segoe UI',\n sans-serif;\n cursor: pointer;\n}\n\n/* A link that starts something, drawn as a button. */\na.button {\n display: inline-block;\n padding: 0.5rem 1rem;\n border: 1px solid var(--accent);\n border-radius: 6px;\n background: var(--accent);\n color: var(--surface);\n font-weight: 500;\n font-size: 0.9375rem;\n line-height: 1.4;\n text-decoration: none;\n}\n\nbutton + button {\n margin-left: 0.5rem;\n border-color: var(--line);\n background: none;\n color: var(--ink);\n}\n\n/* The other choice beside a page's one action, such as Cancel. */\na.button.secondary {\n border-color: var(--line);\n background: none;\n color: var(--ink);\n}\n\n/* A page's action and its alternative, side by side. */\n.choice {\n display: flex;\n flex-wrap: wrap;\n gap: 0.5rem;\n margin-bottom: 1.5rem;\n}\n.choice form {\n margin: 0;\n}\n\n/* Ways to verify: one full-width button per method, none filled over the others. */\n.choices {\n display: grid;\n gap: 0.5rem;\n margin-bottom: 1.5rem;\n}\n.choices form {\n margin: 0;\n}\n\n.choices button {\n width: 100%;\n text-align: left;\n border-color: var(--line);\n background: var(--surface);\n color: var(--ink);\n}\n\n.choices button:hover {\n border-color: var(--muted);\n}\n\n/* A provider's mark before a button's label, at the height of the letters. */\nbutton .provider {\n width: 1rem;\n height: 1rem;\n margin-right: 0.5rem;\n vertical-align: -0.1875rem;\n}\n\n/* A control that ends something. Filled where it is the page's one action. */\nbutton.danger {\n border-color: var(--warn);\n background: var(--warn);\n}\n\n/*\n * A connection on the settings page: the account's card, then what can be done with it.\n * Its state sits in the card's corner, on the line that names the method.\n */\n.connection {\n position: relative;\n margin-bottom: 1rem;\n}\n\n.state {\n position: absolute;\n top: 0.75rem;\n right: 0.875rem;\n display: flex;\n align-items: center;\n gap: 0.375rem;\n height: 1.625rem;\n padding: 0 0.75rem 0 0.5rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n background: var(--surface);\n color: var(--ink);\n font-size: 0.8125rem;\n font-weight: 550;\n}\n\n/*\n * The V the badge draws, in the tone that says how the record stands. A record that claims\n * nothing is drawn faint in the text colour, as the badge draws it.\n */\n.verdict {\n flex-shrink: 0;\n width: 1rem;\n height: 1rem;\n}\n.verdict.pending {\n opacity: 0.45;\n}\n.how .verdict {\n width: 0.875rem;\n height: 0.875rem;\n margin-right: 0.3125rem;\n vertical-align: -0.125rem;\n}\n/* Cropped to its strokes and taller than the heading's letters, centred beside them. */\nh1:has(.verdict) {\n display: flex;\n align-items: center;\n gap: 0.75rem;\n}\nh1 .verdict {\n width: auto;\n height: 2.25rem;\n}\n\n/*\n * In a state pill the V is cropped to its strokes and fills the pill's height, so its arms\n * and its foot meet the border. The pill's left padding keeps the arms clear of its curve.\n */\n.state .verdict {\n width: auto;\n height: 100%;\n}\n\n/*\n * A pill's line includes room for descenders below the letters, which leaves its text\n * sitting high. Trimmed to the letters, the padding alone sets the space above and below.\n * In a state pill the word alone is trimmed, so it centres on the V by its letters.\n */\n@supports (text-box: trim-both cap alphabetic) {\n .listing {\n padding: 0.3125rem 0.5rem;\n text-box: trim-both cap alphabetic;\n }\n .state > span {\n text-box: trim-both cap alphabetic;\n }\n}\n\n/* Links that sit together on one line, wrapping where there is no room. */\n.links {\n display: flex;\n flex-wrap: wrap;\n align-items: center;\n gap: 0.5rem 1.25rem;\n font-size: 0.875rem;\n}\n\n/* Under the account's id, with the facts about it rather than the controls. */\n.evidence {\n margin: 0.375rem 0 0;\n font-size: 0.8125rem;\n}\n\n.connection dl {\n margin-bottom: 0.75rem;\n}\n.connection .side > :last-child {\n margin-bottom: 0;\n}\n.connection pre {\n margin: 0.75rem 0;\n background: var(--surface);\n}\n\n/* The embed snippet is copied once, so it stays closed until asked for. */\n.embed summary {\n width: fit-content;\n cursor: pointer;\n}\n.embed summary:focus-visible {\n outline: 2px solid var(--accent);\n outline-offset: 1px;\n}\n.embed pre {\n margin-top: 0;\n}\n.connection .actions {\n margin-top: 0.875rem;\n}\n\n/* The buttons for one connection in a row, wrapping where there is no room. */\n.actions {\n display: flex;\n flex-wrap: wrap;\n gap: 0.5rem;\n}\n.actions form {\n display: contents;\n}\n\n.actions button,\n.actions a.button {\n margin: 0;\n padding: 0.3125rem 0.75rem;\n border-color: var(--line);\n background: var(--surface);\n color: var(--ink);\n font-size: 0.8125rem;\n}\n\n.actions button:hover,\n.actions a.button:hover {\n border-color: var(--muted);\n}\n\n/* The controls that cannot be undone, together at the foot of the card. */\n.ending {\n display: flex;\n flex-wrap: wrap;\n justify-content: flex-end;\n gap: 0.5rem;\n margin-top: 0.75rem;\n padding-top: 0.75rem;\n border-top: 1px solid var(--line);\n}\n.ending form {\n margin: 0;\n}\n\n.ending button.danger {\n padding: 0.3125rem 0.75rem;\n background: var(--surface);\n color: var(--warn);\n font-size: 0.8125rem;\n}\n\nfieldset {\n margin: 0 0 1.25rem;\n padding: 1rem 1.125rem 0.25rem;\n border: 1px solid var(--line);\n border-radius: 8px;\n}\n\nlegend {\n padding: 0 0.375rem;\n color: var(--muted);\n font-size: 0.8125rem;\n}\n\nfieldset label {\n display: flex;\n align-items: baseline;\n gap: 0.5rem;\n color: var(--ink);\n font-size: 0.9375rem;\n}\n\nfieldset input {\n width: auto;\n margin: 0;\n}\n\nfieldset p {\n margin: 0.25rem 0 0.875rem 1.5rem;\n color: var(--muted);\n font-size: 0.8125rem;\n}\n";