@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.
- package/README.md +183 -94
- package/dist/browser/mark.d.ts +2 -6
- package/dist/browser/mark.js +6 -12
- package/dist/browser/provider-mark.d.ts +3 -3
- package/dist/browser/provider-mark.js +15 -90
- package/dist/instance/auth.d.ts +14 -0
- package/dist/instance/auth.js +80 -0
- package/dist/instance/bin.d.ts +2 -0
- package/dist/instance/bin.js +36 -0
- package/dist/instance/cloudflare.d.ts +57 -0
- package/dist/instance/cloudflare.js +71 -0
- package/dist/instance/env.d.ts +51 -0
- package/dist/instance/env.js +84 -0
- package/dist/instance/index.d.ts +136 -0
- package/dist/instance/index.js +777 -0
- package/dist/instance/init.d.ts +6 -0
- package/dist/instance/init.js +28 -0
- package/dist/instance/limits.d.ts +30 -0
- package/dist/instance/limits.js +45 -0
- package/dist/instance/node.d.ts +13 -0
- package/dist/instance/node.js +43 -0
- package/dist/instance/page.d.ts +18 -0
- package/dist/instance/page.js +30 -0
- package/dist/instance/postgres.d.ts +19 -0
- package/dist/instance/postgres.js +83 -0
- package/dist/instance/routes.d.ts +7 -0
- package/dist/instance/routes.js +22 -0
- package/dist/instance/serve.d.ts +31 -0
- package/dist/instance/serve.js +49 -0
- package/dist/instance/sites.d.ts +169 -0
- package/dist/instance/sites.js +413 -0
- package/dist/instance/starter.d.ts +6 -0
- package/dist/instance/starter.js +99 -0
- package/dist/instance/store.d.ts +20 -0
- package/dist/instance/store.js +11 -0
- package/dist/instance/undici.d.ts +8 -0
- package/dist/instance/undici.js +9 -0
- package/dist/marks.d.ts +44 -0
- package/dist/marks.js +98 -0
- package/dist/server/index.js +8 -56
- package/dist/server/render.d.ts +49 -0
- package/dist/server/render.js +104 -0
- package/dist/server/style.d.ts +1 -1
- package/dist/server/style.js +122 -1
- package/dist/verily.js +96 -104
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +30 -2
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Writes the Cloudflare starter into a directory, all of it or none. If any of its files
|
|
3
|
+
* is already there, nothing is written. If a write fails partway, the files this call
|
|
4
|
+
* made are removed, so a second try starts from the same place.
|
|
5
|
+
*/
|
|
6
|
+
export declare function init(directory: string): Promise<void>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { mkdir, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { cloudflareStarter } from './starter.js';
|
|
4
|
+
/**
|
|
5
|
+
* Writes the Cloudflare starter into a directory, all of it or none. If any of its files
|
|
6
|
+
* is already there, nothing is written. If a write fails partway, the files this call
|
|
7
|
+
* made are removed, so a second try starts from the same place.
|
|
8
|
+
*/
|
|
9
|
+
export async function init(directory) {
|
|
10
|
+
const files = Object.entries(cloudflareStarter).map(([name, text]) => [join(directory, name), text]);
|
|
11
|
+
await mkdir(directory, { recursive: true });
|
|
12
|
+
for (const [path] of files)
|
|
13
|
+
if (await stat(path).catch(() => undefined))
|
|
14
|
+
throw new Error(`${path} already exists. Nothing was written.`);
|
|
15
|
+
const made = [];
|
|
16
|
+
try {
|
|
17
|
+
for (const [path, text] of files) {
|
|
18
|
+
// `wx` fails on a file that appeared since the check, so it is never overwritten.
|
|
19
|
+
await writeFile(path, text, { flag: 'wx' });
|
|
20
|
+
made.push(path);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
catch (error) {
|
|
24
|
+
for (const path of made)
|
|
25
|
+
await rm(path, { force: true });
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Store } from './store.js';
|
|
2
|
+
/**
|
|
3
|
+
* Requests a minute for one signed-in holder, one client without a session, one site's
|
|
4
|
+
* holders, and one site's backend reading its subjects' connections.
|
|
5
|
+
*/
|
|
6
|
+
export declare const limits: {
|
|
7
|
+
session: number;
|
|
8
|
+
client: number;
|
|
9
|
+
site: number;
|
|
10
|
+
read: number;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Counts a request against every bucket it falls in, and refuses it if any is full. All or
|
|
14
|
+
* nothing, in one transaction, so a refused request spends nothing from the others.
|
|
15
|
+
*/
|
|
16
|
+
export declare function allow(storage: Store, buckets: {
|
|
17
|
+
key: string;
|
|
18
|
+
limit: number;
|
|
19
|
+
}[]): Promise<boolean>;
|
|
20
|
+
/**
|
|
21
|
+
* The bucket for a client with no session, by the address the host says the request came
|
|
22
|
+
* from. The address is hashed, never stored as it is.
|
|
23
|
+
*/
|
|
24
|
+
export declare const client: (address: string | null) => string;
|
|
25
|
+
/**
|
|
26
|
+
* Deletes every record the handoff and the limits leave behind once it has expired:
|
|
27
|
+
* handoffs that never reached `/handoff/accept`, site sessions, and idle buckets. Each is written
|
|
28
|
+
* with its expiry under its own prefix for exactly this.
|
|
29
|
+
*/
|
|
30
|
+
export declare function prune(storage: Store): Promise<void>;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { hash } from '../server/service.js';
|
|
2
|
+
/**
|
|
3
|
+
* Requests a minute for one signed-in holder, one client without a session, one site's
|
|
4
|
+
* holders, and one site's backend reading its subjects' connections.
|
|
5
|
+
*/
|
|
6
|
+
export const limits = { session: 60, client: 30, site: 600, read: 600 };
|
|
7
|
+
const windowMs = 60000;
|
|
8
|
+
/**
|
|
9
|
+
* Counts a request against every bucket it falls in, and refuses it if any is full. All or
|
|
10
|
+
* nothing, in one transaction, so a refused request spends nothing from the others.
|
|
11
|
+
*/
|
|
12
|
+
export async function allow(storage, buckets) {
|
|
13
|
+
return storage.transaction(async (tx) => {
|
|
14
|
+
const now = Date.now();
|
|
15
|
+
const next = [];
|
|
16
|
+
for (const { key, limit } of buckets) {
|
|
17
|
+
const previous = await tx.get(`rate/${key}`);
|
|
18
|
+
const bucket = previous && previous.expiresAt > now ? previous : { count: 0, expiresAt: now + windowMs };
|
|
19
|
+
if (bucket.count >= limit)
|
|
20
|
+
return false;
|
|
21
|
+
next.push([`rate/${key}`, { ...bucket, count: bucket.count + 1 }]);
|
|
22
|
+
}
|
|
23
|
+
for (const [key, bucket] of next)
|
|
24
|
+
await tx.put(key, bucket);
|
|
25
|
+
return true;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The bucket for a client with no session, by the address the host says the request came
|
|
30
|
+
* from. The address is hashed, never stored as it is.
|
|
31
|
+
*/
|
|
32
|
+
export const client = (address) => `client/${hash(address ?? 'unknown')}`;
|
|
33
|
+
/**
|
|
34
|
+
* Deletes every record the handoff and the limits leave behind once it has expired:
|
|
35
|
+
* handoffs that never reached `/handoff/accept`, site sessions, and idle buckets. Each is written
|
|
36
|
+
* with its expiry under its own prefix for exactly this.
|
|
37
|
+
*/
|
|
38
|
+
export async function prune(storage) {
|
|
39
|
+
const now = Date.now();
|
|
40
|
+
for (const prefix of ['site/state/', 'site/session/', 'rate/']) {
|
|
41
|
+
for (const [key, value] of await storage.list(prefix))
|
|
42
|
+
if (!(value.expiresAt > now))
|
|
43
|
+
await storage.delete(key);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import type { Instance } from './index.js';
|
|
3
|
+
export interface ListenerOptions {
|
|
4
|
+
/** The origin the instance is served from, as given to `createInstance`. */
|
|
5
|
+
origin: string;
|
|
6
|
+
/**
|
|
7
|
+
* The client's address, which rate limits count by. The socket's by default. Behind a
|
|
8
|
+
* proxy every request comes from the proxy, so read the header that proxy sets instead.
|
|
9
|
+
*/
|
|
10
|
+
address?(request: IncomingMessage): string | null;
|
|
11
|
+
}
|
|
12
|
+
/** Serves an instance from `node:http`: `createServer(nodeListener(instance, { origin }))`. */
|
|
13
|
+
export declare function nodeListener(instance: Pick<Instance, 'handle'>, options: ListenerOptions): (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** The instance's own bound on a body. A larger one is refused before it is buffered. */
|
|
2
|
+
const maxBodyBytes = 65536;
|
|
3
|
+
/** Serves an instance from `node:http`: `createServer(nodeListener(instance, { origin }))`. */
|
|
4
|
+
export function nodeListener(instance, options) {
|
|
5
|
+
const address = options.address ?? ((request) => request.socket.remoteAddress ?? null);
|
|
6
|
+
return async (req, res) => {
|
|
7
|
+
try {
|
|
8
|
+
const chunks = [];
|
|
9
|
+
let length = 0;
|
|
10
|
+
for await (const chunk of req) {
|
|
11
|
+
length += chunk.length;
|
|
12
|
+
if (length > maxBodyBytes) {
|
|
13
|
+
res.writeHead(413, { 'Cache-Control': 'no-store' });
|
|
14
|
+
res.end();
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
chunks.push(Buffer.from(chunk));
|
|
18
|
+
}
|
|
19
|
+
const headers = new Headers();
|
|
20
|
+
for (const [key, value] of Object.entries(req.headers))
|
|
21
|
+
if (value)
|
|
22
|
+
headers.set(key, Array.isArray(value) ? value.join(', ') : value);
|
|
23
|
+
const response = await instance.handle(new Request(new URL(req.url ?? '/', options.origin), {
|
|
24
|
+
method: req.method,
|
|
25
|
+
headers,
|
|
26
|
+
...(chunks.length ? { body: Buffer.concat(chunks) } : {}),
|
|
27
|
+
}), address(req));
|
|
28
|
+
// Each cookie is a header of its own: joined into one, a browser keeps only the first.
|
|
29
|
+
const cookies = response.headers.getSetCookie();
|
|
30
|
+
response.headers.delete('Set-Cookie');
|
|
31
|
+
res.writeHead(response.status, {
|
|
32
|
+
...Object.fromEntries(response.headers),
|
|
33
|
+
...(cookies.length ? { 'Set-Cookie': cookies } : {}),
|
|
34
|
+
});
|
|
35
|
+
res.end(Buffer.from(await response.arrayBuffer()));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
if (!res.headersSent)
|
|
39
|
+
res.writeHead(500, { 'Cache-Control': 'no-store' });
|
|
40
|
+
res.end('Request failed');
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export declare const safeHeaders: {
|
|
2
|
+
'Cache-Control': string;
|
|
3
|
+
'Referrer-Policy': string;
|
|
4
|
+
'X-Robots-Tag': string;
|
|
5
|
+
'X-Content-Type-Options': string;
|
|
6
|
+
'Content-Security-Policy': string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* Every page here says in its heading what it is, the way the library's pages do. The
|
|
10
|
+
* logotype above already says whose it is, so the heading never has to repeat that.
|
|
11
|
+
* `script` is the path of one script the page runs, which its policy then allows.
|
|
12
|
+
*/
|
|
13
|
+
export declare const html: (heading: string, body: string, status?: number, variant?: string, script?: string) => Response;
|
|
14
|
+
/**
|
|
15
|
+
* Every refusal reads the same, so the page says nothing about which check failed. The
|
|
16
|
+
* link goes to the root, which shows whoever asks the page that is theirs.
|
|
17
|
+
*/
|
|
18
|
+
export declare const unavailable: (status?: number) => Response;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { page } from '../server/render.js';
|
|
2
|
+
export const safeHeaders = {
|
|
3
|
+
'Cache-Control': 'no-store',
|
|
4
|
+
'Referrer-Policy': 'same-origin',
|
|
5
|
+
'X-Robots-Tag': 'noindex, nofollow',
|
|
6
|
+
'X-Content-Type-Options': 'nosniff',
|
|
7
|
+
'Content-Security-Policy': "default-src 'none'; style-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'",
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* Every page here says in its heading what it is, the way the library's pages do. The
|
|
11
|
+
* logotype above already says whose it is, so the heading never has to repeat that.
|
|
12
|
+
* `script` is the path of one script the page runs, which its policy then allows.
|
|
13
|
+
*/
|
|
14
|
+
export const html = (heading, body, status = 200, variant = '', script = '') => new Response(page('', heading, body, variant, script), {
|
|
15
|
+
status,
|
|
16
|
+
headers: {
|
|
17
|
+
...safeHeaders,
|
|
18
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
19
|
+
...(script
|
|
20
|
+
? {
|
|
21
|
+
'Content-Security-Policy': safeHeaders['Content-Security-Policy'].replace("style-src 'self';", "style-src 'self'; script-src 'self';"),
|
|
22
|
+
}
|
|
23
|
+
: {}),
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
/**
|
|
27
|
+
* Every refusal reads the same, so the page says nothing about which check failed. The
|
|
28
|
+
* link goes to the root, which shows whoever asks the page that is theirs.
|
|
29
|
+
*/
|
|
30
|
+
export const unavailable = (status = 404) => html('Unavailable', '<p>This resource is unavailable. <a href="/">Go to the start page</a>.</p>', status, 'single');
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { Pool } from 'pg';
|
|
2
|
+
import type { KeyValue, Store } from './store.js';
|
|
3
|
+
/**
|
|
4
|
+
* An instance store on Postgres, in one table of its own. Several installations can share
|
|
5
|
+
* a database, each under its own namespace.
|
|
6
|
+
*/
|
|
7
|
+
export declare class PostgresStore implements Store {
|
|
8
|
+
readonly pool: Pool;
|
|
9
|
+
readonly namespace: string;
|
|
10
|
+
constructor(pool: Pool, namespace?: string);
|
|
11
|
+
/** Creates the table. Safe to run on every start, and from several processes at once. */
|
|
12
|
+
migrate(): Promise<void>;
|
|
13
|
+
get: KeyValue['get'];
|
|
14
|
+
put: KeyValue['put'];
|
|
15
|
+
delete: KeyValue['delete'];
|
|
16
|
+
list: KeyValue['list'];
|
|
17
|
+
transaction<T>(work: (tx: KeyValue) => Promise<T>): Promise<T>;
|
|
18
|
+
private operations;
|
|
19
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An instance store on Postgres, in one table of its own. Several installations can share
|
|
3
|
+
* a database, each under its own namespace.
|
|
4
|
+
*/
|
|
5
|
+
export class PostgresStore {
|
|
6
|
+
pool;
|
|
7
|
+
namespace;
|
|
8
|
+
constructor(pool, namespace = 'verily') {
|
|
9
|
+
this.pool = pool;
|
|
10
|
+
this.namespace = namespace;
|
|
11
|
+
if (!namespace)
|
|
12
|
+
throw new Error('Store namespace required');
|
|
13
|
+
}
|
|
14
|
+
/** Creates the table. Safe to run on every start, and from several processes at once. */
|
|
15
|
+
async migrate() {
|
|
16
|
+
const client = await this.pool.connect();
|
|
17
|
+
try {
|
|
18
|
+
await client.query('BEGIN');
|
|
19
|
+
// IF NOT EXISTS does not make CREATE TABLE safe to race, so one process at a time.
|
|
20
|
+
await client.query("SELECT pg_advisory_xact_lock(hashtextextended('verily migrate', 0))");
|
|
21
|
+
await client.query(`CREATE TABLE IF NOT EXISTS verily_instance (
|
|
22
|
+
namespace text NOT NULL, key text NOT NULL, value jsonb NOT NULL,
|
|
23
|
+
PRIMARY KEY (namespace, key)
|
|
24
|
+
)`);
|
|
25
|
+
await client.query('COMMIT');
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
await client.query('ROLLBACK');
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
finally {
|
|
32
|
+
client.release();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
get = (key) => this.operations(this.pool).get(key);
|
|
36
|
+
put = (key, value) => this.operations(this.pool).put(key, value);
|
|
37
|
+
delete = (key) => this.operations(this.pool).delete(key);
|
|
38
|
+
list = (prefix) => this.operations(this.pool).list(prefix);
|
|
39
|
+
async transaction(work) {
|
|
40
|
+
const client = await this.pool.connect();
|
|
41
|
+
try {
|
|
42
|
+
await client.query('BEGIN');
|
|
43
|
+
// One transaction at a time for this installation, across every process serving it.
|
|
44
|
+
await client.query('SELECT pg_advisory_xact_lock(hashtextextended($1, 0))', [
|
|
45
|
+
`instance ${this.namespace}`,
|
|
46
|
+
]);
|
|
47
|
+
const result = await work(this.operations(client));
|
|
48
|
+
await client.query('COMMIT');
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
await client.query('ROLLBACK');
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
client.release();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
operations(db) {
|
|
60
|
+
const namespace = this.namespace;
|
|
61
|
+
return {
|
|
62
|
+
async get(key) {
|
|
63
|
+
const result = await db.query('SELECT value FROM verily_instance WHERE namespace=$1 AND key=$2', [namespace, key]);
|
|
64
|
+
return result.rows[0]?.value;
|
|
65
|
+
},
|
|
66
|
+
async put(key, value) {
|
|
67
|
+
await db.query(`INSERT INTO verily_instance(namespace,key,value) VALUES($1,$2,$3)
|
|
68
|
+
ON CONFLICT(namespace,key) DO UPDATE SET value=EXCLUDED.value`, [namespace, key, JSON.stringify(value)]);
|
|
69
|
+
},
|
|
70
|
+
async delete(key) {
|
|
71
|
+
await db.query('DELETE FROM verily_instance WHERE namespace=$1 AND key=$2', [
|
|
72
|
+
namespace,
|
|
73
|
+
key,
|
|
74
|
+
]);
|
|
75
|
+
},
|
|
76
|
+
async list(prefix) {
|
|
77
|
+
const result = await db.query(`SELECT key, value FROM verily_instance
|
|
78
|
+
WHERE namespace=$1 AND starts_with(key, $2) ORDER BY key COLLATE "C"`, [namespace, prefix]);
|
|
79
|
+
return result.rows.map((row) => [row.key, row.value]);
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The paths the instance answers itself. The library is mounted at the root of the origin
|
|
3
|
+
* and answers every other path, so a path listed here must be one the library does not
|
|
4
|
+
* serve: the instance looks here first, and a library route of the same name would never be
|
|
5
|
+
* reached.
|
|
6
|
+
*/
|
|
7
|
+
export declare const own: Set<string>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The paths the instance answers itself. The library is mounted at the root of the origin
|
|
3
|
+
* and answers every other path, so a path listed here must be one the library does not
|
|
4
|
+
* serve: the instance looks here first, and a library route of the same name would never be
|
|
5
|
+
* reached.
|
|
6
|
+
*/
|
|
7
|
+
export const own = new Set([
|
|
8
|
+
'/',
|
|
9
|
+
'/login',
|
|
10
|
+
'/logout',
|
|
11
|
+
'/disconnect',
|
|
12
|
+
'/mark',
|
|
13
|
+
'/confirm',
|
|
14
|
+
'/records',
|
|
15
|
+
'/handoff/request',
|
|
16
|
+
'/handoff/accept',
|
|
17
|
+
'/site/connections',
|
|
18
|
+
'/site/enter',
|
|
19
|
+
'/site/enter.js',
|
|
20
|
+
'/site/session',
|
|
21
|
+
'/.well-known/verily-keys.json',
|
|
22
|
+
]);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type Server } from 'node:http';
|
|
2
|
+
import { type InstanceEnv } from './env.js';
|
|
3
|
+
import type { Store } from './store.js';
|
|
4
|
+
/** What the Node server reads besides the instance's own settings. */
|
|
5
|
+
export interface ServeEnv extends InstanceEnv {
|
|
6
|
+
/** A Postgres connection string. The instance keeps everything in one table there. */
|
|
7
|
+
DATABASE_URL?: string;
|
|
8
|
+
/** Separates installations sharing a database. `verily` by default. */
|
|
9
|
+
VERILY_NAMESPACE?: string;
|
|
10
|
+
/** 3000 by default. */
|
|
11
|
+
PORT?: string;
|
|
12
|
+
/** 127.0.0.1 by default, for a proxy on the same machine that terminates TLS. */
|
|
13
|
+
HOST?: string;
|
|
14
|
+
/**
|
|
15
|
+
* The header a proxy in front sets to the client's address, such as `X-Real-IP`. Rate
|
|
16
|
+
* limits count by it. It must be one the proxy sets outright: a header a client can add
|
|
17
|
+
* to, as `X-Forwarded-For` usually is, lets a client pick the address it is counted as.
|
|
18
|
+
* Left out, the address is the connection's, which behind a proxy is the proxy's.
|
|
19
|
+
*/
|
|
20
|
+
CLIENT_ADDRESS_HEADER?: string;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Runs an instance on Node from its settings: Postgres for the store, plain HTTP for a
|
|
24
|
+
* proxy to sit in front of, and upkeep once an hour. Resolves once it is listening.
|
|
25
|
+
*/
|
|
26
|
+
export declare function serve(env: ServeEnv,
|
|
27
|
+
/** A store to use instead of Postgres. */
|
|
28
|
+
given?: Store): Promise<{
|
|
29
|
+
server: Server;
|
|
30
|
+
close(): Promise<void>;
|
|
31
|
+
}>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { Pool } from 'pg';
|
|
3
|
+
import { createInstance } from './index.js';
|
|
4
|
+
import { fromEnv } from './env.js';
|
|
5
|
+
import { nodeListener } from './node.js';
|
|
6
|
+
import { PostgresStore } from './postgres.js';
|
|
7
|
+
const hour = 3600000;
|
|
8
|
+
/**
|
|
9
|
+
* Runs an instance on Node from its settings: Postgres for the store, plain HTTP for a
|
|
10
|
+
* proxy to sit in front of, and upkeep once an hour. Resolves once it is listening.
|
|
11
|
+
*/
|
|
12
|
+
export async function serve(env,
|
|
13
|
+
/** A store to use instead of Postgres. */
|
|
14
|
+
given) {
|
|
15
|
+
const options = fromEnv(env);
|
|
16
|
+
let store = given;
|
|
17
|
+
let pool;
|
|
18
|
+
if (!store) {
|
|
19
|
+
if (!env.DATABASE_URL)
|
|
20
|
+
throw new Error('DATABASE_URL must be set');
|
|
21
|
+
pool = new Pool({ connectionString: env.DATABASE_URL });
|
|
22
|
+
const postgres = new PostgresStore(pool, env.VERILY_NAMESPACE || 'verily');
|
|
23
|
+
await postgres.migrate();
|
|
24
|
+
store = postgres;
|
|
25
|
+
}
|
|
26
|
+
const instance = createInstance({ ...options, store });
|
|
27
|
+
const header = env.CLIENT_ADDRESS_HEADER?.toLowerCase();
|
|
28
|
+
const server = createServer(nodeListener(instance, {
|
|
29
|
+
origin: options.origin,
|
|
30
|
+
...(header
|
|
31
|
+
? { address: (request) => String(request.headers[header] ?? '').trim() || null }
|
|
32
|
+
: {}),
|
|
33
|
+
}));
|
|
34
|
+
const upkeep = () => void instance.upkeep().catch(() => console.error('Verily upkeep failed'));
|
|
35
|
+
const timer = setInterval(upkeep, hour);
|
|
36
|
+
upkeep();
|
|
37
|
+
await new Promise((resolve, reject) => {
|
|
38
|
+
server.once('error', reject);
|
|
39
|
+
server.listen(Number(env.PORT || 3000), env.HOST || '127.0.0.1', resolve);
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
server,
|
|
43
|
+
async close() {
|
|
44
|
+
clearInterval(timer);
|
|
45
|
+
await new Promise((resolve) => server.close(resolve));
|
|
46
|
+
await pool?.end();
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { type FlowResult, type LocalAccount, type Visibility } from '../core/index.js';
|
|
2
|
+
import type { Store } from './store.js';
|
|
3
|
+
/** How long a handoff may take, from `/handoff/request` to `/handoff/accept`, and how far ahead a token may run. */
|
|
4
|
+
export declare const handoffMs: number;
|
|
5
|
+
/** How long a site session lasts once the handoff lands. */
|
|
6
|
+
export declare const sessionMs = 3600000;
|
|
7
|
+
export declare const sessionCookie = "verily_site";
|
|
8
|
+
/** A site that sends its users here instead of hosting Verily itself. */
|
|
9
|
+
export interface Site {
|
|
10
|
+
/** Prefixes its subjects' ids, and names it in every token. */
|
|
11
|
+
id: string;
|
|
12
|
+
/** The site name stamped on its subjects. */
|
|
13
|
+
name: string;
|
|
14
|
+
origin: string;
|
|
15
|
+
/** The site's endpoint that signs a handoff for its signed-in user. */
|
|
16
|
+
authorizeUrl: string;
|
|
17
|
+
/** Where a holder is sent back to when a flow ends. */
|
|
18
|
+
returnUrl: string;
|
|
19
|
+
/** Shared with the site's backend. Signs handoffs one way and results the other. */
|
|
20
|
+
key: string;
|
|
21
|
+
/** The provider ids its holders may use. Absent means every one this instance offers. */
|
|
22
|
+
providers?: string[];
|
|
23
|
+
/** The visibilities its holders may choose. Absent means both. */
|
|
24
|
+
visibility?: Visibility[];
|
|
25
|
+
}
|
|
26
|
+
export type Purpose = 'connect' | 'manage';
|
|
27
|
+
/** How a holder may have an account listed, as the library's mark route takes it. */
|
|
28
|
+
export type Listing = 'preferred' | 'current' | 'unused' | 'retired';
|
|
29
|
+
/**
|
|
30
|
+
* What the instance holds for a disconnect or a mark, which have no flow of their own to
|
|
31
|
+
* hold it.
|
|
32
|
+
*/
|
|
33
|
+
interface Operation {
|
|
34
|
+
connection: string;
|
|
35
|
+
visibility?: Visibility;
|
|
36
|
+
/** How the account is to be listed, for a mark. Absent, the operation is a disconnect. */
|
|
37
|
+
mark?: Listing;
|
|
38
|
+
/** Set once the change has committed. Until then the operation is pending. */
|
|
39
|
+
finishedAt?: number;
|
|
40
|
+
}
|
|
41
|
+
export interface SiteSession {
|
|
42
|
+
site: string;
|
|
43
|
+
purpose: Purpose;
|
|
44
|
+
txn: string;
|
|
45
|
+
local: LocalAccount;
|
|
46
|
+
expiresAt: number;
|
|
47
|
+
/** Set by the first return. Nothing new can start after it, but a retry still resolves. */
|
|
48
|
+
closed?: boolean;
|
|
49
|
+
/** The flow whose result this session returned with. No other flow's result is signed. */
|
|
50
|
+
returned?: string;
|
|
51
|
+
operation?: Operation;
|
|
52
|
+
/**
|
|
53
|
+
* Opened for the dialog on the site's own page, which reads how each flow ended for
|
|
54
|
+
* itself. No flow returns to the site from it, so no flow's end closes it, and the
|
|
55
|
+
* holder can try again in the same dialog.
|
|
56
|
+
*/
|
|
57
|
+
dialog?: true;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The registered sites, from the `SITES` variable, each with its key from the secret
|
|
61
|
+
* `SITE_<ID>_KEY`. Anything wrong in the list refuses to start rather than serving a site
|
|
62
|
+
* half set up. A site whose key is missing or malformed is left out instead.
|
|
63
|
+
*/
|
|
64
|
+
export declare function registry(config: unknown, secrets: Record<string, unknown>): Map<string, Site>;
|
|
65
|
+
/** `base64url(payload) + "." + base64url(HMAC-SHA256(key, payload))`, the payload being JSON. */
|
|
66
|
+
export declare function sign(key: string, payload: object): string;
|
|
67
|
+
/** The payload of a token, without checking it. Only good for choosing which key checks it. */
|
|
68
|
+
export declare function peek(token: string): Record<string, unknown> | undefined;
|
|
69
|
+
/** The payload of a token whose MAC verifies under `key`, compared in constant time. */
|
|
70
|
+
export declare function open(key: string, token: string): Record<string, unknown> | undefined;
|
|
71
|
+
/**
|
|
72
|
+
* Handoffs from registered sites, the sessions they open, and the results sent back. The
|
|
73
|
+
* site vouches for its signed-in user; this is where that is checked, and a handoff is
|
|
74
|
+
* bound to the browser that asked for it, the way OAuth binds a callback with `state`.
|
|
75
|
+
*/
|
|
76
|
+
export declare class Sites {
|
|
77
|
+
private readonly storage;
|
|
78
|
+
readonly sites: Map<string, Site>;
|
|
79
|
+
constructor(storage: Store, sites: Map<string, Site>);
|
|
80
|
+
/**
|
|
81
|
+
* Starts a handoff: a state stored with the site and purpose, set in this browser, and
|
|
82
|
+
* sent to the site to sign into its token. Returns where to go and the cookie to set.
|
|
83
|
+
*/
|
|
84
|
+
begin(site: string | null, purpose: string | null): Promise<{
|
|
85
|
+
location: string;
|
|
86
|
+
cookie: string;
|
|
87
|
+
} | undefined>;
|
|
88
|
+
/**
|
|
89
|
+
* Redeems a signed handoff for a session, or refuses it. Every refusal is the same
|
|
90
|
+
* undefined, so a caller cannot tell which check failed. `validate` is the library's own
|
|
91
|
+
* check of the subject, which the union of every site's origin would not make alone.
|
|
92
|
+
*/
|
|
93
|
+
start(request: Request, token: string, validate: (local: LocalAccount) => LocalAccount): Promise<{
|
|
94
|
+
purpose: Purpose;
|
|
95
|
+
cookies: string[];
|
|
96
|
+
} | undefined>;
|
|
97
|
+
/**
|
|
98
|
+
* Redeems a handoff a site's own page presents, for the connect dialog on that page, and
|
|
99
|
+
* returns the session it opens. There is no redirect to bind with a state. The site's
|
|
100
|
+
* backend gave the token to its signed-in user's page, and a browser sends that page's
|
|
101
|
+
* origin with the request, so the token is marked for this use, taken only from the
|
|
102
|
+
* site's origin, and works once. The session is the same as a redirect opens, carried by
|
|
103
|
+
* the page in a header because the cookie would be another site's on that page.
|
|
104
|
+
*
|
|
105
|
+
* `admit` counts the handoff against the site that signed it, and is asked only once it
|
|
106
|
+
* has verified, before anything is stored: the request has no cookie to say whose it is,
|
|
107
|
+
* and a token that does not verify must spend nothing of the site it names. A handoff
|
|
108
|
+
* not admitted is 'limited', and is not used up.
|
|
109
|
+
*/
|
|
110
|
+
dialog(token: string, origin: string | null, validate: (local: LocalAccount) => LocalAccount, admit: (site: Site) => Promise<boolean>): Promise<{
|
|
111
|
+
site: Site;
|
|
112
|
+
session: string;
|
|
113
|
+
} | 'limited' | undefined>;
|
|
114
|
+
/**
|
|
115
|
+
* Checks a site backend's request to read its own subjects' connections: a token under
|
|
116
|
+
* that site's key, marked as a read so that a handoff token, which passes through a
|
|
117
|
+
* browser, is never one. The ids are the site's own, as it sends them in a handoff, and
|
|
118
|
+
* are only ever looked up under its prefix, so no site reaches another's subjects.
|
|
119
|
+
*/
|
|
120
|
+
read(token: string): {
|
|
121
|
+
site: Site;
|
|
122
|
+
ids: string[];
|
|
123
|
+
} | undefined;
|
|
124
|
+
/** This browser's site session, whether or not it has returned yet. */
|
|
125
|
+
session(request: Request): Promise<{
|
|
126
|
+
key: string;
|
|
127
|
+
session: SiteSession;
|
|
128
|
+
} | undefined>;
|
|
129
|
+
/**
|
|
130
|
+
* A session that can still start something: not yet returned, and not partway through a
|
|
131
|
+
* disconnect. A pending disconnect holds the session's one result, so nothing else may
|
|
132
|
+
* start that could end with another; only retrying that disconnect goes on.
|
|
133
|
+
*/
|
|
134
|
+
open(request: Request): Promise<{
|
|
135
|
+
key: string;
|
|
136
|
+
session: SiteSession;
|
|
137
|
+
} | undefined>;
|
|
138
|
+
/**
|
|
139
|
+
* Where a flow that ended goes, as a function of the stored result alone: the same
|
|
140
|
+
* result signs the same token, since its expiry is counted from when the flow ended.
|
|
141
|
+
* Only the registry says where a site's holders return, and the first return closes the
|
|
142
|
+
* session to anything new.
|
|
143
|
+
*/
|
|
144
|
+
finish(flow: string, context: Record<string, string> | undefined, local: LocalAccount | undefined, result: FlowResult): Promise<string | undefined>;
|
|
145
|
+
/**
|
|
146
|
+
* Takes the session's one return for a flow, and closes the session with it. A site
|
|
147
|
+
* accepts one result for a transaction, so only one is ever signed: the first flow to end
|
|
148
|
+
* holds the return, every load of that flow holds it again, and no flow gets it once a
|
|
149
|
+
* disconnect is recorded, pending or done. Decided in one transaction, as `pending` is,
|
|
150
|
+
* so of a flow ending and a disconnect starting exactly one goes on.
|
|
151
|
+
*/
|
|
152
|
+
private claim;
|
|
153
|
+
/** The signed result a holder carries back to their site. */
|
|
154
|
+
returnUrl(site: Site, local: LocalAccount, txn: string, result: {
|
|
155
|
+
operation: string;
|
|
156
|
+
outcome: string;
|
|
157
|
+
connection?: string;
|
|
158
|
+
visibility?: Visibility;
|
|
159
|
+
finishedAt: number;
|
|
160
|
+
}): string;
|
|
161
|
+
/**
|
|
162
|
+
* Records a disconnect or a mark as pending on the session before anything is changed, so
|
|
163
|
+
* a retry knows what it is finishing. A session already carrying one only resumes that one.
|
|
164
|
+
*/
|
|
165
|
+
pending(key: string, connection: string, visibility: Visibility, mark?: Listing): Promise<SiteSession | undefined>;
|
|
166
|
+
/** Records an operation as done once its change has committed, and closes the session. */
|
|
167
|
+
completed(key: string): Promise<SiteSession>;
|
|
168
|
+
}
|
|
169
|
+
export {};
|