@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/marks.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/** GitHub mark from Primer Octicons (MIT); license in docs/licenses/octicons.txt. */
|
|
2
|
+
const github = {
|
|
3
|
+
viewBox: '0 0 16 16',
|
|
4
|
+
paths: [
|
|
5
|
+
'M6.766 11.328c-2.063-.25-3.516-1.734-3.516-3.656 0-.781.281-1.625.75-2.188-.203-.515-.172-1.609.063-2.062.625-.078 1.468.25 1.968.703.594-.187 1.219-.281 1.985-.281.765 0 1.39.094 1.953.265.484-.437 1.344-.765 1.969-.687.218.422.25 1.515.046 2.047.5.593.766 1.39.766 2.203 0 1.922-1.453 3.375-3.547 3.64.531.344.89 1.094.89 1.954v1.625c0 .468.391.734.86.547C13.781 14.359 16 11.53 16 8.03 16 3.61 12.406 0 7.984 0 3.563 0 0 3.61 0 8.031a7.88 7.88 0 0 0 5.172 7.422c.422.156.828-.125.828-.547v-1.25c-.219.094-.5.156-.75.156-1.031 0-1.64-.562-2.078-1.609-.172-.422-.36-.672-.719-.719-.187-.015-.25-.093-.25-.187 0-.188.313-.328.625-.328.453 0 .844.281 1.25.86.313.452.64.655 1.031.655s.641-.14 1-.5c.266-.265.47-.5.657-.656',
|
|
6
|
+
],
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Discord mark from Simple Icons (CC0); notice in docs/licenses/simple-icons.txt. The mark
|
|
10
|
+
* itself is Discord's trademark, used here only to say an account is a Discord account.
|
|
11
|
+
*/
|
|
12
|
+
const discord = {
|
|
13
|
+
viewBox: '0 0 24 24',
|
|
14
|
+
paths: [
|
|
15
|
+
'M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z',
|
|
16
|
+
],
|
|
17
|
+
};
|
|
18
|
+
/** YouTube mark from Simple Icons (CC0); notice in docs/licenses/simple-icons.txt. */
|
|
19
|
+
const youtube = {
|
|
20
|
+
viewBox: '0 0 24 24',
|
|
21
|
+
paths: [
|
|
22
|
+
'M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z',
|
|
23
|
+
],
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* A key, drawn here rather than taken from anywhere. It is not the OpenPGP logo: that mark
|
|
27
|
+
* is somebody's to license and this repository does not ship artwork it cannot account for.
|
|
28
|
+
* A key is also the truer picture, since what was proved is control of one, not membership
|
|
29
|
+
* of an organisation.
|
|
30
|
+
*/
|
|
31
|
+
const key = {
|
|
32
|
+
viewBox: '0 0 16 16',
|
|
33
|
+
stroke: true,
|
|
34
|
+
paths: [
|
|
35
|
+
'M7.4 8.6a3.3 3.3 0 1 0-4.7 4.7 3.3 3.3 0 0 0 4.7-4.7Z',
|
|
36
|
+
'M7.4 8.6 14 2',
|
|
37
|
+
'M11.2 4.8l1.6 1.6',
|
|
38
|
+
'M9.4 6.6l1.6 1.6',
|
|
39
|
+
],
|
|
40
|
+
};
|
|
41
|
+
/** A globe, drawn here like the key, for a page or a domain on the open web. */
|
|
42
|
+
const globe = {
|
|
43
|
+
viewBox: '0 0 16 16',
|
|
44
|
+
stroke: true,
|
|
45
|
+
paths: [
|
|
46
|
+
'M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Z',
|
|
47
|
+
'M1.5 8h13',
|
|
48
|
+
'M8 1.5C6.1 3.3 5.1 5.5 5.1 8s1 4.7 2.9 6.5',
|
|
49
|
+
'M8 1.5c1.9 1.8 2.9 4 2.9 6.5s-1 4.7-2.9 6.5',
|
|
50
|
+
],
|
|
51
|
+
};
|
|
52
|
+
/** An envelope, drawn here like the key, for a mailbox that read a code sent to it. */
|
|
53
|
+
const envelope = {
|
|
54
|
+
viewBox: '0 0 16 16',
|
|
55
|
+
stroke: true,
|
|
56
|
+
paths: ['M2 3.5h12v9H2z', 'M2.4 4l5.6 4.6L13.6 4'],
|
|
57
|
+
};
|
|
58
|
+
const marks = { github, discord, youtube, openpgp: key, email: envelope };
|
|
59
|
+
/**
|
|
60
|
+
* The mark for a provider, or nothing where there is none. Returning nothing rather than
|
|
61
|
+
* the provider's name keeps this to one job: a caller that also writes the name would
|
|
62
|
+
* otherwise print it twice, and only the caller knows where the name belongs.
|
|
63
|
+
*/
|
|
64
|
+
export function markOf(provider, method) {
|
|
65
|
+
// A site names its own link-back providers, so those fall back on the method's mark,
|
|
66
|
+
// which a domain shown by its DNS or by a file shares.
|
|
67
|
+
return (marks[provider] ?? (['backlink', 'dns', 'wellknown'].includes(method ?? '') ? globe : undefined));
|
|
68
|
+
}
|
|
69
|
+
/** The attributes every path of a mark gets, as name and value pairs. */
|
|
70
|
+
export const pathAttributes = (mark) => mark.stroke
|
|
71
|
+
? [
|
|
72
|
+
['stroke', 'currentColor'],
|
|
73
|
+
['stroke-width', '1.6'],
|
|
74
|
+
['stroke-linecap', 'round'],
|
|
75
|
+
['stroke-linejoin', 'round'],
|
|
76
|
+
]
|
|
77
|
+
: [];
|
|
78
|
+
/** The V's two strokes in each tone: the V, then the check drawn inside it. */
|
|
79
|
+
export const tones = {
|
|
80
|
+
current: ['#D3444C', '#149766'],
|
|
81
|
+
inactive: ['#149766', '#D3444C'],
|
|
82
|
+
pending: ['currentColor', 'currentColor'],
|
|
83
|
+
};
|
|
84
|
+
/** The tone a record's mark is drawn in. A retired account claims nothing, not a failure. */
|
|
85
|
+
export function toneOf(evidence, now) {
|
|
86
|
+
if (evidence.status === 'verified' && evidence.expiresAt > now)
|
|
87
|
+
return 'current';
|
|
88
|
+
return evidence.status === 'retired' ? 'pending' : 'inactive';
|
|
89
|
+
}
|
|
90
|
+
/** V2: the green check fills its section of the symmetric red V. */
|
|
91
|
+
export const verification = {
|
|
92
|
+
viewBox: '-4 -4 264 264',
|
|
93
|
+
/** The box around the strokes alone: the arms' squared ends and the mitred foot. */
|
|
94
|
+
strokes: '25.6 29.1 204.8 228',
|
|
95
|
+
path: 'M40 36 128 220 216 36',
|
|
96
|
+
/** The check's stretch of the V, as a dash along a path measured as 176 long. */
|
|
97
|
+
check: { pathLength: '176', 'stroke-dasharray': '108 176', 'stroke-dashoffset': '-54' },
|
|
98
|
+
};
|
package/dist/server/discord.js
CHANGED
|
@@ -62,6 +62,9 @@ export function discordProvider(options) {
|
|
|
62
62
|
id: user.id,
|
|
63
63
|
handle: user.username,
|
|
64
64
|
profileUrl: `https://discord.com/users/${user.id}`,
|
|
65
|
+
// A Discord id encodes its creation time: the bits above the low 22 are
|
|
66
|
+
// milliseconds since 2015-01-01 UTC.
|
|
67
|
+
createdAt: Number(BigInt(user.id) >> 22n) + 1420070400000,
|
|
65
68
|
};
|
|
66
69
|
},
|
|
67
70
|
};
|
package/dist/server/domain.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { MIMEType } from 'node:util';
|
|
2
|
-
import { Refused, } from '../core/index.js';
|
|
2
|
+
import { Absent, Refused, } from '../core/index.js';
|
|
3
3
|
import { discard, readBounded } from './body.js';
|
|
4
4
|
import { transport } from './public-fetch.js';
|
|
5
5
|
/**
|
|
@@ -47,18 +47,39 @@ export function dnsProvider(options = {}) {
|
|
|
47
47
|
const domain = looked(artifact);
|
|
48
48
|
const { values, dnssec } = await published(domain, options);
|
|
49
49
|
if (!values.includes(expect))
|
|
50
|
-
throw new
|
|
50
|
+
throw new Absent(`No TXT record at ${label}.${domain} has this value`);
|
|
51
51
|
return { account: account(domain), ...(dnssec ? { dnssec } : {}) };
|
|
52
52
|
};
|
|
53
53
|
return {
|
|
54
54
|
...shared(options),
|
|
55
55
|
method: 'dns',
|
|
56
|
-
instructions
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
56
|
+
instructions(expect, artifact) {
|
|
57
|
+
let domain = artifact ? named(artifact) : undefined;
|
|
58
|
+
if (!domain && artifact) {
|
|
59
|
+
try {
|
|
60
|
+
domain = looked(artifact);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
/* An invalid input is never a command. */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const record = domain ? `${label}.${domain}` : `${label}.YOUR-DOMAIN`;
|
|
67
|
+
return [
|
|
68
|
+
domain
|
|
69
|
+
? 'Add a TXT record with this name and value.'
|
|
70
|
+
: 'Add a TXT record with this name and value. Replace YOUR-DOMAIN with the exact domain you enter below.',
|
|
71
|
+
{ code: record },
|
|
72
|
+
'If your DNS host adds the domain automatically, enter only _verily for the root domain. For a subdomain such as www.example.com in the example.com zone, enter _verily.www.',
|
|
73
|
+
{ code: expect },
|
|
74
|
+
domain
|
|
75
|
+
? `Proves control of ${domain} only. Parent domains and subdomains must be verified separately.`
|
|
76
|
+
: 'Proves control of the exact domain you enter. Parent domains and subdomains must be verified separately.',
|
|
77
|
+
'DNS changes can take up to 72 hours to appear. Check the published record with:',
|
|
78
|
+
{ code: `dig TXT ${record} +short` },
|
|
79
|
+
...(domain ? [[{ text: 'Look up this TXT record', href: lookup(domain) }]] : []),
|
|
80
|
+
'Keep the TXT record in place after verification so Verily can check it again.',
|
|
81
|
+
];
|
|
82
|
+
},
|
|
62
83
|
resolve(artifact) {
|
|
63
84
|
const domain = named(artifact);
|
|
64
85
|
return domain ? lookup(domain) : artifact;
|
|
@@ -223,6 +244,9 @@ export function wellKnownProvider(options = {}) {
|
|
|
223
244
|
// The holder named this domain, so a redirect would take the check somewhere else.
|
|
224
245
|
if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400))
|
|
225
246
|
throw new Refused('File redirects, and a redirect is not followed');
|
|
247
|
+
// The file is gone. Any other failure is a failed read.
|
|
248
|
+
if (response.status === 404 || response.status === 410)
|
|
249
|
+
throw new Absent('File unavailable');
|
|
226
250
|
if (!response.ok)
|
|
227
251
|
throw new Refused('File unavailable');
|
|
228
252
|
if (mediaType(response.headers.get('content-type')) !== 'text/plain')
|
|
@@ -232,8 +256,11 @@ export function wellKnownProvider(options = {}) {
|
|
|
232
256
|
// The last line of a file cut short may be the start of some longer address.
|
|
233
257
|
if (truncated)
|
|
234
258
|
lines.pop();
|
|
235
|
-
if (!lines.some((line) => line.trim() === expect))
|
|
236
|
-
|
|
259
|
+
if (!lines.some((line) => line.trim() === expect)) {
|
|
260
|
+
if (truncated)
|
|
261
|
+
throw new Refused('File is too large to read');
|
|
262
|
+
throw new Absent('File has no line with this value');
|
|
263
|
+
}
|
|
237
264
|
}
|
|
238
265
|
finally {
|
|
239
266
|
await discard(response);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Refused } from '../core/index.js';
|
|
1
|
+
import { Absent, Refused } from '../core/index.js';
|
|
2
2
|
/**
|
|
3
3
|
* Proves control of a GitHub account by having its holder publish a given line in a public
|
|
4
4
|
* gist. No app registration, no client secret, and no token: the proof is a public document
|
|
@@ -34,11 +34,14 @@ export function githubGistProvider(options = {}) {
|
|
|
34
34
|
signal: AbortSignal.timeout(15000),
|
|
35
35
|
});
|
|
36
36
|
const gist = await response.json();
|
|
37
|
+
// GitHub answers 404 for a gist that was deleted. Any other failure is a failed read.
|
|
38
|
+
if (response.status === 404)
|
|
39
|
+
throw new Absent('Gist unavailable');
|
|
37
40
|
if (!response.ok || !isRecord(gist))
|
|
38
41
|
throw new Refused('Gist unavailable');
|
|
39
42
|
// A secret gist proves nothing a reader could check for themselves.
|
|
40
43
|
if (gist.public !== true)
|
|
41
|
-
throw new
|
|
44
|
+
throw new Absent('Gist is not public');
|
|
42
45
|
const owner = gist.owner;
|
|
43
46
|
if (!isRecord(owner) ||
|
|
44
47
|
!Number.isSafeInteger(owner.id) ||
|
|
@@ -46,8 +49,13 @@ export function githubGistProvider(options = {}) {
|
|
|
46
49
|
typeof owner.login !== 'string' ||
|
|
47
50
|
!/^[a-zA-Z0-9-]+$/.test(owner.login))
|
|
48
51
|
throw new Refused('Invalid gist owner');
|
|
49
|
-
if (!isRecord(gist.files) || !contains(gist.files, expect))
|
|
50
|
-
|
|
52
|
+
if (!isRecord(gist.files) || !contains(gist.files, expect)) {
|
|
53
|
+
// Part of the gist was left out of the answer, and the line may be in that part.
|
|
54
|
+
// That is a failed read, so the proof keeps counting.
|
|
55
|
+
if (!isRecord(gist.files) || gist.truncated === true || partial(gist.files))
|
|
56
|
+
throw new Refused('Gist is too large to read');
|
|
57
|
+
throw new Absent('Gist does not contain the expected line');
|
|
58
|
+
}
|
|
51
59
|
return {
|
|
52
60
|
id: String(owner.id),
|
|
53
61
|
handle: owner.login,
|
|
@@ -63,6 +71,10 @@ function contains(files, expect) {
|
|
|
63
71
|
typeof file.content === 'string' &&
|
|
64
72
|
file.content.includes(expect));
|
|
65
73
|
}
|
|
74
|
+
/** Whether any file came back cut short or without its content. */
|
|
75
|
+
function partial(files) {
|
|
76
|
+
return Object.values(files).some((file) => !isRecord(file) || file.truncated === true || typeof file.content !== 'string');
|
|
77
|
+
}
|
|
66
78
|
function isRecord(value) {
|
|
67
79
|
return value !== null && typeof value === 'object';
|
|
68
80
|
}
|
package/dist/server/github.js
CHANGED
|
@@ -54,10 +54,13 @@ export function githubProvider(options) {
|
|
|
54
54
|
typeof user.login !== 'string' ||
|
|
55
55
|
!/^[a-zA-Z0-9-]+$/.test(user.login))
|
|
56
56
|
throw new Error('Invalid provider identity');
|
|
57
|
+
// Optional: a missing or unparseable date is left off and the sign-in still succeeds.
|
|
58
|
+
const createdAt = typeof user.created_at === 'string' ? Date.parse(user.created_at) : NaN;
|
|
57
59
|
return {
|
|
58
60
|
id: String(user.id),
|
|
59
61
|
handle: user.login,
|
|
60
62
|
profileUrl: `https://github.com/${user.login}`,
|
|
63
|
+
...(Number.isFinite(createdAt) ? { createdAt } : {}),
|
|
61
64
|
};
|
|
62
65
|
},
|
|
63
66
|
};
|
package/dist/server/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { attestationLabel,
|
|
1
|
+
import { attestationLabel, externalName, isArtifactProvider, isCodeProvider, isRedirectProvider, lastProved, accountsOf, localSide, proofTitle, providerMethod, statusLabel, signedBy, } from '../core/index.js';
|
|
2
2
|
import { artifactAttempts, codeAttempts, VerilyService, Unavailable, } from './service.js';
|
|
3
3
|
import { copyScript, waitScript } from './copy.js';
|
|
4
4
|
import { escape } from './escape.js';
|
|
5
|
-
import {
|
|
5
|
+
import { card, externalCard, marked, moment, page, safeUrl, times, verdict } from './render.js';
|
|
6
|
+
import { toneOf } from '../marks.js';
|
|
6
7
|
import { stylesheet, styleVersion } from './style.js';
|
|
7
8
|
export { VerilyService, Unavailable } from './service.js';
|
|
8
9
|
export { githubProvider } from './github.js';
|
|
@@ -22,7 +23,6 @@ export { resendSender } from './resend.js';
|
|
|
22
23
|
* one runs to tens of kilobytes. Still small enough that the size itself costs nothing.
|
|
23
24
|
*/
|
|
24
25
|
const maxBodyBytes = 65536;
|
|
25
|
-
const page = (prefix, title, body) => `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(title)} · Verily</title><link rel="stylesheet" href="${escape(prefix)}/style.css?v=${styleVersion}"><body><main>${logo}<h1>${escape(title)}</h1>${body}</main></body></html>`;
|
|
26
26
|
const headers = {
|
|
27
27
|
'Cache-Control': 'no-store',
|
|
28
28
|
'Referrer-Policy': 'same-origin',
|
|
@@ -76,21 +76,6 @@ const redirect = (url, cookie) => new Response(null, {
|
|
|
76
76
|
status: 303,
|
|
77
77
|
headers: { ...headers, Location: url, ...(cookie ? { 'Set-Cookie': cookie } : {}) },
|
|
78
78
|
});
|
|
79
|
-
/**
|
|
80
|
-
* One side of a link, with each thing known about it on its own line. A name, the
|
|
81
|
-
* identifier behind it and how it was shown are three separate claims, and a reader
|
|
82
|
-
* running them together in one sentence is the way to misread which was established.
|
|
83
|
-
*
|
|
84
|
-
* Both sides render as the same card, so the pair reads as a pair.
|
|
85
|
-
*/
|
|
86
|
-
function card(heading, name, url, reference, ...extra) {
|
|
87
|
-
const profile = url && safeUrl(url);
|
|
88
|
-
return `<div class="side"><p class="who">${escape(heading)}</p><p class="name">${profile ? `<a href="${escape(profile)}" rel="noreferrer">${escape(name)}</a>` : escape(name)}</p>${typeof reference === 'string'
|
|
89
|
-
? `<p class="reference">${escape(reference)}</p>`
|
|
90
|
-
: reference
|
|
91
|
-
? `<p class="reference fingerprint">${reference.map((group) => `<span>${escape(group)}</span>`).join('')}</p>`
|
|
92
|
-
: ''}${extra.join('')}</div>`;
|
|
93
|
-
}
|
|
94
79
|
/**
|
|
95
80
|
* Names how one side was established, inside that side's card. Where the method published
|
|
96
81
|
* a proof the name is the link to it, which is what lets the reader check the claim
|
|
@@ -216,17 +201,16 @@ function heldNames(providers) {
|
|
|
216
201
|
const names = [...new Set(providers.map(held))];
|
|
217
202
|
return names.length > 1 ? `${names.slice(0, -1).join(', ')} or ${names.at(-1)}` : names[0];
|
|
218
203
|
}
|
|
219
|
-
/**
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
.join('')}</dl>`;
|
|
204
|
+
/** Provider setup with the flow’s return instructions, shared by HTML and the dialog. */
|
|
205
|
+
function proofInstructions(provider, flow) {
|
|
206
|
+
return [
|
|
207
|
+
...provider.instructions(flow.expect, flow.artifact ?? flow.suggested),
|
|
208
|
+
...(flow.dnsDraftId
|
|
209
|
+
? [
|
|
210
|
+
'Your DNS setup is saved for seven days from when you started. To continue later, sign in and choose the DNS method again. For a renewal, reopen the same connection’s renewal. The proof must pass a fresh check; keep this TXT value while you wait.',
|
|
211
|
+
]
|
|
212
|
+
: []),
|
|
213
|
+
];
|
|
230
214
|
}
|
|
231
215
|
/**
|
|
232
216
|
* What the holder is told, as the provider wrote it. A command is set as a block and never
|
|
@@ -250,15 +234,6 @@ function inline(piece) {
|
|
|
250
234
|
? `<a href="${escape(href)}" rel="noreferrer" target="_blank">${escape(piece.text)}</a>`
|
|
251
235
|
: escape(piece.text);
|
|
252
236
|
}
|
|
253
|
-
function safeUrl(value) {
|
|
254
|
-
try {
|
|
255
|
-
const url = new URL(value);
|
|
256
|
-
return ['https:', 'http:'].includes(url.protocol) ? url.href : undefined;
|
|
257
|
-
}
|
|
258
|
-
catch {
|
|
259
|
-
return undefined;
|
|
260
|
-
}
|
|
261
|
-
}
|
|
262
237
|
/**
|
|
263
238
|
* The holder's choice of who may read the record, or the one visibility there is when the
|
|
264
239
|
* subject has no choice. A subject from another site is told what each means there: that
|
|
@@ -297,9 +272,7 @@ function evidencePage(e, base, report, keyId, named) {
|
|
|
297
272
|
// site's own reference would be a third line saying the same thing. The provider's
|
|
298
273
|
// identifier stays: that one is the provider's word, not the site's own wording.
|
|
299
274
|
card(local.heading, local.value, e.local.profileUrl, undefined, attestationNote(e.attestations.local, names)) +
|
|
300
|
-
|
|
301
|
-
// A mailbox's address or a domain is its name already, so there is no second identifier.
|
|
302
|
-
externalIdGroups(e.external) ?? externalId(e.external), externalNotes(e.attestations, names)) +
|
|
275
|
+
externalCard(marked(names.provider, e.provider, e.attestations.external[0].method), e.external, externalNotes(e.attestations, names)) +
|
|
303
276
|
times(e.status === 'retired'
|
|
304
277
|
? [
|
|
305
278
|
['Approved', e.approvedAt],
|
|
@@ -331,7 +304,7 @@ function evidencePage(e, base, report, keyId, named) {
|
|
|
331
304
|
<p class="fine">This connection does not establish legal identity, trustworthiness, content authorship, or permanent ownership.</p>
|
|
332
305
|
<p class="fine"><a href="${escape(base)}/external-revoke/${escape(e.id)}">Remove this connection using your external account</a></p>
|
|
333
306
|
${e.visibility === 'unlisted' ? `<p class="fine"><a href="${escape(base)}/external-share-revoke/${escape(e.id)}">Revoke only this sharing link using your external account</a></p>` : ''}
|
|
334
|
-
<p class="fine"><a href="${escape(report)}" rel="noreferrer">Report an incorrect record</a></p
|
|
307
|
+
<p class="fine"><a href="${escape(report)}" rel="noreferrer">Report an incorrect record</a></p>`, '', '', verdict(toneOf(e, Date.now())));
|
|
335
308
|
}
|
|
336
309
|
/** Joins a subject's card to its accounts: the link itself, drawn as the dialog draws it. */
|
|
337
310
|
const joiner = '<svg class="joiner" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false">' +
|
|
@@ -350,7 +323,7 @@ const joiner = '<svg class="joiner" viewBox="0 0 24 24" fill="none" aria-hidden=
|
|
|
350
323
|
function accountCard(e, external, now) {
|
|
351
324
|
const retired = e.status === 'retired';
|
|
352
325
|
const record = escape(safeUrl(e.evidenceUrl));
|
|
353
|
-
return
|
|
326
|
+
return externalCard(marked(e.providerName, e.provider, e.attestations.external[0].method), e.external, `<p class="how">${verdict(toneOf(e, now))}${escape(statusLabel(e, now))} · via: <a href="${record}">${escape(e.verifierName)}</a>${e.signedUrl ? `<a class="signed-mark" href="${record}#signed">signed</a>` : ''}</p>`, externalNotes({ ...e.attestations, external }, { site: e.siteName, provider: e.providerName }), times(retired
|
|
354
327
|
? [
|
|
355
328
|
['Approved', e.approvedAt],
|
|
356
329
|
['Last verified', lastProved(e)],
|
|
@@ -419,7 +392,7 @@ function checkedPage(d, keyId, base, named) {
|
|
|
419
392
|
const retired = d.version === 2;
|
|
420
393
|
return page(base, retired ? 'Retired connection' : 'Signed record', `<p>${escape(d.verifierName)} signed this record. It shows what stood when it was signed, not what stands now.</p>` +
|
|
421
394
|
card(local.heading, local.value, d.local.profileUrl, undefined, attestationNote(d.attestations.local, names)) +
|
|
422
|
-
|
|
395
|
+
externalCard(marked(names.provider, d.provider, d.attestations.external[0].method), d.external, externalNotes(d.attestations, names)) +
|
|
423
396
|
times(retired
|
|
424
397
|
? [
|
|
425
398
|
['Signed', d.issuedAt],
|
|
@@ -602,7 +575,7 @@ export function createVerily(options) {
|
|
|
602
575
|
view.authorizationUrl = authorizationUrl;
|
|
603
576
|
if (flow.phase === 'pending' && flow.expect && isArtifactProvider(provider))
|
|
604
577
|
Object.assign(view, {
|
|
605
|
-
instructions: provider
|
|
578
|
+
instructions: proofInstructions(provider, flow),
|
|
606
579
|
artifact: provider.artifact,
|
|
607
580
|
field: artifactField(provider),
|
|
608
581
|
input: provider.input ?? 'url',
|
|
@@ -766,7 +739,7 @@ export function createVerily(options) {
|
|
|
766
739
|
if (!['revoke', 'share-revoke'].includes(flow.kind))
|
|
767
740
|
if ((await local(request)).id !== flow.local?.id)
|
|
768
741
|
throw new Unavailable();
|
|
769
|
-
return html(page(prefix, `Verify with ${provider.name}`, `${instructions(provider
|
|
742
|
+
return html(page(prefix, `Verify with ${provider.name}`, `${instructions(proofInstructions(provider, flow))}
|
|
770
743
|
${flow.tries ? `<p>${escape(refusedNote(flow))}</p>` : ''}
|
|
771
744
|
<form method="post" action="${escape(prefix)}/flows/${escape(flow.id)}/submit">
|
|
772
745
|
<label>${escape(artifactField(provider))} ${provider.artifact === 'document'
|
|
@@ -812,7 +785,7 @@ export function createVerily(options) {
|
|
|
812
785
|
? 'Add to connection'
|
|
813
786
|
: 'Confirm connection', `
|
|
814
787
|
${card(subject(flow.local).heading, subject(flow.local).value, flow.local.profileUrl)}
|
|
815
|
-
${
|
|
788
|
+
${externalCard(marked(provider.name, provider.id, providerMethod(provider)), flow.external)}
|
|
816
789
|
${joined ? `<p>This account is already linked here. Confirming adds this method to that connection, beneath the one it was first shown by, and it stays ${escape(joined.visibility)}.</p>` : ''}
|
|
817
790
|
${found ? `<p>${escape(found)}</p>` : ''}
|
|
818
791
|
<p class="fine">${escape(service.siteOf(flow.local))} receives the result. Verified via ${escape(options.verifierName)}.</p>
|
package/dist/server/link.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { legacyHookDecode } from '@exodus/bytes/encoding.js';
|
|
2
2
|
import htmlEncodingSniffer from 'html-encoding-sniffer';
|
|
3
|
-
import { MIMEType } from 'node:util';
|
|
3
|
+
import { isDeepStrictEqual, MIMEType } from 'node:util';
|
|
4
4
|
import { html, parse } from 'parse5';
|
|
5
|
-
import { Refused, } from '../core/index.js';
|
|
5
|
+
import { Absent, Refused, } from '../core/index.js';
|
|
6
6
|
import { discard, readBounded } from './body.js';
|
|
7
7
|
import { transport } from './public-fetch.js';
|
|
8
8
|
/**
|
|
@@ -99,6 +99,9 @@ async function backlinked(response, page, subject, maxBytes) {
|
|
|
99
99
|
// The holder named this address, so a redirect would take the check somewhere else.
|
|
100
100
|
if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400))
|
|
101
101
|
throw new Refused('Page redirects, and a redirect is not followed');
|
|
102
|
+
// The page is gone. Any other failure is a failed read.
|
|
103
|
+
if (response.status === 404 || response.status === 410)
|
|
104
|
+
throw new Absent('Page unavailable');
|
|
102
105
|
if (!response.ok)
|
|
103
106
|
throw new Refused('Page unavailable');
|
|
104
107
|
// A link relation is a link relation wherever it is declared, and a page with no
|
|
@@ -132,7 +135,9 @@ async function backlinked(response, page, subject, maxBytes) {
|
|
|
132
135
|
return;
|
|
133
136
|
}
|
|
134
137
|
// Not finding it in part of a page is not the same as it not being there.
|
|
135
|
-
|
|
138
|
+
if (truncated)
|
|
139
|
+
throw new Refused('Page is too large to read');
|
|
140
|
+
throw new Absent('Page has no rel="me" link to this subject');
|
|
136
141
|
}
|
|
137
142
|
/**
|
|
138
143
|
* The address the holder handed back, once it is one this may fetch. They choose it, so
|
|
@@ -259,7 +264,7 @@ function points(href, base, subject) {
|
|
|
259
264
|
for (const key of new Set(subject.searchParams.keys())) {
|
|
260
265
|
const wanted = subject.searchParams.getAll(key);
|
|
261
266
|
const found = target.searchParams.getAll(key);
|
|
262
|
-
if (found
|
|
267
|
+
if (!isDeepStrictEqual(found, wanted))
|
|
263
268
|
return false;
|
|
264
269
|
}
|
|
265
270
|
return true;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type ExternalAccount } from '../core/index.js';
|
|
2
|
+
import { type MarkTone } from '../marks.js';
|
|
3
|
+
/**
|
|
4
|
+
* The shell every Verily page shares: the stylesheet, the logotype and the heading. `variant`
|
|
5
|
+
* is a class on the page's `main`, such as `single` for a page with one thing on it.
|
|
6
|
+
* `script` is the path of one script the page runs. `mark` is drawn before the heading.
|
|
7
|
+
*/
|
|
8
|
+
export declare const page: (prefix: string, title: string, body: string, variant?: string, script?: string, mark?: string) => string;
|
|
9
|
+
/**
|
|
10
|
+
* The V the badge draws for a record, in the tone that says how the record stands.
|
|
11
|
+
* `cropped` fits the box to the strokes, for a V that has to meet the edges around it.
|
|
12
|
+
*/
|
|
13
|
+
export declare function verdict(tone: MarkTone, cropped?: boolean): string;
|
|
14
|
+
/** A card's heading: its text, and the provider's mark before it where there is one. */
|
|
15
|
+
export type Heading = string | {
|
|
16
|
+
text: string;
|
|
17
|
+
mark: string;
|
|
18
|
+
};
|
|
19
|
+
/** A provider's name with its mark, for a card's heading. */
|
|
20
|
+
export declare function marked(text: string, provider: string, method?: string): Heading;
|
|
21
|
+
/**
|
|
22
|
+
* One side of a link, with each thing known about it on its own line. A name, the
|
|
23
|
+
* identifier behind it and how it was shown are three separate claims, and a reader
|
|
24
|
+
* running them together in one sentence is the way to misread which was established.
|
|
25
|
+
*
|
|
26
|
+
* Both sides render as the same card, so the pair reads as a pair.
|
|
27
|
+
*/
|
|
28
|
+
export declare function card(heading: Heading, name: string, url: string | undefined, reference?: string | string[], ...extra: string[]): string;
|
|
29
|
+
/**
|
|
30
|
+
* The card for an external account: its name, link and id, with the "Joined <date>" text
|
|
31
|
+
* on the name's line when the creation date is known.
|
|
32
|
+
*/
|
|
33
|
+
export declare function externalCard(heading: Heading, external: ExternalAccount, ...extra: string[]): string;
|
|
34
|
+
export declare function side(heading: Heading, name: string, url: string | undefined, reference: string | string[] | undefined, since: string | undefined, extra: string[]): string;
|
|
35
|
+
/** A day, as the date alone. */
|
|
36
|
+
export declare const day: (time: number) => string;
|
|
37
|
+
/** A day as it is said, such as "Jan 11, 2027". In UTC, as every other time here is. */
|
|
38
|
+
export declare const date: (time: number) => string;
|
|
39
|
+
/** How far off a time is in whole days, such as "in 90 days" or "12 days ago". */
|
|
40
|
+
export declare function distance(time: number, now: number): string;
|
|
41
|
+
/** Seconds are the finest thing a record measured in days can mean; milliseconds are noise. */
|
|
42
|
+
export declare const moment: (time: number) => string;
|
|
43
|
+
/**
|
|
44
|
+
* Every time on the page in one list. A date inside a sentence is a date a reader has to
|
|
45
|
+
* find; the times of a record belong together, where they can be compared at a glance.
|
|
46
|
+
*/
|
|
47
|
+
export declare function times(rows: [string, number | undefined][]): string;
|
|
48
|
+
/** A URL as written if it is http(s), and nothing otherwise. */
|
|
49
|
+
export declare function safeUrl(value: string): string | undefined;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { externalId, externalIdGroups, externalLink, externalName, externalSince, } from '../core/index.js';
|
|
2
|
+
import { logo } from '../logo.js';
|
|
3
|
+
import { markOf, pathAttributes, tones, verification } from '../marks.js';
|
|
4
|
+
import { escape } from './escape.js';
|
|
5
|
+
import { styleVersion } from './style.js';
|
|
6
|
+
/**
|
|
7
|
+
* The shell every Verily page shares: the stylesheet, the logotype and the heading. `variant`
|
|
8
|
+
* is a class on the page's `main`, such as `single` for a page with one thing on it.
|
|
9
|
+
* `script` is the path of one script the page runs. `mark` is drawn before the heading.
|
|
10
|
+
*/
|
|
11
|
+
export const page = (prefix, title, body, variant = '', script = '', mark = '') => `<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escape(title)} · Verily</title><link rel="stylesheet" href="${escape(prefix)}/style.css?v=${styleVersion}"><body><main${variant && ` class="${variant}"`}>${logo}<h1>${mark}${escape(title)}</h1>${body}</main>${script && `<script src="${escape(script)}" defer></script>`}</body></html>`;
|
|
12
|
+
/**
|
|
13
|
+
* The V the badge draws for a record, in the tone that says how the record stands.
|
|
14
|
+
* `cropped` fits the box to the strokes, for a V that has to meet the edges around it.
|
|
15
|
+
*/
|
|
16
|
+
export function verdict(tone, cropped = false) {
|
|
17
|
+
const [first, second] = tones[tone];
|
|
18
|
+
const stroke = 'stroke-width="32" stroke-linejoin="miter"';
|
|
19
|
+
const check = Object.entries(verification.check)
|
|
20
|
+
.map(([name, value]) => ` ${name}="${value}"`)
|
|
21
|
+
.join('');
|
|
22
|
+
return `<svg class="verdict ${tone}" viewBox="${cropped ? verification.strokes : verification.viewBox}" fill="none" aria-hidden="true" focusable="false"><path d="${verification.path}" ${stroke} stroke="${first}"/><path d="${verification.path}" ${stroke}${check} stroke="${second}"/></svg>`;
|
|
23
|
+
}
|
|
24
|
+
/** A provider's name with its mark, for a card's heading. */
|
|
25
|
+
export function marked(text, provider, method) {
|
|
26
|
+
const mark = markOf(provider, method);
|
|
27
|
+
if (!mark)
|
|
28
|
+
return text;
|
|
29
|
+
const attributes = pathAttributes(mark)
|
|
30
|
+
.map(([name, value]) => ` ${name}="${value}"`)
|
|
31
|
+
.join('');
|
|
32
|
+
return {
|
|
33
|
+
text,
|
|
34
|
+
mark: `<svg class="provider" viewBox="${mark.viewBox}" fill="${mark.stroke ? 'none' : 'currentColor'}" aria-hidden="true" focusable="false">${mark.paths
|
|
35
|
+
.map((d) => `<path d="${escape(d)}"${attributes}/>`)
|
|
36
|
+
.join('')}</svg>`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* One side of a link, with each thing known about it on its own line. A name, the
|
|
41
|
+
* identifier behind it and how it was shown are three separate claims, and a reader
|
|
42
|
+
* running them together in one sentence is the way to misread which was established.
|
|
43
|
+
*
|
|
44
|
+
* Both sides render as the same card, so the pair reads as a pair.
|
|
45
|
+
*/
|
|
46
|
+
export function card(heading, name, url, reference, ...extra) {
|
|
47
|
+
return side(heading, name, url, reference, undefined, extra);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The card for an external account: its name, link and id, with the "Joined <date>" text
|
|
51
|
+
* on the name's line when the creation date is known.
|
|
52
|
+
*/
|
|
53
|
+
export function externalCard(heading, external, ...extra) {
|
|
54
|
+
return side(heading, externalName(external), externalLink(external),
|
|
55
|
+
// A mailbox's address or a domain is its name already, so there is no second identifier.
|
|
56
|
+
externalIdGroups(external) ?? externalId(external), externalSince(external, day), extra);
|
|
57
|
+
}
|
|
58
|
+
export function side(heading, name, url, reference, since, extra) {
|
|
59
|
+
const profile = url && safeUrl(url);
|
|
60
|
+
return `<div class="side"><p class="who">${typeof heading === 'string' ? escape(heading) : `${heading.mark}${escape(heading.text)}`}</p><p class="name">${profile ? `<a href="${escape(profile)}" rel="noreferrer">${escape(name)}</a>` : escape(name)}${since ? `<span class="since">${escape(since)}</span>` : ''}</p>${typeof reference === 'string'
|
|
61
|
+
? `<p class="reference">${escape(reference)}</p>`
|
|
62
|
+
: reference
|
|
63
|
+
? `<p class="reference fingerprint">${reference.map((group) => `<span>${escape(group)}</span>`).join('')}</p>`
|
|
64
|
+
: ''}${extra.join('')}</div>`;
|
|
65
|
+
}
|
|
66
|
+
/** A day, as the date alone. */
|
|
67
|
+
export const day = (time) => new Date(time).toISOString().slice(0, 10);
|
|
68
|
+
/** A day as it is said, such as "Jan 11, 2027". In UTC, as every other time here is. */
|
|
69
|
+
export const date = (time) => new Date(time).toLocaleDateString('en-US', {
|
|
70
|
+
year: 'numeric',
|
|
71
|
+
month: 'short',
|
|
72
|
+
day: 'numeric',
|
|
73
|
+
timeZone: 'UTC',
|
|
74
|
+
});
|
|
75
|
+
/** How far off a time is in whole days, such as "in 90 days" or "12 days ago". */
|
|
76
|
+
export function distance(time, now) {
|
|
77
|
+
const days = Math.round((time - now) / 86400000);
|
|
78
|
+
if (days === 0)
|
|
79
|
+
return 'today';
|
|
80
|
+
const count = `${Math.abs(days)} day${Math.abs(days) === 1 ? '' : 's'}`;
|
|
81
|
+
return days > 0 ? `in ${count}` : `${count} ago`;
|
|
82
|
+
}
|
|
83
|
+
/** Seconds are the finest thing a record measured in days can mean; milliseconds are noise. */
|
|
84
|
+
export const moment = (time) => new Date(time).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
|
85
|
+
/**
|
|
86
|
+
* Every time on the page in one list. A date inside a sentence is a date a reader has to
|
|
87
|
+
* find; the times of a record belong together, where they can be compared at a glance.
|
|
88
|
+
*/
|
|
89
|
+
export function times(rows) {
|
|
90
|
+
return `<dl>${rows
|
|
91
|
+
.filter(([, time]) => time !== undefined)
|
|
92
|
+
.map(([label, time]) => `<dt>${escape(label)}</dt><dd>${escape(moment(time))}</dd>`)
|
|
93
|
+
.join('')}</dl>`;
|
|
94
|
+
}
|
|
95
|
+
/** A URL as written if it is http(s), and nothing otherwise. */
|
|
96
|
+
export function safeUrl(value) {
|
|
97
|
+
try {
|
|
98
|
+
const url = new URL(value);
|
|
99
|
+
return ['https:', 'http:'].includes(url.protocol) ? url.href : undefined;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
}
|