@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.
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
2
  import { fresh, freshnessMs, isArtifactProvider, isCodeProvider, isRedirectProvider, providerMethod, Refused, status, verifySigned, } from '../core/index.js';
3
+ import { domainOf, keyRecord, namedKeys, namedVerifiers, published, } from './domain.js';
3
4
  import { signer } from './signing.js';
4
5
  export const secret = () => randomBytes(32).toString('base64url');
5
6
  export const hash = (value) => createHash('sha256').update(value).digest('base64url');
@@ -64,6 +65,78 @@ export class VerilyService {
64
65
  const current = await this.signer();
65
66
  return [...(current ? [current.key] : []), ...(this.options.retiredKeys ?? [])];
66
67
  }
68
+ /**
69
+ * What this verifier's domain publishes to name its keys: one TXT record for each, all
70
+ * under the one name. Nothing where it signs nothing, or is not on a domain at all.
71
+ */
72
+ async keyRecords() {
73
+ const domain = domainOf(this.baseUrl);
74
+ const keys = await this.keys();
75
+ return domain && keys.length
76
+ ? { name: `_verily.${domain}`, values: keys.map((key) => keyRecord(key.id)) }
77
+ : undefined;
78
+ }
79
+ /**
80
+ * Where a reader can see this verifier's domain name a key, if it was read doing so, and
81
+ * which domain that was. It is always the domain this instance is on now: a record it
82
+ * signed while on another names that one, and nothing here was asked about it. The
83
+ * answer is kept for ten minutes, so a page that asks on every view costs one lookup.
84
+ */
85
+ async keyNamed(id) {
86
+ const domain = domainOf(this.baseUrl);
87
+ const found = domain && this.options.dns ? await this.lookedUp(domain, 600000) : undefined;
88
+ // Read as `dnsKeys()` reads it, so a fingerprint published in lower case counts here too.
89
+ return found && namedKeys(found.values).includes(id.toUpperCase())
90
+ ? { domain: domain, lookup: found.lookup, dnssec: found.dnssec }
91
+ : undefined;
92
+ }
93
+ /** What each domain was last read publishing, and when. Nothing found is a failed read. */
94
+ lookups = new Map();
95
+ /**
96
+ * What a domain publishes for Verily, read again once the last read is older than
97
+ * `maxAgeMs`. Nothing where it could not be read, which is never the same as nothing
98
+ * published.
99
+ */
100
+ async lookedUp(domain, maxAgeMs) {
101
+ const held = this.lookups.get(domain);
102
+ if (held && this.now() - held.at < maxAgeMs)
103
+ return held.found;
104
+ const found = await this.deadline(published(domain, this.options.dns === true ? {} : this.options.dns || {})).catch(() => undefined);
105
+ this.lookups.set(domain, { at: this.now(), found });
106
+ return found;
107
+ }
108
+ /**
109
+ * The domain whose DNS can say a subject's site chose this verifier, or nothing where
110
+ * there is nothing to ask: no address, or a site this verifier shares a domain with,
111
+ * since whoever holds a domain already holds every name beneath it.
112
+ */
113
+ siteDomain(local) {
114
+ const site = this.options.dns && local.profileUrl ? domainOf(local.profileUrl) : undefined;
115
+ const own = new URL(this.baseUrl).hostname.toLowerCase();
116
+ return site && site !== own && !own.endsWith(`.${site}`) && !site.endsWith(`.${own}`)
117
+ ? site
118
+ : undefined;
119
+ }
120
+ /**
121
+ * The local side as a site's DNS bears it out: the site's word, with the lookup that
122
+ * found the site naming this verifier. Nothing where it does not, or could not be read.
123
+ */
124
+ stated(found) {
125
+ const own = new URL(this.baseUrl).host.toLowerCase();
126
+ // Read as `dnsVerifiers()` reads it: a host is the same host in any case. What is kept
127
+ // is the record as published, since that is what a reader finds at the lookup.
128
+ const named = found && namedVerifiers(found.values).find(({ host }) => host === own);
129
+ return found && named
130
+ ? {
131
+ by: 'backend',
132
+ method: 'declared',
133
+ confirmedAt: this.now(),
134
+ artifactUrl: found.lookup,
135
+ expect: named.record,
136
+ ...(found.dnssec ? { dnssec: true } : {}),
137
+ }
138
+ : undefined;
139
+ }
67
140
  /** The same keys as one armored text, which is what `gpg --import` takes. */
68
141
  async armoredKeys() {
69
142
  return (await this.keys()).map((key) => key.publicKey.trim()).join('\n\n') + '\n';
@@ -208,10 +281,28 @@ export class VerilyService {
208
281
  // freedom: the subject's address is the whole claim, so that method states it here
209
282
  // and gives up per-flow uniqueness for a standing link that is read again on a
210
283
  // schedule instead.
211
- if (artifact)
212
- flow.expect = artifact.expect
213
- ? artifact.expect(subject)
214
- : `Verily proof for ${this.siteOf(subject)}: ${secret()}`;
284
+ //
285
+ // Where such a method has nothing of the subject's to state, it publishes a minted
286
+ // string like any other, and then keeps the one it has: a flow the local holder runs
287
+ // on their own record asks for the string already standing, as it would ask for the
288
+ // same address, and not for a new record in DNS every time the link is renewed.
289
+ //
290
+ // Only a minted string is kept this way. A record that published an address the
291
+ // subject no longer has is a proof of that address, and is not asked for again.
292
+ if (artifact) {
293
+ const stated = artifact.expect?.(subject);
294
+ const standing = artifact.expect && connection && ['renew', 'visibility'].includes(kind)
295
+ ? connection.attestations?.external.find((a) => a.method === artifact.method && a.minted)?.expect
296
+ : undefined;
297
+ flow.expect =
298
+ stated ??
299
+ standing ??
300
+ (artifact.mint
301
+ ? artifact.mint(secret())
302
+ : `Verily proof for ${this.siteOf(subject)}: ${secret()}`);
303
+ if (artifact.expect && stated === undefined)
304
+ flow.minted = true;
305
+ }
215
306
  // These kinds are all started by the local holder, whose own record this is. Removal
216
307
  // from the external side is started by anyone, and is told nothing.
217
308
  if (artifact?.known && ['connect', 'renew', 'visibility'].includes(kind)) {
@@ -277,13 +368,13 @@ export class VerilyService {
277
368
  async offered(id, binding, provider, expect, suggested) {
278
369
  try {
279
370
  const handed = provider.resolve?.(suggested) ?? suggested;
280
- const external = await this.deadline(provider.verify({ artifact: handed, expect }));
371
+ const found = await this.deadline(read(provider, { artifact: handed, expect }));
281
372
  await this.transaction(async (tx) => {
282
373
  const flow = await this.bound(tx, id, binding);
283
374
  flow.phase = 'exchanging';
284
375
  await tx.put('flows', id, flow);
285
376
  });
286
- await this.established(id, binding, external, handed);
377
+ await this.established(id, binding, found.account, handed, found.dnssec);
287
378
  }
288
379
  catch {
289
380
  await this.transaction(async (tx) => {
@@ -352,8 +443,8 @@ export class VerilyService {
352
443
  // Kept as the address that was read, never as the shorthand that named it: it is
353
444
  // where a reader is sent, and what is read again later.
354
445
  const handed = provider.resolve?.(artifact) ?? artifact;
355
- const external = await provider.verify({ artifact: handed, expect });
356
- await this.established(id, binding, external, handed);
446
+ const found = await read(provider, { artifact: handed, expect });
447
+ await this.established(id, binding, found.account, handed, found.dnssec);
357
448
  }
358
449
  catch (error) {
359
450
  await this.refused(id, error, artifact);
@@ -587,7 +678,7 @@ export class VerilyService {
587
678
  * existing connection must still be the same account on the same provider. No binding
588
679
  * means a confirmation link got here, which is held to the link and not to a browser.
589
680
  */
590
- async established(id, binding, external, artifact) {
681
+ async established(id, binding, external, artifact, dnssec = false) {
591
682
  if (typeof external.id !== 'string' ||
592
683
  !external.id ||
593
684
  typeof external.handle !== 'string' ||
@@ -619,12 +710,19 @@ export class VerilyService {
619
710
  // without the holder choosing that method or handing anything back. Read while the
620
711
  // flow is still exchanging: approval is shown once, and must show all it will record.
621
712
  const shown = await this.transaction(accepted);
622
- const standing = ['connect', 'renew'].includes(shown.kind) ? await this.standing(shown) : [];
713
+ const recorded = ['connect', 'renew'].includes(shown.kind);
714
+ const standing = recorded ? await this.standing(shown) : [];
715
+ // The site's own DNS is read here for the same reason: outside a transaction, and
716
+ // before the approval that records what it said. A read a few minutes old will do.
717
+ const site = recorded ? this.siteDomain(shown.local) : undefined;
718
+ const stated = site ? this.stated(await this.lookedUp(site, 300000)) : undefined;
623
719
  await this.transaction(async (tx) => {
624
720
  const flow = await accepted(tx);
625
721
  flow.artifact = artifact;
626
722
  flow.reason = undefined;
627
723
  flow.authenticatedAt = this.now();
724
+ flow.dnssec = dnssec || undefined;
725
+ flow.stated = stated;
628
726
  if (standing.length)
629
727
  flow.standing = standing;
630
728
  flow.phase = 'approval';
@@ -656,10 +754,11 @@ export class VerilyService {
656
754
  try {
657
755
  const expect = provider.expect(flow.local);
658
756
  const known = provider.known(flow.external);
659
- if (!known)
757
+ // A minted string is found nowhere the holder was not asked to put it.
758
+ if (!known || expect === undefined)
660
759
  continue;
661
760
  const handed = provider.resolve?.(known) ?? known;
662
- const external = await this.deadline(provider.verify({ artifact: handed, expect }));
761
+ const { account: external, dnssec } = await this.deadline(read(provider, { artifact: handed, expect }));
663
762
  if (!sameAccount(flow.external, external))
664
763
  continue;
665
764
  found.push({
@@ -668,6 +767,7 @@ export class VerilyService {
668
767
  confirmedAt: this.now(),
669
768
  artifactUrl: handed,
670
769
  expect,
770
+ ...(dnssec ? { dnssec } : {}),
671
771
  });
672
772
  }
673
773
  catch {
@@ -780,7 +880,7 @@ export class VerilyService {
780
880
  connectedAt: this.now(),
781
881
  expiresAt: this.now() + (this.options.validityMs ?? 30 * 86400000),
782
882
  attestations: {
783
- local: this.declared(),
883
+ local: this.declared(flow),
784
884
  external: [this.attestation(flow, provider, connectionId)],
785
885
  },
786
886
  proof: hosted(provider) ? flow.artifact : undefined,
@@ -876,8 +976,12 @@ export class VerilyService {
876
976
  const [main, ...rest] = connection.attestations.external;
877
977
  // A retired record is as it stood when it was retired, and nothing is read after that.
878
978
  const at = connection.retiredAt ?? this.now();
979
+ const { local } = connection.attestations;
879
980
  return {
880
- ...connection.attestations,
981
+ // A site's DNS that has gone unread no longer bears its word out, and the word stands.
982
+ local: fresh(local, at, this.freshness)
983
+ ? local
984
+ : { by: local.by, method: local.method, confirmedAt: local.confirmedAt },
881
985
  external: [main, ...rest.filter((a) => fresh(a, at, this.freshness))],
882
986
  };
883
987
  }
@@ -967,7 +1071,7 @@ export class VerilyService {
967
1071
  // about the new proof and compares ids exactly, and an address read with different
968
1072
  // case names the same profile under a different id.
969
1073
  connection.external = flow.external;
970
- connection.attestations = { local: this.declared(), external: [attestation, ...rest] };
1074
+ connection.attestations = { local: this.declared(flow), external: [attestation, ...rest] };
971
1075
  }
972
1076
  else {
973
1077
  // A proof that named the subject's old address proves nothing about its new one,
@@ -979,7 +1083,7 @@ export class VerilyService {
979
1083
  others.push(attestation);
980
1084
  else
981
1085
  others[at] = attestation;
982
- connection.attestations = { local: this.declared(), external: [main, ...others] };
1086
+ connection.attestations = { local: this.declared(flow), external: [main, ...others] };
983
1087
  }
984
1088
  if (hosted(provider))
985
1089
  connection.proof = flow.artifact;
@@ -994,8 +1098,10 @@ export class VerilyService {
994
1098
  const provider = this.providers.find((p) => p.id === connection.provider && providerMethod(p) === attestation.method);
995
1099
  if (attestation.expect === undefined || !provider || !isArtifactProvider(provider))
996
1100
  return true;
997
- if (!provider.expect)
1101
+ if (!provider.expect || attestation.minted)
998
1102
  return true;
1103
+ // A subject with nothing to state gives nothing back, which no published address
1104
+ // equals: a proof of the address it used to have does not name it now.
999
1105
  try {
1000
1106
  return provider.expect(local) === attestation.expect;
1001
1107
  }
@@ -1003,9 +1109,12 @@ export class VerilyService {
1003
1109
  return false;
1004
1110
  }
1005
1111
  }
1006
- /** The site is the only authority on its own namespace, so it declares the local subject. */
1007
- declared() {
1008
- return { by: 'backend', method: 'declared', confirmedAt: this.now() };
1112
+ /**
1113
+ * The site is the only authority on its own namespace, so it declares the local subject.
1114
+ * Where the flow read the site's DNS naming this verifier, that goes with it.
1115
+ */
1116
+ declared(flow) {
1117
+ return flow.stated ?? { by: 'backend', method: 'declared', confirmedAt: this.now() };
1009
1118
  }
1010
1119
  /** How the provider established the external account, by whatever method the flow ran. */
1011
1120
  attestation(flow, provider, connectionId) {
@@ -1024,6 +1133,10 @@ export class VerilyService {
1024
1133
  ? `${this.baseUrl}/connections/${connectionId}/proof`
1025
1134
  : flow.artifact;
1026
1135
  external.expect = flow.expect;
1136
+ if (flow.minted)
1137
+ external.minted = true;
1138
+ if (flow.dnssec)
1139
+ external.dnssec = true;
1027
1140
  if (hosted)
1028
1141
  external.hosted = true;
1029
1142
  }
@@ -1061,11 +1174,33 @@ export class VerilyService {
1061
1174
  * hardcoding an id that dies whenever one is revoked and replaced. Unlisted records are
1062
1175
  * never included: they must not appear in any directory listing.
1063
1176
  */
1064
- async published() {
1177
+ async published(subject) {
1178
+ return (await this.listed(subject)).filter((e) => e.status === 'verified');
1179
+ }
1180
+ /**
1181
+ * Every public record a badge could show, for the page a person reads: one that has lapsed
1182
+ * or been retired is there to be marked, and one removed is not. `subject` narrows it to
1183
+ * one site or one subject, named by what its records already publish. Earliest connected first.
1184
+ */
1185
+ async listed(subject) {
1065
1186
  return this.transaction(async (tx) => (await tx.list('connections'))
1066
- .filter((c) => c.visibility === 'public')
1187
+ .filter((c) => c.visibility === 'public' &&
1188
+ (!subject ||
1189
+ (this.siteOf(c.local) === subject.site &&
1190
+ (subject.reference === undefined || c.local.reference === subject.reference))))
1067
1191
  .map((c) => this.evidence(c))
1068
- .filter((e) => e.status === 'verified'));
1192
+ .filter((e) => e.status !== 'revoked')
1193
+ .sort((a, b) => a.connectedAt - b.connectedAt));
1194
+ }
1195
+ /**
1196
+ * Every record this instance holds, public and unlisted, for whoever runs it. No route
1197
+ * of the library serves this: a deployment that shows it must first know the reader is
1198
+ * its operator, who can read the storage it comes from anyway. Earliest connected first.
1199
+ */
1200
+ async all() {
1201
+ return this.transaction(async (tx) => (await tx.list('connections'))
1202
+ .map((c) => this.evidence(c))
1203
+ .sort((a, b) => a.connectedAt - b.connectedAt));
1069
1204
  }
1070
1205
  async mine(local) {
1071
1206
  return this.transaction(async (tx) => (await tx.list('connections'))
@@ -1253,8 +1388,72 @@ export class VerilyService {
1253
1388
  // open across a fetch would stall every other request behind the slowest provider.
1254
1389
  for (const item of due)
1255
1390
  confirmed += await this.reread(item);
1391
+ await this.restate(budget);
1256
1392
  return confirmed;
1257
1393
  }
1394
+ /**
1395
+ * Asks each site's DNS again whether it names this verifier, and writes the answer to
1396
+ * every record of that site. One lookup answers for all of a site's records, and
1397
+ * `budget` bounds the sites asked in a run, longest unread first. A site that could not
1398
+ * be read is left as it was: its records lose the proof only once it has gone unread
1399
+ * past `freshnessMs`, or once an answer comes back without the statement.
1400
+ *
1401
+ * When each site was last asked is kept in storage, as a record of its own that `prune()`
1402
+ * removes only with the site's last record. It is apart from the lookups a flow makes
1403
+ * and from anything that expires: an instance restarted or pruned between runs must not
1404
+ * start again from the same few sites, and a site whose holders connect often must
1405
+ * still have its older records rewritten.
1406
+ */
1407
+ async restate(budget) {
1408
+ if (!this.options.dns)
1409
+ return;
1410
+ const interval = this.options.recheckMs ?? 86400000;
1411
+ const now = this.now();
1412
+ const live = (c) => !!c?.attestations &&
1413
+ c.revokedAt === undefined &&
1414
+ c.retiredAt === undefined &&
1415
+ c.expiresAt > this.now();
1416
+ const due = await this.transaction(async (tx) => {
1417
+ const sites = new Set((await tx.list('connections'))
1418
+ .filter(live)
1419
+ .map((c) => this.siteDomain(c.local))
1420
+ .filter((site) => site !== undefined));
1421
+ const reads = new Map((await tx.list('sites')).map((read) => [read.id, read]));
1422
+ // A site never asked about goes first, then the rest by how long ago they were. A
1423
+ // site that gave no answer is due again sooner, and still waits behind every site
1424
+ // asked before it, so a zone that never answers cannot hold up the others.
1425
+ const at = (site) => reads.get(site)?.readAt ?? -Infinity;
1426
+ const wait = (site) => reads.get(site)?.answered === false ? Math.ceil(interval / 4) : interval;
1427
+ return [...sites]
1428
+ .filter((site) => at(site) + wait(site) <= now)
1429
+ .sort((a, b) => at(a) - at(b))
1430
+ .slice(0, budget);
1431
+ });
1432
+ for (const site of due) {
1433
+ // Always asked afresh: a read some flow made says nothing of the records before it.
1434
+ const found = await this.lookedUp(site, 0);
1435
+ await this.transaction((tx) => tx.put('sites', site, { id: site, readAt: this.now(), answered: !!found }));
1436
+ if (!found)
1437
+ continue;
1438
+ const stated = this.stated(found);
1439
+ await this.transaction(async (tx) => {
1440
+ for (const connection of await tx.list('connections')) {
1441
+ if (!live(connection) || this.siteDomain(connection.local) !== site)
1442
+ continue;
1443
+ const { local } = connection.attestations;
1444
+ // Nothing was stated before and nothing is now, so there is nothing to write.
1445
+ if (!stated && !local.artifactUrl)
1446
+ continue;
1447
+ connection.attestations.local = stated ?? {
1448
+ by: local.by,
1449
+ method: local.method,
1450
+ confirmedAt: local.confirmedAt,
1451
+ };
1452
+ await tx.put('connections', connection.id, connection);
1453
+ }
1454
+ });
1455
+ }
1456
+ }
1258
1457
  /**
1259
1458
  * The configured method that can ask again about one attestation, or nothing when there
1260
1459
  * is nothing to ask: no published proof, a method this instance no longer runs, or a
@@ -1275,11 +1474,14 @@ export class VerilyService {
1275
1474
  const { artifactUrl, expect, method } = attestation;
1276
1475
  const main = attestation === connection.attestations.external[0];
1277
1476
  let withdrawn = false;
1477
+ let dnssec = false;
1278
1478
  try {
1279
1479
  if (provider.artifact === 'document')
1280
1480
  withdrawn = await this.deadline(provider.withdrawn(connection.external, connection.proof));
1281
1481
  else {
1282
- const external = await this.deadline(provider.verify({ artifact: artifactUrl, expect: expect }));
1482
+ const found = await this.deadline(read(provider, { artifact: artifactUrl, expect: expect }));
1483
+ const external = found.account;
1484
+ dnssec = found.dnssec === true;
1283
1485
  // The proof must still be the same holder's. An account that changed hands has
1284
1486
  // not reproved anything, whatever is published at the old address.
1285
1487
  if (main
@@ -1314,6 +1516,11 @@ export class VerilyService {
1314
1516
  return 0;
1315
1517
  }
1316
1518
  held.confirmedAt = this.now();
1519
+ // A zone can start or stop being signed, so the flag is as the last read found it.
1520
+ if (dnssec)
1521
+ held.dnssec = true;
1522
+ else
1523
+ delete held.dnssec;
1317
1524
  await tx.put('connections', connection.id, current);
1318
1525
  return 1;
1319
1526
  });
@@ -1345,14 +1552,20 @@ export class VerilyService {
1345
1552
  if (limit.expiresAt <= now)
1346
1553
  await tx.delete('limits', limit.id);
1347
1554
  const audit = await tx.list('audit');
1555
+ // The sites that still have a record here, whose place in the recheck order is kept.
1556
+ const sites = new Set();
1348
1557
  for (const connection of await tx.list('connections')) {
1349
1558
  const ended = connection.revokedAt ??
1350
1559
  (connection.retiredAt === undefined ? connection.expiresAt : Infinity);
1351
1560
  if (ended + retentionMs <= now) {
1352
1561
  await tx.delete('connections', connection.id);
1353
1562
  await tx.delete('shares', connection.id);
1563
+ continue;
1354
1564
  }
1355
- else if (connection.connectedAt === undefined) {
1565
+ const site = this.siteDomain(connection.local);
1566
+ if (site)
1567
+ sites.add(site);
1568
+ if (connection.connectedAt === undefined) {
1356
1569
  // A record from before its first connection was kept. The audit trail still has
1357
1570
  // the moment, for as long as it retains it, and that is the answer. Only past
1358
1571
  // that is the earliest time left on the record used, which is a guess: a sign-in
@@ -1365,6 +1578,11 @@ export class VerilyService {
1365
1578
  await tx.put('connections', connection.id, connection);
1366
1579
  }
1367
1580
  }
1581
+ // When a site was last asked goes only with its last record, never by age: it is
1582
+ // what orders the sites `recheck()` asks, and losing it would put the site first again.
1583
+ for (const read of await tx.list('sites'))
1584
+ if (!sites.has(read.id))
1585
+ await tx.delete('sites', read.id);
1368
1586
  for (const event of audit)
1369
1587
  if (event.at + retentionMs <= now)
1370
1588
  await tx.delete('audit', event.id);
@@ -1408,6 +1626,11 @@ function revives(connection, provider) {
1408
1626
  return (connection.retiredAt === undefined ||
1409
1627
  (connection.attestations?.external[0].method ?? 'oauth') === providerMethod(provider));
1410
1628
  }
1629
+ /** One proof waiting to be read again, and the method that reads it. */
1630
+ /** Whose a proof is and what else its method learned, whichever of the two it offers. */
1631
+ async function read(provider, input) {
1632
+ return provider.prove ? provider.prove(input) : { account: await provider.verify(input) };
1633
+ }
1411
1634
  /**
1412
1635
  * Whether two methods named the same account. Where both carry an id their provider
1413
1636
  * issued, that id settles it. A method that learns only an address, as a link back does,
@@ -8,7 +8,7 @@
8
8
  * leaves. So the styling does the two things that helps with, a measured column and a
9
9
  * legible hierarchy, and nothing that would make a record look like a product.
10
10
  */
11
- export declare const stylesheet = ":root {\n --ink: #23312b;\n --muted: #6b786f;\n --line: #dce2de;\n --surface: #fff;\n --raised: #f7f9f7;\n --accent: #245f43;\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 }\n}\n\n* { box-sizing: border-box; }\n\nbody {\n margin: 0;\n padding: 0 1rem 4rem;\n background: var(--surface);\n color: var(--ink);\n font: 16px/1.65 system-ui, -apple-system, 'Segoe UI', 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: -.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 { margin-bottom: 1.25rem; padding-bottom: 0; border-bottom: none; }\n\nh2 { margin: 2rem 0 .75rem; font-size: 1.125rem; font-weight: 620; letter-spacing: -.01em; }\n\nh3 { margin: 0 0 .5rem; font-size: 1rem; font-weight: 620; }\n\np { margin: 0 0 1rem; overflow-wrap: break-word; }\n\na { color: var(--accent); text-underline-offset: 3px; overflow-wrap: anywhere; }\n\na:hover { text-decoration-thickness: 2px; }\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: .75rem 5.25rem .75rem .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 { font: .8125rem/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }\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: .5rem;\n right: .5rem;\n padding: .1875rem .5rem;\n border: 1px solid var(--line);\n border-radius: 5px;\n background: var(--surface);\n color: var(--muted);\n font: 500 .75rem/1.5 system-ui, sans-serif;\n user-select: none;\n}\n\n.copy:hover { color: var(--ink); border-color: var(--muted); }\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: .75rem;\n padding: .875rem 1rem;\n border: 1px solid var(--line);\n border-radius: 8px;\n background: var(--raised);\n}\n\n.who {\n margin: 0 0 .125rem;\n color: var(--muted);\n font-size: .75rem;\n font-weight: 550;\n letter-spacing: .02em;\n}\n\n.name { margin: 0; font-size: 1.0625rem; font-weight: 620; letter-spacing: -.01em; }\n\n/* The name is the card's subject before it is a link, so the rule is drawn on hover. */\n.name a { text-decoration: none; }\n\n.name a:hover { text-decoration: underline; }\n\n.reference, .how { margin: 0; color: var(--muted); font-size: .8125rem; line-height: 1.5; }\n\n.name + .how, .reference + .how { margin-top: .5rem; }\n.how.additional { padding-left: .75rem; }\n\n.fine { color: var(--muted); font-size: .8125rem; line-height: 1.55; margin-bottom: .625rem; }\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 { font-size: .875rem; line-height: 1.55; margin-bottom: .875rem; }\n/* Arrived at from a badge's \"signed\" link, so the reader is shown where they landed. */\n.signed:target { margin-inline: -.75rem; padding: .625rem .75rem; border-radius: 8px; background: var(--raised); }\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: .3rem 1.5rem;\n margin: 1.25rem 0;\n padding-top: 1rem;\n border-top: 1px solid var(--line);\n font-size: .8125rem;\n}\n\ndt { color: var(--muted); }\n\ndd { margin: 0; font-variant-numeric: tabular-nums; }\n\n/* Inside a card the rule above it is the card's own edge, so the list needs none. */\nsection dl { margin: .5rem 0 1rem; padding-top: 0; border-top: 0; }\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 { margin-bottom: 0; }\n\nform { margin: 0 0 1.5rem; }\n\nlabel { display: block; margin: 0 0 .75rem; color: var(--muted); font-size: .8125rem; }\n\ninput, textarea {\n display: block;\n width: 100%;\n margin-top: .375rem;\n padding: .5rem .625rem;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--surface);\n color: var(--ink);\n font: .875rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;\n}\n\n/* Height comes from the rows attribute, which differs per box. */\ntextarea { resize: vertical; }\n\ninput:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }\n\nbutton {\n padding: .5rem 1rem;\n border: 1px solid var(--accent);\n border-radius: 6px;\n background: var(--accent);\n color: var(--surface);\n font: 500 .9375rem/1.4 inherit;\n cursor: pointer;\n}\n\nbutton + button { margin-left: .5rem; border-color: var(--line); background: none; color: var(--ink); }\n\nfieldset { margin: 0 0 1.25rem; padding: 1rem 1.125rem .25rem; border: 1px solid var(--line); border-radius: 8px; }\n\nlegend { padding: 0 .375rem; color: var(--muted); font-size: .8125rem; }\n\nfieldset label { display: flex; align-items: baseline; gap: .5rem; color: var(--ink); font-size: .9375rem; }\n\nfieldset input { width: auto; margin: 0; }\n\nfieldset p { margin: .25rem 0 .875rem 1.5rem; color: var(--muted); font-size: .8125rem; }\n";
11
+ export declare const stylesheet = ":root {\n --ink: #23312b;\n --muted: #6b786f;\n --line: #dce2de;\n --surface: #fff;\n --raised: #f7f9f7;\n --accent: #245f43;\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 }\n}\n\n* { box-sizing: border-box; }\n\nbody {\n margin: 0;\n padding: 0 1rem 4rem;\n background: var(--surface);\n color: var(--ink);\n font: 16px/1.65 system-ui, -apple-system, 'Segoe UI', 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: -.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 { margin-bottom: 1.25rem; padding-bottom: 0; border-bottom: none; }\n\nh2 { margin: 2rem 0 .75rem; font-size: 1.125rem; font-weight: 620; letter-spacing: -.01em; }\n\nh3 { margin: 0 0 .5rem; font-size: 1rem; font-weight: 620; }\n\np { margin: 0 0 1rem; overflow-wrap: break-word; }\n\na { color: var(--accent); text-underline-offset: 3px; overflow-wrap: anywhere; }\n\na:hover { text-decoration-thickness: 2px; }\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: .75rem 5.25rem .75rem .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 { font: .8125rem/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }\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: .5rem;\n right: .5rem;\n padding: .1875rem .5rem;\n border: 1px solid var(--line);\n border-radius: 5px;\n background: var(--surface);\n color: var(--muted);\n font: 500 .75rem/1.5 system-ui, sans-serif;\n user-select: none;\n}\n\n.copy:hover { color: var(--ink); border-color: var(--muted); }\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: .75rem;\n padding: .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) { max-width: 54rem; }\n\n.accounts {\n display: grid;\n grid-template-columns: repeat(auto-fit, minmax(min(22rem, 100%), 1fr));\n gap: .75rem;\n margin-bottom: .75rem;\n}\n\n.accounts .side { position: relative; display: flex; flex-direction: column; margin-bottom: 0; }\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: .75rem;\n right: .875rem;\n padding: .0625rem .5rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n color: var(--muted);\n font-size: .6875rem;\n font-weight: 550;\n}\n\n.listing.preferred { border-color: var(--accent); color: var(--accent); }\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 { margin-top: auto; }\n.accounts .side p:last-of-type { margin-bottom: .625rem; }\n\n/* Between a subject and its accounts on the page that lists them. */\n.joiner { display: block; width: 1.25rem; height: 1.25rem; margin: 0 auto .75rem; color: var(--muted); }\n\n.who {\n margin: 0 0 .125rem;\n color: var(--muted);\n font-size: .75rem;\n font-weight: 550;\n letter-spacing: .02em;\n}\n\n.name { margin: 0; font-size: 1.0625rem; font-weight: 620; letter-spacing: -.01em; }\n\n/* The name is the card's subject before it is a link, so the rule is drawn on hover. */\n.name a { text-decoration: none; }\n\n.name a:hover { text-decoration: underline; }\n\n.reference, .how { margin: 0; color: var(--muted); font-size: .8125rem; line-height: 1.5; }\n\n.name + .how, .reference + .how { margin-top: .5rem; }\n.fingerprint { font-family: ui-monospace, monospace; text-wrap: balance; }\n.fingerprint span { display: inline-block; margin-right: .5ch; }\n.how.additional { padding-left: .75rem; }\n\n.fine { color: var(--muted); font-size: .8125rem; line-height: 1.55; margin-bottom: .625rem; }\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 { font-size: .875rem; line-height: 1.55; margin-bottom: .875rem; }\n/* Arrived at from a badge's \"signed\" link, so the reader is shown where they landed. */\n.signed:target { margin-inline: -.75rem; padding: .625rem .75rem; border-radius: 8px; background: var(--raised); }\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: .3rem 1.5rem;\n margin: 1.25rem 0;\n padding-top: 1rem;\n border-top: 1px solid var(--line);\n font-size: .8125rem;\n}\n\ndt { color: var(--muted); }\n\ndd { margin: 0; font-variant-numeric: tabular-nums; }\n\n/* Inside a card the rule above it is the card's own edge, so the list needs none. */\nsection dl { margin: .5rem 0 1rem; padding-top: 0; border-top: 0; }\n.side dl { margin: .625rem 0 0; padding-top: .625rem; }\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 { margin-bottom: 0; }\n\nform { margin: 0 0 1.5rem; }\n\nlabel { display: block; margin: 0 0 .75rem; color: var(--muted); font-size: .8125rem; }\n\ninput, textarea {\n display: block;\n width: 100%;\n margin-top: .375rem;\n padding: .5rem .625rem;\n border: 1px solid var(--line);\n border-radius: 6px;\n background: var(--surface);\n color: var(--ink);\n font: .875rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;\n}\n\n/* Height comes from the rows attribute, which differs per box. */\ntextarea { resize: vertical; }\n\ninput:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }\n\nbutton {\n padding: .5rem 1rem;\n border: 1px solid var(--accent);\n border-radius: 6px;\n background: var(--accent);\n color: var(--surface);\n font: 500 .9375rem/1.4 inherit;\n cursor: pointer;\n}\n\nbutton + button { margin-left: .5rem; border-color: var(--line); background: none; color: var(--ink); }\n\nfieldset { margin: 0 0 1.25rem; padding: 1rem 1.125rem .25rem; border: 1px solid var(--line); border-radius: 8px; }\n\nlegend { padding: 0 .375rem; color: var(--muted); font-size: .8125rem; }\n\nfieldset label { display: flex; align-items: baseline; gap: .5rem; color: var(--ink); font-size: .9375rem; }\n\nfieldset input { width: auto; margin: 0; }\n\nfieldset p { margin: .25rem 0 .875rem 1.5rem; color: var(--muted); font-size: .8125rem; }\n";
12
12
  /**
13
13
  * A short digest of the sheet above, put in the link's query so a release that changes the
14
14
  * styling changes its address. Without that the only safe cache is no cache: a reader who
@@ -134,6 +134,43 @@ code { font: .8125rem/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
134
134
  background: var(--raised);
135
135
  }
136
136
 
137
+ /*
138
+ * A subject's accounts are peers, so where there is room they sit two across. The page
139
+ * that lists them is wider for it, and a narrow one keeps them in a single column.
140
+ */
141
+ main:has(.accounts) { max-width: 54rem; }
142
+
143
+ .accounts {
144
+ display: grid;
145
+ grid-template-columns: repeat(auto-fit, minmax(min(22rem, 100%), 1fr));
146
+ gap: .75rem;
147
+ margin-bottom: .75rem;
148
+ }
149
+
150
+ .accounts .side { position: relative; display: flex; flex-direction: column; margin-bottom: 0; }
151
+
152
+ /* How the holder lists the account, in the card's corner and out of the lines below. */
153
+ .listing {
154
+ position: absolute;
155
+ top: .75rem;
156
+ right: .875rem;
157
+ padding: .0625rem .5rem;
158
+ border: 1px solid var(--line);
159
+ border-radius: 999px;
160
+ color: var(--muted);
161
+ font-size: .6875rem;
162
+ font-weight: 550;
163
+ }
164
+
165
+ .listing.preferred { border-color: var(--accent); color: var(--accent); }
166
+
167
+ /* The times sit at the foot of the card, so they line up along a row of uneven cards. */
168
+ .accounts .side dl { margin-top: auto; }
169
+ .accounts .side p:last-of-type { margin-bottom: .625rem; }
170
+
171
+ /* Between a subject and its accounts on the page that lists them. */
172
+ .joiner { display: block; width: 1.25rem; height: 1.25rem; margin: 0 auto .75rem; color: var(--muted); }
173
+
137
174
  .who {
138
175
  margin: 0 0 .125rem;
139
176
  color: var(--muted);
@@ -152,6 +189,8 @@ code { font: .8125rem/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
152
189
  .reference, .how { margin: 0; color: var(--muted); font-size: .8125rem; line-height: 1.5; }
153
190
 
154
191
  .name + .how, .reference + .how { margin-top: .5rem; }
192
+ .fingerprint { font-family: ui-monospace, monospace; text-wrap: balance; }
193
+ .fingerprint span { display: inline-block; margin-right: .5ch; }
155
194
  .how.additional { padding-left: .75rem; }
156
195
 
157
196
  .fine { color: var(--muted); font-size: .8125rem; line-height: 1.55; margin-bottom: .625rem; }
@@ -178,6 +217,7 @@ dd { margin: 0; font-variant-numeric: tabular-nums; }
178
217
 
179
218
  /* Inside a card the rule above it is the card's own edge, so the list needs none. */
180
219
  section dl { margin: .5rem 0 1rem; padding-top: 0; border-top: 0; }
220
+ .side dl { margin: .625rem 0 0; padding-top: .625rem; }
181
221
 
182
222
  section {
183
223
  margin: 0 0 1.25rem;
@@ -78,7 +78,7 @@ export function createSiteClient(options) {
78
78
  * have. The instance sends them on to this site's authorize endpoint with a `state`.
79
79
  */
80
80
  beginUrl(purpose = 'connect') {
81
- return `${instance.origin}/begin?${new URLSearchParams({ site: options.site, purpose })}`;
81
+ return `${instance.origin}/handoff/request?${new URLSearchParams({ site: options.site, purpose })}`;
82
82
  },
83
83
  /**
84
84
  * For the authorize endpoint: where to redirect a signed-in user, with a handoff that
@@ -98,7 +98,7 @@ export function createSiteClient(options) {
98
98
  // Under the five minutes the instance allows, so a slow clock does not overrun it.
99
99
  exp: seconds() + 240,
100
100
  });
101
- return { url: `${instance.origin}/start?${new URLSearchParams({ token })}`, txn };
101
+ return { url: `${instance.origin}/handoff/accept?${new URLSearchParams({ token })}`, txn };
102
102
  },
103
103
  /**
104
104
  * For a page that shows the connect pill with `handoff-url`: a token that vouches for