@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.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +503 -0
  3. package/dist/browser/badge.d.ts +39 -0
  4. package/dist/browser/badge.js +440 -0
  5. package/dist/browser/connect-dialog.d.ts +83 -0
  6. package/dist/browser/connect-dialog.js +514 -0
  7. package/dist/browser/evidence-dialog.d.ts +59 -0
  8. package/dist/browser/evidence-dialog.js +541 -0
  9. package/dist/browser/index.d.ts +59 -0
  10. package/dist/browser/index.js +809 -0
  11. package/dist/browser/logo.d.ts +2 -0
  12. package/dist/browser/logo.js +17 -0
  13. package/dist/browser/mark.d.ts +19 -0
  14. package/dist/browser/mark.js +77 -0
  15. package/dist/browser/provider-mark.d.ts +6 -0
  16. package/dist/browser/provider-mark.js +99 -0
  17. package/dist/browser/signed.d.ts +40 -0
  18. package/dist/browser/signed.js +219 -0
  19. package/dist/browser.d.ts +3 -0
  20. package/dist/browser.js +3 -0
  21. package/dist/core/index.d.ts +454 -0
  22. package/dist/core/index.js +128 -0
  23. package/dist/core/signed.d.ts +49 -0
  24. package/dist/core/signed.js +113 -0
  25. package/dist/index.d.ts +5 -0
  26. package/dist/index.js +5 -0
  27. package/dist/logo.d.ts +21 -0
  28. package/dist/logo.js +38 -0
  29. package/dist/postgres/index.d.ts +12 -0
  30. package/dist/postgres/index.js +76 -0
  31. package/dist/server/addresses.d.ts +2 -0
  32. package/dist/server/addresses.js +61 -0
  33. package/dist/server/body.d.ts +11 -0
  34. package/dist/server/body.js +49 -0
  35. package/dist/server/copy.d.ts +8 -0
  36. package/dist/server/copy.js +42 -0
  37. package/dist/server/discord.d.ts +7 -0
  38. package/dist/server/discord.js +69 -0
  39. package/dist/server/email-logo.d.ts +11 -0
  40. package/dist/server/email-logo.js +122 -0
  41. package/dist/server/email.d.ts +46 -0
  42. package/dist/server/email.js +145 -0
  43. package/dist/server/escape.d.ts +2 -0
  44. package/dist/server/escape.js +2 -0
  45. package/dist/server/github-gist.d.ts +11 -0
  46. package/dist/server/github-gist.js +68 -0
  47. package/dist/server/github.d.ts +7 -0
  48. package/dist/server/github.js +65 -0
  49. package/dist/server/index.d.ts +70 -0
  50. package/dist/server/index.js +862 -0
  51. package/dist/server/link.d.ts +64 -0
  52. package/dist/server/link.js +458 -0
  53. package/dist/server/openpgp.d.ts +49 -0
  54. package/dist/server/openpgp.js +158 -0
  55. package/dist/server/pgp.d.ts +29 -0
  56. package/dist/server/pgp.js +198 -0
  57. package/dist/server/public-fetch.d.ts +23 -0
  58. package/dist/server/public-fetch.js +62 -0
  59. package/dist/server/resend.d.ts +13 -0
  60. package/dist/server/resend.js +37 -0
  61. package/dist/server/service.d.ts +311 -0
  62. package/dist/server/service.js +1178 -0
  63. package/dist/server/signing.d.ts +16 -0
  64. package/dist/server/signing.js +56 -0
  65. package/dist/server/style.d.ts +17 -0
  66. package/dist/server/style.js +244 -0
  67. package/dist/server/wkd.d.ts +7 -0
  68. package/dist/server/wkd.js +65 -0
  69. package/dist/server/youtube.d.ts +11 -0
  70. package/dist/server/youtube.js +99 -0
  71. package/dist/site/index.d.ts +139 -0
  72. package/dist/site/index.js +239 -0
  73. package/dist/verily.js +11907 -0
  74. package/dist/version.d.ts +10 -0
  75. package/dist/version.js +10 -0
  76. package/docs/licenses/gpl-3.0.txt +232 -0
  77. package/docs/licenses/octicons.txt +21 -0
  78. package/docs/licenses/openpgp.txt +172 -0
  79. package/docs/licenses/rubik.txt +93 -0
  80. package/docs/licenses/simple-icons.txt +8 -0
  81. package/package.json +86 -0
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Making a verifier's key and signing with it. Checking what was signed is in
3
+ * `core/signed.ts`, which a page runs too.
4
+ */
5
+ import { type Signer } from '../core/index.js';
6
+ /**
7
+ * A new signing key for a verifier of this name: an armored OpenPGP private key. Keep it
8
+ * secret and keep it backed up.
9
+ *
10
+ * It is an Ed25519 key in the form GnuPG reads, with no subkeys, so `gpg --verify` checks
11
+ * what it signs. The name is the key's own word for whose it is, and more can be added to
12
+ * the same key later without changing its fingerprint.
13
+ */
14
+ export declare function generateSigningKey(name: string): Promise<string>;
15
+ /** Reads a signing key made by `generateSigningKey`, giving its public half and a way to sign. */
16
+ export declare function signer(signingKey: string): Promise<Signer>;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Making a verifier's key and signing with it. Checking what was signed is in
3
+ * `core/signed.ts`, which a page runs too.
4
+ */
5
+ import { config as defaults, createCleartextMessage, enums, generateKey, readPrivateKey, sign, } from 'openpgp';
6
+ import { verifySigned } from '../core/index.js';
7
+ const config = { ...defaults, preferredHashAlgorithm: enums.hash.sha512 };
8
+ /**
9
+ * A new signing key for a verifier of this name: an armored OpenPGP private key. Keep it
10
+ * secret and keep it backed up.
11
+ *
12
+ * It is an Ed25519 key in the form GnuPG reads, with no subkeys, so `gpg --verify` checks
13
+ * what it signs. The name is the key's own word for whose it is, and more can be added to
14
+ * the same key later without changing its fingerprint.
15
+ */
16
+ export async function generateSigningKey(name) {
17
+ const { privateKey } = await generateKey({
18
+ type: 'ecc',
19
+ curve: 'ed25519Legacy',
20
+ userIDs: [{ name }],
21
+ subkeys: [],
22
+ format: 'armored',
23
+ config,
24
+ });
25
+ return privateKey;
26
+ }
27
+ /** Reads a signing key made by `generateSigningKey`, giving its public half and a way to sign. */
28
+ export async function signer(signingKey) {
29
+ const secret = await readPrivateKey({ armoredKey: signingKey, config }).catch(() => undefined);
30
+ if (!secret?.isDecrypted())
31
+ throw new Error('Invalid signing key');
32
+ const key = {
33
+ id: secret.getFingerprint().toUpperCase(),
34
+ alg: 'OpenPGP',
35
+ publicKey: secret.toPublic().armor(),
36
+ };
37
+ const made = {
38
+ key,
39
+ async sign(document) {
40
+ const signed = await sign({
41
+ message: await createCleartextMessage({ text: JSON.stringify(document, null, 2) }),
42
+ signingKeys: secret,
43
+ config,
44
+ });
45
+ // Line endings are no part of what is signed, so the file is given one kind of them.
46
+ return signed.replace(/\r\n/g, '\n');
47
+ },
48
+ };
49
+ // A key that has run out or been revoked, or one that signs with a subkey, would sign
50
+ // records that do not check. That is found out now and not by whoever saved one.
51
+ const probe = { type: 'verily-evidence', version: 1, issuedAt: 0 };
52
+ const checks = await made.sign(probe).then((signed) => verifySigned(signed, [key]), () => undefined);
53
+ if (!checks)
54
+ throw new Error('Invalid signing key');
55
+ return made;
56
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The one stylesheet for every page this library serves, and for the owner page of a
3
+ * deployment that mounts it. Served as a file rather than inlined so the pages can keep a
4
+ * content security policy with no inline styles at all, which is also why nothing here is
5
+ * set through a style attribute.
6
+ *
7
+ * These pages are records: a reader arrives at one to check a claim, reads it once, and
8
+ * leaves. So the styling does the two things that helps with, a measured column and a
9
+ * legible hierarchy, and nothing that would make a record look like a product.
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";
12
+ /**
13
+ * A short digest of the sheet above, put in the link's query so a release that changes the
14
+ * styling changes its address. Without that the only safe cache is no cache: a reader who
15
+ * once loaded a page holds the sheet it came with, and the next release renders inside it.
16
+ */
17
+ export declare const styleVersion: string;
@@ -0,0 +1,244 @@
1
+ /**
2
+ * The one stylesheet for every page this library serves, and for the owner page of a
3
+ * deployment that mounts it. Served as a file rather than inlined so the pages can keep a
4
+ * content security policy with no inline styles at all, which is also why nothing here is
5
+ * set through a style attribute.
6
+ *
7
+ * These pages are records: a reader arrives at one to check a claim, reads it once, and
8
+ * leaves. So the styling does the two things that helps with, a measured column and a
9
+ * legible hierarchy, and nothing that would make a record look like a product.
10
+ */
11
+ export const stylesheet = `:root {
12
+ --ink: #23312b;
13
+ --muted: #6b786f;
14
+ --line: #dce2de;
15
+ --surface: #fff;
16
+ --raised: #f7f9f7;
17
+ --accent: #245f43;
18
+ color-scheme: light dark;
19
+ accent-color: var(--accent);
20
+ }
21
+
22
+ @media (prefers-color-scheme: dark) {
23
+ :root {
24
+ --ink: #dde5e0;
25
+ --muted: #96a39b;
26
+ --line: #2f3b35;
27
+ --surface: #161a18;
28
+ --raised: #1d2220;
29
+ --accent: #7fc4a2;
30
+ }
31
+ }
32
+
33
+ * { box-sizing: border-box; }
34
+
35
+ body {
36
+ margin: 0;
37
+ padding: 0 1rem 4rem;
38
+ background: var(--surface);
39
+ color: var(--ink);
40
+ font: 16px/1.65 system-ui, -apple-system, 'Segoe UI', sans-serif;
41
+ text-rendering: optimizeLegibility;
42
+ }
43
+
44
+ main {
45
+ max-width: 34rem;
46
+ margin: 0 auto;
47
+ padding-top: 3rem;
48
+ }
49
+
50
+ /*
51
+ * The logotype, above the page's own heading, on its own dark plate. Sized so the word is
52
+ * the height it was without the plate, whose margin makes the box that much taller. The
53
+ * word is white in either scheme: the plate is what it is read against, not the page.
54
+ */
55
+ .logo {
56
+ display: block;
57
+ height: 2.5rem;
58
+ margin-bottom: 1.5rem;
59
+ }
60
+
61
+ h1 {
62
+ margin: 0 0 1.75rem;
63
+ padding-bottom: 1rem;
64
+ border-bottom: 1px solid var(--line);
65
+ font-size: 1.5rem;
66
+ font-weight: 620;
67
+ letter-spacing: -.02em;
68
+ line-height: 1.25;
69
+ }
70
+
71
+ /*
72
+ * A page holding one thing needs no rule under its heading. The rule divides a heading
73
+ * from the record beneath it, and a page that only asks for a key has no record to divide.
74
+ */
75
+ main.single h1 { margin-bottom: 1.25rem; padding-bottom: 0; border-bottom: none; }
76
+
77
+ h2 { margin: 2rem 0 .75rem; font-size: 1.125rem; font-weight: 620; letter-spacing: -.01em; }
78
+
79
+ h3 { margin: 0 0 .5rem; font-size: 1rem; font-weight: 620; }
80
+
81
+ p { margin: 0 0 1rem; overflow-wrap: break-word; }
82
+
83
+ a { color: var(--accent); text-underline-offset: 3px; overflow-wrap: anywhere; }
84
+
85
+ a:hover { text-decoration-thickness: 2px; }
86
+
87
+ /*
88
+ * Something to be run or published exactly as written. Its own line breaks are kept and a
89
+ * long line is soft-wrapped rather than cut off: a soft wrap is drawn, not inserted, so
90
+ * what is copied is still character for character what was written. Selecting inside the
91
+ * block takes all of it, since half a command is worse than none.
92
+ */
93
+ pre {
94
+ position: relative;
95
+ margin: 0 0 1rem;
96
+ /* Room along the top line for the copy button, which sits over the block rather than in it. */
97
+ padding: .75rem 5.25rem .75rem .875rem;
98
+ border: 1px solid var(--line);
99
+ border-radius: 6px;
100
+ background: var(--raised);
101
+ white-space: pre-wrap;
102
+ overflow-wrap: anywhere;
103
+ user-select: all;
104
+ }
105
+
106
+ code { font: .8125rem/1.6 ui-monospace, SFMono-Regular, Menlo, monospace; }
107
+
108
+ /* Made by a script, so it exists only where it works. Quiet: the block is the point. */
109
+ .copy {
110
+ position: absolute;
111
+ top: .5rem;
112
+ right: .5rem;
113
+ padding: .1875rem .5rem;
114
+ border: 1px solid var(--line);
115
+ border-radius: 5px;
116
+ background: var(--surface);
117
+ color: var(--muted);
118
+ font: 500 .75rem/1.5 system-ui, sans-serif;
119
+ user-select: none;
120
+ }
121
+
122
+ .copy:hover { color: var(--ink); border-color: var(--muted); }
123
+
124
+ /*
125
+ * The two sides of the link, which is what a reader came to read. Each fact about a side
126
+ * gets its own line: what it is, what it is called, the identifier behind that, and how it
127
+ * was shown. Everything else on the page qualifies the pair, so it is set quieter.
128
+ */
129
+ .side {
130
+ margin-bottom: .75rem;
131
+ padding: .875rem 1rem;
132
+ border: 1px solid var(--line);
133
+ border-radius: 8px;
134
+ background: var(--raised);
135
+ }
136
+
137
+ .who {
138
+ margin: 0 0 .125rem;
139
+ color: var(--muted);
140
+ font-size: .75rem;
141
+ font-weight: 550;
142
+ letter-spacing: .02em;
143
+ }
144
+
145
+ .name { margin: 0; font-size: 1.0625rem; font-weight: 620; letter-spacing: -.01em; }
146
+
147
+ /* The name is the card's subject before it is a link, so the rule is drawn on hover. */
148
+ .name a { text-decoration: none; }
149
+
150
+ .name a:hover { text-decoration: underline; }
151
+
152
+ .reference, .how { margin: 0; color: var(--muted); font-size: .8125rem; line-height: 1.5; }
153
+
154
+ .name + .how, .reference + .how { margin-top: .5rem; }
155
+ .how.additional { padding-left: .75rem; }
156
+
157
+ .fine { color: var(--muted); font-size: .8125rem; line-height: 1.55; margin-bottom: .625rem; }
158
+
159
+ /* That the record is signed is a fact about it, so it is set as one and not as fine print. */
160
+ .signed { font-size: .875rem; line-height: 1.55; margin-bottom: .875rem; }
161
+ /* Arrived at from a badge's "signed" link, so the reader is shown where they landed. */
162
+ .signed:target { margin-inline: -.75rem; padding: .625rem .75rem; border-radius: 8px; background: var(--raised); }
163
+
164
+ /* Every time on the record in one column, so they can be compared rather than hunted for. */
165
+ dl {
166
+ display: grid;
167
+ grid-template-columns: auto 1fr;
168
+ gap: .3rem 1.5rem;
169
+ margin: 1.25rem 0;
170
+ padding-top: 1rem;
171
+ border-top: 1px solid var(--line);
172
+ font-size: .8125rem;
173
+ }
174
+
175
+ dt { color: var(--muted); }
176
+
177
+ dd { margin: 0; font-variant-numeric: tabular-nums; }
178
+
179
+ /* Inside a card the rule above it is the card's own edge, so the list needs none. */
180
+ section dl { margin: .5rem 0 1rem; padding-top: 0; border-top: 0; }
181
+
182
+ section {
183
+ margin: 0 0 1.25rem;
184
+ padding: 1.25rem 1.375rem;
185
+ border: 1px solid var(--line);
186
+ border-radius: 10px;
187
+ }
188
+
189
+ section > :last-child { margin-bottom: 0; }
190
+
191
+ form { margin: 0 0 1.5rem; }
192
+
193
+ label { display: block; margin: 0 0 .75rem; color: var(--muted); font-size: .8125rem; }
194
+
195
+ input, textarea {
196
+ display: block;
197
+ width: 100%;
198
+ margin-top: .375rem;
199
+ padding: .5rem .625rem;
200
+ border: 1px solid var(--line);
201
+ border-radius: 6px;
202
+ background: var(--surface);
203
+ color: var(--ink);
204
+ font: .875rem/1.5 ui-monospace, SFMono-Regular, Menlo, monospace;
205
+ }
206
+
207
+ /* Height comes from the rows attribute, which differs per box. */
208
+ textarea { resize: vertical; }
209
+
210
+ input:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
211
+
212
+ button {
213
+ padding: .5rem 1rem;
214
+ border: 1px solid var(--accent);
215
+ border-radius: 6px;
216
+ background: var(--accent);
217
+ color: var(--surface);
218
+ font: 500 .9375rem/1.4 inherit;
219
+ cursor: pointer;
220
+ }
221
+
222
+ button + button { margin-left: .5rem; border-color: var(--line); background: none; color: var(--ink); }
223
+
224
+ fieldset { margin: 0 0 1.25rem; padding: 1rem 1.125rem .25rem; border: 1px solid var(--line); border-radius: 8px; }
225
+
226
+ legend { padding: 0 .375rem; color: var(--muted); font-size: .8125rem; }
227
+
228
+ fieldset label { display: flex; align-items: baseline; gap: .5rem; color: var(--ink); font-size: .9375rem; }
229
+
230
+ fieldset input { width: auto; margin: 0; }
231
+
232
+ fieldset p { margin: .25rem 0 .875rem 1.5rem; color: var(--muted); font-size: .8125rem; }
233
+ `;
234
+ /**
235
+ * A short digest of the sheet above, put in the link's query so a release that changes the
236
+ * styling changes its address. Without that the only safe cache is no cache: a reader who
237
+ * once loaded a page holds the sheet it came with, and the next release renders inside it.
238
+ */
239
+ export const styleVersion = (() => {
240
+ let hash = 0x811c9dc5;
241
+ for (const char of stylesheet)
242
+ hash = Math.imul(hash ^ char.charCodeAt(0), 0x01000193) >>> 0;
243
+ return hash.toString(36);
244
+ })();
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Where a domain would publish the key for one of its addresses, most specific first. The
3
+ * advanced form lives on a subdomain a mail operator can delegate without touching the
4
+ * main site; the direct form is on the domain itself. Both are tried because both are in
5
+ * use, and either one answering is the domain answering.
6
+ */
7
+ export declare function wkdUrls(address: string): string[];
@@ -0,0 +1,65 @@
1
+ import { createHash } from 'node:crypto';
2
+ /** Z-Base-32, which is not RFC 4648's alphabet: the spec picked this one for legibility. */
3
+ const alphabet = 'ybndrfg8ejkmcpqxot1uwisza345h769';
4
+ /**
5
+ * Domain suffixes that must never be fetched. The address comes off a key a stranger
6
+ * pasted, so it names the host, and these are the names that can resolve to something
7
+ * inside the network running this. Reserved suffixes that resolve nowhere are left alone:
8
+ * asking about them is pointless rather than dangerous, and they are what tests use.
9
+ */
10
+ const reserved = ['local', 'internal', 'localhost', 'home', 'lan', 'corp', 'intranet'];
11
+ function zbase32(data) {
12
+ let bits = 0;
13
+ let value = 0;
14
+ let out = '';
15
+ for (const byte of data) {
16
+ value = (value << 8) | byte;
17
+ bits += 8;
18
+ while (bits >= 5) {
19
+ out += alphabet[(value >>> (bits - 5)) & 31];
20
+ bits -= 5;
21
+ }
22
+ }
23
+ // A twenty byte digest is exactly thirty two characters, so nothing is ever left over.
24
+ if (bits > 0)
25
+ out += alphabet[(value << (5 - bits)) & 31];
26
+ return out;
27
+ }
28
+ /**
29
+ * Splits an address into the parts the directory is addressed by. The local part is
30
+ * lowercased and hashed rather than sent, which is the point of the scheme: a domain can
31
+ * answer for an address without publishing a list of the addresses it has.
32
+ */
33
+ function parts(address) {
34
+ const at = address.lastIndexOf('@');
35
+ if (at < 1 || at === address.length - 1)
36
+ return undefined;
37
+ const local = address.slice(0, at);
38
+ const domain = address.slice(at + 1).toLowerCase();
39
+ // A host, not an address literal and not a suffix that resolves inside the network.
40
+ // Where the name really resolves is checked when it is fetched.
41
+ if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(domain))
42
+ return undefined;
43
+ if (/^[\d.]+$/.test(domain) || reserved.includes(domain.split('.').pop()))
44
+ return undefined;
45
+ if (local.length > 100 || domain.length > 253)
46
+ return undefined;
47
+ return { local, domain };
48
+ }
49
+ /**
50
+ * Where a domain would publish the key for one of its addresses, most specific first. The
51
+ * advanced form lives on a subdomain a mail operator can delegate without touching the
52
+ * main site; the direct form is on the domain itself. Both are tried because both are in
53
+ * use, and either one answering is the domain answering.
54
+ */
55
+ export function wkdUrls(address) {
56
+ const split = parts(address);
57
+ if (!split)
58
+ return [];
59
+ const hashed = zbase32(Uint8Array.from(createHash('sha1').update(split.local.toLowerCase(), 'utf8').digest()));
60
+ const query = `?l=${encodeURIComponent(split.local)}`;
61
+ return [
62
+ `https://openpgpkey.${split.domain}/.well-known/openpgpkey/${split.domain}/hu/${hashed}${query}`,
63
+ `https://${split.domain}/.well-known/openpgpkey/hu/${hashed}${query}`,
64
+ ];
65
+ }
@@ -0,0 +1,11 @@
1
+ import { type RedirectProvider } from '../core/index.js';
2
+ /**
3
+ * Google OAuth client, PKCE S256, `youtube.readonly` scope only. The account proved is the
4
+ * YouTube channel chosen at consent, not the Google account behind it. Tokens are revoked
5
+ * as soon as the channel is read and are never persisted.
6
+ */
7
+ export declare function youtubeProvider(options: {
8
+ clientId: string;
9
+ clientSecret: string;
10
+ fetch?: typeof fetch;
11
+ }): RedirectProvider;
@@ -0,0 +1,99 @@
1
+ import { Refused } from '../core/index.js';
2
+ /**
3
+ * Google OAuth client, PKCE S256, `youtube.readonly` scope only. The account proved is the
4
+ * YouTube channel chosen at consent, not the Google account behind it. Tokens are revoked
5
+ * as soon as the channel is read and are never persisted.
6
+ */
7
+ export function youtubeProvider(options) {
8
+ if (!options.clientId || !options.clientSecret)
9
+ throw new Error('Google credentials required');
10
+ const request = options.fetch ?? fetch;
11
+ return {
12
+ id: 'youtube',
13
+ name: 'YouTube',
14
+ method: 'oauth',
15
+ authorizationUrl({ state, challenge, redirectUri }) {
16
+ const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
17
+ // select_account lets a holder with several Google accounts, or a channel under a
18
+ // brand account, pick the one they mean rather than whichever is signed in.
19
+ url.search = new URLSearchParams({
20
+ response_type: 'code',
21
+ client_id: options.clientId,
22
+ redirect_uri: redirectUri,
23
+ state,
24
+ code_challenge: challenge,
25
+ code_challenge_method: 'S256',
26
+ scope: 'https://www.googleapis.com/auth/youtube.readonly',
27
+ access_type: 'online',
28
+ prompt: 'select_account',
29
+ }).toString();
30
+ return url.href;
31
+ },
32
+ async authenticate({ code, verifier, redirectUri }) {
33
+ const response = await request('https://oauth2.googleapis.com/token', {
34
+ method: 'POST',
35
+ headers: { Accept: 'application/json' },
36
+ signal: AbortSignal.timeout(15000),
37
+ body: new URLSearchParams({
38
+ grant_type: 'authorization_code',
39
+ client_id: options.clientId,
40
+ client_secret: options.clientSecret,
41
+ code,
42
+ code_verifier: verifier,
43
+ redirect_uri: redirectUri,
44
+ }),
45
+ });
46
+ const token = await response.json();
47
+ if (!response.ok || !isRecord(token) || typeof token.access_token !== 'string' || token.error)
48
+ throw new Error('Provider authentication failed');
49
+ const accessToken = token.access_token;
50
+ try {
51
+ const identity = await request('https://www.googleapis.com/youtube/v3/channels?part=snippet&mine=true', {
52
+ headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
53
+ signal: AbortSignal.timeout(15000),
54
+ });
55
+ const body = await identity.json();
56
+ if (!identity.ok || !isRecord(body))
57
+ throw new Error('Invalid provider identity');
58
+ // A Google account that has never created a channel signs in fine but has nothing
59
+ // to link, which is the holder's to fix, so it is said rather than hidden.
60
+ if (!Array.isArray(body.items) || body.items.length === 0)
61
+ throw new Refused('This Google account has no YouTube channel');
62
+ const channel = body.items[0];
63
+ if (body.items.length !== 1 ||
64
+ !isRecord(channel) ||
65
+ typeof channel.id !== 'string' ||
66
+ !/^UC[A-Za-z0-9_-]{22}$/.test(channel.id) ||
67
+ !isRecord(channel.snippet))
68
+ throw new Error('Invalid provider identity');
69
+ // The handle is the @name. The channel id is the stable identity: handles can be
70
+ // changed and then claimed by another channel.
71
+ const customUrl = channel.snippet.customUrl;
72
+ if (customUrl === undefined)
73
+ throw new Refused('This YouTube channel has no handle');
74
+ const handle = typeof customUrl === 'string'
75
+ ? /^@?([\p{L}\p{M}\p{N}._·-]{3,30})$/u.exec(customUrl)?.[1]
76
+ : undefined;
77
+ if (!handle)
78
+ throw new Error('Invalid provider identity');
79
+ return {
80
+ id: channel.id,
81
+ handle,
82
+ profileUrl: `https://www.youtube.com/channel/${channel.id}`,
83
+ };
84
+ }
85
+ finally {
86
+ // The scope reads the whole YouTube account for an hour. Nothing more is needed
87
+ // from it, so it is given back now rather than left to expire.
88
+ await request('https://oauth2.googleapis.com/revoke', {
89
+ method: 'POST',
90
+ signal: AbortSignal.timeout(5000),
91
+ body: new URLSearchParams({ token: accessToken }),
92
+ }).catch(() => undefined);
93
+ }
94
+ },
95
+ };
96
+ }
97
+ function isRecord(value) {
98
+ return typeof value === 'object' && value !== null;
99
+ }
@@ -0,0 +1,139 @@
1
+ import type { Evidence, LocalKind, Visibility } from '../core/index.js';
2
+ /**
3
+ * For a site that uses a hosted Verily instance instead of running its own. The site's
4
+ * backend does three things, and this is all of them: it vouches to the instance for its
5
+ * signed-in user; it reads its users' connections back; and, if it wants, it checks the
6
+ * result a user returns with. `handler()` serves the endpoints the first of those needs,
7
+ * so a site mounts it and writes none of them.
8
+ *
9
+ * Nothing here needs Node. It uses Web Crypto and `fetch`, so it runs wherever a backend
10
+ * does. It must only ever run on a backend: the key is what the instance believes.
11
+ */
12
+ export interface SiteClientOptions {
13
+ /** The instance's origin, such as `https://verily.shirkadeh.org`. */
14
+ instance: string;
15
+ /** This site's id in the instance's registry. */
16
+ site: string;
17
+ /** The key the instance holds for this site. Never sent to a browser. */
18
+ key: string;
19
+ /**
20
+ * Set this and the site's user ids never leave it: every id this client is given is this
21
+ * site's own, and the instance is sent a stand-in made with `pseudonym()`. A subject
22
+ * given no `reference` gets one made the same way. At least 32 characters, kept for this
23
+ * alone, and never changed: a different secret makes every user a different user to the
24
+ * instance, and they lose their links.
25
+ */
26
+ idSecret?: string;
27
+ /** How long `connections()` reuses an answer, in milliseconds. Absent, every call reads. */
28
+ cacheMs?: number;
29
+ /** Replaces the global `fetch`, for a runtime or a test that needs its own. */
30
+ fetch?: typeof fetch;
31
+ }
32
+ /** What `handler()` needs from the site: who is signed in, and where its own pages are. */
33
+ export interface SiteHandlerOptions {
34
+ /**
35
+ * This site's signed-in user for a request, by the site's own session, or undefined if
36
+ * nobody is signed in. Nothing in a request to the handler names a user.
37
+ */
38
+ authenticate(request: Request): Promise<SiteSubject | undefined> | SiteSubject | undefined;
39
+ /** Where a signed-out user who arrives from the instance goes to sign in. Absent, a 401. */
40
+ signInUrl?: string;
41
+ /** Where a user goes when they come back from the instance. Absent, the site's root. */
42
+ returnUrl?: string;
43
+ }
44
+ /** The signed-in user a handoff vouches for, as the instance will record them. */
45
+ export interface SiteSubject {
46
+ /** Private, stable, and never reassigned. The instance never shows it. */
47
+ id: string;
48
+ /** Display text for the subject. */
49
+ label: string;
50
+ /**
51
+ * Durable and safe to show publicly. Never an email address. Optional only for a client
52
+ * with an `idSecret`, which makes one when it is absent.
53
+ */
54
+ reference?: string;
55
+ /** Absent means the site did not say. */
56
+ kind?: LocalKind;
57
+ /** Must be on this site's registered origin. */
58
+ profileUrl?: string;
59
+ }
60
+ /** How a flow a user was sent into ended, as the instance signed it. */
61
+ export interface SiteResult {
62
+ site: string;
63
+ id: string;
64
+ operation: 'connect' | 'renew' | 'visibility' | 'disconnect';
65
+ outcome: 'complete' | 'cancelled' | 'failed';
66
+ /** Present when the outcome is complete. */
67
+ connection?: string;
68
+ visibility?: Visibility;
69
+ /** The transaction of the handoff this result answers. */
70
+ txn: string;
71
+ exp: number;
72
+ }
73
+ /**
74
+ * A stable stand-in for one of a site's own user ids, for a site that would rather its ids
75
+ * never left it. The same secret and value always give the same result, so nothing has to
76
+ * be stored. The secret must never change: a different one gives every user a different
77
+ * stand-in, which the instance takes for a different user. Use a secret kept for this alone,
78
+ * and not the site's key, which is meant to be replaceable.
79
+ *
80
+ * `purpose` separates stand-ins made from one secret, so a private id and a public
81
+ * reference made from the same user id tell nobody they belong together.
82
+ */
83
+ export declare function pseudonym(secret: string, value: string, purpose?: string): Promise<string>;
84
+ export declare function createSiteClient(options: SiteClientOptions): {
85
+ /**
86
+ * Where to link a signed-in user: to verify an account, or to manage the ones they
87
+ * have. The instance sends them on to this site's authorize endpoint with a `state`.
88
+ */
89
+ beginUrl(purpose?: "connect" | "manage"): string;
90
+ /**
91
+ * For the authorize endpoint: where to redirect a signed-in user, with a handoff that
92
+ * vouches for them. Call it only once this site has itself established who they are,
93
+ * with the `state` the instance sent. `txn` names this handoff in the result that
94
+ * comes back; a site that reads its connections from the instance can ignore it.
95
+ */
96
+ authorize(state: string, subject: SiteSubject): Promise<{
97
+ url: string;
98
+ txn: string;
99
+ }>;
100
+ /**
101
+ * For a page that shows the connect pill with `handoff-url`: a token that vouches for
102
+ * the signed-in user to the dialog on that page. Serve it from an endpoint on this
103
+ * site, to its signed-in user only and to a POST from this site's own pages, and call
104
+ * it only once this site has itself established who they are. It works once, within
105
+ * four minutes, and only from a page on this site's registered origin.
106
+ */
107
+ handoff(subject: SiteSubject): Promise<{
108
+ token: string;
109
+ txn: string;
110
+ }>;
111
+ /**
112
+ * Each subject's connections, unlisted ones included, by the id this site vouched for
113
+ * them with. Only this site can read them. Every id asked for is in the answer, with an
114
+ * empty list for a subject who has none. Revoked and expired records are included, so
115
+ * show one only when its `status` is `verified`. With `cacheMs` set, an answer read
116
+ * within it is reused unless `fresh` asks for a new one.
117
+ */
118
+ connections(ids: string[], { fresh }?: {
119
+ fresh?: boolean;
120
+ }): Promise<Record<string, Evidence[]>>;
121
+ /**
122
+ * For the return endpoint: the result a user came back with, if the instance signed it
123
+ * for this site and it has not expired. Undefined otherwise. A site that reads its
124
+ * connections from the instance has no need of it and can send the user straight on.
125
+ *
126
+ * A site that stores what a result says must also check that `id` is the signed-in
127
+ * user and that `txn` is the handoff it is waiting on, since a user's own older result
128
+ * is validly signed too. With an `idSecret`, `id` is the user's stand-in.
129
+ */
130
+ result(token: string): Promise<SiteResult | undefined>;
131
+ /**
132
+ * The endpoints a site needs, as one function from a `Request` to a `Response`. Mount
133
+ * it on every path under one prefix, such as `/api/verily/*`. The authorize and return
134
+ * URLs registered with the instance are `<prefix>/authorize` and `<prefix>/return`,
135
+ * and the connect pill's `handoff-url` is `<prefix>/handoff`.
136
+ */
137
+ handler(site: SiteHandlerOptions): (request: Request) => Promise<Response>;
138
+ };
139
+ export type SiteClient = ReturnType<typeof createSiteClient>;