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