@variance-authority/tribunal 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.
- package/CHANGELOG.md +100 -0
- package/LICENSE +21 -0
- package/README.md +506 -0
- package/dist/__fixtures__/review.d.ts +47 -0
- package/dist/__fixtures__/review.js +145 -0
- package/dist/__fixtures__/review.js.map +1 -0
- package/dist/bindings.d.ts +134 -0
- package/dist/bindings.js +88 -0
- package/dist/bindings.js.map +1 -0
- package/dist/changelog.d.ts +132 -0
- package/dist/changelog.js +129 -0
- package/dist/changelog.js.map +1 -0
- package/dist/history-queries.d.ts +82 -0
- package/dist/history-queries.js +200 -0
- package/dist/history-queries.js.map +1 -0
- package/dist/history-rows.d.ts +42 -0
- package/dist/history-rows.js +148 -0
- package/dist/history-rows.js.map +1 -0
- package/dist/history.d.ts +40 -0
- package/dist/history.js +186 -0
- package/dist/history.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/migration-steps.d.ts +32 -0
- package/dist/migration-steps.js +303 -0
- package/dist/migration-steps.js.map +1 -0
- package/dist/migrations.d.ts +58 -0
- package/dist/migrations.js +283 -0
- package/dist/migrations.js.map +1 -0
- package/dist/next.d.ts +94 -0
- package/dist/next.js +44 -0
- package/dist/next.js.map +1 -0
- package/dist/node/bin.d.ts +120 -0
- package/dist/node/bin.js +337 -0
- package/dist/node/bin.js.map +1 -0
- package/dist/node/bucket.d.ts +58 -0
- package/dist/node/bucket.js +142 -0
- package/dist/node/bucket.js.map +1 -0
- package/dist/node/database.d.ts +33 -0
- package/dist/node/database.js +157 -0
- package/dist/node/database.js.map +1 -0
- package/dist/node/index.d.ts +43 -0
- package/dist/node/index.js +43 -0
- package/dist/node/index.js.map +1 -0
- package/dist/node/serve.d.ts +81 -0
- package/dist/node/serve.js +193 -0
- package/dist/node/serve.js.map +1 -0
- package/dist/node/ui-assets.d.ts +47 -0
- package/dist/node/ui-assets.js +60 -0
- package/dist/node/ui-assets.js.map +1 -0
- package/dist/review-ingest.d.ts +24 -0
- package/dist/review-ingest.js +184 -0
- package/dist/review-ingest.js.map +1 -0
- package/dist/review-read.d.ts +87 -0
- package/dist/review-read.js +361 -0
- package/dist/review-read.js.map +1 -0
- package/dist/review-rows.d.ts +30 -0
- package/dist/review-rows.js +67 -0
- package/dist/review-rows.js.map +1 -0
- package/dist/review-types.d.ts +414 -0
- package/dist/review-types.js +2 -0
- package/dist/review-types.js.map +1 -0
- package/dist/review-write.d.ts +35 -0
- package/dist/review-write.js +86 -0
- package/dist/review-write.js.map +1 -0
- package/dist/review.d.ts +53 -0
- package/dist/review.fixtures.d.ts +47 -0
- package/dist/review.fixtures.js +145 -0
- package/dist/review.fixtures.js.map +1 -0
- package/dist/review.js +245 -0
- package/dist/review.js.map +1 -0
- package/dist/schema.d.ts +36 -0
- package/dist/schema.js +39 -0
- package/dist/schema.js.map +1 -0
- package/dist/store.d.ts +58 -0
- package/dist/store.js +278 -0
- package/dist/store.js.map +1 -0
- package/dist/testing.d.ts +38 -0
- package/dist/testing.js +92 -0
- package/dist/testing.js.map +1 -0
- package/dist/ui/__probe.d.ts +3 -0
- package/dist/ui/__probe.js +5 -0
- package/dist/ui/__probe.js.map +1 -0
- package/dist/ui/attribution.d.ts +46 -0
- package/dist/ui/attribution.js +79 -0
- package/dist/ui/attribution.js.map +1 -0
- package/dist/ui/because.d.ts +51 -0
- package/dist/ui/because.js +52 -0
- package/dist/ui/because.js.map +1 -0
- package/dist/ui/browser-entry.d.ts +2 -0
- package/dist/ui/browser-entry.js +34 -0
- package/dist/ui/browser-entry.js.map +1 -0
- package/dist/ui/builds.d.ts +43 -0
- package/dist/ui/builds.js +79 -0
- package/dist/ui/builds.js.map +1 -0
- package/dist/ui/carried.d.ts +73 -0
- package/dist/ui/carried.js +63 -0
- package/dist/ui/carried.js.map +1 -0
- package/dist/ui/cause.d.ts +73 -0
- package/dist/ui/cause.js +133 -0
- package/dist/ui/cause.js.map +1 -0
- package/dist/ui/change-story.d.ts +153 -0
- package/dist/ui/change-story.js +253 -0
- package/dist/ui/change-story.js.map +1 -0
- package/dist/ui/change.d.ts +51 -0
- package/dist/ui/change.js +125 -0
- package/dist/ui/change.js.map +1 -0
- package/dist/ui/client.d.ts +74 -0
- package/dist/ui/client.js +88 -0
- package/dist/ui/client.js.map +1 -0
- package/dist/ui/consumers.d.ts +80 -0
- package/dist/ui/consumers.js +125 -0
- package/dist/ui/consumers.js.map +1 -0
- package/dist/ui/control.d.ts +63 -0
- package/dist/ui/control.js +39 -0
- package/dist/ui/control.js.map +1 -0
- package/dist/ui/crossing.d.ts +55 -0
- package/dist/ui/crossing.js +74 -0
- package/dist/ui/crossing.js.map +1 -0
- package/dist/ui/declarations.d.ts +36 -0
- package/dist/ui/declarations.js +73 -0
- package/dist/ui/declarations.js.map +1 -0
- package/dist/ui/distance.d.ts +154 -0
- package/dist/ui/distance.js +180 -0
- package/dist/ui/distance.js.map +1 -0
- package/dist/ui/divergence.d.ts +40 -0
- package/dist/ui/divergence.js +176 -0
- package/dist/ui/divergence.js.map +1 -0
- package/dist/ui/docket.d.ts +39 -0
- package/dist/ui/docket.js +172 -0
- package/dist/ui/docket.js.map +1 -0
- package/dist/ui/findings.d.ts +42 -0
- package/dist/ui/findings.js +110 -0
- package/dist/ui/findings.js.map +1 -0
- package/dist/ui/foreseen.d.ts +55 -0
- package/dist/ui/foreseen.js +91 -0
- package/dist/ui/foreseen.js.map +1 -0
- package/dist/ui/glance.d.ts +101 -0
- package/dist/ui/glance.js +101 -0
- package/dist/ui/glance.js.map +1 -0
- package/dist/ui/grouping.d.ts +158 -0
- package/dist/ui/grouping.js +187 -0
- package/dist/ui/grouping.js.map +1 -0
- package/dist/ui/handed.d.ts +60 -0
- package/dist/ui/handed.js +122 -0
- package/dist/ui/handed.js.map +1 -0
- package/dist/ui/history.d.ts +88 -0
- package/dist/ui/history.js +170 -0
- package/dist/ui/history.js.map +1 -0
- package/dist/ui/holding.d.ts +84 -0
- package/dist/ui/holding.js +90 -0
- package/dist/ui/holding.js.map +1 -0
- package/dist/ui/impact.d.ts +31 -0
- package/dist/ui/impact.js +132 -0
- package/dist/ui/impact.js.map +1 -0
- package/dist/ui/index.d.ts +21 -0
- package/dist/ui/index.js +21 -0
- package/dist/ui/index.js.map +1 -0
- package/dist/ui/lead.d.ts +35 -0
- package/dist/ui/lead.js +34 -0
- package/dist/ui/lead.js.map +1 -0
- package/dist/ui/look.d.ts +109 -0
- package/dist/ui/look.js +248 -0
- package/dist/ui/look.js.map +1 -0
- package/dist/ui/mark.d.ts +11 -0
- package/dist/ui/mark.js +13 -0
- package/dist/ui/mark.js.map +1 -0
- package/dist/ui/moved.d.ts +121 -0
- package/dist/ui/moved.js +143 -0
- package/dist/ui/moved.js.map +1 -0
- package/dist/ui/navigation.d.ts +26 -0
- package/dist/ui/navigation.js +53 -0
- package/dist/ui/navigation.js.map +1 -0
- package/dist/ui/order.d.ts +113 -0
- package/dist/ui/order.js +226 -0
- package/dist/ui/order.js.map +1 -0
- package/dist/ui/origins.d.ts +42 -0
- package/dist/ui/origins.js +149 -0
- package/dist/ui/origins.js.map +1 -0
- package/dist/ui/outcome.d.ts +93 -0
- package/dist/ui/outcome.js +162 -0
- package/dist/ui/outcome.js.map +1 -0
- package/dist/ui/parted.d.ts +68 -0
- package/dist/ui/parted.js +101 -0
- package/dist/ui/parted.js.map +1 -0
- package/dist/ui/rail.d.ts +31 -0
- package/dist/ui/rail.js +21 -0
- package/dist/ui/rail.js.map +1 -0
- package/dist/ui/reach.d.ts +66 -0
- package/dist/ui/reach.js +185 -0
- package/dist/ui/reach.js.map +1 -0
- package/dist/ui/regions.d.ts +82 -0
- package/dist/ui/regions.js +67 -0
- package/dist/ui/regions.js.map +1 -0
- package/dist/ui/review.bundle.js +69 -0
- package/dist/ui/review.d.ts +97 -0
- package/dist/ui/review.js +39 -0
- package/dist/ui/review.js.map +1 -0
- package/dist/ui/root.d.ts +72 -0
- package/dist/ui/root.js +102 -0
- package/dist/ui/root.js.map +1 -0
- package/dist/ui/route.d.ts +95 -0
- package/dist/ui/route.js +114 -0
- package/dist/ui/route.js.map +1 -0
- package/dist/ui/run.d.ts +26 -0
- package/dist/ui/run.js +12 -0
- package/dist/ui/run.js.map +1 -0
- package/dist/ui/sense.d.ts +187 -0
- package/dist/ui/sense.js +222 -0
- package/dist/ui/sense.js.map +1 -0
- package/dist/ui/settled.d.ts +43 -0
- package/dist/ui/settled.js +59 -0
- package/dist/ui/settled.js.map +1 -0
- package/dist/ui/shell.d.ts +76 -0
- package/dist/ui/shell.js +65 -0
- package/dist/ui/shell.js.map +1 -0
- package/dist/ui/shift.d.ts +52 -0
- package/dist/ui/shift.js +98 -0
- package/dist/ui/shift.js.map +1 -0
- package/dist/ui/styles-docket.d.ts +18 -0
- package/dist/ui/styles-docket.js +281 -0
- package/dist/ui/styles-docket.js.map +1 -0
- package/dist/ui/styles-stage.d.ts +18 -0
- package/dist/ui/styles-stage.js +92 -0
- package/dist/ui/styles-stage.js.map +1 -0
- package/dist/ui/styles.d.ts +12 -0
- package/dist/ui/styles.js +482 -0
- package/dist/ui/styles.js.map +1 -0
- package/dist/ui/subject.d.ts +36 -0
- package/dist/ui/subject.js +138 -0
- package/dist/ui/subject.js.map +1 -0
- package/dist/ui/text.d.ts +129 -0
- package/dist/ui/text.js +164 -0
- package/dist/ui/text.js.map +1 -0
- package/dist/ui/variations.d.ts +37 -0
- package/dist/ui/variations.js +60 -0
- package/dist/ui/variations.js.map +1 -0
- package/dist/ui/viewer.d.ts +67 -0
- package/dist/ui/viewer.js +246 -0
- package/dist/ui/viewer.js.map +1 -0
- package/dist/worker-auth.d.ts +48 -0
- package/dist/worker-auth.js +74 -0
- package/dist/worker-auth.js.map +1 -0
- package/dist/worker-entry.d.ts +58 -0
- package/dist/worker-entry.js +36 -0
- package/dist/worker-entry.js.map +1 -0
- package/dist/worker-http.d.ts +39 -0
- package/dist/worker-http.js +111 -0
- package/dist/worker-http.js.map +1 -0
- package/dist/worker-input.d.ts +81 -0
- package/dist/worker-input.js +296 -0
- package/dist/worker-input.js.map +1 -0
- package/dist/worker.d.ts +91 -0
- package/dist/worker.js +287 -0
- package/dist/worker.js.map +1 -0
- package/mark.svg +30 -0
- package/migrations/0001_initial.sql +193 -0
- package/migrations/0002_changelog.sql +30 -0
- package/migrations/0003_accessibility.sql +8 -0
- package/migrations/0004_variations.sql +19 -0
- package/migrations/0005_reach.sql +26 -0
- package/migrations/0006_baseline-size.sql +7 -0
- package/migrations/0007_declarations.sql +7 -0
- package/migrations/0008_green-because.sql +7 -0
- package/migrations/0009_moved-bands.sql +6 -0
- package/migrations/0010_composition.sql +15 -0
- package/migrations/0011_movements.sql +21 -0
- package/migrations/0012_control-size.sql +6 -0
- package/package.json +110 -0
- package/wrangler.jsonc +45 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { DatabaseSync } from 'node:sqlite';
|
|
2
|
+
import type { D1Like } from '../bindings.js';
|
|
3
|
+
export interface SqliteDatabase extends D1Like {
|
|
4
|
+
/** Ends the process's hold on the file. `':memory:'` disappears with it. */
|
|
5
|
+
close(): void;
|
|
6
|
+
}
|
|
7
|
+
export interface TribunalDatabase extends SqliteDatabase {
|
|
8
|
+
/** Where it actually landed, absolute — printed at startup so it can be found again. */
|
|
9
|
+
readonly path: string;
|
|
10
|
+
/** The version the file is at once {@link openDatabase} has returned. */
|
|
11
|
+
readonly version: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Open the file, bring it to {@link SCHEMA_VERSION}, and hand back a `D1Like`.
|
|
15
|
+
*
|
|
16
|
+
* Fresh file, deployed file and half-upgraded file are three cases and this is
|
|
17
|
+
* the only place that distinguishes them; every caller downstream gets a
|
|
18
|
+
* database at the current version or an exception with a sentence in it.
|
|
19
|
+
*/
|
|
20
|
+
export declare function openDatabase(path: string): Promise<TribunalDatabase>;
|
|
21
|
+
/** The version a file is at, or `null` for a database with no schema yet. */
|
|
22
|
+
export declare function schemaVersionOf(db: D1Like): Promise<number | null>;
|
|
23
|
+
/**
|
|
24
|
+
* The wrapper itself: `node:sqlite` in, {@link D1Like} out.
|
|
25
|
+
*
|
|
26
|
+
* The one behavioural difference from a `DatabaseSync` used directly is that
|
|
27
|
+
* `first` answers `null` where `get` answers `undefined`. That is not cosmetic:
|
|
28
|
+
* `null` is D1's "no row", the whole store distinguishes it from a failure, and a
|
|
29
|
+
* wrapper that returned `undefined` would let a `=== null` check silently stop
|
|
30
|
+
* being true.
|
|
31
|
+
*/
|
|
32
|
+
export declare function wrapSqlite(database: DatabaseSync): SqliteDatabase;
|
|
33
|
+
//# sourceMappingURL=database.d.ts.map
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { INITIAL_VERSION, MIGRATIONS, SCHEMA_VERSION, applySchema } from '../schema.js';
|
|
3
|
+
/**
|
|
4
|
+
* D1, as a file on a disk somebody owns.
|
|
5
|
+
*
|
|
6
|
+
* D1 *is* SQLite, so this is an adapter rather than a port: the same `SCHEMA`,
|
|
7
|
+
* the same indexes, the same append-only triggers and the same `ON CONFLICT`
|
|
8
|
+
* clauses execute here, through `node:sqlite`, that a Worker executes through the
|
|
9
|
+
* platform. Nothing above this file knows which one it got — `review.ts`,
|
|
10
|
+
* `store.ts` and `history.ts` take a {@link D1Like} and have never named a
|
|
11
|
+
* runtime.
|
|
12
|
+
*
|
|
13
|
+
* The double in [`testing.ts`](../testing.ts) is this same wrapper over
|
|
14
|
+
* `':memory:'`. That is deliberate and it is the point: the adapter an operator
|
|
15
|
+
* deploys is the adapter the suite runs, so a test that passes is a test of the
|
|
16
|
+
* shipped thing rather than of a paraphrase of it.
|
|
17
|
+
*
|
|
18
|
+
* ## What this does that the Worker cannot
|
|
19
|
+
*
|
|
20
|
+
* **It migrates on open.** A Worker must not — `applySchema` is documented as the
|
|
21
|
+
* operator's to call, because a handler that migrates on first use migrates
|
|
22
|
+
* concurrently under load and D1 has no advisory lock to serialize it with. A
|
|
23
|
+
* single process opening a single file at startup has neither problem: it holds
|
|
24
|
+
* the file, it is not yet serving, and `BEGIN IMMEDIATE` is a real lock. So the
|
|
25
|
+
* one piece of ceremony a Cloudflare deployment cannot avoid is one this
|
|
26
|
+
* deployment does not have.
|
|
27
|
+
*
|
|
28
|
+
* **It refuses a database from the future.** A file at a `schema_version` this
|
|
29
|
+
* build has never heard of was written by a newer build, and the columns it would
|
|
30
|
+
* read are the ones it does not know are there. Opening it read-write is how a
|
|
31
|
+
* downgrade quietly writes rows the newer build then misreads.
|
|
32
|
+
*/
|
|
33
|
+
const { DatabaseSync: Database } = createRequire(import.meta.url)('node:sqlite');
|
|
34
|
+
/**
|
|
35
|
+
* Open the file, bring it to {@link SCHEMA_VERSION}, and hand back a `D1Like`.
|
|
36
|
+
*
|
|
37
|
+
* Fresh file, deployed file and half-upgraded file are three cases and this is
|
|
38
|
+
* the only place that distinguishes them; every caller downstream gets a
|
|
39
|
+
* database at the current version or an exception with a sentence in it.
|
|
40
|
+
*/
|
|
41
|
+
export async function openDatabase(path) {
|
|
42
|
+
const database = new Database(path);
|
|
43
|
+
// WAL, because a reviewer reading a build while CI posts one is the ordinary
|
|
44
|
+
// case and rollback-journal SQLite blocks readers behind that writer.
|
|
45
|
+
// `busy_timeout` is the other half: two writers now wait rather than one
|
|
46
|
+
// failing instantly with SQLITE_BUSY.
|
|
47
|
+
database.exec('PRAGMA journal_mode = WAL');
|
|
48
|
+
database.exec('PRAGMA busy_timeout = 5000');
|
|
49
|
+
database.exec('PRAGMA foreign_keys = ON');
|
|
50
|
+
const db = wrapSqlite(database);
|
|
51
|
+
const version = await migrate(db);
|
|
52
|
+
return Object.assign(db, { path, version });
|
|
53
|
+
}
|
|
54
|
+
/** The version a file is at, or `null` for a database with no schema yet. */
|
|
55
|
+
export async function schemaVersionOf(db) {
|
|
56
|
+
const table = await db
|
|
57
|
+
.prepare(`SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'schema_version'`)
|
|
58
|
+
.first();
|
|
59
|
+
if (table === null)
|
|
60
|
+
return null;
|
|
61
|
+
const row = await db.prepare('SELECT version FROM schema_version').first();
|
|
62
|
+
if (row === null || typeof row.version !== 'number') {
|
|
63
|
+
throw new Error('this database has a `schema_version` table with no version in it. That is not a state ' +
|
|
64
|
+
'any migration here can produce, so the file was edited by something else; point ' +
|
|
65
|
+
'`--database` at a new path rather than letting a repair guess what the rest of the rows mean');
|
|
66
|
+
}
|
|
67
|
+
return row.version;
|
|
68
|
+
}
|
|
69
|
+
async function migrate(db) {
|
|
70
|
+
const version = await schemaVersionOf(db);
|
|
71
|
+
if (version === null) {
|
|
72
|
+
await applySchema(db);
|
|
73
|
+
return SCHEMA_VERSION;
|
|
74
|
+
}
|
|
75
|
+
if (version === SCHEMA_VERSION)
|
|
76
|
+
return version;
|
|
77
|
+
if (version > SCHEMA_VERSION) {
|
|
78
|
+
db.close();
|
|
79
|
+
throw new Error(`this database is at schema version ${version} and this build knows ${SCHEMA_VERSION}. It ` +
|
|
80
|
+
'was written by a newer @variance-authority/tribunal, and the columns that build added ' +
|
|
81
|
+
'are the ones this one does not know to write. Upgrade the service rather than ' +
|
|
82
|
+
'downgrading the record');
|
|
83
|
+
}
|
|
84
|
+
if (version < INITIAL_VERSION) {
|
|
85
|
+
db.close();
|
|
86
|
+
throw new Error(`this database is at schema version ${version}, which predates the first published shape ` +
|
|
87
|
+
`(${INITIAL_VERSION}). There is no step from it, because there was never a released ` +
|
|
88
|
+
'build that wrote it');
|
|
89
|
+
}
|
|
90
|
+
// Each step ends by writing the version it lands on, and each runs in its own
|
|
91
|
+
// batch — so an interrupted upgrade leaves the file at a version whose shape it
|
|
92
|
+
// actually has, and the next open resumes from there rather than replaying a
|
|
93
|
+
// step that already landed.
|
|
94
|
+
for (const step of MIGRATIONS.slice(version - INITIAL_VERSION)) {
|
|
95
|
+
await db.batch(step.map((statement) => db.prepare(statement)));
|
|
96
|
+
}
|
|
97
|
+
return SCHEMA_VERSION;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The wrapper itself: `node:sqlite` in, {@link D1Like} out.
|
|
101
|
+
*
|
|
102
|
+
* The one behavioural difference from a `DatabaseSync` used directly is that
|
|
103
|
+
* `first` answers `null` where `get` answers `undefined`. That is not cosmetic:
|
|
104
|
+
* `null` is D1's "no row", the whole store distinguishes it from a failure, and a
|
|
105
|
+
* wrapper that returned `undefined` would let a `=== null` check silently stop
|
|
106
|
+
* being true.
|
|
107
|
+
*/
|
|
108
|
+
export function wrapSqlite(database) {
|
|
109
|
+
const statement = (sql, values) => ({
|
|
110
|
+
bind: (...bound) => statement(sql, bound),
|
|
111
|
+
async first() {
|
|
112
|
+
const row = database.prepare(sql).get(...inputs(values));
|
|
113
|
+
return row === undefined ? null : row;
|
|
114
|
+
},
|
|
115
|
+
async all() {
|
|
116
|
+
return { results: database.prepare(sql).all(...inputs(values)) };
|
|
117
|
+
},
|
|
118
|
+
async run() {
|
|
119
|
+
return database.prepare(sql).run(...inputs(values));
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
return {
|
|
123
|
+
prepare: (sql) => statement(sql, []),
|
|
124
|
+
/**
|
|
125
|
+
* All of them, or none of them.
|
|
126
|
+
*
|
|
127
|
+
* D1 documents `batch` as running inside an implicit transaction that rolls
|
|
128
|
+
* back on any failure, and the history backend's atomicity requirement rests
|
|
129
|
+
* entirely on that. Here it is a real `BEGIN IMMEDIATE`, so a caller that
|
|
130
|
+
* expects a half-written run to leave nothing behind is getting the property
|
|
131
|
+
* rather than the platform's description of it.
|
|
132
|
+
*/
|
|
133
|
+
async batch(statements) {
|
|
134
|
+
database.exec('BEGIN IMMEDIATE');
|
|
135
|
+
try {
|
|
136
|
+
const results = [];
|
|
137
|
+
for (const prepared of statements)
|
|
138
|
+
results.push(await prepared.run());
|
|
139
|
+
database.exec('COMMIT');
|
|
140
|
+
return results;
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
database.exec('ROLLBACK');
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
close: () => database.close(),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* `node:sqlite` accepts `null`, numbers, strings and `Uint8Array`; D1 speaks
|
|
152
|
+
* `ArrayBuffer`. One conversion, in the one place the two vocabularies meet.
|
|
153
|
+
*/
|
|
154
|
+
function inputs(values) {
|
|
155
|
+
return values.map((value) => (value instanceof ArrayBuffer ? new Uint8Array(value) : value));
|
|
156
|
+
}
|
|
157
|
+
//# sourceMappingURL=database.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"database.js","sourceRoot":"","sources":["../../src/node/database.ts"],"names":[],"mappings":"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","sourcesContent":["import { createRequire } from 'node:module';\nimport type { DatabaseSync, SQLInputValue } from 'node:sqlite';\nimport type { D1Like, D1PreparedLike, D1Value } from '../bindings.js';\nimport { INITIAL_VERSION, MIGRATIONS, SCHEMA_VERSION, applySchema } from '../schema.js';\n\n/**\n * D1, as a file on a disk somebody owns.\n *\n * D1 *is* SQLite, so this is an adapter rather than a port: the same `SCHEMA`,\n * the same indexes, the same append-only triggers and the same `ON CONFLICT`\n * clauses execute here, through `node:sqlite`, that a Worker executes through the\n * platform. Nothing above this file knows which one it got — `review.ts`,\n * `store.ts` and `history.ts` take a {@link D1Like} and have never named a\n * runtime.\n *\n * The double in [`testing.ts`](../testing.ts) is this same wrapper over\n * `':memory:'`. That is deliberate and it is the point: the adapter an operator\n * deploys is the adapter the suite runs, so a test that passes is a test of the\n * shipped thing rather than of a paraphrase of it.\n *\n * ## What this does that the Worker cannot\n *\n * **It migrates on open.** A Worker must not — `applySchema` is documented as the\n * operator's to call, because a handler that migrates on first use migrates\n * concurrently under load and D1 has no advisory lock to serialize it with. A\n * single process opening a single file at startup has neither problem: it holds\n * the file, it is not yet serving, and `BEGIN IMMEDIATE` is a real lock. So the\n * one piece of ceremony a Cloudflare deployment cannot avoid is one this\n * deployment does not have.\n *\n * **It refuses a database from the future.** A file at a `schema_version` this\n * build has never heard of was written by a newer build, and the columns it would\n * read are the ones it does not know are there. Opening it read-write is how a\n * downgrade quietly writes rows the newer build then misreads.\n */\n\nconst { DatabaseSync: Database } = createRequire(import.meta.url)('node:sqlite') as {\n DatabaseSync: new (path: string) => DatabaseSync;\n};\n\nexport interface SqliteDatabase extends D1Like {\n /** Ends the process's hold on the file. `':memory:'` disappears with it. */\n close(): void;\n}\n\nexport interface TribunalDatabase extends SqliteDatabase {\n /** Where it actually landed, absolute — printed at startup so it can be found again. */\n readonly path: string;\n /** The version the file is at once {@link openDatabase} has returned. */\n readonly version: number;\n}\n\n/**\n * Open the file, bring it to {@link SCHEMA_VERSION}, and hand back a `D1Like`.\n *\n * Fresh file, deployed file and half-upgraded file are three cases and this is\n * the only place that distinguishes them; every caller downstream gets a\n * database at the current version or an exception with a sentence in it.\n */\nexport async function openDatabase(path: string): Promise<TribunalDatabase> {\n const database = new Database(path);\n\n // WAL, because a reviewer reading a build while CI posts one is the ordinary\n // case and rollback-journal SQLite blocks readers behind that writer.\n // `busy_timeout` is the other half: two writers now wait rather than one\n // failing instantly with SQLITE_BUSY.\n database.exec('PRAGMA journal_mode = WAL');\n database.exec('PRAGMA busy_timeout = 5000');\n database.exec('PRAGMA foreign_keys = ON');\n\n const db = wrapSqlite(database);\n const version = await migrate(db);\n return Object.assign(db, { path, version });\n}\n\n/** The version a file is at, or `null` for a database with no schema yet. */\nexport async function schemaVersionOf(db: D1Like): Promise<number | null> {\n const table = await db\n .prepare(`SELECT name FROM sqlite_schema WHERE type = 'table' AND name = 'schema_version'`)\n .first<{ readonly name: string }>();\n if (table === null) return null;\n\n const row = await db.prepare('SELECT version FROM schema_version').first<{ readonly version: number }>();\n if (row === null || typeof row.version !== 'number') {\n throw new Error(\n 'this database has a `schema_version` table with no version in it. That is not a state ' +\n 'any migration here can produce, so the file was edited by something else; point ' +\n '`--database` at a new path rather than letting a repair guess what the rest of the rows mean',\n );\n }\n return row.version;\n}\n\nasync function migrate(db: SqliteDatabase): Promise<number> {\n const version = await schemaVersionOf(db);\n\n if (version === null) {\n await applySchema(db);\n return SCHEMA_VERSION;\n }\n\n if (version === SCHEMA_VERSION) return version;\n\n if (version > SCHEMA_VERSION) {\n db.close();\n throw new Error(\n `this database is at schema version ${version} and this build knows ${SCHEMA_VERSION}. It ` +\n 'was written by a newer @variance-authority/tribunal, and the columns that build added ' +\n 'are the ones this one does not know to write. Upgrade the service rather than ' +\n 'downgrading the record',\n );\n }\n\n if (version < INITIAL_VERSION) {\n db.close();\n throw new Error(\n `this database is at schema version ${version}, which predates the first published shape ` +\n `(${INITIAL_VERSION}). There is no step from it, because there was never a released ` +\n 'build that wrote it',\n );\n }\n\n // Each step ends by writing the version it lands on, and each runs in its own\n // batch — so an interrupted upgrade leaves the file at a version whose shape it\n // actually has, and the next open resumes from there rather than replaying a\n // step that already landed.\n for (const step of MIGRATIONS.slice(version - INITIAL_VERSION)) {\n await db.batch(step.map((statement) => db.prepare(statement)));\n }\n return SCHEMA_VERSION;\n}\n\n/**\n * The wrapper itself: `node:sqlite` in, {@link D1Like} out.\n *\n * The one behavioural difference from a `DatabaseSync` used directly is that\n * `first` answers `null` where `get` answers `undefined`. That is not cosmetic:\n * `null` is D1's \"no row\", the whole store distinguishes it from a failure, and a\n * wrapper that returned `undefined` would let a `=== null` check silently stop\n * being true.\n */\nexport function wrapSqlite(database: DatabaseSync): SqliteDatabase {\n const statement = (sql: string, values: readonly D1Value[]): D1PreparedLike => ({\n bind: (...bound: readonly D1Value[]) => statement(sql, bound),\n async first<Row>(): Promise<Row | null> {\n const row = database.prepare(sql).get(...inputs(values));\n return row === undefined ? null : (row as Row);\n },\n async all<Row>(): Promise<{ readonly results: readonly Row[] }> {\n return { results: database.prepare(sql).all(...inputs(values)) as Row[] };\n },\n async run(): Promise<unknown> {\n return database.prepare(sql).run(...inputs(values));\n },\n });\n\n return {\n prepare: (sql: string) => statement(sql, []),\n\n /**\n * All of them, or none of them.\n *\n * D1 documents `batch` as running inside an implicit transaction that rolls\n * back on any failure, and the history backend's atomicity requirement rests\n * entirely on that. Here it is a real `BEGIN IMMEDIATE`, so a caller that\n * expects a half-written run to leave nothing behind is getting the property\n * rather than the platform's description of it.\n */\n async batch(statements: readonly D1PreparedLike[]): Promise<unknown> {\n database.exec('BEGIN IMMEDIATE');\n try {\n const results: unknown[] = [];\n for (const prepared of statements) results.push(await prepared.run());\n database.exec('COMMIT');\n return results;\n } catch (error) {\n database.exec('ROLLBACK');\n throw error;\n }\n },\n\n close: () => database.close(),\n };\n}\n\n/**\n * `node:sqlite` accepts `null`, numbers, strings and `Uint8Array`; D1 speaks\n * `ArrayBuffer`. One conversion, in the one place the two vocabularies meet.\n */\nfunction inputs(values: readonly D1Value[]): SQLInputValue[] {\n return values.map((value) => (value instanceof ArrayBuffer ? new Uint8Array(value) : value));\n}\n"]}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The service, on a machine somebody owns — a file, a directory and a port.
|
|
3
|
+
*
|
|
4
|
+
* The same review service `./worker` serves on Cloudflare. D1 is SQLite through
|
|
5
|
+
* `node:sqlite`, R2 is a directory through `node:fs`, and the router between them
|
|
6
|
+
* is byte-for-byte the code a Worker runs, because every module above the
|
|
7
|
+
* bindings takes a `D1Like` and an `R2Like` and has never named a runtime.
|
|
8
|
+
*
|
|
9
|
+
* Requires Node 22 for `node:sqlite`, which is why it is its own entrypoint: a
|
|
10
|
+
* Worker importing `@variance-authority/tribunal/worker` must not pull a Node
|
|
11
|
+
* built-in into its bundle to do it.
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { createDirectoryBucket, openDatabase, serveTribunal } from '@variance-authority/tribunal/node';
|
|
15
|
+
* import { createTribunal } from '@variance-authority/tribunal/worker';
|
|
16
|
+
*
|
|
17
|
+
* const db = await openDatabase('variance-tribunal.db');
|
|
18
|
+
* const tribunal = createTribunal({
|
|
19
|
+
* db,
|
|
20
|
+
* bucket: createDirectoryBucket('variance-tribunal-objects'),
|
|
21
|
+
* project: 'todomvc',
|
|
22
|
+
* ingestToken: process.env.INGEST_TOKEN!,
|
|
23
|
+
* reviewToken: process.env.REVIEW_TOKEN!,
|
|
24
|
+
* });
|
|
25
|
+
*
|
|
26
|
+
* await serveTribunal({
|
|
27
|
+
* tribunal,
|
|
28
|
+
* host: '127.0.0.1',
|
|
29
|
+
* port: 7789,
|
|
30
|
+
* authorize: () => 'review',
|
|
31
|
+
* tokens: { ingest: process.env.INGEST_TOKEN!, review: process.env.REVIEW_TOKEN! },
|
|
32
|
+
* });
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* Or run `variance-authority-tribunal` and configure it with an environment —
|
|
36
|
+
* [`bin.ts`](./bin.ts) is that program, and the policy it applies is written out
|
|
37
|
+
* there rather than assumed here.
|
|
38
|
+
*/
|
|
39
|
+
export { createDirectoryBucket, type DirectoryBucket } from './bucket.js';
|
|
40
|
+
export { openDatabase, schemaVersionOf, wrapSqlite, type SqliteDatabase, type TribunalDatabase, } from './database.js';
|
|
41
|
+
export { MAX_BODY_BYTES, createRequestListener, serveTribunal, type TribunalService, type TribunalServiceOptions, } from './serve.js';
|
|
42
|
+
export { reviewPage, uiAsset, type ReviewPageOptions } from './ui-assets.js';
|
|
43
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The service, on a machine somebody owns — a file, a directory and a port.
|
|
3
|
+
*
|
|
4
|
+
* The same review service `./worker` serves on Cloudflare. D1 is SQLite through
|
|
5
|
+
* `node:sqlite`, R2 is a directory through `node:fs`, and the router between them
|
|
6
|
+
* is byte-for-byte the code a Worker runs, because every module above the
|
|
7
|
+
* bindings takes a `D1Like` and an `R2Like` and has never named a runtime.
|
|
8
|
+
*
|
|
9
|
+
* Requires Node 22 for `node:sqlite`, which is why it is its own entrypoint: a
|
|
10
|
+
* Worker importing `@variance-authority/tribunal/worker` must not pull a Node
|
|
11
|
+
* built-in into its bundle to do it.
|
|
12
|
+
*
|
|
13
|
+
* ```ts
|
|
14
|
+
* import { createDirectoryBucket, openDatabase, serveTribunal } from '@variance-authority/tribunal/node';
|
|
15
|
+
* import { createTribunal } from '@variance-authority/tribunal/worker';
|
|
16
|
+
*
|
|
17
|
+
* const db = await openDatabase('variance-tribunal.db');
|
|
18
|
+
* const tribunal = createTribunal({
|
|
19
|
+
* db,
|
|
20
|
+
* bucket: createDirectoryBucket('variance-tribunal-objects'),
|
|
21
|
+
* project: 'todomvc',
|
|
22
|
+
* ingestToken: process.env.INGEST_TOKEN!,
|
|
23
|
+
* reviewToken: process.env.REVIEW_TOKEN!,
|
|
24
|
+
* });
|
|
25
|
+
*
|
|
26
|
+
* await serveTribunal({
|
|
27
|
+
* tribunal,
|
|
28
|
+
* host: '127.0.0.1',
|
|
29
|
+
* port: 7789,
|
|
30
|
+
* authorize: () => 'review',
|
|
31
|
+
* tokens: { ingest: process.env.INGEST_TOKEN!, review: process.env.REVIEW_TOKEN! },
|
|
32
|
+
* });
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* Or run `variance-authority-tribunal` and configure it with an environment —
|
|
36
|
+
* [`bin.ts`](./bin.ts) is that program, and the policy it applies is written out
|
|
37
|
+
* there rather than assumed here.
|
|
38
|
+
*/
|
|
39
|
+
export { createDirectoryBucket } from './bucket.js';
|
|
40
|
+
export { openDatabase, schemaVersionOf, wrapSqlite, } from './database.js';
|
|
41
|
+
export { MAX_BODY_BYTES, createRequestListener, serveTribunal, } from './serve.js';
|
|
42
|
+
export { reviewPage, uiAsset } from './ui-assets.js';
|
|
43
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/node/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAEH,OAAO,EAAE,qBAAqB,EAAwB,MAAM,aAAa,CAAC;AAC1E,OAAO,EACL,YAAY,EACZ,eAAe,EACf,UAAU,GAGX,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,cAAc,EACd,qBAAqB,EACrB,aAAa,GAGd,MAAM,YAAY,CAAC;AACpB,OAAO,EAAE,UAAU,EAAE,OAAO,EAA0B,MAAM,gBAAgB,CAAC","sourcesContent":["/**\n * The service, on a machine somebody owns — a file, a directory and a port.\n *\n * The same review service `./worker` serves on Cloudflare. D1 is SQLite through\n * `node:sqlite`, R2 is a directory through `node:fs`, and the router between them\n * is byte-for-byte the code a Worker runs, because every module above the\n * bindings takes a `D1Like` and an `R2Like` and has never named a runtime.\n *\n * Requires Node 22 for `node:sqlite`, which is why it is its own entrypoint: a\n * Worker importing `@variance-authority/tribunal/worker` must not pull a Node\n * built-in into its bundle to do it.\n *\n * ```ts\n * import { createDirectoryBucket, openDatabase, serveTribunal } from '@variance-authority/tribunal/node';\n * import { createTribunal } from '@variance-authority/tribunal/worker';\n *\n * const db = await openDatabase('variance-tribunal.db');\n * const tribunal = createTribunal({\n * db,\n * bucket: createDirectoryBucket('variance-tribunal-objects'),\n * project: 'todomvc',\n * ingestToken: process.env.INGEST_TOKEN!,\n * reviewToken: process.env.REVIEW_TOKEN!,\n * });\n *\n * await serveTribunal({\n * tribunal,\n * host: '127.0.0.1',\n * port: 7789,\n * authorize: () => 'review',\n * tokens: { ingest: process.env.INGEST_TOKEN!, review: process.env.REVIEW_TOKEN! },\n * });\n * ```\n *\n * Or run `variance-authority-tribunal` and configure it with an environment —\n * [`bin.ts`](./bin.ts) is that program, and the policy it applies is written out\n * there rather than assumed here.\n */\n\nexport { createDirectoryBucket, type DirectoryBucket } from './bucket.js';\nexport {\n openDatabase,\n schemaVersionOf,\n wrapSqlite,\n type SqliteDatabase,\n type TribunalDatabase,\n} from './database.js';\nexport {\n MAX_BODY_BYTES,\n createRequestListener,\n serveTribunal,\n type TribunalService,\n type TribunalServiceOptions,\n} from './serve.js';\nexport { reviewPage, uiAsset, type ReviewPageOptions } from './ui-assets.js';\n"]}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
|
2
|
+
import type { Tribunal } from '../worker.js';
|
|
3
|
+
/**
|
|
4
|
+
* The socket: `node:http` in, {@link Tribunal.fetch} out.
|
|
5
|
+
*
|
|
6
|
+
* A Worker handler is `(Request) => Promise<Response>` and a Node server is
|
|
7
|
+
* `(IncomingMessage, ServerResponse) => void`, and the whole of this file is the
|
|
8
|
+
* conversion between those two plus the two decisions the conversion forces.
|
|
9
|
+
* Nothing here knows what a build is; every route, every refusal and every status
|
|
10
|
+
* code is the same code a Cloudflare deployment runs.
|
|
11
|
+
*
|
|
12
|
+
* Shaped after [`server/http.ts`](../../../server/src/http.ts): `node:http`, no
|
|
13
|
+
* framework, and no logic of its own beyond transport.
|
|
14
|
+
*
|
|
15
|
+
* ## The two decisions
|
|
16
|
+
*
|
|
17
|
+
* **A body has a ceiling.** A Worker gets one from the platform; a Node process
|
|
18
|
+
* gets one from whoever wrote the reader. An unbounded `POST /review/builds`
|
|
19
|
+
* carrying three hundred base64 PNGs is a heap the operator did not budget for,
|
|
20
|
+
* and the failure mode is the process dying rather than the request being
|
|
21
|
+
* refused. So there is a limit, it is a number, and exceeding it is a 413 with a
|
|
22
|
+
* sentence rather than an OOM.
|
|
23
|
+
*
|
|
24
|
+
* **Who may review is not this file's to assume.** The review token promotes
|
|
25
|
+
* baselines, so a browser must never hold it — the argument is in
|
|
26
|
+
* [`next.ts`](../next.ts) and it does not change because the host did. So
|
|
27
|
+
* `authorize` is required here too, and the mount reuses `createTribunalRoutes`
|
|
28
|
+
* rather than re-deriving the token-attaching rule: one implementation of "which
|
|
29
|
+
* capability is this caller allowed" means the Node service and the Next.js app
|
|
30
|
+
* cannot drift into two answers.
|
|
31
|
+
*/
|
|
32
|
+
/**
|
|
33
|
+
* Largest request body accepted, in bytes.
|
|
34
|
+
*
|
|
35
|
+
* 256 MiB: a build posts every image it kept, base64-encoded, in one JSON
|
|
36
|
+
* document, and a wide Storybook at two device scales gets big. It is a ceiling
|
|
37
|
+
* on damage rather than a target — an operator whose builds approach it should
|
|
38
|
+
* be keeping fewer images, not raising this.
|
|
39
|
+
*/
|
|
40
|
+
export declare const MAX_BODY_BYTES: number;
|
|
41
|
+
export interface TribunalServiceOptions {
|
|
42
|
+
readonly tribunal: Tribunal;
|
|
43
|
+
/**
|
|
44
|
+
* Which capability a request is allowed. No default — see the note above.
|
|
45
|
+
*
|
|
46
|
+
* The shipped policy is in [`bin.ts`](./bin.ts), where an operator can read it.
|
|
47
|
+
*/
|
|
48
|
+
authorize(request: Request): Promise<'ingest' | 'review' | null> | 'ingest' | 'review' | null;
|
|
49
|
+
/** The tokens the tribunal was constructed with, for the mount to attach. */
|
|
50
|
+
readonly tokens: {
|
|
51
|
+
readonly ingest: string;
|
|
52
|
+
readonly review: string;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Serve the review surface at `/`, not just the JSON API.
|
|
56
|
+
*
|
|
57
|
+
* Default `true`. Off is for a deployment that only ingests — a service behind
|
|
58
|
+
* CI with no reviewer on it — where a page nobody opens is a page nobody has to
|
|
59
|
+
* authorize.
|
|
60
|
+
*/
|
|
61
|
+
readonly ui?: boolean;
|
|
62
|
+
/** Shown in the surface as who is deciding. The record carries it on every approval. */
|
|
63
|
+
readonly reviewer?: string;
|
|
64
|
+
}
|
|
65
|
+
export interface TribunalService {
|
|
66
|
+
readonly server: Server;
|
|
67
|
+
/** Where it is actually listening, with the port the OS chose if `0` was asked for. */
|
|
68
|
+
readonly url: string;
|
|
69
|
+
close(): Promise<void>;
|
|
70
|
+
}
|
|
71
|
+
/** Listen, and resolve once the socket is accepting. */
|
|
72
|
+
export declare function serveTribunal(options: TribunalServiceOptions & {
|
|
73
|
+
readonly host: string;
|
|
74
|
+
readonly port: number;
|
|
75
|
+
}): Promise<TribunalService>;
|
|
76
|
+
/**
|
|
77
|
+
* The handler, without the socket — so a test can drive it and a host that
|
|
78
|
+
* already owns its listener can mount it.
|
|
79
|
+
*/
|
|
80
|
+
export declare function createRequestListener(options: TribunalServiceOptions): (request: IncomingMessage, response: ServerResponse) => void;
|
|
81
|
+
//# sourceMappingURL=serve.d.ts.map
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { createTribunalRoutes } from '../next.js';
|
|
3
|
+
import { isPagePath } from '../ui/route.js';
|
|
4
|
+
import { reviewPage, uiAsset } from './ui-assets.js';
|
|
5
|
+
/**
|
|
6
|
+
* The socket: `node:http` in, {@link Tribunal.fetch} out.
|
|
7
|
+
*
|
|
8
|
+
* A Worker handler is `(Request) => Promise<Response>` and a Node server is
|
|
9
|
+
* `(IncomingMessage, ServerResponse) => void`, and the whole of this file is the
|
|
10
|
+
* conversion between those two plus the two decisions the conversion forces.
|
|
11
|
+
* Nothing here knows what a build is; every route, every refusal and every status
|
|
12
|
+
* code is the same code a Cloudflare deployment runs.
|
|
13
|
+
*
|
|
14
|
+
* Shaped after [`server/http.ts`](../../../server/src/http.ts): `node:http`, no
|
|
15
|
+
* framework, and no logic of its own beyond transport.
|
|
16
|
+
*
|
|
17
|
+
* ## The two decisions
|
|
18
|
+
*
|
|
19
|
+
* **A body has a ceiling.** A Worker gets one from the platform; a Node process
|
|
20
|
+
* gets one from whoever wrote the reader. An unbounded `POST /review/builds`
|
|
21
|
+
* carrying three hundred base64 PNGs is a heap the operator did not budget for,
|
|
22
|
+
* and the failure mode is the process dying rather than the request being
|
|
23
|
+
* refused. So there is a limit, it is a number, and exceeding it is a 413 with a
|
|
24
|
+
* sentence rather than an OOM.
|
|
25
|
+
*
|
|
26
|
+
* **Who may review is not this file's to assume.** The review token promotes
|
|
27
|
+
* baselines, so a browser must never hold it — the argument is in
|
|
28
|
+
* [`next.ts`](../next.ts) and it does not change because the host did. So
|
|
29
|
+
* `authorize` is required here too, and the mount reuses `createTribunalRoutes`
|
|
30
|
+
* rather than re-deriving the token-attaching rule: one implementation of "which
|
|
31
|
+
* capability is this caller allowed" means the Node service and the Next.js app
|
|
32
|
+
* cannot drift into two answers.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* Largest request body accepted, in bytes.
|
|
36
|
+
*
|
|
37
|
+
* 256 MiB: a build posts every image it kept, base64-encoded, in one JSON
|
|
38
|
+
* document, and a wide Storybook at two device scales gets big. It is a ceiling
|
|
39
|
+
* on damage rather than a target — an operator whose builds approach it should
|
|
40
|
+
* be keeping fewer images, not raising this.
|
|
41
|
+
*/
|
|
42
|
+
export const MAX_BODY_BYTES = 256 * 1024 * 1024;
|
|
43
|
+
/** Listen, and resolve once the socket is accepting. */
|
|
44
|
+
export async function serveTribunal(options) {
|
|
45
|
+
const server = createServer(createRequestListener(options));
|
|
46
|
+
await new Promise((accept, refuse) => {
|
|
47
|
+
server.once('error', refuse);
|
|
48
|
+
server.listen(options.port, options.host, () => {
|
|
49
|
+
server.removeListener('error', refuse);
|
|
50
|
+
accept();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
const address = server.address();
|
|
54
|
+
const host = address.family === 'IPv6' ? `[${address.address}]` : address.address;
|
|
55
|
+
return {
|
|
56
|
+
server,
|
|
57
|
+
url: `http://${host}:${address.port}`,
|
|
58
|
+
close: () => new Promise((accept, refuse) => {
|
|
59
|
+
server.close((error) => (error === undefined ? accept() : refuse(error)));
|
|
60
|
+
}),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The handler, without the socket — so a test can drive it and a host that
|
|
65
|
+
* already owns its listener can mount it.
|
|
66
|
+
*/
|
|
67
|
+
export function createRequestListener(options) {
|
|
68
|
+
const mounted = createTribunalRoutes(options.tribunal, {
|
|
69
|
+
authorize: options.authorize,
|
|
70
|
+
tokens: options.tokens,
|
|
71
|
+
});
|
|
72
|
+
const serveUi = options.ui ?? true;
|
|
73
|
+
return (incoming, outgoing) => {
|
|
74
|
+
void (async () => {
|
|
75
|
+
try {
|
|
76
|
+
const request = await requestOf(incoming);
|
|
77
|
+
const asset = serveUi ? await pageOrAsset(request, options.reviewer) : null;
|
|
78
|
+
const response = asset ?? (await mounted[method(request)](request));
|
|
79
|
+
await write(outgoing, response);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
await write(outgoing, refusal(error));
|
|
83
|
+
}
|
|
84
|
+
})();
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The surface and its bundle, ahead of the API.
|
|
89
|
+
*
|
|
90
|
+
* Which paths are pages is not decided here. [`ui/route.ts`](../ui/route.ts)
|
|
91
|
+
* holds the table, the browser reads it to turn a path into a view, and this
|
|
92
|
+
* reads the same table to decide who gets the document — because the alternative
|
|
93
|
+
* is two lists, and they drift in the worst direction: a link the client renders
|
|
94
|
+
* happily and the server answers with a 404.
|
|
95
|
+
*
|
|
96
|
+
* Still no prefix match. Every pattern is anchored and none of them shares a
|
|
97
|
+
* first segment with an API route; `serve.test.ts` holds the two apart rather
|
|
98
|
+
* than trusting the reading, since this file must not be the reason a request
|
|
99
|
+
* never reached the router.
|
|
100
|
+
*/
|
|
101
|
+
async function pageOrAsset(request, reviewer) {
|
|
102
|
+
const path = new URL(request.url).pathname;
|
|
103
|
+
if (request.method !== 'GET' && request.method !== 'HEAD')
|
|
104
|
+
return null;
|
|
105
|
+
if (isPagePath(path)) {
|
|
106
|
+
return new Response(reviewPage({ endpoint: '', ...(reviewer === undefined ? {} : { reviewer }) }), {
|
|
107
|
+
headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
if (path === '/ui/review.js') {
|
|
111
|
+
const bundle = await uiAsset();
|
|
112
|
+
if (bundle === null)
|
|
113
|
+
return null;
|
|
114
|
+
return new Response(bundle, {
|
|
115
|
+
headers: {
|
|
116
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
117
|
+
// Immutable because the file changes only when the package does, and a
|
|
118
|
+
// reviewer who upgrades gets a new process on a new URL-less asset —
|
|
119
|
+
// so this is a lie the moment the bundle is rebuilt in place. It is
|
|
120
|
+
// therefore `no-cache`: revalidate, do not re-download.
|
|
121
|
+
'cache-control': 'no-cache',
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
function method(request) {
|
|
128
|
+
if (request.method === 'POST')
|
|
129
|
+
return 'POST';
|
|
130
|
+
if (request.method === 'HEAD')
|
|
131
|
+
return 'HEAD';
|
|
132
|
+
return 'GET';
|
|
133
|
+
}
|
|
134
|
+
/** `IncomingMessage` → `Request`, with the body read to a ceiling. */
|
|
135
|
+
async function requestOf(incoming) {
|
|
136
|
+
const url = new URL(incoming.url ?? '/', `http://${incoming.headers.host ?? 'localhost'}`);
|
|
137
|
+
const headers = new Headers();
|
|
138
|
+
for (const [name, value] of Object.entries(incoming.headers)) {
|
|
139
|
+
if (value === undefined)
|
|
140
|
+
continue;
|
|
141
|
+
for (const one of Array.isArray(value) ? value : [value])
|
|
142
|
+
headers.append(name, one);
|
|
143
|
+
}
|
|
144
|
+
const method = incoming.method ?? 'GET';
|
|
145
|
+
if (method === 'GET' || method === 'HEAD') {
|
|
146
|
+
return new Request(url, { method, headers });
|
|
147
|
+
}
|
|
148
|
+
return new Request(url, { method, headers, body: await body(incoming) });
|
|
149
|
+
}
|
|
150
|
+
async function body(incoming) {
|
|
151
|
+
const chunks = [];
|
|
152
|
+
let size = 0;
|
|
153
|
+
for await (const chunk of incoming) {
|
|
154
|
+
size += chunk.byteLength;
|
|
155
|
+
if (size > MAX_BODY_BYTES) {
|
|
156
|
+
incoming.destroy();
|
|
157
|
+
throw new PayloadTooLarge(`this request body is over ${MAX_BODY_BYTES} bytes, which is the ceiling this service ` +
|
|
158
|
+
'accepts. A build carrying every image it kept is the way this happens; keep fewer ' +
|
|
159
|
+
'images, or split the build across two posts');
|
|
160
|
+
}
|
|
161
|
+
chunks.push(chunk);
|
|
162
|
+
}
|
|
163
|
+
const joined = Buffer.concat(chunks);
|
|
164
|
+
return joined.buffer.slice(joined.byteOffset, joined.byteOffset + joined.byteLength);
|
|
165
|
+
}
|
|
166
|
+
/** `Response` → `ServerResponse`. */
|
|
167
|
+
async function write(outgoing, response) {
|
|
168
|
+
for (const [name, value] of response.headers)
|
|
169
|
+
outgoing.setHeader(name, value);
|
|
170
|
+
outgoing.statusCode = response.status;
|
|
171
|
+
if (response.body === null) {
|
|
172
|
+
outgoing.end();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
outgoing.end(Buffer.from(await response.arrayBuffer()));
|
|
176
|
+
}
|
|
177
|
+
class PayloadTooLarge extends Error {
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* An exception is a 500 with a sentence, and a refused body is a 413.
|
|
181
|
+
*
|
|
182
|
+
* Nothing in `Tribunal.fetch` throws — it answers with a status — so anything
|
|
183
|
+
* arriving here is transport or a construction failure, and the operator reading
|
|
184
|
+
* it is the person who can fix it. The message is theirs; the stack is not.
|
|
185
|
+
*/
|
|
186
|
+
function refusal(error) {
|
|
187
|
+
const message = error instanceof Error ? error.message : 'this service could not answer';
|
|
188
|
+
return new Response(JSON.stringify({ error: message }), {
|
|
189
|
+
status: error instanceof PayloadTooLarge ? 413 : 500,
|
|
190
|
+
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
//# sourceMappingURL=serve.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"serve.js","sourceRoot":"","sources":["../../src/node/serve.ts"],"names":[],"mappings":"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","sourcesContent":["import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';\nimport type { AddressInfo } from 'node:net';\nimport { createTribunalRoutes } from '../next.js';\nimport type { Tribunal } from '../worker.js';\nimport { isPagePath } from '../ui/route.js';\nimport { reviewPage, uiAsset } from './ui-assets.js';\n\n/**\n * The socket: `node:http` in, {@link Tribunal.fetch} out.\n *\n * A Worker handler is `(Request) => Promise<Response>` and a Node server is\n * `(IncomingMessage, ServerResponse) => void`, and the whole of this file is the\n * conversion between those two plus the two decisions the conversion forces.\n * Nothing here knows what a build is; every route, every refusal and every status\n * code is the same code a Cloudflare deployment runs.\n *\n * Shaped after [`server/http.ts`](../../../server/src/http.ts): `node:http`, no\n * framework, and no logic of its own beyond transport.\n *\n * ## The two decisions\n *\n * **A body has a ceiling.** A Worker gets one from the platform; a Node process\n * gets one from whoever wrote the reader. An unbounded `POST /review/builds`\n * carrying three hundred base64 PNGs is a heap the operator did not budget for,\n * and the failure mode is the process dying rather than the request being\n * refused. So there is a limit, it is a number, and exceeding it is a 413 with a\n * sentence rather than an OOM.\n *\n * **Who may review is not this file's to assume.** The review token promotes\n * baselines, so a browser must never hold it — the argument is in\n * [`next.ts`](../next.ts) and it does not change because the host did. So\n * `authorize` is required here too, and the mount reuses `createTribunalRoutes`\n * rather than re-deriving the token-attaching rule: one implementation of \"which\n * capability is this caller allowed\" means the Node service and the Next.js app\n * cannot drift into two answers.\n */\n\n/**\n * Largest request body accepted, in bytes.\n *\n * 256 MiB: a build posts every image it kept, base64-encoded, in one JSON\n * document, and a wide Storybook at two device scales gets big. It is a ceiling\n * on damage rather than a target — an operator whose builds approach it should\n * be keeping fewer images, not raising this.\n */\nexport const MAX_BODY_BYTES = 256 * 1024 * 1024;\n\nexport interface TribunalServiceOptions {\n readonly tribunal: Tribunal;\n /**\n * Which capability a request is allowed. No default — see the note above.\n *\n * The shipped policy is in [`bin.ts`](./bin.ts), where an operator can read it.\n */\n authorize(request: Request): Promise<'ingest' | 'review' | null> | 'ingest' | 'review' | null;\n /** The tokens the tribunal was constructed with, for the mount to attach. */\n readonly tokens: { readonly ingest: string; readonly review: string };\n /**\n * Serve the review surface at `/`, not just the JSON API.\n *\n * Default `true`. Off is for a deployment that only ingests — a service behind\n * CI with no reviewer on it — where a page nobody opens is a page nobody has to\n * authorize.\n */\n readonly ui?: boolean;\n /** Shown in the surface as who is deciding. The record carries it on every approval. */\n readonly reviewer?: string;\n}\n\nexport interface TribunalService {\n readonly server: Server;\n /** Where it is actually listening, with the port the OS chose if `0` was asked for. */\n readonly url: string;\n close(): Promise<void>;\n}\n\n/** Listen, and resolve once the socket is accepting. */\nexport async function serveTribunal(\n options: TribunalServiceOptions & { readonly host: string; readonly port: number },\n): Promise<TribunalService> {\n const server = createServer(createRequestListener(options));\n\n await new Promise<void>((accept, refuse) => {\n server.once('error', refuse);\n server.listen(options.port, options.host, () => {\n server.removeListener('error', refuse);\n accept();\n });\n });\n\n const address = server.address() as AddressInfo;\n const host = address.family === 'IPv6' ? `[${address.address}]` : address.address;\n\n return {\n server,\n url: `http://${host}:${address.port}`,\n close: () =>\n new Promise<void>((accept, refuse) => {\n server.close((error) => (error === undefined ? accept() : refuse(error)));\n }),\n };\n}\n\n/**\n * The handler, without the socket — so a test can drive it and a host that\n * already owns its listener can mount it.\n */\nexport function createRequestListener(\n options: TribunalServiceOptions,\n): (request: IncomingMessage, response: ServerResponse) => void {\n const mounted = createTribunalRoutes(options.tribunal, {\n authorize: options.authorize,\n tokens: options.tokens,\n });\n const serveUi = options.ui ?? true;\n\n return (incoming: IncomingMessage, outgoing: ServerResponse): void => {\n void (async () => {\n try {\n const request = await requestOf(incoming);\n\n const asset = serveUi ? await pageOrAsset(request, options.reviewer) : null;\n const response = asset ?? (await mounted[method(request)](request));\n\n await write(outgoing, response);\n } catch (error) {\n await write(outgoing, refusal(error));\n }\n })();\n };\n}\n\n/**\n * The surface and its bundle, ahead of the API.\n *\n * Which paths are pages is not decided here. [`ui/route.ts`](../ui/route.ts)\n * holds the table, the browser reads it to turn a path into a view, and this\n * reads the same table to decide who gets the document — because the alternative\n * is two lists, and they drift in the worst direction: a link the client renders\n * happily and the server answers with a 404.\n *\n * Still no prefix match. Every pattern is anchored and none of them shares a\n * first segment with an API route; `serve.test.ts` holds the two apart rather\n * than trusting the reading, since this file must not be the reason a request\n * never reached the router.\n */\nasync function pageOrAsset(request: Request, reviewer?: string): Promise<Response | null> {\n const path = new URL(request.url).pathname;\n if (request.method !== 'GET' && request.method !== 'HEAD') return null;\n\n if (isPagePath(path)) {\n return new Response(reviewPage({ endpoint: '', ...(reviewer === undefined ? {} : { reviewer }) }), {\n headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' },\n });\n }\n\n if (path === '/ui/review.js') {\n const bundle = await uiAsset();\n if (bundle === null) return null;\n return new Response(bundle, {\n headers: {\n 'content-type': 'text/javascript; charset=utf-8',\n // Immutable because the file changes only when the package does, and a\n // reviewer who upgrades gets a new process on a new URL-less asset —\n // so this is a lie the moment the bundle is rebuilt in place. It is\n // therefore `no-cache`: revalidate, do not re-download.\n 'cache-control': 'no-cache',\n },\n });\n }\n\n return null;\n}\n\nfunction method(request: Request): 'GET' | 'POST' | 'HEAD' {\n if (request.method === 'POST') return 'POST';\n if (request.method === 'HEAD') return 'HEAD';\n return 'GET';\n}\n\n/** `IncomingMessage` → `Request`, with the body read to a ceiling. */\nasync function requestOf(incoming: IncomingMessage): Promise<Request> {\n const url = new URL(incoming.url ?? '/', `http://${incoming.headers.host ?? 'localhost'}`);\n\n const headers = new Headers();\n for (const [name, value] of Object.entries(incoming.headers)) {\n if (value === undefined) continue;\n for (const one of Array.isArray(value) ? value : [value]) headers.append(name, one);\n }\n\n const method = incoming.method ?? 'GET';\n if (method === 'GET' || method === 'HEAD') {\n return new Request(url, { method, headers });\n }\n\n return new Request(url, { method, headers, body: await body(incoming) });\n}\n\nasync function body(incoming: IncomingMessage): Promise<ArrayBuffer> {\n const chunks: Buffer[] = [];\n let size = 0;\n\n for await (const chunk of incoming) {\n size += (chunk as Buffer).byteLength;\n if (size > MAX_BODY_BYTES) {\n incoming.destroy();\n throw new PayloadTooLarge(\n `this request body is over ${MAX_BODY_BYTES} bytes, which is the ceiling this service ` +\n 'accepts. A build carrying every image it kept is the way this happens; keep fewer ' +\n 'images, or split the build across two posts',\n );\n }\n chunks.push(chunk as Buffer);\n }\n\n const joined = Buffer.concat(chunks);\n return joined.buffer.slice(joined.byteOffset, joined.byteOffset + joined.byteLength) as ArrayBuffer;\n}\n\n/** `Response` → `ServerResponse`. */\nasync function write(outgoing: ServerResponse, response: Response): Promise<void> {\n for (const [name, value] of response.headers) outgoing.setHeader(name, value);\n outgoing.statusCode = response.status;\n\n if (response.body === null) {\n outgoing.end();\n return;\n }\n outgoing.end(Buffer.from(await response.arrayBuffer()));\n}\n\nclass PayloadTooLarge extends Error {}\n\n/**\n * An exception is a 500 with a sentence, and a refused body is a 413.\n *\n * Nothing in `Tribunal.fetch` throws — it answers with a status — so anything\n * arriving here is transport or a construction failure, and the operator reading\n * it is the person who can fix it. The message is theirs; the stack is not.\n */\nfunction refusal(error: unknown): Response {\n const message = error instanceof Error ? error.message : 'this service could not answer';\n return new Response(JSON.stringify({ error: message }), {\n status: error instanceof PayloadTooLarge ? 413 : 500,\n headers: { 'content-type': 'application/json; charset=utf-8' },\n });\n}\n"]}
|