@bkazemi/verily 0.1.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/LICENSE +21 -0
- package/README.md +503 -0
- package/dist/browser/badge.d.ts +39 -0
- package/dist/browser/badge.js +440 -0
- package/dist/browser/connect-dialog.d.ts +83 -0
- package/dist/browser/connect-dialog.js +514 -0
- package/dist/browser/evidence-dialog.d.ts +59 -0
- package/dist/browser/evidence-dialog.js +541 -0
- package/dist/browser/index.d.ts +59 -0
- package/dist/browser/index.js +809 -0
- package/dist/browser/logo.d.ts +2 -0
- package/dist/browser/logo.js +17 -0
- package/dist/browser/mark.d.ts +19 -0
- package/dist/browser/mark.js +77 -0
- package/dist/browser/provider-mark.d.ts +6 -0
- package/dist/browser/provider-mark.js +99 -0
- package/dist/browser/signed.d.ts +40 -0
- package/dist/browser/signed.js +219 -0
- package/dist/browser.d.ts +3 -0
- package/dist/browser.js +3 -0
- package/dist/core/index.d.ts +454 -0
- package/dist/core/index.js +128 -0
- package/dist/core/signed.d.ts +49 -0
- package/dist/core/signed.js +113 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/logo.d.ts +21 -0
- package/dist/logo.js +38 -0
- package/dist/postgres/index.d.ts +12 -0
- package/dist/postgres/index.js +76 -0
- package/dist/server/addresses.d.ts +2 -0
- package/dist/server/addresses.js +61 -0
- package/dist/server/body.d.ts +11 -0
- package/dist/server/body.js +49 -0
- package/dist/server/copy.d.ts +8 -0
- package/dist/server/copy.js +42 -0
- package/dist/server/discord.d.ts +7 -0
- package/dist/server/discord.js +69 -0
- package/dist/server/email-logo.d.ts +11 -0
- package/dist/server/email-logo.js +122 -0
- package/dist/server/email.d.ts +46 -0
- package/dist/server/email.js +145 -0
- package/dist/server/escape.d.ts +2 -0
- package/dist/server/escape.js +2 -0
- package/dist/server/github-gist.d.ts +11 -0
- package/dist/server/github-gist.js +68 -0
- package/dist/server/github.d.ts +7 -0
- package/dist/server/github.js +65 -0
- package/dist/server/index.d.ts +70 -0
- package/dist/server/index.js +862 -0
- package/dist/server/link.d.ts +64 -0
- package/dist/server/link.js +458 -0
- package/dist/server/openpgp.d.ts +49 -0
- package/dist/server/openpgp.js +158 -0
- package/dist/server/pgp.d.ts +29 -0
- package/dist/server/pgp.js +198 -0
- package/dist/server/public-fetch.d.ts +23 -0
- package/dist/server/public-fetch.js +62 -0
- package/dist/server/resend.d.ts +13 -0
- package/dist/server/resend.js +37 -0
- package/dist/server/service.d.ts +311 -0
- package/dist/server/service.js +1178 -0
- package/dist/server/signing.d.ts +16 -0
- package/dist/server/signing.js +56 -0
- package/dist/server/style.d.ts +17 -0
- package/dist/server/style.js +244 -0
- package/dist/server/wkd.d.ts +7 -0
- package/dist/server/wkd.js +65 -0
- package/dist/server/youtube.d.ts +11 -0
- package/dist/server/youtube.js +99 -0
- package/dist/site/index.d.ts +139 -0
- package/dist/site/index.js +239 -0
- package/dist/verily.js +11907 -0
- package/dist/version.d.ts +10 -0
- package/dist/version.js +10 -0
- package/docs/licenses/gpl-3.0.txt +232 -0
- package/docs/licenses/octicons.txt +21 -0
- package/docs/licenses/openpgp.txt +172 -0
- package/docs/licenses/rubik.txt +93 -0
- package/docs/licenses/simple-icons.txt +8 -0
- package/package.json +86 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/** A signed record is a few kilobytes. Anything far past that is not one. */
|
|
2
|
+
const maxSignedLength = 262144;
|
|
3
|
+
let loaded;
|
|
4
|
+
async function load() {
|
|
5
|
+
const pgp = await import('openpgp');
|
|
6
|
+
const weak = [pgp.enums.hash.sha1, pgp.enums.hash.md5, pgp.enums.hash.ripemd];
|
|
7
|
+
return {
|
|
8
|
+
pgp,
|
|
9
|
+
// SHA-1 and older are refused under a key's own signatures as well as under a record.
|
|
10
|
+
config: {
|
|
11
|
+
...pgp.config,
|
|
12
|
+
rejectHashAlgorithms: new Set([...pgp.config.rejectHashAlgorithms, ...weak]),
|
|
13
|
+
rejectMessageHashAlgorithms: new Set([...pgp.config.rejectMessageHashAlgorithms, ...weak]),
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* OpenPGP.js and how it is set, brought in when a record is first checked and not before.
|
|
19
|
+
* On a page it will not start without WebCrypto, which an insecure page lacks, and a page
|
|
20
|
+
* with no signed record on it has no use for it: neither should cost the badge anything.
|
|
21
|
+
*/
|
|
22
|
+
const library = () => (loaded ??= load());
|
|
23
|
+
/** The signatures under a message, which the library keeps and its types leave out. */
|
|
24
|
+
const marks = (message) => message.signature.packets;
|
|
25
|
+
/** A signed record as OpenPGP.js reads it, where it is one message under one signature. */
|
|
26
|
+
async function read(signed) {
|
|
27
|
+
if (typeof signed !== 'string' || signed.length > maxSignedLength)
|
|
28
|
+
return undefined;
|
|
29
|
+
const text = signed.trim();
|
|
30
|
+
const lines = text.split(/\r?\n/);
|
|
31
|
+
const only = (line) => lines.indexOf(line) === lines.lastIndexOf(line);
|
|
32
|
+
// The message and nothing around it: what stands outside one is signed by nobody. Each
|
|
33
|
+
// marker is a whole line, met once, with the last of them the last line there is. A line
|
|
34
|
+
// of the text that begins with a dash is written with "- " before it, so none of the
|
|
35
|
+
// text can be one of these.
|
|
36
|
+
if (lines[0] !== '-----BEGIN PGP SIGNED MESSAGE-----' ||
|
|
37
|
+
lines.at(-1) !== '-----END PGP SIGNATURE-----' ||
|
|
38
|
+
!lines.includes('-----BEGIN PGP SIGNATURE-----') ||
|
|
39
|
+
![lines[0], lines.at(-1), '-----BEGIN PGP SIGNATURE-----'].every(only))
|
|
40
|
+
return undefined;
|
|
41
|
+
try {
|
|
42
|
+
const { pgp, config } = await library();
|
|
43
|
+
const message = await pgp.readCleartextMessage({ cleartextMessage: text, config });
|
|
44
|
+
return marks(message).length === 1 ? message : undefined;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const fingerprint = (bytes) => [...bytes].map((byte) => byte.toString(16).padStart(2, '0').toUpperCase()).join('');
|
|
51
|
+
/**
|
|
52
|
+
* The fingerprint of the key a signed record says signed it, checked or not: which key to
|
|
53
|
+
* look for, and no reason by itself to believe anything.
|
|
54
|
+
*/
|
|
55
|
+
export async function signedBy(signed) {
|
|
56
|
+
const message = await read(signed);
|
|
57
|
+
const by = message && marks(message)[0].issuerFingerprint;
|
|
58
|
+
return by ? fingerprint(by) : undefined;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The document a signed record carries, if one of the keys given signed it, and nothing
|
|
62
|
+
* otherwise. The keys are the caller's to choose: a record is only as good as the reason
|
|
63
|
+
* to believe each key is the verifier's. It says what stood when it was signed, never
|
|
64
|
+
* that it stands now.
|
|
65
|
+
*
|
|
66
|
+
* Whether a signature counts is OpenPGP.js's to say, by the format's own rules: a key is
|
|
67
|
+
* judged as it stood when the record was signed, so one that has since run out, or been
|
|
68
|
+
* retired, still answers for what it signed before, and one revoked as compromised signs
|
|
69
|
+
* nothing at all. Only a record signed by the listed key itself is taken, not by a subkey
|
|
70
|
+
* of it, so the fingerprint a record names is the one a list holds.
|
|
71
|
+
*/
|
|
72
|
+
export async function verifySigned(signed, keys) {
|
|
73
|
+
const message = await read(signed);
|
|
74
|
+
const by = await signedBy(signed);
|
|
75
|
+
if (!message || !by || !Array.isArray(keys))
|
|
76
|
+
return undefined;
|
|
77
|
+
for (const key of keys) {
|
|
78
|
+
if (key === null ||
|
|
79
|
+
typeof key !== 'object' ||
|
|
80
|
+
key.alg !== 'OpenPGP' ||
|
|
81
|
+
key.id !== by ||
|
|
82
|
+
typeof key.publicKey !== 'string' ||
|
|
83
|
+
key.publicKey.length > maxSignedLength)
|
|
84
|
+
continue;
|
|
85
|
+
try {
|
|
86
|
+
const { pgp, config } = await library();
|
|
87
|
+
const published = await pgp.readKey({ armoredKey: key.publicKey, config });
|
|
88
|
+
// The id is recomputed, so a key listed under another's id signs nothing in its name.
|
|
89
|
+
if (published.getFingerprint().toUpperCase() !== key.id)
|
|
90
|
+
continue;
|
|
91
|
+
// Checked against the key itself with its subkeys taken off, so a subkey's signature
|
|
92
|
+
// cannot pass under the key's name whatever the signature says of who made it.
|
|
93
|
+
published.subkeys = [];
|
|
94
|
+
const { signatures, data } = await pgp.verify({
|
|
95
|
+
message,
|
|
96
|
+
verificationKeys: published,
|
|
97
|
+
config,
|
|
98
|
+
});
|
|
99
|
+
// Throws where the signature is not this key's, or the key was not fit to make it.
|
|
100
|
+
await signatures[0].verified;
|
|
101
|
+
const document = JSON.parse(data);
|
|
102
|
+
return document?.type === 'verily-evidence' &&
|
|
103
|
+
document.version === 1 &&
|
|
104
|
+
typeof document.issuedAt === 'number'
|
|
105
|
+
? document
|
|
106
|
+
: undefined;
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
package/dist/logo.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Verily logotype, from docs/design/verily-logo.svg. The word is Rubik reduced to
|
|
3
|
+
* outlines, so nothing here asks a font host for anything. As parts it is drawn in
|
|
4
|
+
* currentColor, so in a dialog it takes the ink of whatever it sits in.
|
|
5
|
+
*
|
|
6
|
+
* Kept as the parts rather than one blob because the two sides draw it differently: a page
|
|
7
|
+
* assembled as a string wants markup, and the badge builds DOM and never sets innerHTML.
|
|
8
|
+
*
|
|
9
|
+
* Regenerate the SVG with docs/design/build-logo.py. Rubik is OFL: docs/licenses/rubik.txt.
|
|
10
|
+
*/
|
|
11
|
+
export declare const logoViewBox = "0 -26 2637 916";
|
|
12
|
+
export declare const logoPaths: readonly {
|
|
13
|
+
d: string;
|
|
14
|
+
fill: string;
|
|
15
|
+
}[];
|
|
16
|
+
/**
|
|
17
|
+
* The logotype as markup, for the pages this library serves as strings. Here the word is
|
|
18
|
+
* always white, on a dark plate of its own, so it is one colour whichever scheme the page
|
|
19
|
+
* is in. This is docs/design/verily-logo-plate.svg, and the README shows the same.
|
|
20
|
+
*/
|
|
21
|
+
export declare const logo: string;
|
package/dist/logo.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Verily logotype, from docs/design/verily-logo.svg. The word is Rubik reduced to
|
|
3
|
+
* outlines, so nothing here asks a font host for anything. As parts it is drawn in
|
|
4
|
+
* currentColor, so in a dialog it takes the ink of whatever it sits in.
|
|
5
|
+
*
|
|
6
|
+
* Kept as the parts rather than one blob because the two sides draw it differently: a page
|
|
7
|
+
* assembled as a string wants markup, and the badge builds DOM and never sets innerHTML.
|
|
8
|
+
*
|
|
9
|
+
* Regenerate the SVG with docs/design/build-logo.py. Rubik is OFL: docs/licenses/rubik.txt.
|
|
10
|
+
*/
|
|
11
|
+
export const logoViewBox = '0 -26 2637 916';
|
|
12
|
+
export const logoPaths = [
|
|
13
|
+
{
|
|
14
|
+
d: 'M36.76 22.11 364.79 708 692.83 22.11 600.38 -22.11 364.79 470.49 129.21 -22.11Z',
|
|
15
|
+
fill: '#D3444C',
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
d: 'M209.69 383.69 364.79 708 648 115.85 555.55 71.64 364.79 470.49 302.14 339.47Z',
|
|
19
|
+
fill: '#149766',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
d: 'M942 710Q838 710 777 646.5Q716 583 710 473Q709 460 709 439.5Q709 419 710 406Q714 335 743 281.5Q772 228 822.5 199Q873 170 941 170Q1017 170 1068.5 202Q1120 234 1147 293Q1174 352 1174 431V448Q1174 459 1167.5 465Q1161 471 1151 471H805Q805 472 805 475Q805 478 805 480Q807 521 823 556.5Q839 592 869.5 614Q900 636 941 636Q977 636 1001 625Q1025 614 1040 600.5Q1055 587 1060 579Q1069 567 1074 564.5Q1079 562 1090 562H1139Q1148 562 1154.5 567.5Q1161 573 1160 583Q1159 598 1144 619.5Q1129 641 1101.5 662Q1074 683 1033.5 696.5Q993 710 942 710ZM805 402H1079V399Q1079 354 1062.5 319Q1046 284 1015 263.5Q984 243 941 243Q898 243 867.5 263.5Q837 284 821 319Q805 354 805 399ZM1317 700Q1307 700 1300.5 693.5Q1294 687 1294 677V204Q1294 194 1300.5 187Q1307 180 1317 180H1363Q1373 180 1380 187Q1387 194 1387 204V248Q1407 214 1442 197Q1477 180 1527 180H1566Q1576 180 1582.5 186.5Q1589 193 1589 203V244Q1589 254 1582.5 260Q1576 266 1566 266H1506Q1452 266 1421 297.5Q1390 329 1390 383V677Q1390 687 1383 693.5Q1376 700 1366 700ZM1697 700Q1687 700 1680.5 693.5Q1674 687 1674 677V203Q1674 193 1680.5 186.5Q1687 180 1697 180H1745Q1755 180 1761.5 186.5Q1768 193 1768 203V677Q1768 687 1761.5 693.5Q1755 700 1745 700ZM1689 83Q1679 83 1672.5 76.5Q1666 70 1666 60V6Q1666 -4 1672.5 -11Q1679 -18 1689 -18H1752Q1762 -18 1769 -11Q1776 -4 1776 6V60Q1776 70 1769 76.5Q1762 83 1752 83ZM1940 700Q1930 700 1923.5 693.5Q1917 687 1917 677V13Q1917 3 1923.5 -3.5Q1930 -10 1940 -10H1987Q1998 -10 2004 -3.5Q2010 3 2010 13V677Q2010 687 2004 693.5Q1998 700 1987 700ZM2250 890Q2242 890 2236 884Q2230 878 2230 870Q2230 866 2231 862Q2232 858 2235 852L2312 669L2121 218Q2116 206 2116 201Q2116 192 2122 186Q2128 180 2137 180H2186Q2197 180 2202.5 185Q2208 190 2210 197L2362 561L2518 197Q2521 190 2526.5 185Q2532 180 2543 180H2590Q2599 180 2605 186Q2611 192 2611 200Q2611 205 2606 218L2322 873Q2319 880 2313.5 885Q2308 890 2297 890Z',
|
|
23
|
+
fill: 'currentColor',
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
/** Room around the logotype inside its plate, as docs/design/verily-logo-plate.svg has it. */
|
|
27
|
+
const pad = 185;
|
|
28
|
+
/**
|
|
29
|
+
* The logotype as markup, for the pages this library serves as strings. Here the word is
|
|
30
|
+
* always white, on a dark plate of its own, so it is one colour whichever scheme the page
|
|
31
|
+
* is in. This is docs/design/verily-logo-plate.svg, and the README shows the same.
|
|
32
|
+
*/
|
|
33
|
+
export const logo = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${-pad} ${-26 - pad} ${2637 + 2 * pad} ${916 + 2 * pad}" class="logo" role="img" aria-label="Verily">` +
|
|
34
|
+
`<rect x="${-pad}" y="${-26 - pad}" width="${2637 + 2 * pad}" height="${916 + 2 * pad}" rx="166" fill="#0d1117"/>` +
|
|
35
|
+
logoPaths
|
|
36
|
+
.map(({ d, fill }) => `<path d="${d}" fill="${fill === 'currentColor' ? '#ffffff' : fill}"/>`)
|
|
37
|
+
.join('') +
|
|
38
|
+
'</svg>';
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Pool } from 'pg';
|
|
2
|
+
import type { Storage, Transaction } from '../core/index.js';
|
|
3
|
+
export { Pool } from 'pg';
|
|
4
|
+
/** One namespace per installation; account tables remain entirely application-owned. */
|
|
5
|
+
export declare class PostgresStorage implements Storage {
|
|
6
|
+
readonly pool: Pool;
|
|
7
|
+
readonly namespace: string;
|
|
8
|
+
constructor(pool: Pool, namespace?: string);
|
|
9
|
+
migrate(): Promise<void>;
|
|
10
|
+
transaction<T>(work: (tx: Transaction) => Promise<T>): Promise<T>;
|
|
11
|
+
private operations;
|
|
12
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export { Pool } from 'pg';
|
|
2
|
+
/** One namespace per installation; account tables remain entirely application-owned. */
|
|
3
|
+
export class PostgresStorage {
|
|
4
|
+
pool;
|
|
5
|
+
namespace;
|
|
6
|
+
constructor(pool, namespace = 'verily') {
|
|
7
|
+
this.pool = pool;
|
|
8
|
+
this.namespace = namespace;
|
|
9
|
+
if (!namespace)
|
|
10
|
+
throw new Error('Storage namespace required');
|
|
11
|
+
}
|
|
12
|
+
async migrate() {
|
|
13
|
+
const client = await this.pool.connect();
|
|
14
|
+
try {
|
|
15
|
+
await client.query('BEGIN');
|
|
16
|
+
// IF NOT EXISTS does not make CREATE TABLE safe to race: two workers starting on a new
|
|
17
|
+
// database both create the table's row type, and one fails. The table is shared by
|
|
18
|
+
// every namespace, so the lock is one of its own rather than the namespace's.
|
|
19
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtextextended('verily migrate', 0))");
|
|
20
|
+
await client.query(`CREATE TABLE IF NOT EXISTS verily_records (
|
|
21
|
+
namespace text NOT NULL, kind text NOT NULL, id text NOT NULL, value jsonb NOT NULL,
|
|
22
|
+
PRIMARY KEY (namespace, kind, id)
|
|
23
|
+
)`);
|
|
24
|
+
await client.query('COMMIT');
|
|
25
|
+
}
|
|
26
|
+
catch (error) {
|
|
27
|
+
await client.query('ROLLBACK');
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
client.release();
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async transaction(work) {
|
|
35
|
+
const client = await this.pool.connect();
|
|
36
|
+
try {
|
|
37
|
+
await client.query('BEGIN');
|
|
38
|
+
// Serialize installation writes, including callback claims and share rotation across workers.
|
|
39
|
+
await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [this.namespace]);
|
|
40
|
+
const result = await work(this.operations(client));
|
|
41
|
+
await client.query('COMMIT');
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
await client.query('ROLLBACK');
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
client.release();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
operations(client) {
|
|
53
|
+
const namespace = this.namespace;
|
|
54
|
+
return {
|
|
55
|
+
async get(kind, id) {
|
|
56
|
+
const result = await client.query('SELECT value FROM verily_records WHERE namespace=$1 AND kind=$2 AND id=$3', [namespace, kind, id]);
|
|
57
|
+
return result.rows[0]?.value;
|
|
58
|
+
},
|
|
59
|
+
async put(kind, id, value) {
|
|
60
|
+
await client.query(`INSERT INTO verily_records(namespace,kind,id,value) VALUES($1,$2,$3,$4)
|
|
61
|
+
ON CONFLICT(namespace,kind,id) DO UPDATE SET value=EXCLUDED.value`, [namespace, kind, id, JSON.stringify(value)]);
|
|
62
|
+
},
|
|
63
|
+
async delete(kind, id) {
|
|
64
|
+
await client.query('DELETE FROM verily_records WHERE namespace=$1 AND kind=$2 AND id=$3', [
|
|
65
|
+
namespace,
|
|
66
|
+
kind,
|
|
67
|
+
id,
|
|
68
|
+
]);
|
|
69
|
+
},
|
|
70
|
+
async list(kind) {
|
|
71
|
+
const result = await client.query('SELECT value FROM verily_records WHERE namespace=$1 AND kind=$2', [namespace, kind]);
|
|
72
|
+
return result.rows.map((row) => row.value);
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { BlockList, isIPv4, isIPv6 } from 'node:net';
|
|
2
|
+
// Everything that is not a unicast address on the public internet: this network, private
|
|
3
|
+
// and shared address space, loopback, link-local, protocol assignments, documentation and
|
|
4
|
+
// benchmarking ranges, multicast and the reserved block.
|
|
5
|
+
const reservedV4 = [
|
|
6
|
+
'0.0.0.0/8',
|
|
7
|
+
'10.0.0.0/8',
|
|
8
|
+
'100.64.0.0/10',
|
|
9
|
+
'127.0.0.0/8',
|
|
10
|
+
'169.254.0.0/16',
|
|
11
|
+
'172.16.0.0/12',
|
|
12
|
+
'192.0.0.0/24',
|
|
13
|
+
'192.0.2.0/24',
|
|
14
|
+
'192.88.99.0/24',
|
|
15
|
+
'192.168.0.0/16',
|
|
16
|
+
'198.18.0.0/15',
|
|
17
|
+
'198.51.100.0/24',
|
|
18
|
+
'203.0.113.0/24',
|
|
19
|
+
'224.0.0.0/4',
|
|
20
|
+
'240.0.0.0/4',
|
|
21
|
+
];
|
|
22
|
+
// IPv6 is allowed only inside global unicast, less the parts of it that are protocol
|
|
23
|
+
// assignments (Teredo among them), documentation, 6to4 relays that tunnel to any IPv4
|
|
24
|
+
// address, and segment routing.
|
|
25
|
+
const reservedV6 = ['2001::/23', '2001:db8::/32', '2002::/16', '3fff::/20', '5f00::/16'];
|
|
26
|
+
const reserved = blocks([...reservedV4.map((cidr) => [cidr, 'ipv4'])]);
|
|
27
|
+
const global = blocks([['2000::/3', 'ipv6']]);
|
|
28
|
+
const reservedGlobal = blocks(reservedV6.map((cidr) => [cidr, 'ipv6']));
|
|
29
|
+
// These carry an IPv4 address in their low 32 bits, and are judged by it.
|
|
30
|
+
const embedding = blocks([
|
|
31
|
+
['::ffff:0:0/96', 'ipv6'],
|
|
32
|
+
['64:ff9b::/96', 'ipv6'],
|
|
33
|
+
]);
|
|
34
|
+
/** Whether an address is somewhere on the public internet, as opposed to inside a network. */
|
|
35
|
+
export function publicAddress(address) {
|
|
36
|
+
if (isIPv4(address))
|
|
37
|
+
return !reserved.check(address, 'ipv4');
|
|
38
|
+
// A zone only ever scopes a link-local address.
|
|
39
|
+
if (!isIPv6(address) || address.includes('%'))
|
|
40
|
+
return false;
|
|
41
|
+
if (embedding.check(address, 'ipv6'))
|
|
42
|
+
return publicAddress(embedded(address));
|
|
43
|
+
return global.check(address, 'ipv6') && !reservedGlobal.check(address, 'ipv6');
|
|
44
|
+
}
|
|
45
|
+
function blocks(ranges) {
|
|
46
|
+
const list = new BlockList();
|
|
47
|
+
for (const [cidr, family] of ranges) {
|
|
48
|
+
const [base, prefix] = cidr.split('/');
|
|
49
|
+
list.addSubnet(base, Number(prefix), family);
|
|
50
|
+
}
|
|
51
|
+
return list;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The IPv4 address in an IPv6 address's low 32 bits. `URL` writes an IPv6 address in its
|
|
55
|
+
* canonical form, where those are always the last two groups, and an empty group is zero.
|
|
56
|
+
*/
|
|
57
|
+
function embedded(address) {
|
|
58
|
+
const groups = new URL(`http://[${address}]`).hostname.slice(1, -1).split(':').slice(-2);
|
|
59
|
+
const value = groups.reduce((value, group) => value * 65536 + parseInt(group || '0', 16), 0);
|
|
60
|
+
return [24, 16, 8, 0].map((shift) => Math.floor(value / 2 ** shift) % 256).join('.');
|
|
61
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads at most `maxBytes` of a response, and says whether it stopped early. A body is
|
|
3
|
+
* whatever the far side chooses to send, so it is never read to the end on trust: the rest
|
|
4
|
+
* is cancelled, which lets go of the connection instead of holding it to the deadline.
|
|
5
|
+
*/
|
|
6
|
+
export declare function readBounded(response: Response, maxBytes: number): Promise<{
|
|
7
|
+
bytes: Uint8Array;
|
|
8
|
+
truncated: boolean;
|
|
9
|
+
}>;
|
|
10
|
+
/** Lets go of a body nobody started reading. One already read has been let go of. */
|
|
11
|
+
export declare function discard(response: Response): Promise<void>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads at most `maxBytes` of a response, and says whether it stopped early. A body is
|
|
3
|
+
* whatever the far side chooses to send, so it is never read to the end on trust: the rest
|
|
4
|
+
* is cancelled, which lets go of the connection instead of holding it to the deadline.
|
|
5
|
+
*/
|
|
6
|
+
export async function readBounded(response, maxBytes) {
|
|
7
|
+
if (!response.body)
|
|
8
|
+
return { bytes: new Uint8Array(0), truncated: false };
|
|
9
|
+
const reader = response.body.getReader();
|
|
10
|
+
const chunks = [];
|
|
11
|
+
let size = 0;
|
|
12
|
+
let truncated = false;
|
|
13
|
+
try {
|
|
14
|
+
for (;;) {
|
|
15
|
+
const { done, value } = await reader.read();
|
|
16
|
+
if (done)
|
|
17
|
+
break;
|
|
18
|
+
if (!value?.byteLength)
|
|
19
|
+
continue;
|
|
20
|
+
// Reaching the limit exactly says nothing about whether the body ends there, so
|
|
21
|
+
// only the end of the stream, read after it, says the whole thing was read.
|
|
22
|
+
if (size >= maxBytes) {
|
|
23
|
+
truncated = true;
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
chunks.push(value.subarray(0, maxBytes - size));
|
|
27
|
+
size += value.byteLength;
|
|
28
|
+
if (size > maxBytes) {
|
|
29
|
+
truncated = true;
|
|
30
|
+
break;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
await reader.cancel().catch(() => { });
|
|
36
|
+
}
|
|
37
|
+
const bytes = new Uint8Array(Math.min(size, maxBytes));
|
|
38
|
+
let at = 0;
|
|
39
|
+
for (const chunk of chunks) {
|
|
40
|
+
bytes.set(chunk, at);
|
|
41
|
+
at += chunk.byteLength;
|
|
42
|
+
}
|
|
43
|
+
return { bytes, truncated };
|
|
44
|
+
}
|
|
45
|
+
/** Lets go of a body nobody started reading. One already read has been let go of. */
|
|
46
|
+
export async function discard(response) {
|
|
47
|
+
if (response.body && !response.body.locked)
|
|
48
|
+
await response.body.cancel().catch(() => { });
|
|
49
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Puts a copy button on every block the page asks the holder to reproduce exactly. The
|
|
3
|
+
* button is made here rather than written into the page, so a reader with no scripting is
|
|
4
|
+
* never shown a control that does nothing: without this the block is still selectable text.
|
|
5
|
+
*
|
|
6
|
+
* Served as a file because these pages allow no inline scripts.
|
|
7
|
+
*/
|
|
8
|
+
export declare const copyScript = "for (const block of document.querySelectorAll('pre')) {\n const code = block.querySelector('code');\n\n if (!code) continue;\n\n const button = document.createElement('button');\n\n button.type = 'button';\n button.className = 'copy';\n button.textContent = 'Copy';\n button.setAttribute('aria-live', 'polite');\n\n button.addEventListener('click', async () => {\n try {\n await navigator.clipboard.writeText(code.textContent);\n button.textContent = 'Copied';\n } catch {\n // No clipboard permission, or an insecure origin. Select it instead, which leaves\n // the holder one keystroke away rather than telling them it worked when it did not.\n // The label stays short so the button never grows over the line it sits on.\n const range = document.createRange();\n const selection = getSelection();\n\n range.selectNodeContents(code);\n selection.removeAllRanges();\n selection.addRange(range);\n button.textContent = 'Selected';\n }\n\n setTimeout(() => (button.textContent = 'Copy'), 2000);\n });\n\n block.prepend(button);\n}\n";
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Puts a copy button on every block the page asks the holder to reproduce exactly. The
|
|
3
|
+
* button is made here rather than written into the page, so a reader with no scripting is
|
|
4
|
+
* never shown a control that does nothing: without this the block is still selectable text.
|
|
5
|
+
*
|
|
6
|
+
* Served as a file because these pages allow no inline scripts.
|
|
7
|
+
*/
|
|
8
|
+
export const copyScript = `for (const block of document.querySelectorAll('pre')) {
|
|
9
|
+
const code = block.querySelector('code');
|
|
10
|
+
|
|
11
|
+
if (!code) continue;
|
|
12
|
+
|
|
13
|
+
const button = document.createElement('button');
|
|
14
|
+
|
|
15
|
+
button.type = 'button';
|
|
16
|
+
button.className = 'copy';
|
|
17
|
+
button.textContent = 'Copy';
|
|
18
|
+
button.setAttribute('aria-live', 'polite');
|
|
19
|
+
|
|
20
|
+
button.addEventListener('click', async () => {
|
|
21
|
+
try {
|
|
22
|
+
await navigator.clipboard.writeText(code.textContent);
|
|
23
|
+
button.textContent = 'Copied';
|
|
24
|
+
} catch {
|
|
25
|
+
// No clipboard permission, or an insecure origin. Select it instead, which leaves
|
|
26
|
+
// the holder one keystroke away rather than telling them it worked when it did not.
|
|
27
|
+
// The label stays short so the button never grows over the line it sits on.
|
|
28
|
+
const range = document.createRange();
|
|
29
|
+
const selection = getSelection();
|
|
30
|
+
|
|
31
|
+
range.selectNodeContents(code);
|
|
32
|
+
selection.removeAllRanges();
|
|
33
|
+
selection.addRange(range);
|
|
34
|
+
button.textContent = 'Selected';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
setTimeout(() => (button.textContent = 'Copy'), 2000);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
block.prepend(button);
|
|
41
|
+
}
|
|
42
|
+
`;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { RedirectProvider } from '../core/index.js';
|
|
2
|
+
/** Discord OAuth2 app, PKCE S256, `identify` scope only. Tokens are never persisted. */
|
|
3
|
+
export declare function discordProvider(options: {
|
|
4
|
+
clientId: string;
|
|
5
|
+
clientSecret: string;
|
|
6
|
+
fetch?: typeof fetch;
|
|
7
|
+
}): RedirectProvider;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/** Discord OAuth2 app, PKCE S256, `identify` scope only. Tokens are never persisted. */
|
|
2
|
+
export function discordProvider(options) {
|
|
3
|
+
if (!options.clientId || !options.clientSecret)
|
|
4
|
+
throw new Error('Discord credentials required');
|
|
5
|
+
const request = options.fetch ?? fetch;
|
|
6
|
+
return {
|
|
7
|
+
id: 'discord',
|
|
8
|
+
name: 'Discord',
|
|
9
|
+
method: 'oauth',
|
|
10
|
+
authorizationUrl({ state, challenge, redirectUri }) {
|
|
11
|
+
const url = new URL('https://discord.com/oauth2/authorize');
|
|
12
|
+
// Discord has no account chooser. Forcing the consent screen at least shows the holder
|
|
13
|
+
// which account they are signed in as before it is linked.
|
|
14
|
+
url.search = new URLSearchParams({
|
|
15
|
+
response_type: 'code',
|
|
16
|
+
client_id: options.clientId,
|
|
17
|
+
redirect_uri: redirectUri,
|
|
18
|
+
state,
|
|
19
|
+
code_challenge: challenge,
|
|
20
|
+
code_challenge_method: 'S256',
|
|
21
|
+
scope: 'identify',
|
|
22
|
+
prompt: 'consent',
|
|
23
|
+
}).toString();
|
|
24
|
+
return url.href;
|
|
25
|
+
},
|
|
26
|
+
async authenticate({ code, verifier, redirectUri }) {
|
|
27
|
+
const response = await request('https://discord.com/api/oauth2/token', {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: { Accept: 'application/json' },
|
|
30
|
+
signal: AbortSignal.timeout(15000),
|
|
31
|
+
body: new URLSearchParams({
|
|
32
|
+
grant_type: 'authorization_code',
|
|
33
|
+
client_id: options.clientId,
|
|
34
|
+
client_secret: options.clientSecret,
|
|
35
|
+
code,
|
|
36
|
+
code_verifier: verifier,
|
|
37
|
+
redirect_uri: redirectUri,
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
const token = await response.json();
|
|
41
|
+
if (!response.ok || !isRecord(token) || typeof token.access_token !== 'string' || token.error)
|
|
42
|
+
throw new Error('Provider authentication failed');
|
|
43
|
+
const identity = await request('https://discord.com/api/v10/users/@me', {
|
|
44
|
+
headers: { Authorization: `Bearer ${token.access_token}`, Accept: 'application/json' },
|
|
45
|
+
signal: AbortSignal.timeout(15000),
|
|
46
|
+
});
|
|
47
|
+
const user = await identity.json();
|
|
48
|
+
// The snowflake is the stable id; usernames can be changed and then taken by someone
|
|
49
|
+
// else. Only the current unique usernames are accepted: an account still on a legacy
|
|
50
|
+
// name#1234 has a name that is not unique by itself and cannot be written as a handle.
|
|
51
|
+
if (!identity.ok ||
|
|
52
|
+
!isRecord(user) ||
|
|
53
|
+
typeof user.id !== 'string' ||
|
|
54
|
+
!/^[1-9][0-9]{0,19}$/.test(user.id) ||
|
|
55
|
+
typeof user.username !== 'string' ||
|
|
56
|
+
!/^[a-z0-9_.]{2,32}$/.test(user.username) ||
|
|
57
|
+
(user.discriminator !== undefined && user.discriminator !== '0'))
|
|
58
|
+
throw new Error('Invalid provider identity');
|
|
59
|
+
return {
|
|
60
|
+
id: user.id,
|
|
61
|
+
handle: user.username,
|
|
62
|
+
profileUrl: `https://discord.com/users/${user.id}`,
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function isRecord(value) {
|
|
68
|
+
return typeof value === 'object' && value !== null;
|
|
69
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Verily logotype on its plate, as a PNG, for mail. A message cannot draw the logotype
|
|
3
|
+
* the way a page does: the clients most people read mail in strip an inline SVG. So it
|
|
4
|
+
* travels inside the message as an image of its own, which is shown without anything
|
|
5
|
+
* being fetched.
|
|
6
|
+
*
|
|
7
|
+
* This is docs/design/verily-logo-plate.svg drawn at 281 by 120, three times the 94 by 40
|
|
8
|
+
* it is shown at, with the plate's corners left transparent. Regenerate it from that file
|
|
9
|
+
* if the logotype changes.
|
|
10
|
+
*/
|
|
11
|
+
export declare const emailLogo: string;
|