@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.
Files changed (271) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/LICENSE +21 -0
  3. package/README.md +506 -0
  4. package/dist/__fixtures__/review.d.ts +47 -0
  5. package/dist/__fixtures__/review.js +145 -0
  6. package/dist/__fixtures__/review.js.map +1 -0
  7. package/dist/bindings.d.ts +134 -0
  8. package/dist/bindings.js +88 -0
  9. package/dist/bindings.js.map +1 -0
  10. package/dist/changelog.d.ts +132 -0
  11. package/dist/changelog.js +129 -0
  12. package/dist/changelog.js.map +1 -0
  13. package/dist/history-queries.d.ts +82 -0
  14. package/dist/history-queries.js +200 -0
  15. package/dist/history-queries.js.map +1 -0
  16. package/dist/history-rows.d.ts +42 -0
  17. package/dist/history-rows.js +148 -0
  18. package/dist/history-rows.js.map +1 -0
  19. package/dist/history.d.ts +40 -0
  20. package/dist/history.js +186 -0
  21. package/dist/history.js.map +1 -0
  22. package/dist/index.d.ts +13 -0
  23. package/dist/index.js +12 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/migration-steps.d.ts +32 -0
  26. package/dist/migration-steps.js +303 -0
  27. package/dist/migration-steps.js.map +1 -0
  28. package/dist/migrations.d.ts +58 -0
  29. package/dist/migrations.js +283 -0
  30. package/dist/migrations.js.map +1 -0
  31. package/dist/next.d.ts +94 -0
  32. package/dist/next.js +44 -0
  33. package/dist/next.js.map +1 -0
  34. package/dist/node/bin.d.ts +120 -0
  35. package/dist/node/bin.js +337 -0
  36. package/dist/node/bin.js.map +1 -0
  37. package/dist/node/bucket.d.ts +58 -0
  38. package/dist/node/bucket.js +142 -0
  39. package/dist/node/bucket.js.map +1 -0
  40. package/dist/node/database.d.ts +33 -0
  41. package/dist/node/database.js +157 -0
  42. package/dist/node/database.js.map +1 -0
  43. package/dist/node/index.d.ts +43 -0
  44. package/dist/node/index.js +43 -0
  45. package/dist/node/index.js.map +1 -0
  46. package/dist/node/serve.d.ts +81 -0
  47. package/dist/node/serve.js +193 -0
  48. package/dist/node/serve.js.map +1 -0
  49. package/dist/node/ui-assets.d.ts +47 -0
  50. package/dist/node/ui-assets.js +60 -0
  51. package/dist/node/ui-assets.js.map +1 -0
  52. package/dist/review-ingest.d.ts +24 -0
  53. package/dist/review-ingest.js +184 -0
  54. package/dist/review-ingest.js.map +1 -0
  55. package/dist/review-read.d.ts +87 -0
  56. package/dist/review-read.js +361 -0
  57. package/dist/review-read.js.map +1 -0
  58. package/dist/review-rows.d.ts +30 -0
  59. package/dist/review-rows.js +67 -0
  60. package/dist/review-rows.js.map +1 -0
  61. package/dist/review-types.d.ts +414 -0
  62. package/dist/review-types.js +2 -0
  63. package/dist/review-types.js.map +1 -0
  64. package/dist/review-write.d.ts +35 -0
  65. package/dist/review-write.js +86 -0
  66. package/dist/review-write.js.map +1 -0
  67. package/dist/review.d.ts +53 -0
  68. package/dist/review.fixtures.d.ts +47 -0
  69. package/dist/review.fixtures.js +145 -0
  70. package/dist/review.fixtures.js.map +1 -0
  71. package/dist/review.js +245 -0
  72. package/dist/review.js.map +1 -0
  73. package/dist/schema.d.ts +36 -0
  74. package/dist/schema.js +39 -0
  75. package/dist/schema.js.map +1 -0
  76. package/dist/store.d.ts +58 -0
  77. package/dist/store.js +278 -0
  78. package/dist/store.js.map +1 -0
  79. package/dist/testing.d.ts +38 -0
  80. package/dist/testing.js +92 -0
  81. package/dist/testing.js.map +1 -0
  82. package/dist/ui/__probe.d.ts +3 -0
  83. package/dist/ui/__probe.js +5 -0
  84. package/dist/ui/__probe.js.map +1 -0
  85. package/dist/ui/attribution.d.ts +46 -0
  86. package/dist/ui/attribution.js +79 -0
  87. package/dist/ui/attribution.js.map +1 -0
  88. package/dist/ui/because.d.ts +51 -0
  89. package/dist/ui/because.js +52 -0
  90. package/dist/ui/because.js.map +1 -0
  91. package/dist/ui/browser-entry.d.ts +2 -0
  92. package/dist/ui/browser-entry.js +34 -0
  93. package/dist/ui/browser-entry.js.map +1 -0
  94. package/dist/ui/builds.d.ts +43 -0
  95. package/dist/ui/builds.js +79 -0
  96. package/dist/ui/builds.js.map +1 -0
  97. package/dist/ui/carried.d.ts +73 -0
  98. package/dist/ui/carried.js +63 -0
  99. package/dist/ui/carried.js.map +1 -0
  100. package/dist/ui/cause.d.ts +73 -0
  101. package/dist/ui/cause.js +133 -0
  102. package/dist/ui/cause.js.map +1 -0
  103. package/dist/ui/change-story.d.ts +153 -0
  104. package/dist/ui/change-story.js +253 -0
  105. package/dist/ui/change-story.js.map +1 -0
  106. package/dist/ui/change.d.ts +51 -0
  107. package/dist/ui/change.js +125 -0
  108. package/dist/ui/change.js.map +1 -0
  109. package/dist/ui/client.d.ts +74 -0
  110. package/dist/ui/client.js +88 -0
  111. package/dist/ui/client.js.map +1 -0
  112. package/dist/ui/consumers.d.ts +80 -0
  113. package/dist/ui/consumers.js +125 -0
  114. package/dist/ui/consumers.js.map +1 -0
  115. package/dist/ui/control.d.ts +63 -0
  116. package/dist/ui/control.js +39 -0
  117. package/dist/ui/control.js.map +1 -0
  118. package/dist/ui/crossing.d.ts +55 -0
  119. package/dist/ui/crossing.js +74 -0
  120. package/dist/ui/crossing.js.map +1 -0
  121. package/dist/ui/declarations.d.ts +36 -0
  122. package/dist/ui/declarations.js +73 -0
  123. package/dist/ui/declarations.js.map +1 -0
  124. package/dist/ui/distance.d.ts +154 -0
  125. package/dist/ui/distance.js +180 -0
  126. package/dist/ui/distance.js.map +1 -0
  127. package/dist/ui/divergence.d.ts +40 -0
  128. package/dist/ui/divergence.js +176 -0
  129. package/dist/ui/divergence.js.map +1 -0
  130. package/dist/ui/docket.d.ts +39 -0
  131. package/dist/ui/docket.js +172 -0
  132. package/dist/ui/docket.js.map +1 -0
  133. package/dist/ui/findings.d.ts +42 -0
  134. package/dist/ui/findings.js +110 -0
  135. package/dist/ui/findings.js.map +1 -0
  136. package/dist/ui/foreseen.d.ts +55 -0
  137. package/dist/ui/foreseen.js +91 -0
  138. package/dist/ui/foreseen.js.map +1 -0
  139. package/dist/ui/glance.d.ts +101 -0
  140. package/dist/ui/glance.js +101 -0
  141. package/dist/ui/glance.js.map +1 -0
  142. package/dist/ui/grouping.d.ts +158 -0
  143. package/dist/ui/grouping.js +187 -0
  144. package/dist/ui/grouping.js.map +1 -0
  145. package/dist/ui/handed.d.ts +60 -0
  146. package/dist/ui/handed.js +122 -0
  147. package/dist/ui/handed.js.map +1 -0
  148. package/dist/ui/history.d.ts +88 -0
  149. package/dist/ui/history.js +170 -0
  150. package/dist/ui/history.js.map +1 -0
  151. package/dist/ui/holding.d.ts +84 -0
  152. package/dist/ui/holding.js +90 -0
  153. package/dist/ui/holding.js.map +1 -0
  154. package/dist/ui/impact.d.ts +31 -0
  155. package/dist/ui/impact.js +132 -0
  156. package/dist/ui/impact.js.map +1 -0
  157. package/dist/ui/index.d.ts +21 -0
  158. package/dist/ui/index.js +21 -0
  159. package/dist/ui/index.js.map +1 -0
  160. package/dist/ui/lead.d.ts +35 -0
  161. package/dist/ui/lead.js +34 -0
  162. package/dist/ui/lead.js.map +1 -0
  163. package/dist/ui/look.d.ts +109 -0
  164. package/dist/ui/look.js +248 -0
  165. package/dist/ui/look.js.map +1 -0
  166. package/dist/ui/mark.d.ts +11 -0
  167. package/dist/ui/mark.js +13 -0
  168. package/dist/ui/mark.js.map +1 -0
  169. package/dist/ui/moved.d.ts +121 -0
  170. package/dist/ui/moved.js +143 -0
  171. package/dist/ui/moved.js.map +1 -0
  172. package/dist/ui/navigation.d.ts +26 -0
  173. package/dist/ui/navigation.js +53 -0
  174. package/dist/ui/navigation.js.map +1 -0
  175. package/dist/ui/order.d.ts +113 -0
  176. package/dist/ui/order.js +226 -0
  177. package/dist/ui/order.js.map +1 -0
  178. package/dist/ui/origins.d.ts +42 -0
  179. package/dist/ui/origins.js +149 -0
  180. package/dist/ui/origins.js.map +1 -0
  181. package/dist/ui/outcome.d.ts +93 -0
  182. package/dist/ui/outcome.js +162 -0
  183. package/dist/ui/outcome.js.map +1 -0
  184. package/dist/ui/parted.d.ts +68 -0
  185. package/dist/ui/parted.js +101 -0
  186. package/dist/ui/parted.js.map +1 -0
  187. package/dist/ui/rail.d.ts +31 -0
  188. package/dist/ui/rail.js +21 -0
  189. package/dist/ui/rail.js.map +1 -0
  190. package/dist/ui/reach.d.ts +66 -0
  191. package/dist/ui/reach.js +185 -0
  192. package/dist/ui/reach.js.map +1 -0
  193. package/dist/ui/regions.d.ts +82 -0
  194. package/dist/ui/regions.js +67 -0
  195. package/dist/ui/regions.js.map +1 -0
  196. package/dist/ui/review.bundle.js +69 -0
  197. package/dist/ui/review.d.ts +97 -0
  198. package/dist/ui/review.js +39 -0
  199. package/dist/ui/review.js.map +1 -0
  200. package/dist/ui/root.d.ts +72 -0
  201. package/dist/ui/root.js +102 -0
  202. package/dist/ui/root.js.map +1 -0
  203. package/dist/ui/route.d.ts +95 -0
  204. package/dist/ui/route.js +114 -0
  205. package/dist/ui/route.js.map +1 -0
  206. package/dist/ui/run.d.ts +26 -0
  207. package/dist/ui/run.js +12 -0
  208. package/dist/ui/run.js.map +1 -0
  209. package/dist/ui/sense.d.ts +187 -0
  210. package/dist/ui/sense.js +222 -0
  211. package/dist/ui/sense.js.map +1 -0
  212. package/dist/ui/settled.d.ts +43 -0
  213. package/dist/ui/settled.js +59 -0
  214. package/dist/ui/settled.js.map +1 -0
  215. package/dist/ui/shell.d.ts +76 -0
  216. package/dist/ui/shell.js +65 -0
  217. package/dist/ui/shell.js.map +1 -0
  218. package/dist/ui/shift.d.ts +52 -0
  219. package/dist/ui/shift.js +98 -0
  220. package/dist/ui/shift.js.map +1 -0
  221. package/dist/ui/styles-docket.d.ts +18 -0
  222. package/dist/ui/styles-docket.js +281 -0
  223. package/dist/ui/styles-docket.js.map +1 -0
  224. package/dist/ui/styles-stage.d.ts +18 -0
  225. package/dist/ui/styles-stage.js +92 -0
  226. package/dist/ui/styles-stage.js.map +1 -0
  227. package/dist/ui/styles.d.ts +12 -0
  228. package/dist/ui/styles.js +482 -0
  229. package/dist/ui/styles.js.map +1 -0
  230. package/dist/ui/subject.d.ts +36 -0
  231. package/dist/ui/subject.js +138 -0
  232. package/dist/ui/subject.js.map +1 -0
  233. package/dist/ui/text.d.ts +129 -0
  234. package/dist/ui/text.js +164 -0
  235. package/dist/ui/text.js.map +1 -0
  236. package/dist/ui/variations.d.ts +37 -0
  237. package/dist/ui/variations.js +60 -0
  238. package/dist/ui/variations.js.map +1 -0
  239. package/dist/ui/viewer.d.ts +67 -0
  240. package/dist/ui/viewer.js +246 -0
  241. package/dist/ui/viewer.js.map +1 -0
  242. package/dist/worker-auth.d.ts +48 -0
  243. package/dist/worker-auth.js +74 -0
  244. package/dist/worker-auth.js.map +1 -0
  245. package/dist/worker-entry.d.ts +58 -0
  246. package/dist/worker-entry.js +36 -0
  247. package/dist/worker-entry.js.map +1 -0
  248. package/dist/worker-http.d.ts +39 -0
  249. package/dist/worker-http.js +111 -0
  250. package/dist/worker-http.js.map +1 -0
  251. package/dist/worker-input.d.ts +81 -0
  252. package/dist/worker-input.js +296 -0
  253. package/dist/worker-input.js.map +1 -0
  254. package/dist/worker.d.ts +91 -0
  255. package/dist/worker.js +287 -0
  256. package/dist/worker.js.map +1 -0
  257. package/mark.svg +30 -0
  258. package/migrations/0001_initial.sql +193 -0
  259. package/migrations/0002_changelog.sql +30 -0
  260. package/migrations/0003_accessibility.sql +8 -0
  261. package/migrations/0004_variations.sql +19 -0
  262. package/migrations/0005_reach.sql +26 -0
  263. package/migrations/0006_baseline-size.sql +7 -0
  264. package/migrations/0007_declarations.sql +7 -0
  265. package/migrations/0008_green-because.sql +7 -0
  266. package/migrations/0009_moved-bands.sql +6 -0
  267. package/migrations/0010_composition.sql +15 -0
  268. package/migrations/0011_movements.sql +21 -0
  269. package/migrations/0012_control-size.sql +6 -0
  270. package/package.json +110 -0
  271. package/wrangler.jsonc +45 -0
@@ -0,0 +1,283 @@
1
+ /**
2
+ * The database as it first shipped, as statements rather than as a script.
3
+ *
4
+ * D1's `exec` accepts multiple statements only when each one is on a single
5
+ * line, which a trigger body cannot be, and splitting a script on `;` would cut
6
+ * every trigger in half — the semicolons inside `BEGIN … END` are part of the
7
+ * statement. So the schema is a list from the start, applied with `batch`, and
8
+ * there is no parser here that could get it wrong.
9
+ *
10
+ * ## Two halves
11
+ *
12
+ * The first half is this spec's own: baselines, the render cache, builds,
13
+ * per-subject verdicts, and decisions. The second half is
14
+ * [spec 0002](../../../docs/specs/0002-history-store.md)'s, and it is a
15
+ * deliberate transcription of
16
+ * [`server/backend-sqlite.ts`](../../server/src/backend-sqlite.ts) — the same
17
+ * columns, the same indexes, and above all the same append-only triggers with
18
+ * the same wording. Two backends that disagree about what a row is are two
19
+ * different records wearing one name, and the trigger messages are the part an
20
+ * operator actually reads, so they are copied rather than paraphrased.
21
+ *
22
+ * ## Two rules the tables enforce that no code has to remember
23
+ *
24
+ * **The identity partition is the primary key.** `baselines` is keyed by
25
+ * `(project, identity_digest, subject, label)`. A baseline written by one
26
+ * machine is not reachable as this machine's, because it is a different row —
27
+ * the same argument [ADR-0011](../../../docs/context/adr/0011-durable-and-ephemeral-retention.md)
28
+ * makes about the directory store's layout, and with the same absence of a check
29
+ * somebody could forget to write.
30
+ *
31
+ * **Instants are stored twice.** The original text, and its parsed
32
+ * milliseconds; every window is compared on the number. ISO-8601 sorts lexically
33
+ * only while every timestamp shares one offset, and a store fed by CI jobs in
34
+ * two regions does not.
35
+ */
36
+ /**
37
+ * The version the frozen initial set lands a database on.
38
+ *
39
+ * Exported because the step list in
40
+ * [`migration-steps.ts`](./migration-steps.js) is indexed against it: step `i`
41
+ * lands on `INITIAL_VERSION + i + 1`. An operator holding a deployed database
42
+ * reads its `schema_version`, and without this the only way to line that number
43
+ * up with a step is to derive it from `SCHEMA_VERSION - MIGRATIONS.length`.
44
+ */
45
+ export const INITIAL_VERSION = 3;
46
+ /**
47
+ * What a run kept, and what a review decided, in order — as this database first
48
+ * shipped.
49
+ *
50
+ * Order matters: tables before their indexes, and both before the triggers that
51
+ * reference them.
52
+ *
53
+ * Frozen at {@link INITIAL_VERSION}. Everything since is a step in
54
+ * [`migration-steps.ts`](./migration-steps.js), for the reason given there; a
55
+ * new table added here would reach a fresh deployment and no existing one.
56
+ */
57
+ export const INITIAL = [
58
+ `CREATE TABLE schema_version (
59
+ version INTEGER NOT NULL
60
+ ) STRICT`,
61
+ `INSERT INTO schema_version (version) VALUES (${INITIAL_VERSION})`,
62
+ // ---------------------------------------------------------------- baselines
63
+ // `label` is NOT NULL with '' standing for absent, and an empty label is
64
+ // refused at the door (see `store.ts`). SQLite permits NULL in the columns of
65
+ // an ordinary PRIMARY KEY, which would make two unlabelled baselines for one
66
+ // subject two rows that never collide — the uniqueness that stops a second
67
+ // `put` overwriting the first would silently not exist.
68
+ `CREATE TABLE baselines (
69
+ project TEXT NOT NULL,
70
+ identity_digest TEXT NOT NULL,
71
+ subject TEXT NOT NULL,
72
+ label TEXT NOT NULL,
73
+ identity TEXT NOT NULL,
74
+ document_digest TEXT NOT NULL,
75
+ width INTEGER NOT NULL,
76
+ height INTEGER NOT NULL,
77
+ missing_fonts TEXT NOT NULL,
78
+ object_key TEXT NOT NULL,
79
+ at TEXT NOT NULL,
80
+ at_ms INTEGER NOT NULL,
81
+ PRIMARY KEY (project, identity_digest, subject, label)
82
+ ) STRICT`,
83
+ // The sibling scan. `find` must be able to say "we have seen this, on a
84
+ // machine you are not", which is a lookup across identities for one key —
85
+ // a different leading column from the primary key's, so it needs its own index
86
+ // or every wrong-machine run becomes a full table scan.
87
+ `CREATE INDEX baselines_across_identities ON baselines (project, subject, label, at_ms DESC)`,
88
+ `CREATE TABLE render_cache (
89
+ project TEXT NOT NULL,
90
+ identity_digest TEXT NOT NULL,
91
+ document_digest TEXT NOT NULL,
92
+ identity TEXT NOT NULL,
93
+ width INTEGER NOT NULL,
94
+ height INTEGER NOT NULL,
95
+ missing_fonts TEXT NOT NULL,
96
+ object_key TEXT NOT NULL,
97
+ at_ms INTEGER NOT NULL,
98
+ PRIMARY KEY (project, identity_digest, document_digest)
99
+ ) STRICT`,
100
+ // ------------------------------------------------------------------- review
101
+ // `says_not_observed` is a flag about the *writer*, not about the run: `1`
102
+ // means the report stated its coverage list, `0` means it never said. A report
103
+ // that skipped nothing and a report that declined to say are different claims,
104
+ // and collapsing them lets "nothing to review" be printed on the authority of a
105
+ // writer that never looked (`report`'s `notObserved`).
106
+ `CREATE TABLE builds (
107
+ project TEXT NOT NULL,
108
+ build TEXT NOT NULL,
109
+ "commit" TEXT NOT NULL,
110
+ branch TEXT,
111
+ intent TEXT,
112
+ at TEXT NOT NULL,
113
+ at_ms INTEGER NOT NULL,
114
+ identity TEXT NOT NULL,
115
+ identity_digest TEXT NOT NULL,
116
+ retention TEXT NOT NULL,
117
+ run_version INTEGER NOT NULL,
118
+ says_not_observed INTEGER NOT NULL,
119
+ PRIMARY KEY (project, build)
120
+ ) STRICT`,
121
+ `CREATE INDEX builds_recent ON builds (project, at_ms DESC)`,
122
+ // No `label` column, and that is the report's shape rather than an omission:
123
+ // `ObservationRecord` carries a subject and no label, so a build cannot
124
+ // distinguish two images of one subject. `baselines` can, because `put` can.
125
+ // The day a run reports labels this table grows a column and the promotion path
126
+ // stops hard-coding ''.
127
+ // The `candidate_*` columns are what makes approval a promotion rather than a
128
+ // recording. A baseline is an image *plus* the document digest it was painted
129
+ // from and the identity that painted it — the sidecar the cheap `describe` path
130
+ // answers from. A review surface holding only pixels could not write one, so it
131
+ // would have to re-render to approve, and a surface that can render can record
132
+ // something nobody looked at. They are nullable because a build may carry a
133
+ // verdict for a subject whose image it did not keep; approving that subject is
134
+ // refused rather than invented.
135
+ `CREATE TABLE build_subjects (
136
+ project TEXT NOT NULL,
137
+ build TEXT NOT NULL,
138
+ subject TEXT NOT NULL,
139
+ verdict TEXT NOT NULL,
140
+ because TEXT NOT NULL,
141
+ changed_pixels INTEGER NOT NULL,
142
+ regions TEXT NOT NULL,
143
+ truncated TEXT,
144
+ missing_fonts TEXT,
145
+ findings TEXT,
146
+ before_key TEXT,
147
+ after_key TEXT,
148
+ diff_key TEXT,
149
+ candidate_document_digest TEXT,
150
+ candidate_width INTEGER,
151
+ candidate_height INTEGER,
152
+ candidate_missing_fonts TEXT,
153
+ PRIMARY KEY (project, build, subject)
154
+ ) STRICT`,
155
+ `CREATE TABLE build_not_observed (
156
+ project TEXT NOT NULL,
157
+ build TEXT NOT NULL,
158
+ subject TEXT NOT NULL,
159
+ kind TEXT NOT NULL,
160
+ because TEXT NOT NULL,
161
+ PRIMARY KEY (project, build, subject)
162
+ ) STRICT`,
163
+ // Append-only, like everything else that records a fact about a moment. A
164
+ // reviewer who approves and then changes their mind leaves two rows; the second
165
+ // decision is only reviewable because the first one is still there.
166
+ `CREATE TABLE decisions (
167
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
168
+ project TEXT NOT NULL,
169
+ build TEXT NOT NULL,
170
+ subject TEXT NOT NULL,
171
+ decision TEXT NOT NULL,
172
+ decided_by TEXT NOT NULL,
173
+ note TEXT,
174
+ at TEXT NOT NULL,
175
+ at_ms INTEGER NOT NULL
176
+ ) STRICT`,
177
+ `CREATE INDEX decisions_latest ON decisions (project, build, subject, seq DESC)`,
178
+ `CREATE TRIGGER decisions_are_append_only BEFORE UPDATE ON decisions BEGIN
179
+ SELECT RAISE(ABORT, 'decisions are append-only: a rewritten decision erases the review it replaced, and the earlier one is what makes the later one reviewable');
180
+ END`,
181
+ `CREATE TRIGGER decisions_are_permanent BEFORE DELETE ON decisions BEGIN
182
+ SELECT RAISE(ABORT, 'decisions are append-only: a deleted approval leaves a promoted baseline nobody can attribute to anyone');
183
+ END`,
184
+ // ------------------------------------------------------------------ history
185
+ // Transcribed from `server/backend-sqlite.ts`. Same columns, same indexes,
186
+ // same trigger wording — see the note at the top of this file.
187
+ `CREATE TABLE runs (
188
+ project TEXT NOT NULL,
189
+ run TEXT NOT NULL,
190
+ "commit" TEXT NOT NULL,
191
+ profile TEXT NOT NULL,
192
+ at TEXT NOT NULL,
193
+ at_ms INTEGER NOT NULL,
194
+ -- Nullable, and the null is load-bearing: a run that never said what it
195
+ -- examined must not be read as one that examined nothing, because that
196
+ -- number becomes the denominator of a flake rate.
197
+ swept INTEGER
198
+ ) STRICT`,
199
+ `CREATE UNIQUE INDEX runs_identity ON runs (project, run, profile)`,
200
+ `CREATE INDEX runs_window ON runs (project, at_ms)`,
201
+ `CREATE TABLE observations (
202
+ project TEXT NOT NULL,
203
+ subject TEXT NOT NULL,
204
+ component TEXT NOT NULL,
205
+ band TEXT NOT NULL,
206
+ hash TEXT NOT NULL,
207
+ profile TEXT NOT NULL,
208
+ "commit" TEXT NOT NULL,
209
+ run TEXT NOT NULL,
210
+ at TEXT NOT NULL,
211
+ at_ms INTEGER NOT NULL,
212
+ accepted INTEGER NOT NULL,
213
+ file TEXT
214
+ ) STRICT`,
215
+ `CREATE INDEX observations_area ON observations (project, subject, component, band, at_ms)`,
216
+ `CREATE INDEX observations_component ON observations (project, component, at_ms)`,
217
+ `CREATE INDEX observations_reach ON observations (project, component, subject, at_ms)`,
218
+ `CREATE TABLE token_values (
219
+ project TEXT NOT NULL,
220
+ token TEXT NOT NULL,
221
+ value TEXT NOT NULL,
222
+ "commit" TEXT NOT NULL,
223
+ at TEXT NOT NULL,
224
+ at_ms INTEGER NOT NULL,
225
+ run TEXT NOT NULL,
226
+ accepted INTEGER NOT NULL
227
+ ) STRICT`,
228
+ `CREATE INDEX token_values_journey ON token_values (project, token, at_ms)`,
229
+ `CREATE TABLE instabilities (
230
+ project TEXT NOT NULL,
231
+ subject TEXT NOT NULL,
232
+ component TEXT,
233
+ band TEXT,
234
+ profile TEXT NOT NULL,
235
+ "commit" TEXT NOT NULL,
236
+ run TEXT NOT NULL,
237
+ at TEXT NOT NULL,
238
+ at_ms INTEGER NOT NULL,
239
+ absorbed_by TEXT
240
+ ) STRICT`,
241
+ `CREATE INDEX instabilities_subject ON instabilities (project, subject, at_ms)`,
242
+ `CREATE TABLE approvals (
243
+ project TEXT NOT NULL,
244
+ subject TEXT NOT NULL,
245
+ run TEXT NOT NULL,
246
+ at TEXT NOT NULL,
247
+ at_ms INTEGER NOT NULL,
248
+ approver TEXT
249
+ ) STRICT`,
250
+ `CREATE UNIQUE INDEX approvals_identity ON approvals (project, subject, run)`,
251
+ `CREATE INDEX approvals_window ON approvals (project, at_ms)`,
252
+ `CREATE TRIGGER runs_are_append_only BEFORE UPDATE ON runs BEGIN
253
+ SELECT RAISE(ABORT, 'runs are append-only: a recorded run is a fact about a moment, and rewriting one changes a denominator somebody already read');
254
+ END`,
255
+ `CREATE TRIGGER runs_are_permanent BEFORE DELETE ON runs BEGIN
256
+ SELECT RAISE(ABORT, 'runs are append-only: deleting a quiet run inflates every rate computed over its window');
257
+ END`,
258
+ `CREATE TRIGGER observations_are_append_only BEFORE UPDATE ON observations BEGIN
259
+ SELECT RAISE(ABORT, 'observations are append-only: two hashes for one key are two rows, and an update is the merge this store exists to avoid');
260
+ END`,
261
+ `CREATE TRIGGER observations_are_permanent BEFORE DELETE ON observations BEGIN
262
+ SELECT RAISE(ABORT, 'observations are append-only: a deleted change is a change nobody can ever ask about again');
263
+ END`,
264
+ `CREATE TRIGGER token_values_are_append_only BEFORE UPDATE ON token_values BEGIN
265
+ SELECT RAISE(ABORT, 'token values are append-only: a rewritten value breaks the journey that was the reason for keeping it');
266
+ END`,
267
+ `CREATE TRIGGER token_values_are_permanent BEFORE DELETE ON token_values BEGIN
268
+ SELECT RAISE(ABORT, 'token values are append-only: a removed step turns a drift total into a lower bound with nothing saying so');
269
+ END`,
270
+ `CREATE TRIGGER instabilities_are_append_only BEFORE UPDATE ON instabilities BEGIN
271
+ SELECT RAISE(ABORT, 'instabilities are append-only: an occurrence is a fact about one run, and rewriting one changes a rate somebody already acted on');
272
+ END`,
273
+ `CREATE TRIGGER instabilities_are_permanent BEFORE DELETE ON instabilities BEGIN
274
+ SELECT RAISE(ABORT, 'instabilities are append-only: deleting an occurrence is how a flake that was fixed becomes a flake that never happened');
275
+ END`,
276
+ `CREATE TRIGGER approvals_are_append_only BEFORE UPDATE ON approvals BEGIN
277
+ SELECT RAISE(ABORT, 'approvals are append-only: a rewritten acceptance changes what a reviewer agreed to after they agreed to it');
278
+ END`,
279
+ `CREATE TRIGGER approvals_are_permanent BEFORE DELETE ON approvals BEGIN
280
+ SELECT RAISE(ABORT, 'approvals are append-only: a deleted acceptance turns a reviewed change back into an unreviewed one, and every drift total over it drops');
281
+ END`,
282
+ ];
283
+ //# sourceMappingURL=migrations.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"migrations.js","sourceRoot":"","sources":["../src/migrations.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AAGH;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,CAAC;AAEjC;;;;;;;;;;GAUG;AACH,MAAM,CAAC,MAAM,OAAO,GAAsB;IACxC;;YAEU;IACV,gDAAgD,eAAe,GAAG;IAElE,6EAA6E;IAE7E,yEAAyE;IACzE,8EAA8E;IAC9E,6EAA6E;IAC7E,2EAA2E;IAC3E,wDAAwD;IACxD;;;;;;;;;;;;;;YAcU;IAEV,wEAAwE;IACxE,0EAA0E;IAC1E,+EAA+E;IAC/E,wDAAwD;IACxD,6FAA6F;IAE7F;;;;;;;;;;;YAWU;IAEV,6EAA6E;IAE7E,2EAA2E;IAC3E,+EAA+E;IAC/E,+EAA+E;IAC/E,gFAAgF;IAChF,uDAAuD;IACvD;;;;;;;;;;;;;;YAcU;IACV,4DAA4D;IAE5D,6EAA6E;IAC7E,wEAAwE;IACxE,6EAA6E;IAC7E,gFAAgF;IAChF,wBAAwB;IACxB,8EAA8E;IAC9E,8EAA8E;IAC9E,gFAAgF;IAChF,gFAAgF;IAChF,+EAA+E;IAC/E,4EAA4E;IAC5E,+EAA+E;IAC/E,gCAAgC;IAChC;;;;;;;;;;;;;;;;;;;YAmBU;IAEV;;;;;;;YAOU;IAEV,0EAA0E;IAC1E,gFAAgF;IAChF,oEAAoE;IACpE;;;;;;;;;;YAUU;IACV,gFAAgF;IAEhF;;OAEK;IACL;;OAEK;IAEL,6EAA6E;IAC7E,2EAA2E;IAC3E,+DAA+D;IAE/D;;;;;;;;;;;YAWU;IACV,mEAAmE;IACnE,mDAAmD;IAEnD;;;;;;;;;;;;;YAaU;IACV,gGAAgG;IAChG,iFAAiF;IACjF,0FAA0F;IAE1F;;;;;;;;;YASU;IACV,2EAA2E;IAE3E;;;;;;;;;;;YAWU;IACV,+EAA+E;IAE/E;;;;;;;YAOU;IACV,6EAA6E;IAC7E,6DAA6D;IAE7D;;OAEK;IACL;;OAEK;IACL;;OAEK;IACL;;OAEK;IACL;;OAEK;IACL;;OAEK;IACL;;OAEK;IACL;;OAEK;IACL;;OAEK;IACL;;OAEK;CACN,CAAC","sourcesContent":["/**\n * The database as it first shipped, as statements rather than as a script.\n *\n * D1's `exec` accepts multiple statements only when each one is on a single\n * line, which a trigger body cannot be, and splitting a script on `;` would cut\n * every trigger in half — the semicolons inside `BEGIN … END` are part of the\n * statement. So the schema is a list from the start, applied with `batch`, and\n * there is no parser here that could get it wrong.\n *\n * ## Two halves\n *\n * The first half is this spec's own: baselines, the render cache, builds,\n * per-subject verdicts, and decisions. The second half is\n * [spec 0002](../../../docs/specs/0002-history-store.md)'s, and it is a\n * deliberate transcription of\n * [`server/backend-sqlite.ts`](../../server/src/backend-sqlite.ts) — the same\n * columns, the same indexes, and above all the same append-only triggers with\n * the same wording. Two backends that disagree about what a row is are two\n * different records wearing one name, and the trigger messages are the part an\n * operator actually reads, so they are copied rather than paraphrased.\n *\n * ## Two rules the tables enforce that no code has to remember\n *\n * **The identity partition is the primary key.** `baselines` is keyed by\n * `(project, identity_digest, subject, label)`. A baseline written by one\n * machine is not reachable as this machine's, because it is a different row —\n * the same argument [ADR-0011](../../../docs/context/adr/0011-durable-and-ephemeral-retention.md)\n * makes about the directory store's layout, and with the same absence of a check\n * somebody could forget to write.\n *\n * **Instants are stored twice.** The original text, and its parsed\n * milliseconds; every window is compared on the number. ISO-8601 sorts lexically\n * only while every timestamp shares one offset, and a store fed by CI jobs in\n * two regions does not.\n */\n\n\n/**\n * The version the frozen initial set lands a database on.\n *\n * Exported because the step list in\n * [`migration-steps.ts`](./migration-steps.js) is indexed against it: step `i`\n * lands on `INITIAL_VERSION + i + 1`. An operator holding a deployed database\n * reads its `schema_version`, and without this the only way to line that number\n * up with a step is to derive it from `SCHEMA_VERSION - MIGRATIONS.length`.\n */\nexport const INITIAL_VERSION = 3;\n\n/**\n * What a run kept, and what a review decided, in order — as this database first\n * shipped.\n *\n * Order matters: tables before their indexes, and both before the triggers that\n * reference them.\n *\n * Frozen at {@link INITIAL_VERSION}. Everything since is a step in\n * [`migration-steps.ts`](./migration-steps.js), for the reason given there; a\n * new table added here would reach a fresh deployment and no existing one.\n */\nexport const INITIAL: readonly string[] = [\n `CREATE TABLE schema_version (\n version INTEGER NOT NULL\n ) STRICT`,\n `INSERT INTO schema_version (version) VALUES (${INITIAL_VERSION})`,\n\n // ---------------------------------------------------------------- baselines\n\n // `label` is NOT NULL with '' standing for absent, and an empty label is\n // refused at the door (see `store.ts`). SQLite permits NULL in the columns of\n // an ordinary PRIMARY KEY, which would make two unlabelled baselines for one\n // subject two rows that never collide — the uniqueness that stops a second\n // `put` overwriting the first would silently not exist.\n `CREATE TABLE baselines (\n project TEXT NOT NULL,\n identity_digest TEXT NOT NULL,\n subject TEXT NOT NULL,\n label TEXT NOT NULL,\n identity TEXT NOT NULL,\n document_digest TEXT NOT NULL,\n width INTEGER NOT NULL,\n height INTEGER NOT NULL,\n missing_fonts TEXT NOT NULL,\n object_key TEXT NOT NULL,\n at TEXT NOT NULL,\n at_ms INTEGER NOT NULL,\n PRIMARY KEY (project, identity_digest, subject, label)\n ) STRICT`,\n\n // The sibling scan. `find` must be able to say \"we have seen this, on a\n // machine you are not\", which is a lookup across identities for one key —\n // a different leading column from the primary key's, so it needs its own index\n // or every wrong-machine run becomes a full table scan.\n `CREATE INDEX baselines_across_identities ON baselines (project, subject, label, at_ms DESC)`,\n\n `CREATE TABLE render_cache (\n project TEXT NOT NULL,\n identity_digest TEXT NOT NULL,\n document_digest TEXT NOT NULL,\n identity TEXT NOT NULL,\n width INTEGER NOT NULL,\n height INTEGER NOT NULL,\n missing_fonts TEXT NOT NULL,\n object_key TEXT NOT NULL,\n at_ms INTEGER NOT NULL,\n PRIMARY KEY (project, identity_digest, document_digest)\n ) STRICT`,\n\n // ------------------------------------------------------------------- review\n\n // `says_not_observed` is a flag about the *writer*, not about the run: `1`\n // means the report stated its coverage list, `0` means it never said. A report\n // that skipped nothing and a report that declined to say are different claims,\n // and collapsing them lets \"nothing to review\" be printed on the authority of a\n // writer that never looked (`report`'s `notObserved`).\n `CREATE TABLE builds (\n project TEXT NOT NULL,\n build TEXT NOT NULL,\n \"commit\" TEXT NOT NULL,\n branch TEXT,\n intent TEXT,\n at TEXT NOT NULL,\n at_ms INTEGER NOT NULL,\n identity TEXT NOT NULL,\n identity_digest TEXT NOT NULL,\n retention TEXT NOT NULL,\n run_version INTEGER NOT NULL,\n says_not_observed INTEGER NOT NULL,\n PRIMARY KEY (project, build)\n ) STRICT`,\n `CREATE INDEX builds_recent ON builds (project, at_ms DESC)`,\n\n // No `label` column, and that is the report's shape rather than an omission:\n // `ObservationRecord` carries a subject and no label, so a build cannot\n // distinguish two images of one subject. `baselines` can, because `put` can.\n // The day a run reports labels this table grows a column and the promotion path\n // stops hard-coding ''.\n // The `candidate_*` columns are what makes approval a promotion rather than a\n // recording. A baseline is an image *plus* the document digest it was painted\n // from and the identity that painted it — the sidecar the cheap `describe` path\n // answers from. A review surface holding only pixels could not write one, so it\n // would have to re-render to approve, and a surface that can render can record\n // something nobody looked at. They are nullable because a build may carry a\n // verdict for a subject whose image it did not keep; approving that subject is\n // refused rather than invented.\n `CREATE TABLE build_subjects (\n project TEXT NOT NULL,\n build TEXT NOT NULL,\n subject TEXT NOT NULL,\n verdict TEXT NOT NULL,\n because TEXT NOT NULL,\n changed_pixels INTEGER NOT NULL,\n regions TEXT NOT NULL,\n truncated TEXT,\n missing_fonts TEXT,\n findings TEXT,\n before_key TEXT,\n after_key TEXT,\n diff_key TEXT,\n candidate_document_digest TEXT,\n candidate_width INTEGER,\n candidate_height INTEGER,\n candidate_missing_fonts TEXT,\n PRIMARY KEY (project, build, subject)\n ) STRICT`,\n\n `CREATE TABLE build_not_observed (\n project TEXT NOT NULL,\n build TEXT NOT NULL,\n subject TEXT NOT NULL,\n kind TEXT NOT NULL,\n because TEXT NOT NULL,\n PRIMARY KEY (project, build, subject)\n ) STRICT`,\n\n // Append-only, like everything else that records a fact about a moment. A\n // reviewer who approves and then changes their mind leaves two rows; the second\n // decision is only reviewable because the first one is still there.\n `CREATE TABLE decisions (\n seq INTEGER PRIMARY KEY AUTOINCREMENT,\n project TEXT NOT NULL,\n build TEXT NOT NULL,\n subject TEXT NOT NULL,\n decision TEXT NOT NULL,\n decided_by TEXT NOT NULL,\n note TEXT,\n at TEXT NOT NULL,\n at_ms INTEGER NOT NULL\n ) STRICT`,\n `CREATE INDEX decisions_latest ON decisions (project, build, subject, seq DESC)`,\n\n `CREATE TRIGGER decisions_are_append_only BEFORE UPDATE ON decisions BEGIN\n SELECT RAISE(ABORT, 'decisions are append-only: a rewritten decision erases the review it replaced, and the earlier one is what makes the later one reviewable');\n END`,\n `CREATE TRIGGER decisions_are_permanent BEFORE DELETE ON decisions BEGIN\n SELECT RAISE(ABORT, 'decisions are append-only: a deleted approval leaves a promoted baseline nobody can attribute to anyone');\n END`,\n\n // ------------------------------------------------------------------ history\n // Transcribed from `server/backend-sqlite.ts`. Same columns, same indexes,\n // same trigger wording — see the note at the top of this file.\n\n `CREATE TABLE runs (\n project TEXT NOT NULL,\n run TEXT NOT NULL,\n \"commit\" TEXT NOT NULL,\n profile TEXT NOT NULL,\n at TEXT NOT NULL,\n at_ms INTEGER NOT NULL,\n -- Nullable, and the null is load-bearing: a run that never said what it\n -- examined must not be read as one that examined nothing, because that\n -- number becomes the denominator of a flake rate.\n swept INTEGER\n ) STRICT`,\n `CREATE UNIQUE INDEX runs_identity ON runs (project, run, profile)`,\n `CREATE INDEX runs_window ON runs (project, at_ms)`,\n\n `CREATE TABLE observations (\n project TEXT NOT NULL,\n subject TEXT NOT NULL,\n component TEXT NOT NULL,\n band TEXT NOT NULL,\n hash TEXT NOT NULL,\n profile TEXT NOT NULL,\n \"commit\" TEXT NOT NULL,\n run TEXT NOT NULL,\n at TEXT NOT NULL,\n at_ms INTEGER NOT NULL,\n accepted INTEGER NOT NULL,\n file TEXT\n ) STRICT`,\n `CREATE INDEX observations_area ON observations (project, subject, component, band, at_ms)`,\n `CREATE INDEX observations_component ON observations (project, component, at_ms)`,\n `CREATE INDEX observations_reach ON observations (project, component, subject, at_ms)`,\n\n `CREATE TABLE token_values (\n project TEXT NOT NULL,\n token TEXT NOT NULL,\n value TEXT NOT NULL,\n \"commit\" TEXT NOT NULL,\n at TEXT NOT NULL,\n at_ms INTEGER NOT NULL,\n run TEXT NOT NULL,\n accepted INTEGER NOT NULL\n ) STRICT`,\n `CREATE INDEX token_values_journey ON token_values (project, token, at_ms)`,\n\n `CREATE TABLE instabilities (\n project TEXT NOT NULL,\n subject TEXT NOT NULL,\n component TEXT,\n band TEXT,\n profile TEXT NOT NULL,\n \"commit\" TEXT NOT NULL,\n run TEXT NOT NULL,\n at TEXT NOT NULL,\n at_ms INTEGER NOT NULL,\n absorbed_by TEXT\n ) STRICT`,\n `CREATE INDEX instabilities_subject ON instabilities (project, subject, at_ms)`,\n\n `CREATE TABLE approvals (\n project TEXT NOT NULL,\n subject TEXT NOT NULL,\n run TEXT NOT NULL,\n at TEXT NOT NULL,\n at_ms INTEGER NOT NULL,\n approver TEXT\n ) STRICT`,\n `CREATE UNIQUE INDEX approvals_identity ON approvals (project, subject, run)`,\n `CREATE INDEX approvals_window ON approvals (project, at_ms)`,\n\n `CREATE TRIGGER runs_are_append_only BEFORE UPDATE ON runs BEGIN\n SELECT RAISE(ABORT, 'runs are append-only: a recorded run is a fact about a moment, and rewriting one changes a denominator somebody already read');\n END`,\n `CREATE TRIGGER runs_are_permanent BEFORE DELETE ON runs BEGIN\n SELECT RAISE(ABORT, 'runs are append-only: deleting a quiet run inflates every rate computed over its window');\n END`,\n `CREATE TRIGGER observations_are_append_only BEFORE UPDATE ON observations BEGIN\n SELECT RAISE(ABORT, 'observations are append-only: two hashes for one key are two rows, and an update is the merge this store exists to avoid');\n END`,\n `CREATE TRIGGER observations_are_permanent BEFORE DELETE ON observations BEGIN\n SELECT RAISE(ABORT, 'observations are append-only: a deleted change is a change nobody can ever ask about again');\n END`,\n `CREATE TRIGGER token_values_are_append_only BEFORE UPDATE ON token_values BEGIN\n SELECT RAISE(ABORT, 'token values are append-only: a rewritten value breaks the journey that was the reason for keeping it');\n END`,\n `CREATE TRIGGER token_values_are_permanent BEFORE DELETE ON token_values BEGIN\n SELECT RAISE(ABORT, 'token values are append-only: a removed step turns a drift total into a lower bound with nothing saying so');\n END`,\n `CREATE TRIGGER instabilities_are_append_only BEFORE UPDATE ON instabilities BEGIN\n SELECT RAISE(ABORT, 'instabilities are append-only: an occurrence is a fact about one run, and rewriting one changes a rate somebody already acted on');\n END`,\n `CREATE TRIGGER instabilities_are_permanent BEFORE DELETE ON instabilities BEGIN\n SELECT RAISE(ABORT, 'instabilities are append-only: deleting an occurrence is how a flake that was fixed becomes a flake that never happened');\n END`,\n `CREATE TRIGGER approvals_are_append_only BEFORE UPDATE ON approvals BEGIN\n SELECT RAISE(ABORT, 'approvals are append-only: a rewritten acceptance changes what a reviewer agreed to after they agreed to it');\n END`,\n `CREATE TRIGGER approvals_are_permanent BEFORE DELETE ON approvals BEGIN\n SELECT RAISE(ABORT, 'approvals are append-only: a deleted acceptance turns a reviewed change back into an unreviewed one, and every drift total over it drops');\n END`,\n];\n"]}
package/dist/next.d.ts ADDED
@@ -0,0 +1,94 @@
1
+ import type { Tribunal } from './worker.js';
2
+ /**
3
+ * The Worker, mounted inside a Next.js App Router app — which on Cloudflare means
4
+ * [vinext](https://vinext.io).
5
+ *
6
+ * The whole adapter is one idea: a Next route handler is
7
+ * `(Request) => Response | Promise<Response>`, and so is
8
+ * {@link Tribunal.fetch}. What is left is a mount prefix to strip and one
9
+ * decision the operator has to make, below.
10
+ *
11
+ * ```ts
12
+ * // app/variance/[[...path]]/route.ts
13
+ * import { createTribunal } from '@variance-authority/tribunal/worker';
14
+ * import { createTribunalRoutes } from '@variance-authority/tribunal/next';
15
+ *
16
+ * const worker = createTribunal({
17
+ * db: process.env.DB, bucket: process.env.BUCKET, project: 'todomvc',
18
+ * ingestToken: process.env.VARIANCE_INGEST_TOKEN!,
19
+ * reviewToken: process.env.VARIANCE_REVIEW_TOKEN!,
20
+ * });
21
+ *
22
+ * export const { GET, POST, HEAD } = createTribunalRoutes(worker, {
23
+ * basePath: '/variance',
24
+ * authorize: async (request) => (await isSignedIn(request)) ? 'review' : null,
25
+ * });
26
+ * ```
27
+ *
28
+ * ## The one decision this adapter does not make for you
29
+ *
30
+ * A browser rendering the review surface must not be handed the review token —
31
+ * a token in a page is a token in everybody's devtools, and this one **promotes
32
+ * baselines**. So the token stays on the server and the handler attaches it,
33
+ * which means the handler, not the token, is now the gate.
34
+ *
35
+ * `authorize` is that gate, and it has no default. Returning `'review'` for every
36
+ * request would publish an approve button to the internet, and a package that
37
+ * shipped that as a convenience would be shipping the failure. An operator who
38
+ * genuinely wants the surface open writes `() => 'review'` themselves, in their
39
+ * own file, where the next person reviewing that repository can see it.
40
+ *
41
+ * A request that `authorize` refuses is answered 401 here, without reaching the
42
+ * Worker — so the app's own session and the deployment's tokens stay two
43
+ * separate things, and neither is asked to be the other.
44
+ */
45
+ export interface TribunalRouteOptions {
46
+ /**
47
+ * Where the route is mounted, e.g. `/variance` for `app/variance/[[...path]]`.
48
+ *
49
+ * Stripped before the request reaches the Worker, which knows only its own
50
+ * paths. Without it every request would arrive as `/variance/review/builds`
51
+ * and 404 against a route table that has never heard of the prefix.
52
+ */
53
+ readonly basePath?: string;
54
+ /**
55
+ * Which capability this request is allowed, decided by the operator's own app.
56
+ *
57
+ * `null` is a 401. There is no default — see the note above.
58
+ */
59
+ authorize(request: Request): Promise<'ingest' | 'review' | null> | 'ingest' | 'review' | null;
60
+ /**
61
+ * The tokens the Worker was constructed with.
62
+ *
63
+ * Passed again rather than read off the worker, because a `Tribunal` is
64
+ * deliberately a `fetch` handler and nothing else: a handler that could be
65
+ * asked for its own secrets is a handler that can leak them by being logged.
66
+ */
67
+ readonly tokens: {
68
+ readonly ingest: string;
69
+ readonly review: string;
70
+ };
71
+ }
72
+ /**
73
+ * The three method exports a Next.js route file needs.
74
+ *
75
+ * `GET`, `POST` and `HEAD` are the same handler; the framework wants them named.
76
+ * Spread them straight out of a route module — `export const { GET, POST, HEAD }
77
+ * = createTribunalRoutes(...)`.
78
+ */
79
+ export interface TribunalRoutes {
80
+ GET(request: Request): Promise<Response>;
81
+ POST(request: Request): Promise<Response>;
82
+ HEAD(request: Request): Promise<Response>;
83
+ }
84
+ /**
85
+ * Mount the service inside an app that already knows who its users are.
86
+ *
87
+ * Strips `basePath`, asks `authorize` what this request is allowed, and
88
+ * **replaces** the authorization header with the token that answer implies —
89
+ * replaces, so a caller cannot present its own bearer and choose its own
90
+ * capability. A refusal is answered 401 without the Worker seeing the request,
91
+ * which keeps the app's session and the deployment's tokens two separate things.
92
+ */
93
+ export declare function createTribunalRoutes(worker: Tribunal, options: TribunalRouteOptions): TribunalRoutes;
94
+ //# sourceMappingURL=next.d.ts.map
package/dist/next.js ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Mount the service inside an app that already knows who its users are.
3
+ *
4
+ * Strips `basePath`, asks `authorize` what this request is allowed, and
5
+ * **replaces** the authorization header with the token that answer implies —
6
+ * replaces, so a caller cannot present its own bearer and choose its own
7
+ * capability. A refusal is answered 401 without the Worker seeing the request,
8
+ * which keeps the app's session and the deployment's tokens two separate things.
9
+ */
10
+ export function createTribunalRoutes(worker, options) {
11
+ const basePath = (options.basePath ?? '').replace(/\/$/, '');
12
+ const handle = async (request) => {
13
+ const granted = await options.authorize(request);
14
+ if (granted === null) {
15
+ return new Response(JSON.stringify({ error: 'not authorized for this deployment' }), {
16
+ status: 401,
17
+ headers: { 'content-type': 'application/json; charset=utf-8' },
18
+ });
19
+ }
20
+ const url = new URL(request.url);
21
+ if (basePath !== '' && url.pathname.startsWith(basePath)) {
22
+ url.pathname = url.pathname.slice(basePath.length) || '/';
23
+ }
24
+ const headers = new Headers(request.headers);
25
+ // Replaced rather than added. A caller that sent its own bearer would
26
+ // otherwise decide its own capability, and `authorize` would be advisory.
27
+ headers.set('authorization', `Bearer ${granted === 'ingest' ? options.tokens.ingest : options.tokens.review}`);
28
+ return worker.fetch(new Request(url, {
29
+ method: request.method,
30
+ headers,
31
+ ...(request.method === 'GET' || request.method === 'HEAD'
32
+ ? {}
33
+ : { body: await request.arrayBuffer() }),
34
+ }));
35
+ };
36
+ return {
37
+ GET: handle,
38
+ POST: handle,
39
+ // A `HEAD` is a `GET` whose body Next discards. Declared so the framework
40
+ // does not answer 405 for a request every image preloader makes.
41
+ HEAD: handle,
42
+ };
43
+ }
44
+ //# sourceMappingURL=next.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"next.js","sourceRoot":"","sources":["../src/next.ts"],"names":[],"mappings":"AAsFA;;;;;;;;GAQG;AACH,MAAM,UAAU,oBAAoB,CAClC,MAAgB,EAChB,OAA6B;IAE7B,MAAM,QAAQ,GAAG,CAAC,OAAO,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC,OAAO,CAAC,KAAK,EAAE,EAAE,CAAC,CAAC;IAE7D,MAAM,MAAM,GAAG,KAAK,EAAE,OAAgB,EAAqB,EAAE;QAC3D,MAAM,OAAO,GAAG,MAAM,OAAO,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC;QACjD,IAAI,OAAO,KAAK,IAAI,EAAE,CAAC;YACrB,OAAO,IAAI,QAAQ,CAAC,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,oCAAoC,EAAE,CAAC,EAAE;gBACnF,MAAM,EAAE,GAAG;gBACX,OAAO,EAAE,EAAE,cAAc,EAAE,iCAAiC,EAAE;aAC/D,CAAC,CAAC;QACL,CAAC;QAED,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC;QACjC,IAAI,QAAQ,KAAK,EAAE,IAAI,GAAG,CAAC,QAAQ,CAAC,UAAU,CAAC,QAAQ,CAAC,EAAE,CAAC;YACzD,GAAG,CAAC,QAAQ,GAAG,GAAG,CAAC,QAAQ,CAAC,KAAK,CAAC,QAAQ,CAAC,MAAM,CAAC,IAAI,GAAG,CAAC;QAC5D,CAAC;QAED,MAAM,OAAO,GAAG,IAAI,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC,CAAC;QAC7C,sEAAsE;QACtE,0EAA0E;QAC1E,OAAO,CAAC,GAAG,CACT,eAAe,EACf,UAAU,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,CACjF,CAAC;QAEF,OAAO,MAAM,CAAC,KAAK,CACjB,IAAI,OAAO,CAAC,GAAG,EAAE;YACf,MAAM,EAAE,OAAO,CAAC,MAAM;YACtB,OAAO;YACP,GAAG,CAAC,OAAO,CAAC,MAAM,KAAK,KAAK,IAAI,OAAO,CAAC,MAAM,KAAK,MAAM;gBACvD,CAAC,CAAC,EAAE;gBACJ,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,WAAW,EAAE,EAAE,CAAC;SAC3C,CAAC,CACH,CAAC;IACJ,CAAC,CAAC;IAEF,OAAO;QACL,GAAG,EAAE,MAAM;QACX,IAAI,EAAE,MAAM;QACZ,0EAA0E;QAC1E,iEAAiE;QACjE,IAAI,EAAE,MAAM;KACb,CAAC;AACJ,CAAC","sourcesContent":["import type { Tribunal } from './worker.js';\n\n/**\n * The Worker, mounted inside a Next.js App Router app — which on Cloudflare means\n * [vinext](https://vinext.io).\n *\n * The whole adapter is one idea: a Next route handler is\n * `(Request) => Response | Promise<Response>`, and so is\n * {@link Tribunal.fetch}. What is left is a mount prefix to strip and one\n * decision the operator has to make, below.\n *\n * ```ts\n * // app/variance/[[...path]]/route.ts\n * import { createTribunal } from '@variance-authority/tribunal/worker';\n * import { createTribunalRoutes } from '@variance-authority/tribunal/next';\n *\n * const worker = createTribunal({\n * db: process.env.DB, bucket: process.env.BUCKET, project: 'todomvc',\n * ingestToken: process.env.VARIANCE_INGEST_TOKEN!,\n * reviewToken: process.env.VARIANCE_REVIEW_TOKEN!,\n * });\n *\n * export const { GET, POST, HEAD } = createTribunalRoutes(worker, {\n * basePath: '/variance',\n * authorize: async (request) => (await isSignedIn(request)) ? 'review' : null,\n * });\n * ```\n *\n * ## The one decision this adapter does not make for you\n *\n * A browser rendering the review surface must not be handed the review token —\n * a token in a page is a token in everybody's devtools, and this one **promotes\n * baselines**. So the token stays on the server and the handler attaches it,\n * which means the handler, not the token, is now the gate.\n *\n * `authorize` is that gate, and it has no default. Returning `'review'` for every\n * request would publish an approve button to the internet, and a package that\n * shipped that as a convenience would be shipping the failure. An operator who\n * genuinely wants the surface open writes `() => 'review'` themselves, in their\n * own file, where the next person reviewing that repository can see it.\n *\n * A request that `authorize` refuses is answered 401 here, without reaching the\n * Worker — so the app's own session and the deployment's tokens stay two\n * separate things, and neither is asked to be the other.\n */\n\nexport interface TribunalRouteOptions {\n /**\n * Where the route is mounted, e.g. `/variance` for `app/variance/[[...path]]`.\n *\n * Stripped before the request reaches the Worker, which knows only its own\n * paths. Without it every request would arrive as `/variance/review/builds`\n * and 404 against a route table that has never heard of the prefix.\n */\n readonly basePath?: string;\n\n /**\n * Which capability this request is allowed, decided by the operator's own app.\n *\n * `null` is a 401. There is no default — see the note above.\n */\n authorize(request: Request): Promise<'ingest' | 'review' | null> | 'ingest' | 'review' | null;\n\n /**\n * The tokens the Worker was constructed with.\n *\n * Passed again rather than read off the worker, because a `Tribunal` is\n * deliberately a `fetch` handler and nothing else: a handler that could be\n * asked for its own secrets is a handler that can leak them by being logged.\n */\n readonly tokens: { readonly ingest: string; readonly review: string };\n}\n\n/**\n * The three method exports a Next.js route file needs.\n *\n * `GET`, `POST` and `HEAD` are the same handler; the framework wants them named.\n * Spread them straight out of a route module — `export const { GET, POST, HEAD }\n * = createTribunalRoutes(...)`.\n */\nexport interface TribunalRoutes {\n GET(request: Request): Promise<Response>;\n POST(request: Request): Promise<Response>;\n HEAD(request: Request): Promise<Response>;\n}\n\n/**\n * Mount the service inside an app that already knows who its users are.\n *\n * Strips `basePath`, asks `authorize` what this request is allowed, and\n * **replaces** the authorization header with the token that answer implies —\n * replaces, so a caller cannot present its own bearer and choose its own\n * capability. A refusal is answered 401 without the Worker seeing the request,\n * which keeps the app's session and the deployment's tokens two separate things.\n */\nexport function createTribunalRoutes(\n worker: Tribunal,\n options: TribunalRouteOptions,\n): TribunalRoutes {\n const basePath = (options.basePath ?? '').replace(/\\/$/, '');\n\n const handle = async (request: Request): Promise<Response> => {\n const granted = await options.authorize(request);\n if (granted === null) {\n return new Response(JSON.stringify({ error: 'not authorized for this deployment' }), {\n status: 401,\n headers: { 'content-type': 'application/json; charset=utf-8' },\n });\n }\n\n const url = new URL(request.url);\n if (basePath !== '' && url.pathname.startsWith(basePath)) {\n url.pathname = url.pathname.slice(basePath.length) || '/';\n }\n\n const headers = new Headers(request.headers);\n // Replaced rather than added. A caller that sent its own bearer would\n // otherwise decide its own capability, and `authorize` would be advisory.\n headers.set(\n 'authorization',\n `Bearer ${granted === 'ingest' ? options.tokens.ingest : options.tokens.review}`,\n );\n\n return worker.fetch(\n new Request(url, {\n method: request.method,\n headers,\n ...(request.method === 'GET' || request.method === 'HEAD'\n ? {}\n : { body: await request.arrayBuffer() }),\n }),\n );\n };\n\n return {\n GET: handle,\n POST: handle,\n // A `HEAD` is a `GET` whose body Next discards. Declared so the framework\n // does not answer 405 for a request every image preloader makes.\n HEAD: handle,\n };\n}\n"]}
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ import { type TribunalDatabase } from './database.js';
3
+ import { type TribunalService } from './serve.js';
4
+ /**
5
+ * `variance-authority-tribunal` — the thing the operator runs.
6
+ *
7
+ * A process, a port, a file, a directory and two tokens. The same review
8
+ * service a Cloudflare deployment serves, with SQLite where D1 was and a
9
+ * directory where R2 was; every route, every refusal and every status code is
10
+ * the same code.
11
+ *
12
+ * Configuration is environment variables rather than flags, matching
13
+ * [`server`](../../../server/src/bin.ts) — a service is started by a supervisor,
14
+ * a unit file or a container, and all three pass an environment.
15
+ *
16
+ * ## The three things this refuses
17
+ *
18
+ * **No tokens, no service.** Inherited from `createTribunal` rather than
19
+ * re-implemented: two bearer tokens, sixteen characters each, and not the same
20
+ * string. The refusal is the constructor's and it is quoted straight to stderr.
21
+ *
22
+ * **A project, named.** There is no default. An invented one puts two
23
+ * repositories' baselines in one namespace and the first symptom is a mass
24
+ * `changed` — the argument is the package's, and `worker-entry` only falls back
25
+ * to `default` because a Worker environment has nobody to ask. A person typing a
26
+ * command does.
27
+ *
28
+ * **Loopback unless somebody typed otherwise.** Exposure is a decision, not an
29
+ * accident of a bind address. And on a non-loopback bind the **review surface is
30
+ * not served at all** — see {@link authorizeFor}, which is the one piece of
31
+ * policy this file owns.
32
+ */
33
+ export declare const PORT_VARIABLE = "VARIANCE_TRIBUNAL_PORT";
34
+ export declare const HOST_VARIABLE = "VARIANCE_TRIBUNAL_HOST";
35
+ export declare const DATABASE_VARIABLE = "VARIANCE_TRIBUNAL_DB";
36
+ export declare const STORAGE_VARIABLE = "VARIANCE_TRIBUNAL_STORAGE";
37
+ export declare const PROJECT_VARIABLE = "VARIANCE_TRIBUNAL_PROJECT";
38
+ export declare const INGEST_TOKEN_VARIABLE = "VARIANCE_TRIBUNAL_INGEST_TOKEN";
39
+ export declare const REVIEW_TOKEN_VARIABLE = "VARIANCE_TRIBUNAL_REVIEW_TOKEN";
40
+ export declare const RETENTION_VARIABLE = "VARIANCE_TRIBUNAL_RETENTION_DAYS";
41
+ export declare const REVIEWER_VARIABLE = "VARIANCE_TRIBUNAL_REVIEWER";
42
+ export declare const TRUST_NETWORK_VARIABLE = "VARIANCE_TRIBUNAL_TRUST_NETWORK";
43
+ export interface TribunalConfig {
44
+ readonly port: number;
45
+ readonly host: string;
46
+ /** Absolute, so the startup line names the file rather than a guess. */
47
+ readonly database: string;
48
+ /** Absolute, for the same reason. */
49
+ readonly storage: string;
50
+ readonly project: string;
51
+ readonly ingestToken: string;
52
+ readonly reviewToken: string;
53
+ readonly retentionDays?: number;
54
+ readonly reviewer: string;
55
+ /**
56
+ * Whether the socket is reachable only from this machine.
57
+ *
58
+ * Derived rather than configured: it is a fact about the bind address, and a
59
+ * setting that could disagree with the address would be a setting that lies.
60
+ */
61
+ readonly loopback: boolean;
62
+ }
63
+ /**
64
+ * Read the configuration, or explain exactly which variable is wrong.
65
+ *
66
+ * Pure and exported, so every refusal below is testable without binding a port.
67
+ * A rule that can only be exercised by starting a process is a rule that gets
68
+ * tested once, by hand, before it is weakened.
69
+ */
70
+ export declare function readConfig(env: Readonly<Record<string, string | undefined>>): TribunalConfig;
71
+ /**
72
+ * The one piece of policy this file owns: who is allowed what.
73
+ *
74
+ * `createTribunalRoutes` requires an `authorize` and gives it no default, on the
75
+ * grounds that returning `'review'` for every request would publish an approve
76
+ * button. This is a host writing that function in its own file, where the next
77
+ * person can read it — which is exactly what that refusal asks for.
78
+ *
79
+ * Three rules, in order:
80
+ *
81
+ * 1. **A caller holding a token gets what the token is for.** CI posts with the
82
+ * ingest token from wherever CI runs. The mount replaces the header before the
83
+ * Worker sees it, so this is the only place the caller's own bearer is read.
84
+ * 2. **On a loopback bind, a caller with no token reviews.** The socket is the
85
+ * gate: only this machine can reach it, and the person at this machine is the
86
+ * reviewer. This is what lets the served page work with no credential in it.
87
+ * 3. **On a network bind, no token is no capability** — and the surface is not
88
+ * served at all, so there is no page to be tricked into carrying one.
89
+ */
90
+ export declare function authorizeFor(config: TribunalConfig): (request: Request) => 'ingest' | 'review' | null;
91
+ export interface RunningTribunal extends TribunalService {
92
+ readonly database: TribunalDatabase;
93
+ }
94
+ /**
95
+ * Open the file, make the directory, bind the port, and say what was done.
96
+ *
97
+ * The startup line names the absolute database path, the absolute object
98
+ * directory, the project, the schema version and the bind address, and names
99
+ * neither token. Everything an operator needs to confirm they configured the
100
+ * right thing, and nothing that turns a shipped log into a credential.
101
+ */
102
+ export declare function start(env: Readonly<Record<string, string | undefined>>, write?: (line: string) => void): Promise<RunningTribunal>;
103
+ /**
104
+ * A flag is not a way to configure this, and saying so is the whole point.
105
+ *
106
+ * `process.argv` was read by nothing here, which is defensible — configuration
107
+ * is an environment, for the reason on the module — and silently accepting the
108
+ * arguments of the configuration this *is not* is not. `--database ./x.db` on a
109
+ * process that only reads {@link DATABASE_VARIABLE} starts a service against a
110
+ * fresh empty file in the working directory, reports success, and prints a
111
+ * startup line that reads as confirmation because it is honestly reporting what
112
+ * the process did. The operator's next move is to ask why their builds are
113
+ * gone.
114
+ *
115
+ * So: refuse, and name the variable the flag was reaching for. Returning the
116
+ * usage text rather than writing it keeps this pure — the refusals above are
117
+ * testable without binding a port and this one is no different.
118
+ */
119
+ export declare function readArguments(argv: readonly string[]): string | null;
120
+ //# sourceMappingURL=bin.d.ts.map