@bkazemi/verily 0.7.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.
Files changed (48) hide show
  1. package/README.md +183 -94
  2. package/dist/browser/mark.d.ts +2 -6
  3. package/dist/browser/mark.js +6 -12
  4. package/dist/browser/provider-mark.d.ts +3 -3
  5. package/dist/browser/provider-mark.js +15 -90
  6. package/dist/instance/auth.d.ts +14 -0
  7. package/dist/instance/auth.js +80 -0
  8. package/dist/instance/bin.d.ts +2 -0
  9. package/dist/instance/bin.js +36 -0
  10. package/dist/instance/cloudflare.d.ts +57 -0
  11. package/dist/instance/cloudflare.js +71 -0
  12. package/dist/instance/env.d.ts +51 -0
  13. package/dist/instance/env.js +84 -0
  14. package/dist/instance/index.d.ts +136 -0
  15. package/dist/instance/index.js +777 -0
  16. package/dist/instance/init.d.ts +6 -0
  17. package/dist/instance/init.js +28 -0
  18. package/dist/instance/limits.d.ts +30 -0
  19. package/dist/instance/limits.js +45 -0
  20. package/dist/instance/node.d.ts +13 -0
  21. package/dist/instance/node.js +43 -0
  22. package/dist/instance/page.d.ts +18 -0
  23. package/dist/instance/page.js +30 -0
  24. package/dist/instance/postgres.d.ts +19 -0
  25. package/dist/instance/postgres.js +83 -0
  26. package/dist/instance/routes.d.ts +7 -0
  27. package/dist/instance/routes.js +22 -0
  28. package/dist/instance/serve.d.ts +31 -0
  29. package/dist/instance/serve.js +49 -0
  30. package/dist/instance/sites.d.ts +169 -0
  31. package/dist/instance/sites.js +413 -0
  32. package/dist/instance/starter.d.ts +6 -0
  33. package/dist/instance/starter.js +99 -0
  34. package/dist/instance/store.d.ts +20 -0
  35. package/dist/instance/store.js +11 -0
  36. package/dist/instance/undici.d.ts +8 -0
  37. package/dist/instance/undici.js +9 -0
  38. package/dist/marks.d.ts +44 -0
  39. package/dist/marks.js +98 -0
  40. package/dist/server/index.js +8 -56
  41. package/dist/server/render.d.ts +49 -0
  42. package/dist/server/render.js +104 -0
  43. package/dist/server/style.d.ts +1 -1
  44. package/dist/server/style.js +122 -1
  45. package/dist/verily.js +96 -104
  46. package/dist/version.d.ts +1 -1
  47. package/dist/version.js +1 -1
  48. package/package.json +30 -2
@@ -0,0 +1,413 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto';
2
+ import { expired, } from '../core/index.js';
3
+ import { hash, secret } from '../server/service.js';
4
+ /** How long a handoff may take, from `/handoff/request` to `/handoff/accept`, and how far ahead a token may run. */
5
+ export const handoffMs = 5 * 60000;
6
+ /** How long a site session lasts once the handoff lands. */
7
+ export const sessionMs = 3600000;
8
+ /** How long a result stays acceptable. A flow expires sooner, so every retry of one is covered. */
9
+ const resultMs = 10 * 60000;
10
+ /** How many subjects one read may name. */
11
+ const maxReadIds = 50;
12
+ const stateCookie = 'verily_handoff';
13
+ export const sessionCookie = 'verily_site';
14
+ /** A loopback origin may be plain HTTP, as the library allows its own base URL to be. */
15
+ function secureOrigin(value) {
16
+ const url = new URL(value);
17
+ return (url.origin === value &&
18
+ (url.protocol === 'https:' ||
19
+ (url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname))));
20
+ }
21
+ /**
22
+ * The registered sites, from the `SITES` variable, each with its key from the secret
23
+ * `SITE_<ID>_KEY`. Anything wrong in the list refuses to start rather than serving a site
24
+ * half set up. A site whose key is missing or malformed is left out instead.
25
+ */
26
+ export function registry(config, secrets) {
27
+ const list = typeof config === 'string' ? JSON.parse(config) : (config ?? []);
28
+ if (!Array.isArray(list))
29
+ throw new Error('SITES must be a list');
30
+ const sites = new Map();
31
+ for (const entry of list) {
32
+ const { id, name, origin, authorizeUrl, returnUrl, providers, visibility } = entry ?? {};
33
+ if (typeof id !== 'string' || !/^[a-z0-9-]{1,32}$/.test(id) || sites.has(id))
34
+ throw new Error('Each site needs a unique id of lowercase letters, digits and hyphens');
35
+ const key = secrets[`SITE_${id.toUpperCase().replace(/-/g, '_')}_KEY`];
36
+ if (typeof name !== 'string' || !name || name.length > 100)
37
+ throw new Error(`Site ${id} needs a name`);
38
+ if (typeof origin !== 'string' || !secureOrigin(origin))
39
+ throw new Error(`Site ${id} needs an HTTPS origin without a trailing slash`);
40
+ for (const url of [authorizeUrl, returnUrl])
41
+ if (typeof url !== 'string' || new URL(url).origin !== origin)
42
+ throw new Error(`Site ${id} must authorize and return on its own origin`);
43
+ if (providers !== undefined &&
44
+ (!Array.isArray(providers) ||
45
+ !providers.length ||
46
+ providers.some((p) => typeof p !== 'string' || !p)))
47
+ throw new Error(`Site ${id} must list at least one provider id, or none at all`);
48
+ if (visibility !== undefined &&
49
+ (!Array.isArray(visibility) ||
50
+ !visibility.length ||
51
+ visibility.some((v) => v !== 'public' && v !== 'unlisted')))
52
+ throw new Error(`Site ${id} must list "public", "unlisted" or both, or no visibility at all`);
53
+ // A secret is set apart from a deployment and cannot be checked before one. A wrong
54
+ // one leaves its own site unserved, and never takes the rest of the instance with it.
55
+ if (typeof key !== 'string' || !/^[A-Za-z0-9_-]{43,}$/.test(key)) {
56
+ console.error(`Site ${id} is not served: it needs a base64url key of at least 32 bytes`);
57
+ continue;
58
+ }
59
+ sites.set(id, {
60
+ id,
61
+ name,
62
+ origin,
63
+ authorizeUrl: authorizeUrl,
64
+ returnUrl: returnUrl,
65
+ key,
66
+ ...(providers ? { providers: providers } : {}),
67
+ ...(visibility ? { visibility: visibility } : {}),
68
+ });
69
+ }
70
+ return sites;
71
+ }
72
+ const mac = (key, payload) => createHmac('sha256', key).update(payload).digest();
73
+ /** `base64url(payload) + "." + base64url(HMAC-SHA256(key, payload))`, the payload being JSON. */
74
+ export function sign(key, payload) {
75
+ const bytes = Buffer.from(JSON.stringify(payload));
76
+ return `${bytes.toString('base64url')}.${mac(key, bytes).toString('base64url')}`;
77
+ }
78
+ /** The payload of a token, without checking it. Only good for choosing which key checks it. */
79
+ export function peek(token) {
80
+ try {
81
+ const value = JSON.parse(Buffer.from(token.split('.')[0], 'base64url').toString());
82
+ return value && typeof value === 'object' && !Array.isArray(value)
83
+ ? value
84
+ : undefined;
85
+ }
86
+ catch {
87
+ return undefined;
88
+ }
89
+ }
90
+ /** The payload of a token whose MAC verifies under `key`, compared in constant time. */
91
+ export function open(key, token) {
92
+ const parts = token.split('.');
93
+ if (parts.length !== 2)
94
+ return undefined;
95
+ const payload = Buffer.from(parts[0], 'base64url');
96
+ const given = Buffer.from(parts[1], 'base64url');
97
+ const expected = mac(key, payload);
98
+ if (given.length !== expected.length || !timingSafeEqual(given, expected))
99
+ return undefined;
100
+ return peek(token);
101
+ }
102
+ const cookie = (request, name) => request.headers
103
+ .get('cookie')
104
+ ?.split(';')
105
+ .map((part) => part.trim())
106
+ .find((part) => part.startsWith(`${name}=`))
107
+ ?.slice(name.length + 1);
108
+ const setCookie = (name, value, ms) => `${name}=${value}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${ms / 1000}`;
109
+ const string = (value, max = 500) => typeof value === 'string' && value.length > 0 && value.length <= max;
110
+ /**
111
+ * The subject a handoff vouches for, as the library will hold it, or undefined if the
112
+ * handoff describes one badly. `validate` is the library's own check of the subject, which
113
+ * the union of every site's origin would not make alone.
114
+ */
115
+ function subject(site, payload, validate) {
116
+ if (!string(payload.id, 400) ||
117
+ !string(payload.label) ||
118
+ !string(payload.reference) ||
119
+ (payload.kind !== undefined && !['account', 'page', 'site'].includes(String(payload.kind))) ||
120
+ (payload.profileUrl !== undefined &&
121
+ (typeof payload.profileUrl !== 'string' ||
122
+ !URL.canParse(payload.profileUrl) ||
123
+ new URL(payload.profileUrl).origin !== site.origin)))
124
+ return undefined;
125
+ try {
126
+ return validate({
127
+ id: `${site.id}:${payload.id}`,
128
+ ...(payload.kind ? { kind: payload.kind } : {}),
129
+ label: payload.label,
130
+ reference: payload.reference,
131
+ ...(payload.profileUrl ? { profileUrl: payload.profileUrl } : {}),
132
+ siteName: site.name,
133
+ });
134
+ }
135
+ catch {
136
+ return undefined;
137
+ }
138
+ }
139
+ /**
140
+ * Handoffs from registered sites, the sessions they open, and the results sent back. The
141
+ * site vouches for its signed-in user; this is where that is checked, and a handoff is
142
+ * bound to the browser that asked for it, the way OAuth binds a callback with `state`.
143
+ */
144
+ export class Sites {
145
+ storage;
146
+ sites;
147
+ constructor(storage, sites) {
148
+ this.storage = storage;
149
+ this.sites = sites;
150
+ }
151
+ /**
152
+ * Starts a handoff: a state stored with the site and purpose, set in this browser, and
153
+ * sent to the site to sign into its token. Returns where to go and the cookie to set.
154
+ */
155
+ async begin(site, purpose) {
156
+ const registered = this.sites.get(site ?? '');
157
+ if (!registered || !['connect', 'manage'].includes(purpose ?? ''))
158
+ return undefined;
159
+ const state = secret();
160
+ await this.storage.put(`site/state/${hash(state)}`, {
161
+ site: registered.id,
162
+ purpose: purpose,
163
+ expiresAt: Date.now() + handoffMs,
164
+ });
165
+ const location = new URL(registered.authorizeUrl);
166
+ location.searchParams.set('state', state);
167
+ return { location: location.href, cookie: setCookie(stateCookie, state, handoffMs) };
168
+ }
169
+ /**
170
+ * Redeems a signed handoff for a session, or refuses it. Every refusal is the same
171
+ * undefined, so a caller cannot tell which check failed. `validate` is the library's own
172
+ * check of the subject, which the union of every site's origin would not make alone.
173
+ */
174
+ async start(request, token, validate) {
175
+ const site = this.sites.get(String(peek(token)?.site ?? ''));
176
+ if (!site)
177
+ return undefined;
178
+ const payload = open(site.key, token);
179
+ const now = Date.now();
180
+ const held = cookie(request, stateCookie);
181
+ if (!payload ||
182
+ payload.site !== site.id ||
183
+ typeof payload.exp !== 'number' ||
184
+ payload.exp * 1000 <= now ||
185
+ payload.exp * 1000 > now + handoffMs ||
186
+ !string(payload.state) ||
187
+ !held ||
188
+ // The state this browser was given, which a browser sent someone else's link lacks.
189
+ !timingSafeEqual(Buffer.from(hash(held)), Buffer.from(hash(payload.state))) ||
190
+ !string(payload.txn))
191
+ return undefined;
192
+ const local = subject(site, payload, validate);
193
+ if (!local)
194
+ return undefined;
195
+ const session = secret();
196
+ const txn = payload.txn;
197
+ // The state is consumed with the session made, so the token and the state each work once.
198
+ const purpose = await this.storage.transaction(async (tx) => {
199
+ const key = `site/state/${hash(payload.state)}`;
200
+ const state = await tx.get(key);
201
+ if (!state || state.site !== site.id || expired(state, now))
202
+ return undefined;
203
+ await tx.delete(key);
204
+ await tx.put(`site/session/${hash(session)}`, {
205
+ site: site.id,
206
+ purpose: state.purpose,
207
+ txn,
208
+ local,
209
+ expiresAt: now + sessionMs,
210
+ });
211
+ return state.purpose;
212
+ });
213
+ if (!purpose)
214
+ return undefined;
215
+ return {
216
+ purpose,
217
+ cookies: [setCookie(sessionCookie, session, sessionMs), setCookie(stateCookie, '', 0)],
218
+ };
219
+ }
220
+ /**
221
+ * Redeems a handoff a site's own page presents, for the connect dialog on that page, and
222
+ * returns the session it opens. There is no redirect to bind with a state. The site's
223
+ * backend gave the token to its signed-in user's page, and a browser sends that page's
224
+ * origin with the request, so the token is marked for this use, taken only from the
225
+ * site's origin, and works once. The session is the same as a redirect opens, carried by
226
+ * the page in a header because the cookie would be another site's on that page.
227
+ *
228
+ * `admit` counts the handoff against the site that signed it, and is asked only once it
229
+ * has verified, before anything is stored: the request has no cookie to say whose it is,
230
+ * and a token that does not verify must spend nothing of the site it names. A handoff
231
+ * not admitted is 'limited', and is not used up.
232
+ */
233
+ async dialog(token, origin, validate, admit) {
234
+ const site = this.sites.get(String(peek(token)?.site ?? ''));
235
+ if (!site || origin !== site.origin)
236
+ return undefined;
237
+ const payload = open(site.key, token);
238
+ const now = Date.now();
239
+ if (!payload ||
240
+ payload.site !== site.id ||
241
+ payload.op !== 'dialog' ||
242
+ typeof payload.exp !== 'number' ||
243
+ payload.exp * 1000 <= now ||
244
+ payload.exp * 1000 > now + handoffMs ||
245
+ !string(payload.txn))
246
+ return undefined;
247
+ const local = subject(site, payload, validate);
248
+ if (!local)
249
+ return undefined;
250
+ if (!(await admit(site)))
251
+ return 'limited';
252
+ const session = secret();
253
+ const txn = payload.txn;
254
+ // Kept until the token would have expired anyway, so the token works once.
255
+ const used = `site/state/${hash(`dialog ${site.id} ${txn}`)}`;
256
+ const opened = await this.storage.transaction(async (tx) => {
257
+ if (await tx.get(used))
258
+ return false;
259
+ await tx.put(used, { site: site.id, purpose: 'connect', expiresAt: now + handoffMs });
260
+ await tx.put(`site/session/${hash(session)}`, {
261
+ site: site.id,
262
+ purpose: 'connect',
263
+ txn,
264
+ local,
265
+ expiresAt: now + sessionMs,
266
+ dialog: true,
267
+ });
268
+ return true;
269
+ });
270
+ return opened ? { site, session } : undefined;
271
+ }
272
+ /**
273
+ * Checks a site backend's request to read its own subjects' connections: a token under
274
+ * that site's key, marked as a read so that a handoff token, which passes through a
275
+ * browser, is never one. The ids are the site's own, as it sends them in a handoff, and
276
+ * are only ever looked up under its prefix, so no site reaches another's subjects.
277
+ */
278
+ read(token) {
279
+ const site = this.sites.get(String(peek(token)?.site ?? ''));
280
+ if (!site)
281
+ return undefined;
282
+ const payload = open(site.key, token);
283
+ const now = Date.now();
284
+ if (!payload ||
285
+ payload.site !== site.id ||
286
+ payload.op !== 'read' ||
287
+ typeof payload.exp !== 'number' ||
288
+ payload.exp * 1000 <= now ||
289
+ payload.exp * 1000 > now + handoffMs ||
290
+ !Array.isArray(payload.ids) ||
291
+ !payload.ids.length ||
292
+ payload.ids.length > maxReadIds ||
293
+ !payload.ids.every((id) => string(id, 400)))
294
+ return undefined;
295
+ return { site, ids: [...new Set(payload.ids)] };
296
+ }
297
+ /** This browser's site session, whether or not it has returned yet. */
298
+ async session(request) {
299
+ const token = cookie(request, sessionCookie);
300
+ if (!token)
301
+ return undefined;
302
+ const key = `site/session/${hash(token)}`;
303
+ const session = await this.storage.get(key);
304
+ if (!session || expired(session, Date.now()) || !this.sites.has(session.site))
305
+ return undefined;
306
+ return { key, session };
307
+ }
308
+ /**
309
+ * A session that can still start something: not yet returned, and not partway through a
310
+ * disconnect. A pending disconnect holds the session's one result, so nothing else may
311
+ * start that could end with another; only retrying that disconnect goes on.
312
+ */
313
+ async open(request) {
314
+ const found = await this.session(request);
315
+ return found && !found.session.closed && !found.session.operation ? found : undefined;
316
+ }
317
+ /**
318
+ * Where a flow that ended goes, as a function of the stored result alone: the same
319
+ * result signs the same token, since its expiry is counted from when the flow ended.
320
+ * Only the registry says where a site's holders return, and the first return closes the
321
+ * session to anything new.
322
+ */
323
+ async finish(flow, context, local, result) {
324
+ const site = this.sites.get(context?.site ?? '');
325
+ if (!site || !context?.txn || !local)
326
+ return undefined;
327
+ if (!['connect', 'renew', 'visibility'].includes(result.kind))
328
+ return undefined;
329
+ // The sign-in window of a dialog shows the flow's own result page and goes nowhere.
330
+ if (context.session?.startsWith('site/session/') &&
331
+ (await this.storage.get(context.session))?.dialog)
332
+ return undefined;
333
+ // Another flow's return, or a disconnect, already speaks for this transaction. This
334
+ // flow ended all the same, however it ended, and its holder sees the result page.
335
+ if (!(await this.claim(context.session, flow)))
336
+ return undefined;
337
+ return this.returnUrl(site, local, context.txn, {
338
+ operation: result.kind,
339
+ outcome: result.phase,
340
+ connection: result.connectionId,
341
+ visibility: result.visibility,
342
+ finishedAt: result.finishedAt,
343
+ });
344
+ }
345
+ /**
346
+ * Takes the session's one return for a flow, and closes the session with it. A site
347
+ * accepts one result for a transaction, so only one is ever signed: the first flow to end
348
+ * holds the return, every load of that flow holds it again, and no flow gets it once a
349
+ * disconnect is recorded, pending or done. Decided in one transaction, as `pending` is,
350
+ * so of a flow ending and a disconnect starting exactly one goes on.
351
+ */
352
+ async claim(key, flow) {
353
+ if (!key?.startsWith('site/session/'))
354
+ return false;
355
+ return this.storage.transaction(async (tx) => {
356
+ const session = await tx.get(key);
357
+ if (!session || expired(session, Date.now()) || session.operation)
358
+ return false;
359
+ if (session.returned)
360
+ return session.returned === flow;
361
+ await tx.put(key, { ...session, closed: true, returned: flow });
362
+ return true;
363
+ });
364
+ }
365
+ /** The signed result a holder carries back to their site. */
366
+ returnUrl(site, local, txn, result) {
367
+ if (!local.id.startsWith(`${site.id}:`))
368
+ throw new Error('Subject from another site');
369
+ const complete = result.outcome === 'complete';
370
+ const token = sign(site.key, {
371
+ site: site.id,
372
+ id: local.id.slice(site.id.length + 1),
373
+ operation: result.operation,
374
+ outcome: result.outcome,
375
+ ...(complete ? { connection: result.connection, visibility: result.visibility } : {}),
376
+ txn,
377
+ exp: Math.floor((result.finishedAt + resultMs) / 1000),
378
+ });
379
+ const url = new URL(site.returnUrl);
380
+ url.searchParams.set('result', token);
381
+ return url.href;
382
+ }
383
+ /**
384
+ * Records a disconnect or a mark as pending on the session before anything is changed, so
385
+ * a retry knows what it is finishing. A session already carrying one only resumes that one.
386
+ */
387
+ async pending(key, connection, visibility, mark) {
388
+ return this.storage.transaction(async (tx) => {
389
+ const session = await tx.get(key);
390
+ if (!session || expired(session, Date.now()))
391
+ return undefined;
392
+ if (session.operation)
393
+ return session.operation.connection === connection && session.operation.mark === mark
394
+ ? session
395
+ : undefined;
396
+ if (session.closed)
397
+ return undefined;
398
+ session.operation = { connection, visibility, ...(mark ? { mark } : {}) };
399
+ await tx.put(key, session);
400
+ return session;
401
+ });
402
+ }
403
+ /** Records an operation as done once its change has committed, and closes the session. */
404
+ async completed(key) {
405
+ return this.storage.transaction(async (tx) => {
406
+ const session = (await tx.get(key));
407
+ session.operation.finishedAt ??= Date.now();
408
+ session.closed = true;
409
+ await tx.put(key, session);
410
+ return session;
411
+ });
412
+ }
413
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * The files of a project that runs an instance on Cloudflare. All of the instance is in the
3
+ * package, so the project is its settings and one line that names the entry, and it takes
4
+ * a new release by updating the dependency.
5
+ */
6
+ export declare const cloudflareStarter: Record<string, string>;
@@ -0,0 +1,99 @@
1
+ import { version } from '../version.js';
2
+ /** The package as a project depends on it: this release, and later ones compatible with it. */
3
+ const release = `^${version.slice(1)}`;
4
+ const wrangler = `// Your instance's Cloudflare settings. Fill in account_id and the vars below.
5
+ //
6
+ // Secrets are never set here. Use \`npx wrangler secret put NAME\` for OWNER_KEY, the
7
+ // secret you sign in with: at least 32 random bytes, base64url, such as the output of
8
+ // node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"
9
+ //
10
+ // A link back from a GitHub profile, a DNS record and an OpenPGP signature are always
11
+ // offered. Each sign-in is offered once both of its secrets are set:
12
+ // GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET
13
+ // DISCORD_CLIENT_ID and DISCORD_CLIENT_SECRET
14
+ // GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET (YouTube)
15
+ // Each app needs <PUBLIC_ORIGIN>/callback as a redirect.
16
+ // Email is offered once the RESEND_API_KEY secret and EMAIL_FROM below are set.
17
+ {
18
+ "$schema": "node_modules/wrangler/config-schema.json",
19
+ "name": "verily",
20
+ "account_id": "YOUR_CLOUDFLARE_ACCOUNT_ID",
21
+ "main": "worker.ts",
22
+ "compatibility_date": "2026-07-01",
23
+ "compatibility_flags": ["nodejs_compat"],
24
+ // The library loads undici only on Node, so the Worker build gets a stand-in for it.
25
+ "alias": { "undici": "@bkazemi/verily/instance/undici" },
26
+
27
+ // Serve the backend from a host a reader can tie to the site making the
28
+ // claim. Keep workers_dev true until the custom domain answers, then set it
29
+ // false: the Worker rejects every origin except PUBLIC_ORIGIN, so only one
30
+ // of the two can serve at a time.
31
+ "workers_dev": true,
32
+ // "routes": [{ "pattern": "verily.example.org", "custom_domain": true }],
33
+ "preview_urls": false,
34
+ "observability": { "enabled": false },
35
+
36
+ // Keep the object name, binding and class identity stable across deploys:
37
+ // changing them abandons the stored connections.
38
+ "durable_objects": {
39
+ "bindings": [{ "name": "VERILY", "class_name": "VerilyStore" }],
40
+ },
41
+ "migrations": [{ "tag": "v1", "new_sqlite_classes": ["VerilyStore"] }],
42
+
43
+ "vars": {
44
+ // Drives baseUrl, every evidenceUrl and the verifier name. HTTPS, no
45
+ // trailing slash, and it must match the origin actually serving requests.
46
+ "PUBLIC_ORIGIN": "https://verily.example.org",
47
+ // The site whose local subject is being linked. This is a claim the
48
+ // evidence attributes to the site, not something the backend demonstrates.
49
+ "SITE_NAME": "example.org",
50
+ // What is linked: "account", "page" or "site". Omit for an account.
51
+ "OWNER_KIND": "site",
52
+ "OWNER_LABEL": "your-handle",
53
+ "OWNER_REFERENCE": "example.org author",
54
+ "OWNER_PROFILE_URL": "https://example.org/about/",
55
+ "REPORT_URL": "mailto:you@example.org",
56
+ // The sender of emailed codes, on a domain verified with Resend. Email is offered
57
+ // only when this and the RESEND_API_KEY secret are both set.
58
+ // "EMAIL_FROM": "Verily <verify@example.org>",
59
+ // Messages mailed in a day, across everyone. Omit for 100, Resend's free allowance.
60
+ // "EMAIL_DAILY_LIMIT": 100,
61
+ // Public records are signed with a key this installation makes and keeps. Set "off"
62
+ // to sign nothing, or set the SIGNING_KEY secret to supply the key yourself.
63
+ // "SIGNING": "off",
64
+ // Sites that send their users here instead of hosting Verily; each also needs
65
+ // its key as the secret SITE_<ID>_KEY.
66
+ // "SITES": [
67
+ // {
68
+ // "id": "partner",
69
+ // "name": "partner.example",
70
+ // "origin": "https://partner.example",
71
+ // "authorizeUrl": "https://partner.example/verily/authorize",
72
+ // "returnUrl": "https://partner.example/verily/return",
73
+ // // Optional: the provider ids its holders may use. Omit for all of them.
74
+ // "providers": ["discord", "youtube"],
75
+ // // Optional: the visibilities its holders may choose. Omit for both.
76
+ // "visibility": ["unlisted"],
77
+ // },
78
+ // ],
79
+ },
80
+ }
81
+ `;
82
+ /**
83
+ * The files of a project that runs an instance on Cloudflare. All of the instance is in the
84
+ * package, so the project is its settings and one line that names the entry, and it takes
85
+ * a new release by updating the dependency.
86
+ */
87
+ export const cloudflareStarter = {
88
+ 'package.json': `${JSON.stringify({
89
+ name: 'verily-instance',
90
+ private: true,
91
+ type: 'module',
92
+ scripts: { dev: 'wrangler dev --local', deploy: 'wrangler deploy' },
93
+ dependencies: { '@bkazemi/verily': release },
94
+ devDependencies: { wrangler: '^4.132.0' },
95
+ }, null, 2)}\n`,
96
+ 'worker.ts': "export { default, VerilyStore } from '@bkazemi/verily/instance/cloudflare';\n",
97
+ 'wrangler.jsonc': wrangler,
98
+ '.gitignore': 'node_modules\n.wrangler\n.dev.vars\n',
99
+ };
@@ -0,0 +1,20 @@
1
+ import type { Storage } from '../core/index.js';
2
+ /** Reads and writes values by key. Values are plain JSON. */
3
+ export interface KeyValue {
4
+ get<T>(key: string): Promise<T | undefined>;
5
+ put<T>(key: string, value: T): Promise<void>;
6
+ delete(key: string): Promise<void>;
7
+ /** Every entry whose key starts with `prefix`. */
8
+ list<T>(prefix: string): Promise<[string, T][]>;
9
+ }
10
+ /**
11
+ * Where an instance keeps everything: the library's records and its own data, such as
12
+ * owner sessions, site sessions, handoffs and rate limits. A host supplies one of these.
13
+ *
14
+ * `transaction` must run one at a time and write nothing if `work` rejects.
15
+ */
16
+ export interface Store extends KeyValue {
17
+ transaction<T>(work: (tx: KeyValue) => Promise<T>): Promise<T>;
18
+ }
19
+ /** The library's record storage, kept in a store under `verily/<kind>/<id>`. */
20
+ export declare function recordStorage(store: Store): Storage;
@@ -0,0 +1,11 @@
1
+ /** The library's record storage, kept in a store under `verily/<kind>/<id>`. */
2
+ export function recordStorage(store) {
3
+ return {
4
+ transaction: (work) => store.transaction((tx) => work({
5
+ get: (kind, id) => tx.get(`verily/${kind}/${id}`),
6
+ put: (kind, id, value) => tx.put(`verily/${kind}/${id}`, value),
7
+ delete: (kind, id) => tx.delete(`verily/${kind}/${id}`),
8
+ list: async (kind) => (await tx.list(`verily/${kind}/`)).map(([, value]) => value),
9
+ })),
10
+ };
11
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Stands in for undici in the Worker build. Only `publicFetch()` loads it, and only on
3
+ * Node, so on Workers the real one would be a megabyte of code that can never run.
4
+ */
5
+ declare function unavailable(): never;
6
+ export declare const Agent: typeof unavailable;
7
+ export declare const fetch: typeof unavailable;
8
+ export {};
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Stands in for undici in the Worker build. Only `publicFetch()` loads it, and only on
3
+ * Node, so on Workers the real one would be a megabyte of code that can never run.
4
+ */
5
+ function unavailable() {
6
+ throw new Error('undici is not available on Workers');
7
+ }
8
+ export const Agent = unavailable;
9
+ export const fetch = unavailable;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The provider marks, as data, so the badge script and the server's pages draw the same
3
+ * ones. Filled marks are in the text colour; the ones drawn here are rounded strokes.
4
+ */
5
+ export interface Mark {
6
+ viewBox: string;
7
+ paths: string[];
8
+ /** Drawn as strokes rather than filled. */
9
+ stroke?: boolean;
10
+ }
11
+ /**
12
+ * The mark for a provider, or nothing where there is none. Returning nothing rather than
13
+ * the provider's name keeps this to one job: a caller that also writes the name would
14
+ * otherwise print it twice, and only the caller knows where the name belongs.
15
+ */
16
+ export declare function markOf(provider: string, method?: string): Mark | undefined;
17
+ /** The attributes every path of a mark gets, as name and value pairs. */
18
+ export declare const pathAttributes: (mark: Mark) => [string, string][];
19
+ /**
20
+ * How sure the verification mark is: `current` for a verification that holds, `inactive`
21
+ * for one that does not, and `pending` for one that claims nothing, such as a retired
22
+ * account, which is drawn in the surrounding text colour.
23
+ */
24
+ export type MarkTone = 'current' | 'inactive' | 'pending';
25
+ /** The V's two strokes in each tone: the V, then the check drawn inside it. */
26
+ export declare const tones: Record<MarkTone, [string, string]>;
27
+ /** The tone a record's mark is drawn in. A retired account claims nothing, not a failure. */
28
+ export declare function toneOf(evidence: {
29
+ status: string;
30
+ expiresAt: number;
31
+ }, now: number): MarkTone;
32
+ /** V2: the green check fills its section of the symmetric red V. */
33
+ export declare const verification: {
34
+ viewBox: string;
35
+ /** The box around the strokes alone: the arms' squared ends and the mitred foot. */
36
+ strokes: string;
37
+ path: string;
38
+ /** The check's stretch of the V, as a dash along a path measured as 176 long. */
39
+ check: {
40
+ pathLength: string;
41
+ 'stroke-dasharray': string;
42
+ 'stroke-dashoffset': string;
43
+ };
44
+ };