@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,337 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from 'node:fs';
3
+ import { userInfo } from 'node:os';
4
+ import { resolve } from 'node:path';
5
+ import process from 'node:process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { createDirectoryBucket } from './bucket.js';
8
+ import { openDatabase } from './database.js';
9
+ import { serveTribunal } from './serve.js';
10
+ import { createTribunal } from '../worker.js';
11
+ /**
12
+ * `variance-authority-tribunal` — the thing the operator runs.
13
+ *
14
+ * A process, a port, a file, a directory and two tokens. The same review
15
+ * service a Cloudflare deployment serves, with SQLite where D1 was and a
16
+ * directory where R2 was; every route, every refusal and every status code is
17
+ * the same code.
18
+ *
19
+ * Configuration is environment variables rather than flags, matching
20
+ * [`server`](../../../server/src/bin.ts) — a service is started by a supervisor,
21
+ * a unit file or a container, and all three pass an environment.
22
+ *
23
+ * ## The three things this refuses
24
+ *
25
+ * **No tokens, no service.** Inherited from `createTribunal` rather than
26
+ * re-implemented: two bearer tokens, sixteen characters each, and not the same
27
+ * string. The refusal is the constructor's and it is quoted straight to stderr.
28
+ *
29
+ * **A project, named.** There is no default. An invented one puts two
30
+ * repositories' baselines in one namespace and the first symptom is a mass
31
+ * `changed` — the argument is the package's, and `worker-entry` only falls back
32
+ * to `default` because a Worker environment has nobody to ask. A person typing a
33
+ * command does.
34
+ *
35
+ * **Loopback unless somebody typed otherwise.** Exposure is a decision, not an
36
+ * accident of a bind address. And on a non-loopback bind the **review surface is
37
+ * not served at all** — see {@link authorizeFor}, which is the one piece of
38
+ * policy this file owns.
39
+ */
40
+ export const PORT_VARIABLE = 'VARIANCE_TRIBUNAL_PORT';
41
+ export const HOST_VARIABLE = 'VARIANCE_TRIBUNAL_HOST';
42
+ export const DATABASE_VARIABLE = 'VARIANCE_TRIBUNAL_DB';
43
+ export const STORAGE_VARIABLE = 'VARIANCE_TRIBUNAL_STORAGE';
44
+ export const PROJECT_VARIABLE = 'VARIANCE_TRIBUNAL_PROJECT';
45
+ export const INGEST_TOKEN_VARIABLE = 'VARIANCE_TRIBUNAL_INGEST_TOKEN';
46
+ export const REVIEW_TOKEN_VARIABLE = 'VARIANCE_TRIBUNAL_REVIEW_TOKEN';
47
+ export const RETENTION_VARIABLE = 'VARIANCE_TRIBUNAL_RETENTION_DAYS';
48
+ export const REVIEWER_VARIABLE = 'VARIANCE_TRIBUNAL_REVIEWER';
49
+ export const TRUST_NETWORK_VARIABLE = 'VARIANCE_TRIBUNAL_TRUST_NETWORK';
50
+ const DEFAULT_PORT = 7789;
51
+ const DEFAULT_HOST = '127.0.0.1';
52
+ const DEFAULT_DATABASE = 'variance-tribunal.db';
53
+ const DEFAULT_STORAGE = 'variance-tribunal-objects';
54
+ /** Addresses that can only be reached from the machine the process is on. */
55
+ const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1']);
56
+ /**
57
+ * Read the configuration, or explain exactly which variable is wrong.
58
+ *
59
+ * Pure and exported, so every refusal below is testable without binding a port.
60
+ * A rule that can only be exercised by starting a process is a rule that gets
61
+ * tested once, by hand, before it is weakened.
62
+ */
63
+ export function readConfig(env) {
64
+ const project = (env[PROJECT_VARIABLE] ?? '').trim();
65
+ if (project === '') {
66
+ throw new Error(`${PROJECT_VARIABLE} is not set. It scopes every row and every object key, so one ` +
67
+ 'deployment can serve several repositories without their `story:card` colliding. There ' +
68
+ 'is no default on purpose: an invented one puts two projects\' baselines in one ' +
69
+ 'namespace, and the first symptom is every subject reporting `changed` at once.');
70
+ }
71
+ const host = orDefault(env[HOST_VARIABLE], DEFAULT_HOST);
72
+ const loopback = LOOPBACK.has(host);
73
+ if (!loopback && orDefault(env[TRUST_NETWORK_VARIABLE], '') === '') {
74
+ throw new Error(`${HOST_VARIABLE} is "${host}", which is reachable from outside this machine, and ` +
75
+ `${TRUST_NETWORK_VARIABLE} is not set. This service has bearer tokens and no accounts, ` +
76
+ 'no TLS and no rate limit, so a network bind is a decision somebody should have typed. ' +
77
+ `Set ${TRUST_NETWORK_VARIABLE}=1 to confirm it — and read what it costs: the review ` +
78
+ 'surface is not served on a network bind, because there would be nothing between it and ' +
79
+ 'an approve button on the internet. Put the Next.js adapter behind your own sign-in ' +
80
+ 'instead, or leave this on loopback behind a reverse proxy that authenticates.');
81
+ }
82
+ const rawPort = env[PORT_VARIABLE];
83
+ const port = rawPort === undefined || rawPort === '' ? DEFAULT_PORT : Number(rawPort);
84
+ if (!Number.isInteger(port) || port < 0 || port > 65_535) {
85
+ throw new Error(`${PORT_VARIABLE} must be a whole number between 0 and 65535; received "${rawPort ?? ''}"`);
86
+ }
87
+ const rawRetention = env[RETENTION_VARIABLE];
88
+ const retentionDays = rawRetention === undefined || rawRetention === '' ? undefined : Number(rawRetention);
89
+ if (retentionDays !== undefined && (!Number.isFinite(retentionDays) || retentionDays <= 0)) {
90
+ throw new Error(`${RETENTION_VARIABLE} must be a positive number of days; received "${rawRetention ?? ''}". ` +
91
+ 'Leave it unset for the package default rather than passing zero, which would sweep ' +
92
+ 'every build the first time anyone asked.');
93
+ }
94
+ return {
95
+ port,
96
+ host,
97
+ loopback,
98
+ database: resolve(orDefault(env[DATABASE_VARIABLE], DEFAULT_DATABASE)),
99
+ storage: resolve(orDefault(env[STORAGE_VARIABLE], DEFAULT_STORAGE)),
100
+ project,
101
+ // Passed through unvalidated: `createTribunal` refuses a short token and two
102
+ // identical ones, and a second copy of that rule here is a second place for
103
+ // it to be relaxed.
104
+ ingestToken: env[INGEST_TOKEN_VARIABLE] ?? '',
105
+ reviewToken: env[REVIEW_TOKEN_VARIABLE] ?? '',
106
+ ...(retentionDays === undefined ? {} : { retentionDays }),
107
+ reviewer: orDefault(env[REVIEWER_VARIABLE], defaultReviewer()),
108
+ };
109
+ }
110
+ /**
111
+ * The one piece of policy this file owns: who is allowed what.
112
+ *
113
+ * `createTribunalRoutes` requires an `authorize` and gives it no default, on the
114
+ * grounds that returning `'review'` for every request would publish an approve
115
+ * button. This is a host writing that function in its own file, where the next
116
+ * person can read it — which is exactly what that refusal asks for.
117
+ *
118
+ * Three rules, in order:
119
+ *
120
+ * 1. **A caller holding a token gets what the token is for.** CI posts with the
121
+ * ingest token from wherever CI runs. The mount replaces the header before the
122
+ * Worker sees it, so this is the only place the caller's own bearer is read.
123
+ * 2. **On a loopback bind, a caller with no token reviews.** The socket is the
124
+ * gate: only this machine can reach it, and the person at this machine is the
125
+ * reviewer. This is what lets the served page work with no credential in it.
126
+ * 3. **On a network bind, no token is no capability** — and the surface is not
127
+ * served at all, so there is no page to be tricked into carrying one.
128
+ */
129
+ export function authorizeFor(config) {
130
+ return (request) => {
131
+ const bearer = bearerOf(request);
132
+ if (bearer !== null) {
133
+ if (bearer === config.ingestToken)
134
+ return 'ingest';
135
+ if (bearer === config.reviewToken)
136
+ return 'review';
137
+ return null;
138
+ }
139
+ return config.loopback ? 'review' : null;
140
+ };
141
+ }
142
+ function bearerOf(request) {
143
+ const header = request.headers.get('authorization');
144
+ if (header === null)
145
+ return null;
146
+ const match = /^Bearer\s+(.+)$/i.exec(header.trim());
147
+ return match === null ? null : match[1];
148
+ }
149
+ /**
150
+ * Open the file, make the directory, bind the port, and say what was done.
151
+ *
152
+ * The startup line names the absolute database path, the absolute object
153
+ * directory, the project, the schema version and the bind address, and names
154
+ * neither token. Everything an operator needs to confirm they configured the
155
+ * right thing, and nothing that turns a shipped log into a credential.
156
+ */
157
+ export async function start(env, write = (line) => process.stdout.write(line)) {
158
+ const config = readConfig(env);
159
+ const database = await openDatabase(config.database);
160
+ let service;
161
+ try {
162
+ const tribunal = createTribunal({
163
+ db: database,
164
+ bucket: createDirectoryBucket(config.storage),
165
+ project: config.project,
166
+ ingestToken: config.ingestToken,
167
+ reviewToken: config.reviewToken,
168
+ ...(config.retentionDays === undefined ? {} : { retentionDays: config.retentionDays }),
169
+ });
170
+ service = await serveTribunal({
171
+ tribunal,
172
+ host: config.host,
173
+ port: config.port,
174
+ authorize: authorizeFor(config),
175
+ tokens: { ingest: config.ingestToken, review: config.reviewToken },
176
+ ui: config.loopback,
177
+ reviewer: config.reviewer,
178
+ });
179
+ }
180
+ catch (error) {
181
+ // A refused token or a taken port leaves the database open otherwise, holding
182
+ // its lock against the process the operator is about to start instead.
183
+ database.close();
184
+ throw error;
185
+ }
186
+ write(`variance-authority tribunal listening on ${service.url}\n` +
187
+ ` project: ${config.project}\n` +
188
+ ` database: ${config.database} (schema version ${database.version})\n` +
189
+ ` objects: ${config.storage}\n` +
190
+ ` review: ${config.loopback
191
+ ? `served at ${service.url} — this bind is reachable only from this machine`
192
+ : `not served: ${HOST_VARIABLE} is a network address. The JSON API is up and wants a bearer token`}\n` +
193
+ ` auth: bearer tokens from ${INGEST_TOKEN_VARIABLE} and ${REVIEW_TOKEN_VARIABLE}\n`);
194
+ return {
195
+ ...service,
196
+ database,
197
+ async close() {
198
+ await service.close();
199
+ database.close();
200
+ },
201
+ };
202
+ }
203
+ function orDefault(value, fallback) {
204
+ return value === undefined || value.trim() === '' ? fallback : value.trim();
205
+ }
206
+ /**
207
+ * Who a decision is recorded as when nobody said.
208
+ *
209
+ * There are no accounts here — a decision carries a name, and the name should be
210
+ * a person rather than the word "reviewer". The OS knows one; `userInfo` throws
211
+ * on a host with no passwd entry, which some containers are.
212
+ */
213
+ function defaultReviewer() {
214
+ try {
215
+ return userInfo().username;
216
+ }
217
+ catch {
218
+ return 'reviewer';
219
+ }
220
+ }
221
+ /**
222
+ * A flag is not a way to configure this, and saying so is the whole point.
223
+ *
224
+ * `process.argv` was read by nothing here, which is defensible — configuration
225
+ * is an environment, for the reason on the module — and silently accepting the
226
+ * arguments of the configuration this *is not* is not. `--database ./x.db` on a
227
+ * process that only reads {@link DATABASE_VARIABLE} starts a service against a
228
+ * fresh empty file in the working directory, reports success, and prints a
229
+ * startup line that reads as confirmation because it is honestly reporting what
230
+ * the process did. The operator's next move is to ask why their builds are
231
+ * gone.
232
+ *
233
+ * So: refuse, and name the variable the flag was reaching for. Returning the
234
+ * usage text rather than writing it keeps this pure — the refusals above are
235
+ * testable without binding a port and this one is no different.
236
+ */
237
+ export function readArguments(argv) {
238
+ if (argv.length === 0)
239
+ return null;
240
+ if (argv.includes('--help') || argv.includes('-h'))
241
+ return USAGE;
242
+ const given = argv[0];
243
+ const flag = given.split('=')[0];
244
+ const variable = AS_VARIABLE[flag];
245
+ throw new Error(variable === undefined
246
+ ? `this service takes no arguments and received \`${given}\`. It is configured by ` +
247
+ `environment, because a service is started by a supervisor, a unit file or a ` +
248
+ `container, and all three pass one.\n\n${USAGE}`
249
+ : `\`${flag}\` is ${variable}, and this service is configured by environment rather ` +
250
+ `than by flags. Accepting the flag and ignoring it would start the service against ` +
251
+ `the default instead — for ${DATABASE_VARIABLE}, an empty database in the working ` +
252
+ `directory, reported as a successful start.\n\n${USAGE}`);
253
+ }
254
+ /** The flags somebody reaches for, and what each one actually is. */
255
+ const AS_VARIABLE = {
256
+ '--db': DATABASE_VARIABLE,
257
+ '--database': DATABASE_VARIABLE,
258
+ '--objects': STORAGE_VARIABLE,
259
+ '--storage': STORAGE_VARIABLE,
260
+ '--port': PORT_VARIABLE,
261
+ '--host': HOST_VARIABLE,
262
+ '--project': PROJECT_VARIABLE,
263
+ '--retention': RETENTION_VARIABLE,
264
+ '--reviewer': REVIEWER_VARIABLE,
265
+ };
266
+ /** Every variable, in the order somebody sets them. */
267
+ const VARIABLES = [
268
+ [PROJECT_VARIABLE, 'required — scopes every row and every object key'],
269
+ [INGEST_TOKEN_VARIABLE, 'required — what CI pushes with'],
270
+ [REVIEW_TOKEN_VARIABLE, 'required — what a person decides with'],
271
+ [DATABASE_VARIABLE, `the SQLite file (default ${DEFAULT_DATABASE})`],
272
+ [STORAGE_VARIABLE, `where images are kept (default ${DEFAULT_STORAGE})`],
273
+ [PORT_VARIABLE, `default ${DEFAULT_PORT}`],
274
+ [HOST_VARIABLE, `default ${DEFAULT_HOST}; a network bind serves no review page`],
275
+ [TRUST_NETWORK_VARIABLE, 'confirms a network bind was meant'],
276
+ [RETENTION_VARIABLE, 'days after which a build is swept'],
277
+ [REVIEWER_VARIABLE, 'the name a decision is recorded under'],
278
+ ];
279
+ /**
280
+ * Built rather than typed out, and padded from the names themselves: a
281
+ * hand-aligned block goes crooked the first time one of them is renamed.
282
+ */
283
+ const USAGE = ((widest) => [
284
+ 'variance-authority-tribunal — a process, a port, a file and two tokens.',
285
+ '',
286
+ 'Configured by environment. There are no flags.',
287
+ '',
288
+ ...VARIABLES.map(([name, what]) => ` ${name.padEnd(widest)} ${what}`),
289
+ '',
290
+ ].join('\n'))(Math.max(...VARIABLES.map(([name]) => name.length)));
291
+ async function main() {
292
+ let service;
293
+ try {
294
+ const usage = readArguments(process.argv.slice(2));
295
+ if (usage !== null) {
296
+ process.stdout.write(usage);
297
+ return;
298
+ }
299
+ service = await start(process.env);
300
+ }
301
+ catch (error) {
302
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
303
+ process.exit(1);
304
+ return;
305
+ }
306
+ // Both signals close the database rather than letting the process die with a
307
+ // write in flight. SQLite recovers from that, but an operator reading a WAL
308
+ // recovery message has no way to know nothing was lost.
309
+ for (const signal of ['SIGINT', 'SIGTERM']) {
310
+ process.once(signal, () => {
311
+ void service.close().then(() => process.exit(0), (error) => {
312
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
313
+ process.exit(1);
314
+ });
315
+ });
316
+ }
317
+ }
318
+ /**
319
+ * Whether `entry` names this file, following links on both sides.
320
+ *
321
+ * A package manager installs a bin as a symlink — `node_modules/.bin/variance-authority-tribunal`
322
+ * pointing here — so `process.argv[1]` is the link and `import.meta.url` is its
323
+ * target. Compared as written they never match, and the guard below would then
324
+ * skip `main` and let the process exit 0 without serving anything.
325
+ */
326
+ function isProgram(entry) {
327
+ try {
328
+ return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
329
+ }
330
+ catch {
331
+ return false;
332
+ }
333
+ }
334
+ if (process.argv[1] !== undefined && isProgram(process.argv[1])) {
335
+ await main();
336
+ }
337
+ //# sourceMappingURL=bin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bin.js","sourceRoot":"","sources":["../../src/node/bin.ts"],"names":[],"mappings":"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","sourcesContent":["#!/usr/bin/env node\nimport { realpathSync } from 'node:fs';\nimport { userInfo } from 'node:os';\nimport { resolve } from 'node:path';\nimport process from 'node:process';\nimport { fileURLToPath } from 'node:url';\nimport { createDirectoryBucket } from './bucket.js';\nimport { openDatabase, type TribunalDatabase } from './database.js';\nimport { serveTribunal, type TribunalService } from './serve.js';\nimport { createTribunal } from '../worker.js';\n\n/**\n * `variance-authority-tribunal` — the thing the operator runs.\n *\n * A process, a port, a file, a directory and two tokens. The same review\n * service a Cloudflare deployment serves, with SQLite where D1 was and a\n * directory where R2 was; every route, every refusal and every status code is\n * the same code.\n *\n * Configuration is environment variables rather than flags, matching\n * [`server`](../../../server/src/bin.ts) — a service is started by a supervisor,\n * a unit file or a container, and all three pass an environment.\n *\n * ## The three things this refuses\n *\n * **No tokens, no service.** Inherited from `createTribunal` rather than\n * re-implemented: two bearer tokens, sixteen characters each, and not the same\n * string. The refusal is the constructor's and it is quoted straight to stderr.\n *\n * **A project, named.** There is no default. An invented one puts two\n * repositories' baselines in one namespace and the first symptom is a mass\n * `changed` — the argument is the package's, and `worker-entry` only falls back\n * to `default` because a Worker environment has nobody to ask. A person typing a\n * command does.\n *\n * **Loopback unless somebody typed otherwise.** Exposure is a decision, not an\n * accident of a bind address. And on a non-loopback bind the **review surface is\n * not served at all** — see {@link authorizeFor}, which is the one piece of\n * policy this file owns.\n */\n\nexport const PORT_VARIABLE = 'VARIANCE_TRIBUNAL_PORT';\nexport const HOST_VARIABLE = 'VARIANCE_TRIBUNAL_HOST';\nexport const DATABASE_VARIABLE = 'VARIANCE_TRIBUNAL_DB';\nexport const STORAGE_VARIABLE = 'VARIANCE_TRIBUNAL_STORAGE';\nexport const PROJECT_VARIABLE = 'VARIANCE_TRIBUNAL_PROJECT';\nexport const INGEST_TOKEN_VARIABLE = 'VARIANCE_TRIBUNAL_INGEST_TOKEN';\nexport const REVIEW_TOKEN_VARIABLE = 'VARIANCE_TRIBUNAL_REVIEW_TOKEN';\nexport const RETENTION_VARIABLE = 'VARIANCE_TRIBUNAL_RETENTION_DAYS';\nexport const REVIEWER_VARIABLE = 'VARIANCE_TRIBUNAL_REVIEWER';\nexport const TRUST_NETWORK_VARIABLE = 'VARIANCE_TRIBUNAL_TRUST_NETWORK';\n\nconst DEFAULT_PORT = 7789;\nconst DEFAULT_HOST = '127.0.0.1';\nconst DEFAULT_DATABASE = 'variance-tribunal.db';\nconst DEFAULT_STORAGE = 'variance-tribunal-objects';\n\n/** Addresses that can only be reached from the machine the process is on. */\nconst LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1']);\n\nexport interface TribunalConfig {\n readonly port: number;\n readonly host: string;\n /** Absolute, so the startup line names the file rather than a guess. */\n readonly database: string;\n /** Absolute, for the same reason. */\n readonly storage: string;\n readonly project: string;\n readonly ingestToken: string;\n readonly reviewToken: string;\n readonly retentionDays?: number;\n readonly reviewer: string;\n /**\n * Whether the socket is reachable only from this machine.\n *\n * Derived rather than configured: it is a fact about the bind address, and a\n * setting that could disagree with the address would be a setting that lies.\n */\n readonly loopback: boolean;\n}\n\n/**\n * Read the configuration, or explain exactly which variable is wrong.\n *\n * Pure and exported, so every refusal below is testable without binding a port.\n * A rule that can only be exercised by starting a process is a rule that gets\n * tested once, by hand, before it is weakened.\n */\nexport function readConfig(env: Readonly<Record<string, string | undefined>>): TribunalConfig {\n const project = (env[PROJECT_VARIABLE] ?? '').trim();\n if (project === '') {\n throw new Error(\n `${PROJECT_VARIABLE} is not set. It scopes every row and every object key, so one ` +\n 'deployment can serve several repositories without their `story:card` colliding. There ' +\n 'is no default on purpose: an invented one puts two projects\\' baselines in one ' +\n 'namespace, and the first symptom is every subject reporting `changed` at once.',\n );\n }\n\n const host = orDefault(env[HOST_VARIABLE], DEFAULT_HOST);\n const loopback = LOOPBACK.has(host);\n if (!loopback && orDefault(env[TRUST_NETWORK_VARIABLE], '') === '') {\n throw new Error(\n `${HOST_VARIABLE} is \"${host}\", which is reachable from outside this machine, and ` +\n `${TRUST_NETWORK_VARIABLE} is not set. This service has bearer tokens and no accounts, ` +\n 'no TLS and no rate limit, so a network bind is a decision somebody should have typed. ' +\n `Set ${TRUST_NETWORK_VARIABLE}=1 to confirm it — and read what it costs: the review ` +\n 'surface is not served on a network bind, because there would be nothing between it and ' +\n 'an approve button on the internet. Put the Next.js adapter behind your own sign-in ' +\n 'instead, or leave this on loopback behind a reverse proxy that authenticates.',\n );\n }\n\n const rawPort = env[PORT_VARIABLE];\n const port = rawPort === undefined || rawPort === '' ? DEFAULT_PORT : Number(rawPort);\n if (!Number.isInteger(port) || port < 0 || port > 65_535) {\n throw new Error(\n `${PORT_VARIABLE} must be a whole number between 0 and 65535; received \"${rawPort ?? ''}\"`,\n );\n }\n\n const rawRetention = env[RETENTION_VARIABLE];\n const retentionDays = rawRetention === undefined || rawRetention === '' ? undefined : Number(rawRetention);\n if (retentionDays !== undefined && (!Number.isFinite(retentionDays) || retentionDays <= 0)) {\n throw new Error(\n `${RETENTION_VARIABLE} must be a positive number of days; received \"${rawRetention ?? ''}\". ` +\n 'Leave it unset for the package default rather than passing zero, which would sweep ' +\n 'every build the first time anyone asked.',\n );\n }\n\n return {\n port,\n host,\n loopback,\n database: resolve(orDefault(env[DATABASE_VARIABLE], DEFAULT_DATABASE)),\n storage: resolve(orDefault(env[STORAGE_VARIABLE], DEFAULT_STORAGE)),\n project,\n // Passed through unvalidated: `createTribunal` refuses a short token and two\n // identical ones, and a second copy of that rule here is a second place for\n // it to be relaxed.\n ingestToken: env[INGEST_TOKEN_VARIABLE] ?? '',\n reviewToken: env[REVIEW_TOKEN_VARIABLE] ?? '',\n ...(retentionDays === undefined ? {} : { retentionDays }),\n reviewer: orDefault(env[REVIEWER_VARIABLE], defaultReviewer()),\n };\n}\n\n/**\n * The one piece of policy this file owns: who is allowed what.\n *\n * `createTribunalRoutes` requires an `authorize` and gives it no default, on the\n * grounds that returning `'review'` for every request would publish an approve\n * button. This is a host writing that function in its own file, where the next\n * person can read it — which is exactly what that refusal asks for.\n *\n * Three rules, in order:\n *\n * 1. **A caller holding a token gets what the token is for.** CI posts with the\n * ingest token from wherever CI runs. The mount replaces the header before the\n * Worker sees it, so this is the only place the caller's own bearer is read.\n * 2. **On a loopback bind, a caller with no token reviews.** The socket is the\n * gate: only this machine can reach it, and the person at this machine is the\n * reviewer. This is what lets the served page work with no credential in it.\n * 3. **On a network bind, no token is no capability** — and the surface is not\n * served at all, so there is no page to be tricked into carrying one.\n */\nexport function authorizeFor(config: TribunalConfig): (request: Request) => 'ingest' | 'review' | null {\n return (request: Request) => {\n const bearer = bearerOf(request);\n if (bearer !== null) {\n if (bearer === config.ingestToken) return 'ingest';\n if (bearer === config.reviewToken) return 'review';\n return null;\n }\n return config.loopback ? 'review' : null;\n };\n}\n\nfunction bearerOf(request: Request): string | null {\n const header = request.headers.get('authorization');\n if (header === null) return null;\n const match = /^Bearer\\s+(.+)$/i.exec(header.trim());\n return match === null ? null : (match[1] as string);\n}\n\nexport interface RunningTribunal extends TribunalService {\n readonly database: TribunalDatabase;\n}\n\n/**\n * Open the file, make the directory, bind the port, and say what was done.\n *\n * The startup line names the absolute database path, the absolute object\n * directory, the project, the schema version and the bind address, and names\n * neither token. Everything an operator needs to confirm they configured the\n * right thing, and nothing that turns a shipped log into a credential.\n */\nexport async function start(\n env: Readonly<Record<string, string | undefined>>,\n write: (line: string) => void = (line) => process.stdout.write(line),\n): Promise<RunningTribunal> {\n const config = readConfig(env);\n const database = await openDatabase(config.database);\n\n let service: TribunalService;\n try {\n const tribunal = createTribunal({\n db: database,\n bucket: createDirectoryBucket(config.storage),\n project: config.project,\n ingestToken: config.ingestToken,\n reviewToken: config.reviewToken,\n ...(config.retentionDays === undefined ? {} : { retentionDays: config.retentionDays }),\n });\n\n service = await serveTribunal({\n tribunal,\n host: config.host,\n port: config.port,\n authorize: authorizeFor(config),\n tokens: { ingest: config.ingestToken, review: config.reviewToken },\n ui: config.loopback,\n reviewer: config.reviewer,\n });\n } catch (error) {\n // A refused token or a taken port leaves the database open otherwise, holding\n // its lock against the process the operator is about to start instead.\n database.close();\n throw error;\n }\n\n write(\n `variance-authority tribunal listening on ${service.url}\\n` +\n ` project: ${config.project}\\n` +\n ` database: ${config.database} (schema version ${database.version})\\n` +\n ` objects: ${config.storage}\\n` +\n ` review: ${\n config.loopback\n ? `served at ${service.url} — this bind is reachable only from this machine`\n : `not served: ${HOST_VARIABLE} is a network address. The JSON API is up and wants a bearer token`\n }\\n` +\n ` auth: bearer tokens from ${INGEST_TOKEN_VARIABLE} and ${REVIEW_TOKEN_VARIABLE}\\n`,\n );\n\n return {\n ...service,\n database,\n async close(): Promise<void> {\n await service.close();\n database.close();\n },\n };\n}\n\nfunction orDefault(value: string | undefined, fallback: string): string {\n return value === undefined || value.trim() === '' ? fallback : value.trim();\n}\n\n/**\n * Who a decision is recorded as when nobody said.\n *\n * There are no accounts here — a decision carries a name, and the name should be\n * a person rather than the word \"reviewer\". The OS knows one; `userInfo` throws\n * on a host with no passwd entry, which some containers are.\n */\nfunction defaultReviewer(): string {\n try {\n return userInfo().username;\n } catch {\n return 'reviewer';\n }\n}\n\n/**\n * A flag is not a way to configure this, and saying so is the whole point.\n *\n * `process.argv` was read by nothing here, which is defensible — configuration\n * is an environment, for the reason on the module — and silently accepting the\n * arguments of the configuration this *is not* is not. `--database ./x.db` on a\n * process that only reads {@link DATABASE_VARIABLE} starts a service against a\n * fresh empty file in the working directory, reports success, and prints a\n * startup line that reads as confirmation because it is honestly reporting what\n * the process did. The operator's next move is to ask why their builds are\n * gone.\n *\n * So: refuse, and name the variable the flag was reaching for. Returning the\n * usage text rather than writing it keeps this pure — the refusals above are\n * testable without binding a port and this one is no different.\n */\nexport function readArguments(argv: readonly string[]): string | null {\n if (argv.length === 0) return null;\n if (argv.includes('--help') || argv.includes('-h')) return USAGE;\n\n const given = argv[0] as string;\n const flag = given.split('=')[0] as string;\n const variable = AS_VARIABLE[flag];\n\n throw new Error(\n variable === undefined\n ? `this service takes no arguments and received \\`${given}\\`. It is configured by ` +\n `environment, because a service is started by a supervisor, a unit file or a ` +\n `container, and all three pass one.\\n\\n${USAGE}`\n : `\\`${flag}\\` is ${variable}, and this service is configured by environment rather ` +\n `than by flags. Accepting the flag and ignoring it would start the service against ` +\n `the default instead — for ${DATABASE_VARIABLE}, an empty database in the working ` +\n `directory, reported as a successful start.\\n\\n${USAGE}`,\n );\n}\n\n/** The flags somebody reaches for, and what each one actually is. */\nconst AS_VARIABLE: Readonly<Record<string, string>> = {\n '--db': DATABASE_VARIABLE,\n '--database': DATABASE_VARIABLE,\n '--objects': STORAGE_VARIABLE,\n '--storage': STORAGE_VARIABLE,\n '--port': PORT_VARIABLE,\n '--host': HOST_VARIABLE,\n '--project': PROJECT_VARIABLE,\n '--retention': RETENTION_VARIABLE,\n '--reviewer': REVIEWER_VARIABLE,\n};\n\n/** Every variable, in the order somebody sets them. */\nconst VARIABLES: readonly (readonly [string, string])[] = [\n [PROJECT_VARIABLE, 'required — scopes every row and every object key'],\n [INGEST_TOKEN_VARIABLE, 'required — what CI pushes with'],\n [REVIEW_TOKEN_VARIABLE, 'required — what a person decides with'],\n [DATABASE_VARIABLE, `the SQLite file (default ${DEFAULT_DATABASE})`],\n [STORAGE_VARIABLE, `where images are kept (default ${DEFAULT_STORAGE})`],\n [PORT_VARIABLE, `default ${DEFAULT_PORT}`],\n [HOST_VARIABLE, `default ${DEFAULT_HOST}; a network bind serves no review page`],\n [TRUST_NETWORK_VARIABLE, 'confirms a network bind was meant'],\n [RETENTION_VARIABLE, 'days after which a build is swept'],\n [REVIEWER_VARIABLE, 'the name a decision is recorded under'],\n];\n\n/**\n * Built rather than typed out, and padded from the names themselves: a\n * hand-aligned block goes crooked the first time one of them is renamed.\n */\nconst USAGE = ((widest: number) =>\n [\n 'variance-authority-tribunal — a process, a port, a file and two tokens.',\n '',\n 'Configured by environment. There are no flags.',\n '',\n ...VARIABLES.map(([name, what]) => ` ${name.padEnd(widest)} ${what}`),\n '',\n ].join('\\n'))(Math.max(...VARIABLES.map(([name]) => name.length)));\n\nasync function main(): Promise<void> {\n let service: RunningTribunal;\n try {\n const usage = readArguments(process.argv.slice(2));\n if (usage !== null) {\n process.stdout.write(usage);\n return;\n }\n service = await start(process.env);\n } catch (error) {\n process.stderr.write(`${error instanceof Error ? error.message : String(error)}\\n`);\n process.exit(1);\n return;\n }\n\n // Both signals close the database rather than letting the process die with a\n // write in flight. SQLite recovers from that, but an operator reading a WAL\n // recovery message has no way to know nothing was lost.\n for (const signal of ['SIGINT', 'SIGTERM'] as const) {\n process.once(signal, () => {\n void service.close().then(\n () => process.exit(0),\n (error: unknown) => {\n process.stderr.write(`${error instanceof Error ? error.message : String(error)}\\n`);\n process.exit(1);\n },\n );\n });\n }\n}\n\n/**\n * Whether `entry` names this file, following links on both sides.\n *\n * A package manager installs a bin as a symlink — `node_modules/.bin/variance-authority-tribunal`\n * pointing here — so `process.argv[1]` is the link and `import.meta.url` is its\n * target. Compared as written they never match, and the guard below would then\n * skip `main` and let the process exit 0 without serving anything.\n */\nfunction isProgram(entry: string): boolean {\n try {\n return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));\n } catch {\n return false;\n }\n}\n\nif (process.argv[1] !== undefined && isProgram(process.argv[1])) {\n await main();\n}\n"]}
@@ -0,0 +1,58 @@
1
+ import type { R2Like } from '../bindings.js';
2
+ /**
3
+ * R2, as a directory somebody owns.
4
+ *
5
+ * Four methods, and every one of them is a file operation. What makes this worth
6
+ * a file of its own is not the I/O — it is that an object key is a *string this
7
+ * package composed*, and a filesystem path is a thing an operating system
8
+ * resolves. Between those two sits every traversal bug ever written, so the
9
+ * mapping is checked rather than concatenated.
10
+ *
11
+ * ## Keys, and what may become a path
12
+ *
13
+ * The keys are built in [`store.ts`](../store.ts) and
14
+ * [`review-write.ts`](../review-write.ts) and have three shapes:
15
+ *
16
+ * ```
17
+ * <project>/baselines/<identityDigest>/<encoded subject>.png
18
+ * <project>/cache/<identityDigest>/<documentDigest>.png
19
+ * <project>/builds/<encoded build>/<encoded subject>-after.png
20
+ * ```
21
+ *
22
+ * Every volatile segment is already `encodeURIComponent`'d, which removes `/`
23
+ * and leaves `.` — so `..` is reachable only through `project`, which is an
24
+ * operator's own string and is the one segment nothing encodes. Rather than
25
+ * trusting that, every segment is checked here: this is the layer that owns the
26
+ * filesystem, and a rule enforced where the risk is cannot be dropped by a
27
+ * caller that forgets it exists.
28
+ *
29
+ * ## The hazard that is documented rather than solved
30
+ *
31
+ * **A case-insensitive filesystem folds two subjects into one.** macOS's default
32
+ * APFS and Windows both do; `story:Card` and `story:card` are two baselines, two
33
+ * rows, and one file. The consequence is a comparison against the wrong image,
34
+ * which is the failure this project exists to refuse — so it is said here rather
35
+ * than left for somebody to find.
36
+ *
37
+ * It is not solved by escaping, because escaping does not help: any encoding
38
+ * that survives a case-folding filesystem has to fold case itself, which loses
39
+ * the name. The plain-directory backend in
40
+ * [`@variance-authority/store`](../../../store) makes the same trade with the
41
+ * same layout, and the honest mitigation is the same one: on a case-insensitive
42
+ * volume, a project whose subject ids differ only by case needs a different
43
+ * volume. Case collisions are detected on `put` (below) rather than merged
44
+ * silently.
45
+ */
46
+ export interface DirectoryBucket extends R2Like {
47
+ /** Where objects land, absolute. Printed at startup so the bytes can be found. */
48
+ readonly root: string;
49
+ }
50
+ /**
51
+ * Objects under `root`, one file per key, directories made on demand.
52
+ *
53
+ * `root` is created if it does not exist. It is not emptied, checked for
54
+ * foreign files, or claimed: an operator pointing two projects at one directory
55
+ * gets two project-prefixed trees, which is what the key layout is for.
56
+ */
57
+ export declare function createDirectoryBucket(root: string): DirectoryBucket;
58
+ //# sourceMappingURL=bucket.d.ts.map
@@ -0,0 +1,142 @@
1
+ import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
2
+ import { isAbsolute, join, resolve, sep } from 'node:path';
3
+ /**
4
+ * Objects under `root`, one file per key, directories made on demand.
5
+ *
6
+ * `root` is created if it does not exist. It is not emptied, checked for
7
+ * foreign files, or claimed: an operator pointing two projects at one directory
8
+ * gets two project-prefixed trees, which is what the key layout is for.
9
+ */
10
+ export function createDirectoryBucket(root) {
11
+ const base = resolve(root);
12
+ const pathOf = (key) => {
13
+ const full = join(base, ...segmentsOf(key));
14
+ // The check that is not redundant with `segmentsOf`: a symlink inside the
15
+ // tree can point out of it, and only the resolved path knows. Cheap, and it
16
+ // is the last line before an `unlink`.
17
+ if (full !== base && !full.startsWith(base + sep)) {
18
+ throw new Error(`the object key ${JSON.stringify(key)} resolves outside ${base}`);
19
+ }
20
+ return full;
21
+ };
22
+ return {
23
+ root: base,
24
+ async get(key) {
25
+ const bytes = await readFile(pathOf(key)).catch(absentAsNull);
26
+ if (bytes === null)
27
+ return null;
28
+ return {
29
+ arrayBuffer: async () => bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength),
30
+ };
31
+ },
32
+ /**
33
+ * Existence without the bytes — a `stat`, which is what R2's `head` is.
34
+ *
35
+ * `describe` spends one of these per subject to confirm a sidecar row's
36
+ * object is still there, so it is on the hot path of every run and must not
37
+ * read a megabyte to answer a boolean.
38
+ */
39
+ async head(key) {
40
+ const found = await stat(pathOf(key)).catch(absentAsNull);
41
+ return found === null || !found.isFile() ? null : { key, size: found.size };
42
+ },
43
+ /**
44
+ * Write, and make the write atomic.
45
+ *
46
+ * A baseline half on disk is worse than a baseline absent: `find` would
47
+ * return a truncated PNG, the decoder would fail, and the failure would
48
+ * arrive as a comparison error rather than as the disk-full it is. So the
49
+ * bytes land beside the target and are renamed onto it, which is atomic
50
+ * within a filesystem — and the temporary name carries the process id so two
51
+ * services sharing a directory cannot rename each other's half-written file
52
+ * into place.
53
+ */
54
+ async put(key, value) {
55
+ const target = pathOf(key);
56
+ await mkdir(join(target, '..'), { recursive: true });
57
+ const existing = await stat(target).catch(absentAsNull);
58
+ if (existing !== null)
59
+ await refuseCaseFold(target, key);
60
+ const staging = `${target}.${process.pid}.${writes++}.part`;
61
+ try {
62
+ await writeFile(staging, new Uint8Array(value));
63
+ await rename(staging, target);
64
+ }
65
+ catch (error) {
66
+ await rm(staging, { force: true });
67
+ throw error;
68
+ }
69
+ return { key };
70
+ },
71
+ async delete(keys) {
72
+ for (const key of typeof keys === 'string' ? [keys] : keys) {
73
+ await rm(pathOf(key), { force: true });
74
+ }
75
+ return undefined;
76
+ },
77
+ };
78
+ }
79
+ /** Distinguishes "no such object" from "the disk said no", which are not the same answer. */
80
+ function absentAsNull(error) {
81
+ if (error.code === 'ENOENT' || error.code === 'ENOTDIR')
82
+ return null;
83
+ throw error;
84
+ }
85
+ /**
86
+ * Split a key into path segments, refusing every one that is not a file name.
87
+ *
88
+ * `.` and `..` are the traversal; the empty segment is a doubled slash, which
89
+ * `join` would swallow and which would silently address a different object than
90
+ * the key names. A backslash is refused because Windows treats it as a
91
+ * separator and POSIX does not, and a store whose key space depends on the
92
+ * host's opinion of one character is a store whose keys change when it moves.
93
+ */
94
+ function segmentsOf(key) {
95
+ if (key === '' || isAbsolute(key)) {
96
+ throw new Error(`the object key ${JSON.stringify(key)} is not a relative key`);
97
+ }
98
+ const segments = key.split('/');
99
+ for (const segment of segments) {
100
+ if (segment === '' || segment === '.' || segment === '..' || /[\\\0]/.test(segment)) {
101
+ throw new Error(`the object key ${JSON.stringify(key)} has a segment (${JSON.stringify(segment)}) that ` +
102
+ 'is not a file name. Keys are composed by this package from the project name and ' +
103
+ 'percent-encoded ids; a project name containing a slash, a dot on its own, or a ' +
104
+ 'backslash is the way this happens');
105
+ }
106
+ }
107
+ return segments;
108
+ }
109
+ /**
110
+ * Refuse a write that a case-folding volume would land on another key's object.
111
+ *
112
+ * Only reachable when the target already exists, and that is the whole subtlety:
113
+ * "this object is being rewritten" and "a different key just folded onto this
114
+ * one" are the same `stat` result, and only the directory listing tells them
115
+ * apart. If the exact file name is present, the volume is not folding and the
116
+ * write is an ordinary overwrite. If it is absent while the path resolves, the
117
+ * volume folded — and the bytes about to be written belong to a different
118
+ * subject than the ones already there.
119
+ *
120
+ * The opposite check — refusing on a case-*sensitive* volume because two
121
+ * variants exist — would be wrong: there, they are two objects, correctly, and
122
+ * refusing the second one would break a project whose subject ids differ by case
123
+ * on exactly the filesystem that handles them properly.
124
+ */
125
+ async function refuseCaseFold(target, key) {
126
+ const directory = join(target, '..');
127
+ const name = target.slice(directory.length + 1);
128
+ const entries = await readdir(directory).catch(() => []);
129
+ if (entries.includes(name))
130
+ return;
131
+ const folded = entries.find((entry) => entry.toLowerCase() === name.toLowerCase());
132
+ if (folded === undefined)
133
+ return;
134
+ throw new Error(`the object key ${JSON.stringify(key)} resolves to an object stored under a different name ` +
135
+ `(${JSON.stringify(folded)}) that differs from it only by case. This volume folds the two ` +
136
+ 'into one file, so writing here would replace another subject\'s image and every later ' +
137
+ 'comparison would run against the wrong one. Put the object store on a case-sensitive ' +
138
+ 'filesystem, or give the two subjects ids that differ by more than case');
139
+ }
140
+ /** Distinguishes two staging files written in the same millisecond by one process. */
141
+ let writes = 0;
142
+ //# sourceMappingURL=bucket.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"bucket.js","sourceRoot":"","sources":["../../src/node/bucket.ts"],"names":[],"mappings":"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","sourcesContent":["import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';\nimport { isAbsolute, join, resolve, sep } from 'node:path';\nimport type { R2Like, R2ObjectLike } from '../bindings.js';\n\n/**\n * R2, as a directory somebody owns.\n *\n * Four methods, and every one of them is a file operation. What makes this worth\n * a file of its own is not the I/O — it is that an object key is a *string this\n * package composed*, and a filesystem path is a thing an operating system\n * resolves. Between those two sits every traversal bug ever written, so the\n * mapping is checked rather than concatenated.\n *\n * ## Keys, and what may become a path\n *\n * The keys are built in [`store.ts`](../store.ts) and\n * [`review-write.ts`](../review-write.ts) and have three shapes:\n *\n * ```\n * <project>/baselines/<identityDigest>/<encoded subject>.png\n * <project>/cache/<identityDigest>/<documentDigest>.png\n * <project>/builds/<encoded build>/<encoded subject>-after.png\n * ```\n *\n * Every volatile segment is already `encodeURIComponent`'d, which removes `/`\n * and leaves `.` — so `..` is reachable only through `project`, which is an\n * operator's own string and is the one segment nothing encodes. Rather than\n * trusting that, every segment is checked here: this is the layer that owns the\n * filesystem, and a rule enforced where the risk is cannot be dropped by a\n * caller that forgets it exists.\n *\n * ## The hazard that is documented rather than solved\n *\n * **A case-insensitive filesystem folds two subjects into one.** macOS's default\n * APFS and Windows both do; `story:Card` and `story:card` are two baselines, two\n * rows, and one file. The consequence is a comparison against the wrong image,\n * which is the failure this project exists to refuse — so it is said here rather\n * than left for somebody to find.\n *\n * It is not solved by escaping, because escaping does not help: any encoding\n * that survives a case-folding filesystem has to fold case itself, which loses\n * the name. The plain-directory backend in\n * [`@variance-authority/store`](../../../store) makes the same trade with the\n * same layout, and the honest mitigation is the same one: on a case-insensitive\n * volume, a project whose subject ids differ only by case needs a different\n * volume. Case collisions are detected on `put` (below) rather than merged\n * silently.\n */\n\nexport interface DirectoryBucket extends R2Like {\n /** Where objects land, absolute. Printed at startup so the bytes can be found. */\n readonly root: string;\n}\n\n/**\n * Objects under `root`, one file per key, directories made on demand.\n *\n * `root` is created if it does not exist. It is not emptied, checked for\n * foreign files, or claimed: an operator pointing two projects at one directory\n * gets two project-prefixed trees, which is what the key layout is for.\n */\nexport function createDirectoryBucket(root: string): DirectoryBucket {\n const base = resolve(root);\n\n const pathOf = (key: string): string => {\n const full = join(base, ...segmentsOf(key));\n\n // The check that is not redundant with `segmentsOf`: a symlink inside the\n // tree can point out of it, and only the resolved path knows. Cheap, and it\n // is the last line before an `unlink`.\n if (full !== base && !full.startsWith(base + sep)) {\n throw new Error(`the object key ${JSON.stringify(key)} resolves outside ${base}`);\n }\n return full;\n };\n\n return {\n root: base,\n\n async get(key: string): Promise<R2ObjectLike | null> {\n const bytes = await readFile(pathOf(key)).catch(absentAsNull);\n if (bytes === null) return null;\n return {\n arrayBuffer: async (): Promise<ArrayBuffer> =>\n bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer,\n };\n },\n\n /**\n * Existence without the bytes — a `stat`, which is what R2's `head` is.\n *\n * `describe` spends one of these per subject to confirm a sidecar row's\n * object is still there, so it is on the hot path of every run and must not\n * read a megabyte to answer a boolean.\n */\n async head(key: string): Promise<unknown | null> {\n const found = await stat(pathOf(key)).catch(absentAsNull);\n return found === null || !found.isFile() ? null : { key, size: found.size };\n },\n\n /**\n * Write, and make the write atomic.\n *\n * A baseline half on disk is worse than a baseline absent: `find` would\n * return a truncated PNG, the decoder would fail, and the failure would\n * arrive as a comparison error rather than as the disk-full it is. So the\n * bytes land beside the target and are renamed onto it, which is atomic\n * within a filesystem — and the temporary name carries the process id so two\n * services sharing a directory cannot rename each other's half-written file\n * into place.\n */\n async put(key: string, value: ArrayBuffer): Promise<unknown> {\n const target = pathOf(key);\n await mkdir(join(target, '..'), { recursive: true });\n\n const existing = await stat(target).catch(absentAsNull);\n if (existing !== null) await refuseCaseFold(target, key);\n\n const staging = `${target}.${process.pid}.${writes++}.part`;\n try {\n await writeFile(staging, new Uint8Array(value));\n await rename(staging, target);\n } catch (error) {\n await rm(staging, { force: true });\n throw error;\n }\n return { key };\n },\n\n async delete(keys: string | readonly string[]): Promise<unknown> {\n for (const key of typeof keys === 'string' ? [keys] : keys) {\n await rm(pathOf(key), { force: true });\n }\n return undefined;\n },\n };\n}\n\n/** Distinguishes \"no such object\" from \"the disk said no\", which are not the same answer. */\nfunction absentAsNull(error: NodeJS.ErrnoException): null {\n if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return null;\n throw error;\n}\n\n/**\n * Split a key into path segments, refusing every one that is not a file name.\n *\n * `.` and `..` are the traversal; the empty segment is a doubled slash, which\n * `join` would swallow and which would silently address a different object than\n * the key names. A backslash is refused because Windows treats it as a\n * separator and POSIX does not, and a store whose key space depends on the\n * host's opinion of one character is a store whose keys change when it moves.\n */\nfunction segmentsOf(key: string): readonly string[] {\n if (key === '' || isAbsolute(key)) {\n throw new Error(`the object key ${JSON.stringify(key)} is not a relative key`);\n }\n\n const segments = key.split('/');\n for (const segment of segments) {\n if (segment === '' || segment === '.' || segment === '..' || /[\\\\\\0]/.test(segment)) {\n throw new Error(\n `the object key ${JSON.stringify(key)} has a segment (${JSON.stringify(segment)}) that ` +\n 'is not a file name. Keys are composed by this package from the project name and ' +\n 'percent-encoded ids; a project name containing a slash, a dot on its own, or a ' +\n 'backslash is the way this happens',\n );\n }\n }\n return segments;\n}\n\n/**\n * Refuse a write that a case-folding volume would land on another key's object.\n *\n * Only reachable when the target already exists, and that is the whole subtlety:\n * \"this object is being rewritten\" and \"a different key just folded onto this\n * one\" are the same `stat` result, and only the directory listing tells them\n * apart. If the exact file name is present, the volume is not folding and the\n * write is an ordinary overwrite. If it is absent while the path resolves, the\n * volume folded — and the bytes about to be written belong to a different\n * subject than the ones already there.\n *\n * The opposite check — refusing on a case-*sensitive* volume because two\n * variants exist — would be wrong: there, they are two objects, correctly, and\n * refusing the second one would break a project whose subject ids differ by case\n * on exactly the filesystem that handles them properly.\n */\nasync function refuseCaseFold(target: string, key: string): Promise<void> {\n const directory = join(target, '..');\n const name = target.slice(directory.length + 1);\n const entries = await readdir(directory).catch(() => [] as string[]);\n if (entries.includes(name)) return;\n\n const folded = entries.find((entry) => entry.toLowerCase() === name.toLowerCase());\n if (folded === undefined) return;\n\n throw new Error(\n `the object key ${JSON.stringify(key)} resolves to an object stored under a different name ` +\n `(${JSON.stringify(folded)}) that differs from it only by case. This volume folds the two ` +\n 'into one file, so writing here would replace another subject\\'s image and every later ' +\n 'comparison would run against the wrong one. Put the object store on a case-sensitive ' +\n 'filesystem, or give the two subjects ids that differ by more than case',\n );\n}\n\n/** Distinguishes two staging files written in the same millisecond by one process. */\nlet writes = 0;\n"]}