@bkazemi/verily 0.5.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.
- package/README.md +25 -6
- package/dist/browser/badge.d.ts +4 -1
- package/dist/browser/badge.js +7 -4
- package/dist/browser/connect-dialog.js +10 -7
- package/dist/browser/evidence-dialog.d.ts +23 -4
- package/dist/browser/evidence-dialog.js +335 -49
- package/dist/browser/index.d.ts +7 -1
- package/dist/browser/index.js +27 -42
- package/dist/core/index.d.ts +106 -2
- package/dist/core/index.js +74 -2
- package/dist/server/discord.js +3 -0
- package/dist/server/domain.js +37 -10
- package/dist/server/github-gist.js +16 -4
- package/dist/server/github.js +3 -0
- package/dist/server/index.js +108 -11
- package/dist/server/link.js +9 -4
- package/dist/server/openpgp.d.ts +4 -0
- package/dist/server/openpgp.js +10 -1
- package/dist/server/service.d.ts +59 -2
- package/dist/server/service.js +240 -22
- package/dist/server/style.d.ts +1 -1
- package/dist/server/style.js +50 -1
- package/dist/server/youtube.js +5 -0
- package/dist/verily.js +345 -84
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/server/service.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes } from 'node:crypto';
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
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 !==
|
|
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));
|
|
@@ -1174,11 +1337,23 @@ export class VerilyService {
|
|
|
1174
1337
|
* hardcoding an id that dies whenever one is revoked and replaced. Unlisted records are
|
|
1175
1338
|
* never included: they must not appear in any directory listing.
|
|
1176
1339
|
*/
|
|
1177
|
-
async published() {
|
|
1340
|
+
async published(subject) {
|
|
1341
|
+
return (await this.listed(subject)).filter((e) => e.status === 'verified');
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Every public record a badge could show, for the page a person reads: one that has lapsed
|
|
1345
|
+
* or been retired is there to be marked, and one removed is not. `subject` narrows it to
|
|
1346
|
+
* one site or one subject, named by what its records already publish. Earliest connected first.
|
|
1347
|
+
*/
|
|
1348
|
+
async listed(subject) {
|
|
1178
1349
|
return this.transaction(async (tx) => (await tx.list('connections'))
|
|
1179
|
-
.filter((c) => c.visibility === 'public'
|
|
1350
|
+
.filter((c) => c.visibility === 'public' &&
|
|
1351
|
+
(!subject ||
|
|
1352
|
+
(this.siteOf(c.local) === subject.site &&
|
|
1353
|
+
(subject.reference === undefined || c.local.reference === subject.reference))))
|
|
1180
1354
|
.map((c) => this.evidence(c))
|
|
1181
|
-
.filter((e) => e.status
|
|
1355
|
+
.filter((e) => e.status !== 'revoked')
|
|
1356
|
+
.sort((a, b) => a.connectedAt - b.connectedAt));
|
|
1182
1357
|
}
|
|
1183
1358
|
/**
|
|
1184
1359
|
* Every record this instance holds, public and unlisted, for whoever runs it. No route
|
|
@@ -1316,7 +1491,7 @@ export class VerilyService {
|
|
|
1316
1491
|
async shared(token) {
|
|
1317
1492
|
return this.transaction(async (tx) => {
|
|
1318
1493
|
const share = (await tx.list('shares')).find((s) => s.tokenHash === hash(token));
|
|
1319
|
-
if (!share || share.revokedAt !== undefined || share
|
|
1494
|
+
if (!share || share.revokedAt !== undefined || expired(share, this.now()))
|
|
1320
1495
|
throw new Unavailable();
|
|
1321
1496
|
const connection = await tx.get('connections', share.connectionId);
|
|
1322
1497
|
if (!connection || connection.visibility !== 'unlisted' || connection.revokedAt !== undefined)
|
|
@@ -1357,6 +1532,11 @@ export class VerilyService {
|
|
|
1357
1532
|
return 0;
|
|
1358
1533
|
const interval = this.options.recheckMs ?? 86400000;
|
|
1359
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;
|
|
1360
1540
|
const due = await this.transaction(async (tx) => (await tx.list('connections'))
|
|
1361
1541
|
.filter((c) => c.revokedAt === undefined &&
|
|
1362
1542
|
c.retiredAt === undefined &&
|
|
@@ -1368,15 +1548,23 @@ export class VerilyService {
|
|
|
1368
1548
|
attestation,
|
|
1369
1549
|
provider: this.rereads(connection, attestation),
|
|
1370
1550
|
}))
|
|
1371
|
-
.filter((item) => item.provider !== undefined &&
|
|
1372
|
-
.
|
|
1373
|
-
.
|
|
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
|
+
}));
|
|
1374
1560
|
let confirmed = 0;
|
|
1375
1561
|
// Serially and outside any transaction: storage here serializes writes, so holding one
|
|
1376
1562
|
// open across a fetch would stall every other request behind the slowest provider.
|
|
1377
1563
|
for (const item of due)
|
|
1378
1564
|
confirmed += await this.reread(item);
|
|
1379
1565
|
await this.restate(budget);
|
|
1566
|
+
// Report records that expired or went unconfirmed since the last run.
|
|
1567
|
+
await this.announce();
|
|
1380
1568
|
return confirmed;
|
|
1381
1569
|
}
|
|
1382
1570
|
/**
|
|
@@ -1463,6 +1651,9 @@ export class VerilyService {
|
|
|
1463
1651
|
const main = attestation === connection.attestations.external[0];
|
|
1464
1652
|
let withdrawn = false;
|
|
1465
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();
|
|
1466
1657
|
try {
|
|
1467
1658
|
if (provider.artifact === 'document')
|
|
1468
1659
|
withdrawn = await this.deadline(provider.withdrawn(connection.external, connection.proof));
|
|
@@ -1475,11 +1666,11 @@ export class VerilyService {
|
|
|
1475
1666
|
if (main
|
|
1476
1667
|
? external.id !== connection.external.id
|
|
1477
1668
|
: !sameAccount(connection.external, external))
|
|
1478
|
-
|
|
1669
|
+
missed = 'absent';
|
|
1479
1670
|
}
|
|
1480
1671
|
}
|
|
1481
|
-
catch {
|
|
1482
|
-
|
|
1672
|
+
catch (error) {
|
|
1673
|
+
missed = error instanceof Absent ? 'absent' : 'unread';
|
|
1483
1674
|
}
|
|
1484
1675
|
return this.transaction(async (tx) => {
|
|
1485
1676
|
const current = await tx.get('connections', connection.id);
|
|
@@ -1494,6 +1685,14 @@ export class VerilyService {
|
|
|
1494
1685
|
const held = main ? first : rest.find((a) => a.method === method);
|
|
1495
1686
|
if (held?.method !== method || held.artifactUrl !== artifactUrl)
|
|
1496
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;
|
|
1497
1696
|
// The holder said this identity is no longer theirs, which is a choice, not decay.
|
|
1498
1697
|
if (withdrawn) {
|
|
1499
1698
|
current.revokedAt = this.now();
|
|
@@ -1503,7 +1702,18 @@ export class VerilyService {
|
|
|
1503
1702
|
await this.audit(tx, connection.id, 'revoke', 'external');
|
|
1504
1703
|
return 0;
|
|
1505
1704
|
}
|
|
1506
|
-
|
|
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;
|
|
1507
1717
|
// A zone can start or stop being signed, so the flag is as the last read found it.
|
|
1508
1718
|
if (dnssec)
|
|
1509
1719
|
held.dnssec = true;
|
|
@@ -1534,10 +1744,13 @@ export class VerilyService {
|
|
|
1534
1744
|
await this.transaction(async (tx) => {
|
|
1535
1745
|
const now = this.now();
|
|
1536
1746
|
for (const flow of await tx.list('flows'))
|
|
1537
|
-
if (flow
|
|
1747
|
+
if (expired(flow, now))
|
|
1538
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);
|
|
1539
1752
|
for (const limit of await tx.list('limits'))
|
|
1540
|
-
if (limit
|
|
1753
|
+
if (expired(limit, now))
|
|
1541
1754
|
await tx.delete('limits', limit.id);
|
|
1542
1755
|
const audit = await tx.list('audit');
|
|
1543
1756
|
// The sites that still have a record here, whose place in the recheck order is kept.
|
|
@@ -1640,10 +1853,15 @@ function sameAccount(a, b) {
|
|
|
1640
1853
|
* Wraps a transaction so records read through it are in the current shape. They are
|
|
1641
1854
|
* rewritten in that shape the next time they are put.
|
|
1642
1855
|
*/
|
|
1643
|
-
function upgraded(tx) {
|
|
1856
|
+
function upgraded(tx, written) {
|
|
1644
1857
|
return {
|
|
1645
1858
|
get: async (kind, id) => upgrade(kind, await tx.get(kind, id)),
|
|
1646
|
-
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
|
+
},
|
|
1647
1865
|
delete: (kind, id) => tx.delete(kind, id),
|
|
1648
1866
|
list: async (kind) => (await tx.list(kind)).map((value) => upgrade(kind, value)),
|
|
1649
1867
|
};
|
package/dist/server/style.d.ts
CHANGED
|
@@ -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/* 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
|
package/dist/server/style.js
CHANGED
|
@@ -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);
|
|
@@ -142,7 +179,14 @@ code { font: .8125rem/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
|
142
179
|
letter-spacing: .02em;
|
|
143
180
|
}
|
|
144
181
|
|
|
145
|
-
|
|
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%); }
|
|
146
190
|
|
|
147
191
|
/* The name is the card's subject before it is a link, so the rule is drawn on hover. */
|
|
148
192
|
.name a { text-decoration: none; }
|
|
@@ -152,7 +196,11 @@ code { font: .8125rem/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
|
152
196
|
.reference, .how { margin: 0; color: var(--muted); font-size: .8125rem; line-height: 1.5; }
|
|
153
197
|
|
|
154
198
|
.name + .how, .reference + .how { margin-top: .5rem; }
|
|
199
|
+
.fingerprint { font-family: ui-monospace, monospace; text-wrap: balance; }
|
|
200
|
+
.fingerprint span { display: inline-block; margin-right: .5ch; }
|
|
155
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; }
|
|
156
204
|
|
|
157
205
|
.fine { color: var(--muted); font-size: .8125rem; line-height: 1.55; margin-bottom: .625rem; }
|
|
158
206
|
|
|
@@ -178,6 +226,7 @@ dd { margin: 0; font-variant-numeric: tabular-nums; }
|
|
|
178
226
|
|
|
179
227
|
/* Inside a card the rule above it is the card's own edge, so the list needs none. */
|
|
180
228
|
section dl { margin: .5rem 0 1rem; padding-top: 0; border-top: 0; }
|
|
229
|
+
.side dl { margin: .625rem 0 0; padding-top: .625rem; }
|
|
181
230
|
|
|
182
231
|
section {
|
|
183
232
|
margin: 0 0 1.25rem;
|
package/dist/server/youtube.js
CHANGED
|
@@ -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 {
|