@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,239 @@
1
+ /** How many subjects the instance reads in one request. */
2
+ const maxReadIds = 50;
3
+ const encoder = new TextEncoder();
4
+ function encode(bytes) {
5
+ let binary = '';
6
+ for (const byte of bytes)
7
+ binary += String.fromCharCode(byte);
8
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
9
+ }
10
+ function decode(text) {
11
+ const binary = atob(text.replace(/-/g, '+').replace(/_/g, '/'));
12
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0));
13
+ }
14
+ const hmacKey = (secret) => crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, [
15
+ 'sign',
16
+ 'verify',
17
+ ]);
18
+ /** `base64url(payload) + "." + base64url(HMAC-SHA256(key, payload))`, the payload being JSON. */
19
+ async function sign(secret, payload) {
20
+ const bytes = encoder.encode(JSON.stringify(payload));
21
+ const mac = await crypto.subtle.sign('HMAC', await hmacKey(secret), bytes);
22
+ return `${encode(bytes)}.${encode(new Uint8Array(mac))}`;
23
+ }
24
+ const random = () => encode(crypto.getRandomValues(new Uint8Array(16)));
25
+ const seconds = () => Math.floor(Date.now() / 1000);
26
+ const unstored = { 'Cache-Control': 'no-store' };
27
+ /**
28
+ * A stable stand-in for one of a site's own user ids, for a site that would rather its ids
29
+ * never left it. The same secret and value always give the same result, so nothing has to
30
+ * be stored. The secret must never change: a different one gives every user a different
31
+ * stand-in, which the instance takes for a different user. Use a secret kept for this alone,
32
+ * and not the site's key, which is meant to be replaceable.
33
+ *
34
+ * `purpose` separates stand-ins made from one secret, so a private id and a public
35
+ * reference made from the same user id tell nobody they belong together.
36
+ */
37
+ export async function pseudonym(secret, value, purpose = 'id') {
38
+ if (secret.length < 32)
39
+ throw new Error('A pseudonym secret needs at least 32 characters');
40
+ const mac = await crypto.subtle.sign('HMAC', await hmacKey(secret), encoder.encode(`verily ${purpose}\n${value}`));
41
+ return encode(new Uint8Array(mac));
42
+ }
43
+ export function createSiteClient(options) {
44
+ const instance = new URL(options.instance);
45
+ if (instance.origin !== options.instance ||
46
+ (instance.protocol !== 'https:' &&
47
+ !(instance.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(instance.hostname))))
48
+ throw new Error('instance must be an HTTPS origin without a trailing slash');
49
+ if (!/^[a-z0-9-]{1,32}$/.test(options.site))
50
+ throw new Error('Invalid site id');
51
+ if (!/^[A-Za-z0-9_-]{43,}$/.test(options.key))
52
+ throw new Error('key must be the base64url key the instance holds for this site');
53
+ const request = options.fetch ?? fetch;
54
+ const idSecret = options.idSecret;
55
+ /** The id the instance knows a user by: the site's own, or its stand-in. */
56
+ const sent = async (id) => (idSecret ? pseudonym(idSecret, id) : id);
57
+ /** A subject as it goes into a token, common to both kinds of handoff. */
58
+ async function vouched(subject) {
59
+ const reference = subject.reference ??
60
+ (idSecret
61
+ ? `${options.site}-${(await pseudonym(idSecret, subject.id, 'reference')).slice(0, 10)}`
62
+ : undefined);
63
+ if (!reference)
64
+ throw new Error('A subject needs a reference, or the client an idSecret');
65
+ return {
66
+ id: await sent(subject.id),
67
+ ...(subject.kind ? { kind: subject.kind } : {}),
68
+ label: subject.label,
69
+ reference,
70
+ ...(subject.profileUrl ? { profileUrl: subject.profileUrl } : {}),
71
+ };
72
+ }
73
+ /** What was last read for each id sent, while `cacheMs` says it may be reused. */
74
+ const cached = new Map();
75
+ const client = {
76
+ /**
77
+ * Where to link a signed-in user: to verify an account, or to manage the ones they
78
+ * have. The instance sends them on to this site's authorize endpoint with a `state`.
79
+ */
80
+ beginUrl(purpose = 'connect') {
81
+ return `${instance.origin}/begin?${new URLSearchParams({ site: options.site, purpose })}`;
82
+ },
83
+ /**
84
+ * For the authorize endpoint: where to redirect a signed-in user, with a handoff that
85
+ * vouches for them. Call it only once this site has itself established who they are,
86
+ * with the `state` the instance sent. `txn` names this handoff in the result that
87
+ * comes back; a site that reads its connections from the instance can ignore it.
88
+ */
89
+ async authorize(state, subject) {
90
+ if (!state)
91
+ throw new Error('The state the instance sent is required');
92
+ const txn = random();
93
+ const token = await sign(options.key, {
94
+ site: options.site,
95
+ state,
96
+ ...(await vouched(subject)),
97
+ txn,
98
+ // Under the five minutes the instance allows, so a slow clock does not overrun it.
99
+ exp: seconds() + 240,
100
+ });
101
+ return { url: `${instance.origin}/start?${new URLSearchParams({ token })}`, txn };
102
+ },
103
+ /**
104
+ * For a page that shows the connect pill with `handoff-url`: a token that vouches for
105
+ * the signed-in user to the dialog on that page. Serve it from an endpoint on this
106
+ * site, to its signed-in user only and to a POST from this site's own pages, and call
107
+ * it only once this site has itself established who they are. It works once, within
108
+ * four minutes, and only from a page on this site's registered origin.
109
+ */
110
+ async handoff(subject) {
111
+ const txn = random();
112
+ const token = await sign(options.key, {
113
+ site: options.site,
114
+ op: 'dialog',
115
+ ...(await vouched(subject)),
116
+ txn,
117
+ exp: seconds() + 240,
118
+ });
119
+ return { token, txn };
120
+ },
121
+ /**
122
+ * Each subject's connections, unlisted ones included, by the id this site vouched for
123
+ * them with. Only this site can read them. Every id asked for is in the answer, with an
124
+ * empty list for a subject who has none. Revoked and expired records are included, so
125
+ * show one only when its `status` is `verified`. With `cacheMs` set, an answer read
126
+ * within it is reused unless `fresh` asks for a new one.
127
+ */
128
+ async connections(ids, { fresh = false } = {}) {
129
+ // No prototype, so an id such as `__proto__` is an entry like any other.
130
+ const answer = Object.create(null);
131
+ const found = Object.create(null);
132
+ const names = new Map();
133
+ const now = Date.now();
134
+ for (const id of new Set(ids))
135
+ names.set(id, await sent(id));
136
+ // An answer too old to reuse is dropped, or a client that lives as long as its
137
+ // server would keep every user it ever read.
138
+ for (const [id, held] of cached)
139
+ if (now - held.readAt >= (options.cacheMs ?? 0))
140
+ cached.delete(id);
141
+ const wanted = [...new Set(names.values())].filter((id) => {
142
+ const held = cached.get(id);
143
+ if (fresh || !held || now - held.readAt >= (options.cacheMs ?? 0))
144
+ return true;
145
+ found[id] = held.connections;
146
+ return false;
147
+ });
148
+ for (let at = 0; at < wanted.length; at += maxReadIds) {
149
+ const token = await sign(options.key, {
150
+ site: options.site,
151
+ op: 'read',
152
+ ids: wanted.slice(at, at + maxReadIds),
153
+ exp: seconds() + 60,
154
+ });
155
+ const response = await request(`${instance.origin}/site/connections`, {
156
+ headers: { Authorization: `Bearer ${token}` },
157
+ });
158
+ if (!response.ok)
159
+ throw new Error(`The instance answered ${response.status}`);
160
+ const { connections } = (await response.json());
161
+ for (const id of Object.keys(connections)) {
162
+ found[id] = connections[id];
163
+ if (options.cacheMs)
164
+ cached.set(id, { readAt: now, connections: found[id] });
165
+ }
166
+ }
167
+ for (const [id, name] of names)
168
+ answer[id] = found[name] ?? [];
169
+ return answer;
170
+ },
171
+ /**
172
+ * For the return endpoint: the result a user came back with, if the instance signed it
173
+ * for this site and it has not expired. Undefined otherwise. A site that reads its
174
+ * connections from the instance has no need of it and can send the user straight on.
175
+ *
176
+ * A site that stores what a result says must also check that `id` is the signed-in
177
+ * user and that `txn` is the handoff it is waiting on, since a user's own older result
178
+ * is validly signed too. With an `idSecret`, `id` is the user's stand-in.
179
+ */
180
+ async result(token) {
181
+ const [payload, mac, ...rest] = token.split('.');
182
+ if (!payload || !mac || rest.length)
183
+ return undefined;
184
+ try {
185
+ const bytes = decode(payload);
186
+ if (!(await crypto.subtle.verify('HMAC', await hmacKey(options.key), decode(mac), bytes)))
187
+ return undefined;
188
+ const result = JSON.parse(new TextDecoder().decode(bytes));
189
+ return result.site === options.site &&
190
+ typeof result.exp === 'number' &&
191
+ result.exp > seconds()
192
+ ? result
193
+ : undefined;
194
+ }
195
+ catch {
196
+ return undefined;
197
+ }
198
+ },
199
+ /**
200
+ * The endpoints a site needs, as one function from a `Request` to a `Response`. Mount
201
+ * it on every path under one prefix, such as `/api/verily/*`. The authorize and return
202
+ * URLs registered with the instance are `<prefix>/authorize` and `<prefix>/return`,
203
+ * and the connect pill's `handoff-url` is `<prefix>/handoff`.
204
+ */
205
+ handler(site) {
206
+ const go = (location) => new Response(null, { status: 303, headers: { Location: location, ...unstored } });
207
+ const refuse = (status) => new Response(null, { status, headers: unstored });
208
+ return async (request) => {
209
+ const url = new URL(request.url);
210
+ const route = `${request.method} ${url.pathname.split('/').pop()}`;
211
+ // Wherever the user went on the instance, this site reads their links from it.
212
+ if (route === 'GET return')
213
+ return go(site.returnUrl ?? '/');
214
+ if (route === 'GET authorize') {
215
+ const state = url.searchParams.get('state');
216
+ if (!state)
217
+ return refuse(400);
218
+ const subject = await site.authenticate(request);
219
+ if (!subject)
220
+ return site.signInUrl ? go(site.signInUrl) : refuse(401);
221
+ return go((await client.authorize(state, subject)).url);
222
+ }
223
+ if (route === 'POST handoff') {
224
+ // The token is for this site's own pages. A browser says where a request is from.
225
+ if ((request.headers.get('sec-fetch-site') ?? 'same-origin') !== 'same-origin')
226
+ return refuse(403);
227
+ const subject = await site.authenticate(request);
228
+ if (!subject)
229
+ return refuse(401);
230
+ return new Response(JSON.stringify({ token: (await client.handoff(subject)).token }), {
231
+ headers: { 'Content-Type': 'application/json', ...unstored },
232
+ });
233
+ }
234
+ return refuse(404);
235
+ };
236
+ },
237
+ };
238
+ return client;
239
+ }