@bkazemi/verily 0.6.0 → 0.8.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 +197 -91
- package/dist/browser/connect-dialog.js +3 -4
- package/dist/browser/evidence-dialog.d.ts +7 -2
- package/dist/browser/evidence-dialog.js +62 -26
- package/dist/browser/index.js +3 -0
- package/dist/browser/mark.d.ts +2 -6
- package/dist/browser/mark.js +6 -12
- package/dist/browser/provider-mark.d.ts +3 -3
- package/dist/browser/provider-mark.js +15 -90
- package/dist/core/index.d.ts +78 -2
- package/dist/core/index.js +26 -2
- package/dist/instance/auth.d.ts +14 -0
- package/dist/instance/auth.js +80 -0
- package/dist/instance/bin.d.ts +2 -0
- package/dist/instance/bin.js +36 -0
- package/dist/instance/cloudflare.d.ts +57 -0
- package/dist/instance/cloudflare.js +71 -0
- package/dist/instance/env.d.ts +51 -0
- package/dist/instance/env.js +84 -0
- package/dist/instance/index.d.ts +136 -0
- package/dist/instance/index.js +777 -0
- package/dist/instance/init.d.ts +6 -0
- package/dist/instance/init.js +28 -0
- package/dist/instance/limits.d.ts +30 -0
- package/dist/instance/limits.js +45 -0
- package/dist/instance/node.d.ts +13 -0
- package/dist/instance/node.js +43 -0
- package/dist/instance/page.d.ts +18 -0
- package/dist/instance/page.js +30 -0
- package/dist/instance/postgres.d.ts +19 -0
- package/dist/instance/postgres.js +83 -0
- package/dist/instance/routes.d.ts +7 -0
- package/dist/instance/routes.js +22 -0
- package/dist/instance/serve.d.ts +31 -0
- package/dist/instance/serve.js +49 -0
- package/dist/instance/sites.d.ts +169 -0
- package/dist/instance/sites.js +413 -0
- package/dist/instance/starter.d.ts +6 -0
- package/dist/instance/starter.js +99 -0
- package/dist/instance/store.d.ts +20 -0
- package/dist/instance/store.js +11 -0
- package/dist/instance/undici.d.ts +8 -0
- package/dist/instance/undici.js +9 -0
- package/dist/marks.d.ts +44 -0
- package/dist/marks.js +98 -0
- 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 +20 -47
- package/dist/server/link.js +9 -4
- package/dist/server/render.d.ts +49 -0
- package/dist/server/render.js +104 -0
- package/dist/server/service.d.ts +44 -1
- package/dist/server/service.js +225 -19
- package/dist/server/style.d.ts +1 -1
- package/dist/server/style.js +132 -2
- package/dist/server/youtube.js +5 -0
- package/dist/verily.js +155 -124
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +30 -2
package/dist/server/service.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Attestations, type Connection, type Evidence, type ExternalAccount, type Flow, type LocalAccount, type Mark, type Provider, type SignedDocument, type SignedEvidence, type Storage, type VerifierKey, type Visibility } from '../core/index.js';
|
|
1
|
+
import { type Change, type Attestations, type Connection, type Evidence, type ExternalAccount, type Flow, type LocalAccount, type Mark, type Provider, type SignedDocument, type SignedEvidence, type Storage, type VerifierKey, type Visibility } from '../core/index.js';
|
|
2
2
|
import { type DnsOptions, type Published } from './domain.js';
|
|
3
3
|
export declare const secret: () => string;
|
|
4
4
|
export declare const hash: (value: string) => string;
|
|
@@ -65,6 +65,18 @@ export interface ServiceOptions {
|
|
|
65
65
|
* is on the site's domain or a subdomain of it. Unset reads nothing.
|
|
66
66
|
*/
|
|
67
67
|
dns?: boolean | DnsOptions;
|
|
68
|
+
/**
|
|
69
|
+
* Called when a record's status, visibility, expiry or mark changes, with the record as
|
|
70
|
+
* it stands now. Changes made by a request are delivered before that request returns.
|
|
71
|
+
* Expiry and a proof going unconfirmed happen with time alone, so those are delivered by
|
|
72
|
+
* `recheck()` or `announce()`.
|
|
73
|
+
*
|
|
74
|
+
* Delivery is at least once: a call that throws, or takes longer than `recheckTimeoutMs`,
|
|
75
|
+
* is retried on the next `recheck()` or `announce()`. A call that timed out is not
|
|
76
|
+
* cancelled and may finish after a newer one, so apply a change only if its `revision`
|
|
77
|
+
* is higher than the last one applied for that record.
|
|
78
|
+
*/
|
|
79
|
+
onChange?: (change: Change) => void | Promise<void>;
|
|
68
80
|
now?: () => number;
|
|
69
81
|
}
|
|
70
82
|
export declare class VerilyService {
|
|
@@ -296,6 +308,37 @@ export declare class VerilyService {
|
|
|
296
308
|
private ended;
|
|
297
309
|
/** Every read of a connection goes through here, so a record in an older shape is upgraded. */
|
|
298
310
|
private transaction;
|
|
311
|
+
/** The parts of a record that `onChange` reports, as they are at `now`. */
|
|
312
|
+
private reportable;
|
|
313
|
+
/**
|
|
314
|
+
* Delivers every record whose state differs from the one last reported to `onChange`,
|
|
315
|
+
* up to `budget` records. `ids` limits it to those records. Returns how many were
|
|
316
|
+
* delivered. Does nothing without `onChange`.
|
|
317
|
+
*
|
|
318
|
+
* Records whose last delivery failed are tried after the others, oldest attempt first,
|
|
319
|
+
* so a record that keeps failing does not hold up the rest.
|
|
320
|
+
*/
|
|
321
|
+
announce(ids?: string[], budget?: number): Promise<number>;
|
|
322
|
+
/** A storage transaction whose writes are not themselves announced. */
|
|
323
|
+
private unreported;
|
|
324
|
+
/** The last delivery started. Each delivery waits for the one before it. */
|
|
325
|
+
private delivering;
|
|
326
|
+
/**
|
|
327
|
+
* Whether a record still has something to deliver: its state differs from the one last
|
|
328
|
+
* reported, or its last delivery started and was never confirmed. An unconfirmed
|
|
329
|
+
* delivery may have reached the receiver, so it is sent again even if the state has
|
|
330
|
+
* since gone back to the one reported.
|
|
331
|
+
*/
|
|
332
|
+
private unsettled;
|
|
333
|
+
/**
|
|
334
|
+
* Delivers one record to `onChange` if it has something to deliver. Returns 1 if the
|
|
335
|
+
* delivery was confirmed.
|
|
336
|
+
*
|
|
337
|
+
* Each delivery reads the record and takes the next revision in one storage
|
|
338
|
+
* transaction, so a higher revision always carries a later state, whichever process or
|
|
339
|
+
* callback finishes first. Within this process deliveries also run one at a time.
|
|
340
|
+
*/
|
|
341
|
+
private deliver;
|
|
299
342
|
/**
|
|
300
343
|
* The methods a reader is shown. An additional method whose proof has gone unread no
|
|
301
344
|
* longer shows anything, so it is left out until it reads again; the main one is
|
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));
|
|
@@ -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
|
|
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 &&
|
|
1384
|
-
.
|
|
1385
|
-
.
|
|
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
|
-
|
|
1669
|
+
missed = 'absent';
|
|
1491
1670
|
}
|
|
1492
1671
|
}
|
|
1493
|
-
catch {
|
|
1494
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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) =>
|
|
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
|
};
|
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/*\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 --warn: #a4442b;\n color-scheme: light dark;\n accent-color: var(--accent);\n}\n\n@media (prefers-color-scheme: dark) {\n :root {\n --ink: #dde5e0;\n --muted: #96a39b;\n --line: #2f3b35;\n --surface: #161a18;\n --raised: #1d2220;\n --accent: #7fc4a2;\n --warn: #e59a82;\n }\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\n/*\n * A page holding one thing sits in the middle of the window rather than at its top,\n * where it would leave most of the window empty below it.\n */\nbody:has(main.single) { display: flex; min-height: 100vh; min-height: 100dvh; }\nmain.single { width: 100%; max-width: 24rem; margin: auto; padding-bottom: 3rem; }\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/* A provider's mark before its name, at the height of the letters. */\n.who .provider { display: inline-block; width: .875rem; height: .875rem; margin-right: .375rem; vertical-align: -.125rem; }\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 system-ui, -apple-system, 'Segoe UI', sans-serif;\n cursor: pointer;\n}\n\n/* A link that starts something, drawn as a button. */\na.button {\n display: inline-block;\n padding: .5rem 1rem;\n border: 1px solid var(--accent);\n border-radius: 6px;\n background: var(--accent);\n color: var(--surface);\n font-weight: 500;\n font-size: .9375rem;\n line-height: 1.4;\n text-decoration: none;\n}\n\nbutton + button { margin-left: .5rem; border-color: var(--line); background: none; color: var(--ink); }\n\n/* The other choice beside a page's one action, such as Cancel. */\na.button.secondary { border-color: var(--line); background: none; color: var(--ink); }\n\n/* A page's action and its alternative, side by side. */\n.choice { display: flex; flex-wrap: wrap; gap: .5rem; margin-bottom: 1.5rem; }\n.choice form { margin: 0; }\n\n/* A control that ends something. Filled where it is the page's one action. */\nbutton.danger { border-color: var(--warn); background: var(--warn); }\n\n/*\n * A connection on the settings page: the account's card, then what can be done with it.\n * Its state sits in the card's corner, on the line that names the method.\n */\n.connection { position: relative; margin-bottom: 1rem; }\n\n.state {\n position: absolute;\n top: .75rem;\n right: .875rem;\n display: flex;\n align-items: center;\n gap: .375rem;\n height: 1.625rem;\n padding: 0 .75rem 0 .5rem;\n border: 1px solid var(--line);\n border-radius: 999px;\n background: var(--surface);\n color: var(--ink);\n font-size: .8125rem;\n font-weight: 550;\n}\n\n/*\n * The V the badge draws, in the tone that says how the record stands. A record that claims\n * nothing is drawn faint in the text colour, as the badge draws it.\n */\n.verdict { flex-shrink: 0; width: 1rem; height: 1rem; }\n.verdict.pending { opacity: .45; }\n.how .verdict { width: .875rem; height: .875rem; margin-right: .3125rem; vertical-align: -.125rem; }\nh1 .verdict { width: 1.25rem; height: 1.25rem; margin-right: .5rem; vertical-align: -.0625rem; }\n\n/*\n * In a state pill the V is cropped to its strokes and fills the pill's height, so its arms\n * and its foot meet the border. The pill's left padding keeps the arms clear of its curve.\n */\n.state .verdict { width: auto; height: 100%; }\n\n/*\n * A pill's line includes room for descenders below the letters, which leaves its text\n * sitting high. Trimmed to the letters, the padding alone sets the space above and below.\n * In a state pill the word alone is trimmed, so it centres on the V by its letters.\n */\n@supports (text-box: trim-both cap alphabetic) {\n .listing { padding: .3125rem .5rem; text-box: trim-both cap alphabetic; }\n .state > span { text-box: trim-both cap alphabetic; }\n}\n\n/* Links that sit together on one line, wrapping where there is no room. */\n.links { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem 1.25rem; font-size: .875rem; }\n\n/* Under the account's id, with the facts about it rather than the controls. */\n.evidence { margin: .375rem 0 0; font-size: .8125rem; }\n\n.connection dl { margin-bottom: .75rem; }\n.connection .side > :last-child { margin-bottom: 0; }\n.connection pre { margin: .75rem 0; background: var(--surface); }\n.connection .actions { margin-top: .875rem; }\n\n/* The buttons for one connection in a row, wrapping where there is no room. */\n.actions { display: flex; flex-wrap: wrap; gap: .5rem; }\n.actions form { display: contents; }\n\n.actions button, .actions a.button {\n margin: 0;\n padding: .3125rem .75rem;\n border-color: var(--line);\n background: var(--surface);\n color: var(--ink);\n font-size: .8125rem;\n}\n\n.actions button:hover, .actions a.button:hover { border-color: var(--muted); }\n\n/* The controls that cannot be undone, together at the foot of the card. */\n.ending { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .5rem; margin-top: .75rem; padding-top: .75rem; border-top: 1px solid var(--line); }\n.ending form { margin: 0; }\n\n.ending button.danger {\n padding: .3125rem .75rem;\n background: var(--surface);\n color: var(--warn);\n font-size: .8125rem;\n}\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
|