@bkazemi/verily 0.7.0 → 0.8.1

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 +46 -67
  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 +145 -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
@@ -1,9 +1,5 @@
1
- /**
2
- * How sure the mark is: `current` for a verification that holds, `inactive` for one that
3
- * does not, and `pending` for one nobody has confirmed yet, which claims nothing and is
4
- * drawn in the surrounding text colour.
5
- */
6
- export type MarkTone = 'current' | 'inactive' | 'pending';
1
+ import { type MarkTone } from '../marks.js';
2
+ export type { MarkTone };
7
3
  /**
8
4
  * The check draws itself in from its left end once the verification holds: when a pill's
9
5
  * mark first becomes `current`, and each time the dialog opens on a current one.
@@ -1,8 +1,4 @@
1
- const tones = {
2
- current: ['#D3444C', '#149766'],
3
- inactive: ['#149766', '#D3444C'],
4
- pending: ['currentColor', 'currentColor'],
5
- };
1
+ import { tones, verification } from '../marks.js';
6
2
  /**
7
3
  * The check draws itself in from its left end once the verification holds: when a pill's
8
4
  * mark first becomes `current`, and each time the dialog opens on a current one.
@@ -24,20 +20,18 @@ export function paintMark(svg, tone) {
24
20
  export function verificationMark(tone = 'current') {
25
21
  const namespace = 'http://www.w3.org/2000/svg';
26
22
  const svg = document.createElementNS(namespace, 'svg');
27
- svg.setAttribute('viewBox', '-4 -4 264 264');
23
+ svg.setAttribute('viewBox', verification.viewBox);
28
24
  svg.setAttribute('fill', 'none');
29
25
  svg.setAttribute('aria-hidden', 'true');
30
26
  svg.setAttribute('focusable', 'false');
31
27
  for (const layer of [0, 1]) {
32
28
  const path = document.createElementNS(namespace, 'path');
33
- path.setAttribute('d', 'M40 36 128 220 216 36');
29
+ path.setAttribute('d', verification.path);
34
30
  path.setAttribute('stroke-width', '32');
35
31
  path.setAttribute('stroke-linejoin', 'miter');
36
- if (layer === 1) {
37
- path.setAttribute('pathLength', '176');
38
- path.setAttribute('stroke-dasharray', '108 176');
39
- path.setAttribute('stroke-dashoffset', '-54');
40
- }
32
+ if (layer === 1)
33
+ for (const [name, value] of Object.entries(verification.check))
34
+ path.setAttribute(name, value);
41
35
  svg.append(path);
42
36
  }
43
37
  paintMark(svg, tone);
@@ -1,6 +1,6 @@
1
1
  /**
2
- * The mark for a provider, or nothing where there is none. Returning nothing rather than
3
- * the provider's name keeps this to one job: a caller that also writes the name would
4
- * otherwise print it twice, and only the caller knows where the name belongs.
2
+ * The mark for a provider as an element, or nothing where there is none. Returning nothing
3
+ * rather than the provider's name keeps this to one job: a caller that also writes the
4
+ * name would otherwise print it twice, and only the caller knows where the name belongs.
5
5
  */
6
6
  export declare function providerMark(provider: string, method?: string): SVGSVGElement | undefined;
@@ -1,101 +1,26 @@
1
+ import { markOf, pathAttributes } from '../marks.js';
1
2
  const namespace = 'http://www.w3.org/2000/svg';
2
- function mark() {
3
+ /**
4
+ * The mark for a provider as an element, or nothing where there is none. Returning nothing
5
+ * rather than the provider's name keeps this to one job: a caller that also writes the
6
+ * name would otherwise print it twice, and only the caller knows where the name belongs.
7
+ */
8
+ export function providerMark(provider, method) {
9
+ const mark = markOf(provider, method);
10
+ if (!mark)
11
+ return undefined;
3
12
  const svg = document.createElementNS(namespace, 'svg');
4
- svg.setAttribute('viewBox', '0 0 16 16');
13
+ svg.setAttribute('viewBox', mark.viewBox);
5
14
  svg.setAttribute('class', 'provider');
6
15
  svg.setAttribute('aria-hidden', 'true');
7
16
  svg.setAttribute('focusable', 'false');
8
- return svg;
9
- }
10
- /** GitHub mark from Primer Octicons (MIT); license in docs/licenses/octicons.txt. */
11
- function github() {
12
- const svg = mark();
13
- const path = document.createElementNS(namespace, 'path');
14
- svg.setAttribute('fill', 'currentColor');
15
- path.setAttribute('d', 'M6.766 11.328c-2.063-.25-3.516-1.734-3.516-3.656 0-.781.281-1.625.75-2.188-.203-.515-.172-1.609.063-2.062.625-.078 1.468.25 1.968.703.594-.187 1.219-.281 1.985-.281.765 0 1.39.094 1.953.265.484-.437 1.344-.765 1.969-.687.218.422.25 1.515.046 2.047.5.593.766 1.39.766 2.203 0 1.922-1.453 3.375-3.547 3.64.531.344.89 1.094.89 1.954v1.625c0 .468.391.734.86.547C13.781 14.359 16 11.53 16 8.03 16 3.61 12.406 0 7.984 0 3.563 0 0 3.61 0 8.031a7.88 7.88 0 0 0 5.172 7.422c.422.156.828-.125.828-.547v-1.25c-.219.094-.5.156-.75.156-1.031 0-1.64-.562-2.078-1.609-.172-.422-.36-.672-.719-.719-.187-.015-.25-.093-.25-.187 0-.188.313-.328.625-.328.453 0 .844.281 1.25.86.313.452.64.655 1.031.655s.641-.14 1-.5c.266-.265.47-.5.657-.656');
16
- svg.append(path);
17
- return svg;
18
- }
19
- /**
20
- * Discord mark from Simple Icons (CC0); notice in docs/licenses/simple-icons.txt. The mark
21
- * itself is Discord's trademark, used here only to say an account is a Discord account.
22
- */
23
- function discord() {
24
- const svg = mark();
25
- const path = document.createElementNS(namespace, 'path');
26
- svg.setAttribute('viewBox', '0 0 24 24');
27
- svg.setAttribute('fill', 'currentColor');
28
- path.setAttribute('d', 'M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z');
29
- svg.append(path);
30
- return svg;
31
- }
32
- /** YouTube mark from Simple Icons (CC0); notice in docs/licenses/simple-icons.txt. */
33
- function youtube() {
34
- const svg = mark();
35
- const path = document.createElementNS(namespace, 'path');
36
- svg.setAttribute('viewBox', '0 0 24 24');
37
- svg.setAttribute('fill', 'currentColor');
38
- path.setAttribute('d', 'M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z');
39
- svg.append(path);
40
- return svg;
41
- }
42
- /**
43
- * A key, drawn here rather than taken from anywhere. It is not the OpenPGP logo: that mark
44
- * is somebody's to license and this repository does not ship artwork it cannot account for.
45
- * A key is also the truer picture, since what was proved is control of one, not membership
46
- * of an organisation.
47
- */
48
- function key() {
49
- return lines([
50
- 'M7.4 8.6a3.3 3.3 0 1 0-4.7 4.7 3.3 3.3 0 0 0 4.7-4.7Z',
51
- 'M7.4 8.6 14 2',
52
- 'M11.2 4.8l1.6 1.6',
53
- 'M9.4 6.6l1.6 1.6',
54
- ]);
55
- }
56
- /** A globe, drawn here like the key, for a page or a domain on the open web. */
57
- function globe() {
58
- return lines([
59
- 'M8 1.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13Z',
60
- 'M1.5 8h13',
61
- 'M8 1.5C6.1 3.3 5.1 5.5 5.1 8s1 4.7 2.9 6.5',
62
- 'M8 1.5c1.9 1.8 2.9 4 2.9 6.5s-1 4.7-2.9 6.5',
63
- ]);
64
- }
65
- /** An envelope, drawn here like the key, for a mailbox that read a code sent to it. */
66
- function envelope() {
67
- return lines(['M2 3.5h12v9H2z', 'M2.4 4l5.6 4.6L13.6 4']);
68
- }
69
- /** A mark drawn as rounded strokes in the text colour. */
70
- function lines(paths) {
71
- const svg = mark();
72
- svg.setAttribute('fill', 'none');
73
- for (const d of paths) {
17
+ svg.setAttribute('fill', mark.stroke ? 'none' : 'currentColor');
18
+ for (const d of mark.paths) {
74
19
  const path = document.createElementNS(namespace, 'path');
75
20
  path.setAttribute('d', d);
76
- path.setAttribute('stroke', 'currentColor');
77
- path.setAttribute('stroke-width', '1.6');
78
- path.setAttribute('stroke-linecap', 'round');
79
- path.setAttribute('stroke-linejoin', 'round');
21
+ for (const [name, value] of pathAttributes(mark))
22
+ path.setAttribute(name, value);
80
23
  svg.append(path);
81
24
  }
82
25
  return svg;
83
26
  }
84
- /**
85
- * The mark for a provider, or nothing where there is none. Returning nothing rather than
86
- * the provider's name keeps this to one job: a caller that also writes the name would
87
- * otherwise print it twice, and only the caller knows where the name belongs.
88
- */
89
- export function providerMark(provider, method) {
90
- const marks = {
91
- github,
92
- discord,
93
- youtube,
94
- openpgp: key,
95
- email: envelope,
96
- };
97
- // A site names its own link-back providers, so those fall back on the method's mark,
98
- // which a domain shown by its DNS or by a file shares.
99
- return (marks[provider]?.() ??
100
- (['backlink', 'dns', 'wellknown'].includes(method ?? '') ? globe() : undefined));
101
- }
@@ -0,0 +1,14 @@
1
+ import type { Store } from './store.js';
2
+ export declare class OwnerAuth {
3
+ private readonly storage;
4
+ private readonly ownerKey;
5
+ constructor(storage: Store, ownerKey: string);
6
+ private token;
7
+ authenticated(request: Request): Promise<boolean>;
8
+ /** Which owner session a request belongs to, by the hash its record is stored under. */
9
+ session(request: Request): Promise<string | undefined>;
10
+ login(key: string): Promise<string | undefined>;
11
+ logout(request: Request): Promise<string>;
12
+ allow(bucket: 'login', limit: number, windowMs: number): Promise<boolean>;
13
+ prune(): Promise<void>;
14
+ }
@@ -0,0 +1,80 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import { expired } from '../core/index.js';
3
+ import { hash, secret } from '../server/service.js';
4
+ const sessionMs = 8 * 3600000;
5
+ // Use a normal host-only cookie name. Secure, HttpOnly, SameSite and Path=/
6
+ // provide the relevant protections while avoiding __Host-prefix rejection by
7
+ // browsers or privacy extensions on workers.dev.
8
+ const cookieName = 'verily_owner';
9
+ export class OwnerAuth {
10
+ storage;
11
+ ownerKey;
12
+ constructor(storage, ownerKey) {
13
+ this.storage = storage;
14
+ this.ownerKey = ownerKey;
15
+ if (!/^[A-Za-z0-9_-]{43,}$/.test(ownerKey))
16
+ throw new Error('OWNER_KEY must be a randomly generated base64url secret of at least 32 bytes');
17
+ }
18
+ token(request) {
19
+ return request.headers
20
+ .get('cookie')
21
+ ?.split(';')
22
+ .map((part) => part.trim())
23
+ .find((part) => part.startsWith(`${cookieName}=`))
24
+ ?.slice(cookieName.length + 1);
25
+ }
26
+ async authenticated(request) {
27
+ return (await this.session(request)) !== undefined;
28
+ }
29
+ /** Which owner session a request belongs to, by the hash its record is stored under. */
30
+ async session(request) {
31
+ const token = this.token(request);
32
+ if (!token)
33
+ return undefined;
34
+ const session = await this.storage.get(`owner/session/${hash(token)}`);
35
+ return !!session && session.expiresAt > Date.now() && session.keyHash === hash(this.ownerKey)
36
+ ? hash(token)
37
+ : undefined;
38
+ }
39
+ async login(key) {
40
+ if (!timingSafeEqual(Buffer.from(hash(key)), Buffer.from(hash(this.ownerKey))))
41
+ return;
42
+ const token = secret();
43
+ await this.storage.delete('owner/rate/v2/login');
44
+ await this.storage.put(`owner/session/${hash(token)}`, {
45
+ expiresAt: Date.now() + sessionMs,
46
+ keyHash: hash(this.ownerKey),
47
+ });
48
+ return `${cookieName}=${token}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=${sessionMs / 1000}`;
49
+ }
50
+ async logout(request) {
51
+ const token = this.token(request);
52
+ if (token)
53
+ await this.storage.delete(`owner/session/${hash(token)}`);
54
+ return `${cookieName}=; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=0`;
55
+ }
56
+ async allow(bucket, limit, windowMs) {
57
+ return this.storage.transaction(async (tx) => {
58
+ const key = `owner/rate/v2/${bucket}`;
59
+ const previous = await tx.get(key);
60
+ const next = previous && previous.until > Date.now()
61
+ ? previous
62
+ : { count: 0, until: Date.now() + windowMs };
63
+ if (next.count >= limit)
64
+ return false;
65
+ next.count++;
66
+ await tx.put(key, next);
67
+ return true;
68
+ });
69
+ }
70
+ async prune() {
71
+ // One counter for every POST, from before requests were limited per session and client.
72
+ await this.storage.delete('owner/rate/v2/mutation');
73
+ await this.storage.transaction(async (tx) => {
74
+ for (const [key, session] of await tx.list('owner/session/')) {
75
+ if (expired(session, Date.now()) || session.keyHash !== hash(this.ownerKey))
76
+ await tx.delete(key);
77
+ }
78
+ });
79
+ }
80
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import { serve } from './serve.js';
3
+ import { init } from './init.js';
4
+ const usage = `Usage:
5
+ verily serve Run an instance on Node, from the environment.
6
+ verily init cloudflare [dir] Write a Cloudflare project into dir.`;
7
+ /** Writes the starter, then says what to do with it. */
8
+ async function start(directory) {
9
+ await init(directory);
10
+ console.log(`Wrote a Cloudflare project to ${directory}
11
+
12
+ Next, in that directory:
13
+ 1. npm install
14
+ 2. Fill in account_id and the vars in wrangler.jsonc.
15
+ 3. npx wrangler secret put OWNER_KEY
16
+ 4. npm run deploy`);
17
+ }
18
+ const [command, target, directory] = process.argv.slice(2);
19
+ try {
20
+ if (command === 'serve') {
21
+ const { server } = await serve(process.env);
22
+ const { address, port } = server.address();
23
+ console.log(`Verily instance for ${process.env.PUBLIC_ORIGIN} listening on ${address}:${port}`);
24
+ }
25
+ else if (command === 'init' && target === 'cloudflare') {
26
+ await start(directory ?? '.');
27
+ }
28
+ else {
29
+ console.error(usage);
30
+ process.exit(command === undefined || command === '--help' || command === '-h' ? 0 : 2);
31
+ }
32
+ }
33
+ catch (error) {
34
+ console.error(error instanceof Error ? error.message : error);
35
+ process.exit(1);
36
+ }
@@ -0,0 +1,57 @@
1
+ import type { Storage } from '../core/index.js';
2
+ import { type Instance } from './index.js';
3
+ import { type InstanceEnv } from './env.js';
4
+ import { type Store } from './store.js';
5
+ /**
6
+ * The parts of Cloudflare's own types this file uses, written out here so the package
7
+ * needs none of Cloudflare's to be installed. A Durable Object's real storage, state and
8
+ * namespace each fit the one below.
9
+ */
10
+ interface ObjectKeyValue {
11
+ get<T>(key: string): Promise<T | undefined>;
12
+ put<T>(key: string, value: T): Promise<void>;
13
+ delete(key: string): Promise<boolean>;
14
+ list<T>(options: {
15
+ prefix: string;
16
+ }): Promise<Map<string, T>>;
17
+ }
18
+ export interface ObjectStorage extends ObjectKeyValue {
19
+ transaction<T>(work: (tx: ObjectKeyValue) => Promise<T>): Promise<T>;
20
+ getAlarm(): Promise<number | null>;
21
+ setAlarm(time: number): Promise<void>;
22
+ }
23
+ export interface ObjectState {
24
+ storage: ObjectStorage;
25
+ blockConcurrencyWhile<T>(work: () => Promise<T>): Promise<T>;
26
+ }
27
+ interface ObjectNamespace {
28
+ idFromName(name: string): unknown;
29
+ get(id: never): {
30
+ fetch(url: string, init: RequestInit): Promise<Response>;
31
+ };
32
+ }
33
+ /** The instance's settings, as vars and secrets, and the binding of the object that runs it. */
34
+ export interface Env extends InstanceEnv {
35
+ VERILY: ObjectNamespace;
36
+ }
37
+ /** A Durable Object's storage as an instance store. It is SQLite-backed, not Workers KV. */
38
+ export declare function cloudflareStore(storage: ObjectStorage): Store;
39
+ /** The library's record storage on a Durable Object, for a Worker that embeds the backend. */
40
+ export declare class CloudflareStorage implements Storage {
41
+ private readonly records;
42
+ constructor(storage: ObjectStorage);
43
+ transaction: Storage['transaction'];
44
+ }
45
+ /** The Worker: turns away what needs no storage, and passes the rest to the one object. */
46
+ declare const _default: {
47
+ fetch(request: Request, env: Env): Promise<Response>;
48
+ };
49
+ export default _default;
50
+ /** The instance on a Durable Object: its storage, an hourly alarm, and settings from `env`. */
51
+ export declare class VerilyStore {
52
+ private readonly ctx;
53
+ protected readonly instance: Instance;
54
+ constructor(ctx: ObjectState, env: Env);
55
+ alarm(): Promise<void>;
56
+ fetch(request: Request): Promise<Response>;
57
+ }
@@ -0,0 +1,71 @@
1
+ import { admit, createInstance } from './index.js';
2
+ import { fromEnv } from './env.js';
3
+ import { html } from './page.js';
4
+ import { recordStorage } from './store.js';
5
+ /** Values are copied on the way in and out, so a caller never shares one with storage. */
6
+ const keyValue = (storage) => ({
7
+ get: async (key) => structuredClone(await storage.get(key)),
8
+ put: (key, value) => storage.put(key, structuredClone(value)),
9
+ delete: async (key) => {
10
+ await storage.delete(key);
11
+ },
12
+ list: async (prefix) => [...(await storage.list({ prefix })).entries()].map(([key, value]) => [key, structuredClone(value)]),
13
+ });
14
+ /** A Durable Object's storage as an instance store. It is SQLite-backed, not Workers KV. */
15
+ export function cloudflareStore(storage) {
16
+ return {
17
+ ...keyValue(storage),
18
+ transaction: (work) => storage.transaction((tx) => work(keyValue(tx))),
19
+ };
20
+ }
21
+ /** The library's record storage on a Durable Object, for a Worker that embeds the backend. */
22
+ export class CloudflareStorage {
23
+ records;
24
+ constructor(storage) {
25
+ this.records = recordStorage(cloudflareStore(storage));
26
+ }
27
+ transaction = (work) => this.records.transaction(work);
28
+ }
29
+ const hour = 3600000;
30
+ /** The Worker: turns away what needs no storage, and passes the rest to the one object. */
31
+ export default {
32
+ async fetch(request, env) {
33
+ // Turned away here, a request never wakes the object.
34
+ const admitted = await admit(request, env.PUBLIC_ORIGIN);
35
+ if (admitted instanceof Response)
36
+ return admitted;
37
+ try {
38
+ // One installation, with an identity that survives deployments and hostname changes.
39
+ return await env.VERILY.get(env.VERILY.idFromName('site-owner')).fetch(admitted.url, {
40
+ method: admitted.method,
41
+ headers: Object.fromEntries(admitted.headers),
42
+ body: admitted.method === 'POST' ? await admitted.arrayBuffer() : undefined,
43
+ redirect: 'manual',
44
+ });
45
+ }
46
+ catch {
47
+ return html('Temporarily unavailable', '', 503);
48
+ }
49
+ },
50
+ };
51
+ /** The instance on a Durable Object: its storage, an hourly alarm, and settings from `env`. */
52
+ export class VerilyStore {
53
+ ctx;
54
+ instance;
55
+ constructor(ctx, env) {
56
+ this.ctx = ctx;
57
+ this.instance = createInstance({ ...fromEnv(env), store: cloudflareStore(ctx.storage) });
58
+ ctx.blockConcurrencyWhile(async () => {
59
+ if ((await ctx.storage.getAlarm()) === null)
60
+ await ctx.storage.setAlarm(Date.now() + hour);
61
+ });
62
+ }
63
+ async alarm() {
64
+ // Schedule first so transient cleanup failures never permanently stop maintenance.
65
+ await this.ctx.storage.setAlarm(Date.now() + hour);
66
+ await this.instance.upkeep();
67
+ }
68
+ fetch(request) {
69
+ return this.instance.handle(request, request.headers.get('cf-connecting-ip'));
70
+ }
71
+ }
@@ -0,0 +1,51 @@
1
+ import type { LocalKind } from '../core/index.js';
2
+ import type { InstanceOptions } from './index.js';
3
+ /** The settings an instance reads, under the same names on every host. */
4
+ export interface InstanceEnv {
5
+ /** The origin the instance is served from. HTTPS, with no trailing slash. */
6
+ PUBLIC_ORIGIN: string;
7
+ SITE_NAME: string;
8
+ /** What the owner links: one of their accounts, a page, or the site. Defaults to account. */
9
+ OWNER_KIND?: LocalKind;
10
+ OWNER_LABEL: string;
11
+ OWNER_REFERENCE: string;
12
+ OWNER_PROFILE_URL: string;
13
+ /** The secret the owner signs in with: at least 32 random bytes, base64url. */
14
+ OWNER_KEY: string;
15
+ REPORT_URL: string;
16
+ /** GitHub sign-in is offered only when both are set. */
17
+ GITHUB_CLIENT_ID?: string;
18
+ GITHUB_CLIENT_SECRET?: string;
19
+ /** Discord sign-in is offered only when both are set. */
20
+ DISCORD_CLIENT_ID?: string;
21
+ DISCORD_CLIENT_SECRET?: string;
22
+ /** YouTube sign-in is offered only when both are set. */
23
+ GOOGLE_CLIENT_ID?: string;
24
+ GOOGLE_CLIENT_SECRET?: string;
25
+ /**
26
+ * Email is offered only when both are set: a Resend API key, and the sender its codes
27
+ * come from, as `Name <address>` or an address on a domain verified with Resend.
28
+ */
29
+ RESEND_API_KEY?: string;
30
+ EMAIL_FROM?: string;
31
+ /** How many codes may be mailed in a day, where the mail allowance is not the default. */
32
+ EMAIL_DAILY_LIMIT?: string | number;
33
+ /**
34
+ * Public records are signed unless this is `off`. The key is `SIGNING_KEY` where one is
35
+ * set, armored. Otherwise one is made on first use and kept in the store.
36
+ */
37
+ SIGNING?: string;
38
+ SIGNING_KEY?: string;
39
+ /**
40
+ * The sites that send their users here instead of hosting Verily, as a JSON list of
41
+ * `{ id, name, origin, authorizeUrl, returnUrl }`. Each needs its key in
42
+ * `SITE_<ID>_KEY`, such as `SITE_PARTNER_KEY`.
43
+ */
44
+ SITES?: string | unknown[];
45
+ [key: `SITE_${string}_KEY`]: string | undefined;
46
+ }
47
+ /**
48
+ * An instance's options from its settings. Every host builds them here, so two hosts given
49
+ * the same settings offer the same methods and behave the same. The host adds the store.
50
+ */
51
+ export declare function fromEnv(env: InstanceEnv): Omit<InstanceOptions, 'store'>;
@@ -0,0 +1,84 @@
1
+ import { discordProvider, dnsProvider, emailProvider, githubLinkProvider, githubProvider, pgpProvider, resendSender, youtubeProvider, } from '../server/index.js';
2
+ import { registry } from './sites.js';
3
+ /** The configured count of mailed messages a day, if it is a count at all. */
4
+ function dailyMail(configured) {
5
+ const count = Number(configured);
6
+ return Number.isSafeInteger(count) && count > 0 ? count : undefined;
7
+ }
8
+ /**
9
+ * An instance's options from its settings. Every host builds them here, so two hosts given
10
+ * the same settings offer the same methods and behave the same. The host adds the store.
11
+ */
12
+ export function fromEnv(env) {
13
+ for (const name of [
14
+ 'PUBLIC_ORIGIN',
15
+ 'SITE_NAME',
16
+ 'OWNER_LABEL',
17
+ 'OWNER_REFERENCE',
18
+ 'OWNER_PROFILE_URL',
19
+ 'OWNER_KEY',
20
+ 'REPORT_URL',
21
+ ])
22
+ if (!env[name])
23
+ throw new Error(`${name} must be set`);
24
+ return {
25
+ origin: env.PUBLIC_ORIGIN,
26
+ siteName: env.SITE_NAME,
27
+ owner: {
28
+ // A site with no user accounts must not have its subject described as one.
29
+ kind: env.OWNER_KIND || undefined,
30
+ label: env.OWNER_LABEL,
31
+ reference: env.OWNER_REFERENCE,
32
+ profileUrl: env.OWNER_PROFILE_URL,
33
+ },
34
+ ownerKey: env.OWNER_KEY,
35
+ reportUrl: env.REPORT_URL,
36
+ // The same methods on every host, so only ones every host can run safely. The file
37
+ // method and a web key directory read a host the holder names, and a Cloudflare Worker
38
+ // cannot check where that resolves, so neither is offered.
39
+ providers: [
40
+ // Two ways of showing one GitHub account. Whichever is used first is the record's
41
+ // main method, and the other is listed beneath it once used.
42
+ ...(env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET
43
+ ? [
44
+ githubProvider({
45
+ clientId: env.GITHUB_CLIENT_ID,
46
+ clientSecret: env.GITHUB_CLIENT_SECRET,
47
+ }),
48
+ ]
49
+ : []),
50
+ githubLinkProvider(),
51
+ // A domain shown by a record in its DNS, which is one fetch to a fixed resolver.
52
+ dnsProvider(),
53
+ // A key needs no registration and no secret. An address on a key is shown only when
54
+ // the keyserver confirms it.
55
+ pgpProvider(),
56
+ ...(env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET
57
+ ? [
58
+ discordProvider({
59
+ clientId: env.DISCORD_CLIENT_ID,
60
+ clientSecret: env.DISCORD_CLIENT_SECRET,
61
+ }),
62
+ ]
63
+ : []),
64
+ ...(env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET
65
+ ? [
66
+ youtubeProvider({
67
+ clientId: env.GOOGLE_CLIENT_ID,
68
+ clientSecret: env.GOOGLE_CLIENT_SECRET,
69
+ }),
70
+ ]
71
+ : []),
72
+ ...(env.RESEND_API_KEY && env.EMAIL_FROM
73
+ ? [
74
+ emailProvider({
75
+ send: resendSender({ apiKey: env.RESEND_API_KEY, from: env.EMAIL_FROM }),
76
+ }),
77
+ ]
78
+ : []),
79
+ ],
80
+ sites: registry(env.SITES, env),
81
+ signingKey: env.SIGNING === 'off' ? false : env.SIGNING_KEY || undefined,
82
+ mailPerDay: dailyMail(env.EMAIL_DAILY_LIMIT),
83
+ };
84
+ }