@bkazemi/verily 0.6.0 → 0.7.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
- import { fresh, freshnessMs, isArtifactProvider, isCodeProvider, isRedirectProvider, providerMethod, Refused, status, verifySigned, } from '../core/index.js';
2
+ import { isDeepStrictEqual } from 'node:util';
3
+ import { Absent, expired, fresh, freshnessMs, isArtifactProvider, isCodeProvider, isRedirectProvider, providerMethod, Refused, status, verifySigned, } from '../core/index.js';
3
4
  import { domainOf, keyRecord, namedKeys, namedVerifiers, published, } from './domain.js';
4
5
  import { signer } from './signing.js';
5
6
  export const secret = () => randomBytes(32).toString('base64url');
@@ -312,6 +313,37 @@ export class VerilyService {
312
313
  if (suggested)
313
314
  flow.suggested = suggested;
314
315
  }
316
+ // A draft carries only public setup, never a session or a successful proof.
317
+ // Scope it to the exact subject and operation. Starting again
318
+ // authenticates the holder as usual and mints a fresh binding and deadline.
319
+ // Context belongs to the current visit: hosted sessions and return transactions
320
+ // change when the holder signs in again and must never be restored from a draft.
321
+ if (artifact?.method === 'dns' && local && ['connect', 'renew'].includes(kind)) {
322
+ const id = hash(JSON.stringify([
323
+ local.id,
324
+ this.siteOf(local),
325
+ local.reference,
326
+ local.profileUrl,
327
+ provider.id,
328
+ kind,
329
+ connectionId,
330
+ ]));
331
+ const saved = await tx.get('dnsDrafts', id);
332
+ const draft = saved && saved.expiresAt > this.now() ? saved : undefined;
333
+ flow.dnsDraftId = id;
334
+ if (draft) {
335
+ flow.expect = draft.expect;
336
+ if (draft.artifact)
337
+ flow.suggested = draft.artifact;
338
+ }
339
+ await tx.put('dnsDrafts', id, {
340
+ id,
341
+ flowId: flow.id,
342
+ expect: flow.expect,
343
+ ...(draft?.artifact ? { artifact: draft.artifact } : {}),
344
+ expiresAt: draft?.expiresAt ?? this.now() + 7 * 86400000,
345
+ });
346
+ }
315
347
  await tx.put('flows', flow.id, flow);
316
348
  return provider;
317
349
  });
@@ -328,7 +360,7 @@ export class VerilyService {
328
360
  flowId: flow.id,
329
361
  binding,
330
362
  expect: flow.expect,
331
- instructions: artifact.instructions(flow.expect),
363
+ instructions: artifact.instructions(flow.expect, flow.suggested),
332
364
  }
333
365
  : isRedirectProvider(provider)
334
366
  ? {
@@ -435,6 +467,13 @@ export class VerilyService {
435
467
  const provider = this.providerOf(flow);
436
468
  if (flow.phase !== 'pending' || !flow.expect || !isArtifactProvider(provider))
437
469
  throw new Unavailable();
470
+ if (flow.dnsDraftId) {
471
+ const draft = await tx.get('dnsDrafts', flow.dnsDraftId);
472
+ if (draft?.flowId === flow.id) {
473
+ draft.artifact = artifact;
474
+ await tx.put('dnsDrafts', draft.id, draft);
475
+ }
476
+ }
438
477
  flow.phase = 'exchanging';
439
478
  await tx.put('flows', id, flow);
440
479
  return { provider, expect: flow.expect };
@@ -589,7 +628,7 @@ export class VerilyService {
589
628
  async linked(tx, id, token) {
590
629
  const flow = await tx.get('flows', id);
591
630
  if (!flow ||
592
- flow.expiresAt <= this.now() ||
631
+ expired(flow, this.now()) ||
593
632
  flow.phase !== 'pending' ||
594
633
  flow.sent?.linkHash !== hash(token))
595
634
  throw new Unavailable();
@@ -635,7 +674,7 @@ export class VerilyService {
635
674
  }
636
675
  async bound(tx, id, binding) {
637
676
  const flow = await tx.get('flows', id);
638
- if (!flow || flow.bindingHash !== hash(binding) || flow.expiresAt <= this.now())
677
+ if (!flow || flow.bindingHash !== hash(binding) || expired(flow, this.now()))
639
678
  throw new Unavailable();
640
679
  return flow;
641
680
  }
@@ -685,7 +724,11 @@ export class VerilyService {
685
724
  !external.handle ||
686
725
  typeof external.profileUrl !== 'string' ||
687
726
  // A mailbox has no page to point at, so its own address stands in for one.
688
- new URL(external.profileUrl).protocol !== (external.kind === 'mailbox' ? 'mailto:' : 'https:'))
727
+ new URL(external.profileUrl).protocol !==
728
+ (external.kind === 'mailbox' ? 'mailto:' : 'https:') ||
729
+ // Reject a creation date in the future, allowing five minutes of clock skew.
730
+ (external.createdAt !== undefined &&
731
+ !(Number.isFinite(external.createdAt) && external.createdAt <= this.now() + 300000)))
689
732
  throw new Unavailable();
690
733
  // The flow as it stands once this account is accepted for it, or a refusal. Asked
691
734
  // twice, because what it is checked against can change while proofs are being read.
@@ -948,6 +991,11 @@ export class VerilyService {
948
991
  */
949
992
  async ended(tx, flow, phase, connection) {
950
993
  connection ??= flow.connectionId ? await tx.get('connections', flow.connectionId) : undefined;
994
+ if (flow.dnsDraftId && phase !== 'failed') {
995
+ const draft = await tx.get('dnsDrafts', flow.dnsDraftId);
996
+ if (draft?.flowId === flow.id)
997
+ await tx.delete('dnsDrafts', draft.id);
998
+ }
951
999
  flow.phase = phase;
952
1000
  flow.result = {
953
1001
  kind: flow.kind,
@@ -957,9 +1005,120 @@ export class VerilyService {
957
1005
  };
958
1006
  }
959
1007
  /** Every read of a connection goes through here, so a record in an older shape is upgraded. */
960
- transaction(work) {
1008
+ async transaction(work) {
1009
+ const written = new Set();
1010
+ const result = await this.options.storage.transaction((tx) => work(upgraded(tx, written)));
1011
+ // Report the records this transaction wrote, once it has committed. A failure here is
1012
+ // left for the next announce() and never fails the request that made the change.
1013
+ if (written.size && this.options.onChange)
1014
+ await this.announce([...written]).catch(() => 0);
1015
+ return result;
1016
+ }
1017
+ /** The parts of a record that `onChange` reports, as they are at `now`. */
1018
+ reportable(connection, now) {
1019
+ return {
1020
+ status: status(connection, now, this.freshness),
1021
+ visibility: connection.visibility,
1022
+ expiresAt: connection.expiresAt,
1023
+ ...(connection.mark ? { mark: connection.mark } : {}),
1024
+ };
1025
+ }
1026
+ /**
1027
+ * Delivers every record whose state differs from the one last reported to `onChange`,
1028
+ * up to `budget` records. `ids` limits it to those records. Returns how many were
1029
+ * delivered. Does nothing without `onChange`.
1030
+ *
1031
+ * Records whose last delivery failed are tried after the others, oldest attempt first,
1032
+ * so a record that keeps failing does not hold up the rest.
1033
+ */
1034
+ async announce(ids, budget = 50) {
1035
+ if (!this.options.onChange || budget <= 0)
1036
+ return 0;
1037
+ const now = this.now();
1038
+ const due = await this.unreported(async (tx) => {
1039
+ const records = ids
1040
+ ? await Promise.all(ids.map((id) => tx.get('connections', id)))
1041
+ : await tx.list('connections');
1042
+ return records
1043
+ .filter((connection) => connection !== undefined)
1044
+ .filter((c) => this.unsettled(c, now))
1045
+ .sort((x, y) => (x.reportTriedAt ?? 0) - (y.reportTriedAt ?? 0))
1046
+ .slice(0, budget)
1047
+ .map((connection) => connection.id);
1048
+ });
1049
+ let delivered = 0;
1050
+ for (const id of due)
1051
+ delivered += await this.deliver(id);
1052
+ return delivered;
1053
+ }
1054
+ /** A storage transaction whose writes are not themselves announced. */
1055
+ unreported(work) {
961
1056
  return this.options.storage.transaction((tx) => work(upgraded(tx)));
962
1057
  }
1058
+ /** The last delivery started. Each delivery waits for the one before it. */
1059
+ delivering = Promise.resolve();
1060
+ /**
1061
+ * Whether a record still has something to deliver: its state differs from the one last
1062
+ * reported, or its last delivery started and was never confirmed. An unconfirmed
1063
+ * delivery may have reached the receiver, so it is sent again even if the state has
1064
+ * since gone back to the one reported.
1065
+ */
1066
+ unsettled(connection, now) {
1067
+ return (connection.reportTriedAt !== undefined ||
1068
+ !isDeepStrictEqual(connection.reported, this.reportable(connection, now)));
1069
+ }
1070
+ /**
1071
+ * Delivers one record to `onChange` if it has something to deliver. Returns 1 if the
1072
+ * delivery was confirmed.
1073
+ *
1074
+ * Each delivery reads the record and takes the next revision in one storage
1075
+ * transaction, so a higher revision always carries a later state, whichever process or
1076
+ * callback finishes first. Within this process deliveries also run one at a time.
1077
+ */
1078
+ deliver(id) {
1079
+ const run = this.delivering.then(async () => {
1080
+ const now = this.now();
1081
+ // Start the delivery: read the state, take a revision, and mark it unconfirmed.
1082
+ const attempt = await this.unreported(async (tx) => {
1083
+ const connection = await tx.get('connections', id);
1084
+ if (!connection || !this.unsettled(connection, now))
1085
+ return undefined;
1086
+ const revision = (connection.reportRevision ?? 0) + 1;
1087
+ await tx.put('connections', id, {
1088
+ ...connection,
1089
+ reportRevision: revision,
1090
+ reportTriedAt: now,
1091
+ });
1092
+ return { connection, revision, state: this.reportable(connection, now) };
1093
+ });
1094
+ if (!attempt)
1095
+ return 0;
1096
+ const { connection, revision, state } = attempt;
1097
+ try {
1098
+ await this.deadline(Promise.resolve(this.options.onChange({
1099
+ evidence: this.evidence(connection),
1100
+ local: connection.local,
1101
+ ...(connection.reported ? { previous: connection.reported } : {}),
1102
+ revision,
1103
+ })));
1104
+ }
1105
+ catch {
1106
+ // Left unconfirmed, so the next announce() delivers the record again.
1107
+ return 0;
1108
+ }
1109
+ // Confirm it, unless a later delivery of the record has started since.
1110
+ await this.unreported(async (tx) => {
1111
+ const current = await tx.get('connections', id);
1112
+ if (!current || current.reportRevision !== revision)
1113
+ return;
1114
+ const { reportTriedAt: _tried, ...rest } = current;
1115
+ await tx.put('connections', id, { ...rest, reported: state });
1116
+ });
1117
+ return 1;
1118
+ });
1119
+ this.delivering = run.catch(() => 0);
1120
+ return run;
1121
+ }
963
1122
  /**
964
1123
  * The methods a reader is shown. An additional method whose proof has gone unread no
965
1124
  * longer shows anything, so it is left out until it reads again; the main one is
@@ -1061,6 +1220,9 @@ export class VerilyService {
1061
1220
  const [main, ...rest] = connection.attestations?.external ?? [
1062
1221
  { by: 'provider', method: 'oauth', confirmedAt: connection.authenticatedAt },
1063
1222
  ];
1223
+ // Keep a creation date the record already has when this method did not report one.
1224
+ const createdAt = flow.external.createdAt ?? connection.external.createdAt;
1225
+ const created = createdAt === undefined ? {} : { createdAt };
1064
1226
  connection.local = flow.local;
1065
1227
  connection.approvedAt = this.now();
1066
1228
  connection.expiresAt = this.now() + (this.options.validityMs ?? 30 * 86400000);
@@ -1070,10 +1232,11 @@ export class VerilyService {
1070
1232
  // The account is taken as this method just named it. A recheck asks the same method
1071
1233
  // about the new proof and compares ids exactly, and an address read with different
1072
1234
  // case names the same profile under a different id.
1073
- connection.external = flow.external;
1235
+ connection.external = { ...flow.external, ...created };
1074
1236
  connection.attestations = { local: this.declared(flow), external: [attestation, ...rest] };
1075
1237
  }
1076
1238
  else {
1239
+ connection.external = { ...connection.external, ...created };
1077
1240
  // A proof that named the subject's old address proves nothing about its new one,
1078
1241
  // and rereading it would go on confirming a link to where the subject used to be.
1079
1242
  const others = rest.filter((a) => this.names(connection, a, flow.local));
@@ -1328,7 +1491,7 @@ export class VerilyService {
1328
1491
  async shared(token) {
1329
1492
  return this.transaction(async (tx) => {
1330
1493
  const share = (await tx.list('shares')).find((s) => s.tokenHash === hash(token));
1331
- if (!share || share.revokedAt !== undefined || share.expiresAt <= this.now())
1494
+ if (!share || share.revokedAt !== undefined || expired(share, this.now()))
1332
1495
  throw new Unavailable();
1333
1496
  const connection = await tx.get('connections', share.connectionId);
1334
1497
  if (!connection || connection.visibility !== 'unlisted' || connection.revokedAt !== undefined)
@@ -1369,6 +1532,11 @@ export class VerilyService {
1369
1532
  return 0;
1370
1533
  const interval = this.options.recheckMs ?? 86400000;
1371
1534
  const now = this.now();
1535
+ const spent = new Map();
1536
+ // When a proof was last read, or last tried. One whose last read did not confirm it,
1537
+ // because the read failed or the proof was missing, is tried again four times as soon.
1538
+ const tried = (a) => a.readAt ?? a.confirmedAt;
1539
+ const wait = (a) => tried(a) > a.confirmedAt ? Math.ceil(interval / 4) : interval;
1372
1540
  const due = await this.transaction(async (tx) => (await tx.list('connections'))
1373
1541
  .filter((c) => c.revokedAt === undefined &&
1374
1542
  c.retiredAt === undefined &&
@@ -1380,15 +1548,23 @@ export class VerilyService {
1380
1548
  attestation,
1381
1549
  provider: this.rereads(connection, attestation),
1382
1550
  }))
1383
- .filter((item) => item.provider !== undefined && item.attestation.confirmedAt + interval <= now))
1384
- .sort((a, b) => a.attestation.confirmedAt - b.attestation.confirmedAt)
1385
- .slice(0, budget));
1551
+ .filter((item) => item.provider !== undefined &&
1552
+ tried(item.attestation) + wait(item.attestation) <= now))
1553
+ .sort((a, b) => tried(a.attestation) - tried(b.attestation))
1554
+ // The budget is per provider, so one provider's backlog does not delay the others.
1555
+ .filter((item) => {
1556
+ const used = spent.get(item.provider.id) ?? 0;
1557
+ spent.set(item.provider.id, used + 1);
1558
+ return used < budget;
1559
+ }));
1386
1560
  let confirmed = 0;
1387
1561
  // Serially and outside any transaction: storage here serializes writes, so holding one
1388
1562
  // open across a fetch would stall every other request behind the slowest provider.
1389
1563
  for (const item of due)
1390
1564
  confirmed += await this.reread(item);
1391
1565
  await this.restate(budget);
1566
+ // Report records that expired or went unconfirmed since the last run.
1567
+ await this.announce();
1392
1568
  return confirmed;
1393
1569
  }
1394
1570
  /**
@@ -1475,6 +1651,9 @@ export class VerilyService {
1475
1651
  const main = attestation === connection.attestations.external[0];
1476
1652
  let withdrawn = false;
1477
1653
  let dnssec = false;
1654
+ // 'absent': the read worked and the proof was not there. 'unread': the read failed.
1655
+ let missed;
1656
+ const started = this.now();
1478
1657
  try {
1479
1658
  if (provider.artifact === 'document')
1480
1659
  withdrawn = await this.deadline(provider.withdrawn(connection.external, connection.proof));
@@ -1487,11 +1666,11 @@ export class VerilyService {
1487
1666
  if (main
1488
1667
  ? external.id !== connection.external.id
1489
1668
  : !sameAccount(connection.external, external))
1490
- return 0;
1669
+ missed = 'absent';
1491
1670
  }
1492
1671
  }
1493
- catch {
1494
- return 0;
1672
+ catch (error) {
1673
+ missed = error instanceof Absent ? 'absent' : 'unread';
1495
1674
  }
1496
1675
  return this.transaction(async (tx) => {
1497
1676
  const current = await tx.get('connections', connection.id);
@@ -1506,6 +1685,14 @@ export class VerilyService {
1506
1685
  const held = main ? first : rest.find((a) => a.method === method);
1507
1686
  if (held?.method !== method || held.artifactUrl !== artifactUrl)
1508
1687
  return 0;
1688
+ // Drop this answer if something newer than this read has been written since the
1689
+ // read was picked: a renewal that replaced or confirmed the proof, or another
1690
+ // recheck's read that started at or after this one, whatever that read found.
1691
+ const replaced = held.expect !== expect ||
1692
+ (held.confirmedAt !== attestation.confirmedAt && held.confirmedAt >= started);
1693
+ const reread = held.readAt !== undefined && held.readAt !== attestation.readAt && held.readAt >= started;
1694
+ if (replaced || reread)
1695
+ return 0;
1509
1696
  // The holder said this identity is no longer theirs, which is a choice, not decay.
1510
1697
  if (withdrawn) {
1511
1698
  current.revokedAt = this.now();
@@ -1515,7 +1702,18 @@ export class VerilyService {
1515
1702
  await this.audit(tx, connection.id, 'revoke', 'external');
1516
1703
  return 0;
1517
1704
  }
1518
- held.confirmedAt = this.now();
1705
+ // Times are the moment the read started, so answers can be put in order.
1706
+ held.readAt = started;
1707
+ if (missed) {
1708
+ // A failed read leaves the proof counting until freshnessMs runs out. A proof
1709
+ // that was read and is missing stops counting now.
1710
+ if (missed === 'absent')
1711
+ held.absentAt ??= started;
1712
+ await tx.put('connections', connection.id, current);
1713
+ return 0;
1714
+ }
1715
+ held.confirmedAt = started;
1716
+ delete held.absentAt;
1519
1717
  // A zone can start or stop being signed, so the flag is as the last read found it.
1520
1718
  if (dnssec)
1521
1719
  held.dnssec = true;
@@ -1546,10 +1744,13 @@ export class VerilyService {
1546
1744
  await this.transaction(async (tx) => {
1547
1745
  const now = this.now();
1548
1746
  for (const flow of await tx.list('flows'))
1549
- if (flow.expiresAt <= now)
1747
+ if (expired(flow, now))
1550
1748
  await tx.delete('flows', flow.id);
1749
+ for (const draft of await tx.list('dnsDrafts'))
1750
+ if (expired(draft, now))
1751
+ await tx.delete('dnsDrafts', draft.id);
1551
1752
  for (const limit of await tx.list('limits'))
1552
- if (limit.expiresAt <= now)
1753
+ if (expired(limit, now))
1553
1754
  await tx.delete('limits', limit.id);
1554
1755
  const audit = await tx.list('audit');
1555
1756
  // The sites that still have a record here, whose place in the recheck order is kept.
@@ -1652,10 +1853,15 @@ function sameAccount(a, b) {
1652
1853
  * Wraps a transaction so records read through it are in the current shape. They are
1653
1854
  * rewritten in that shape the next time they are put.
1654
1855
  */
1655
- function upgraded(tx) {
1856
+ function upgraded(tx, written) {
1656
1857
  return {
1657
1858
  get: async (kind, id) => upgrade(kind, await tx.get(kind, id)),
1658
- put: (kind, id, value) => tx.put(kind, id, value),
1859
+ put: (kind, id, value) => {
1860
+ // Collects the ids of the connections written, for transaction() to report.
1861
+ if (kind === 'connections')
1862
+ written?.add(id);
1863
+ return tx.put(kind, id, value);
1864
+ },
1659
1865
  delete: (kind, id) => tx.delete(kind, id),
1660
1866
  list: async (kind) => (await tx.list(kind)).map((value) => upgrade(kind, value)),
1661
1867
  };
@@ -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/*\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";
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/* Clipped 4px left of the text, which hides the date's dot when the date wraps to a new line. */\n.name { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 1rem; margin: 0 0 0 -4px; padding-left: 4px; overflow-x: clip; font-size: 1.0625rem; font-weight: 620; letter-spacing: -.01em; }\n\n/* The account's creation date, after the name and a dot. It wraps under a long name. */\n.name .since { position: relative; color: var(--muted); font-size: .8125rem; font-weight: 400; letter-spacing: 0; white-space: nowrap; }\n\n/* The dot sits in the gap before the date. */\n.name .since::before { content: \"\u00B7\"; position: absolute; left: -.5rem; transform: translateX(-50%); }\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/* The \"signed\" pill after the verifier's name, one size smaller than the line it sits on. */\n.how .signed-mark { display: inline-block; margin-left: .3125rem; padding: 0 .375rem; border: 1px solid var(--line); border-radius: 999px; font-size: .625rem; font-weight: 550; line-height: 1.3; text-decoration: none; vertical-align: .0625rem; }\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
@@ -179,7 +179,14 @@ main:has(.accounts) { max-width: 54rem; }
179
179
  letter-spacing: .02em;
180
180
  }
181
181
 
182
- .name { margin: 0; font-size: 1.0625rem; font-weight: 620; letter-spacing: -.01em; }
182
+ /* Clipped 4px left of the text, which hides the date's dot when the date wraps to a new line. */
183
+ .name { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 1rem; margin: 0 0 0 -4px; padding-left: 4px; overflow-x: clip; font-size: 1.0625rem; font-weight: 620; letter-spacing: -.01em; }
184
+
185
+ /* The account's creation date, after the name and a dot. It wraps under a long name. */
186
+ .name .since { position: relative; color: var(--muted); font-size: .8125rem; font-weight: 400; letter-spacing: 0; white-space: nowrap; }
187
+
188
+ /* The dot sits in the gap before the date. */
189
+ .name .since::before { content: "·"; position: absolute; left: -.5rem; transform: translateX(-50%); }
183
190
 
184
191
  /* The name is the card's subject before it is a link, so the rule is drawn on hover. */
185
192
  .name a { text-decoration: none; }
@@ -192,6 +199,8 @@ main:has(.accounts) { max-width: 54rem; }
192
199
  .fingerprint { font-family: ui-monospace, monospace; text-wrap: balance; }
193
200
  .fingerprint span { display: inline-block; margin-right: .5ch; }
194
201
  .how.additional { padding-left: .75rem; }
202
+ /* The "signed" pill after the verifier's name, one size smaller than the line it sits on. */
203
+ .how .signed-mark { display: inline-block; margin-left: .3125rem; padding: 0 .375rem; border: 1px solid var(--line); border-radius: 999px; font-size: .625rem; font-weight: 550; line-height: 1.3; text-decoration: none; vertical-align: .0625rem; }
195
204
 
196
205
  .fine { color: var(--muted); font-size: .8125rem; line-height: 1.55; margin-bottom: .625rem; }
197
206
 
@@ -78,10 +78,15 @@ export function youtubeProvider(options) {
78
78
  : undefined;
79
79
  if (!handle)
80
80
  throw new Error('Invalid provider identity');
81
+ // The channel's creation date, not the Google account's. Optional: a missing or
82
+ // unparseable date is left off and the sign-in still succeeds.
83
+ const publishedAt = channel.snippet.publishedAt;
84
+ const createdAt = typeof publishedAt === 'string' ? Date.parse(publishedAt) : NaN;
81
85
  return {
82
86
  id: channel.id,
83
87
  handle,
84
88
  profileUrl: `https://www.youtube.com/channel/${channel.id}`,
89
+ ...(Number.isFinite(createdAt) ? { createdAt } : {}),
85
90
  };
86
91
  }
87
92
  finally {