1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330import express from "express";
import { accessSync, constants, existsSync, mkdirSync, realpathSync } from "node:fs";
import { dirname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
apiToken,
applyTrustProxy,
bedrockApiWarning,
githubToken,
identityWarning,
loadConfig,
perAgentKeyWarning,
promptCacheTtlWarning,
} from "./config.ts";
import { Store } from "./store/db.ts";
import { makeAuthMiddleware } from "./auth/middleware.ts";
import { meRouter } from "./routes/me.ts";
import { sessionsRouter } from "./routes/sessions.ts";
import { statsRouter } from "./routes/stats.ts";
import { limitsRouter } from "./routes/limits.ts";
import { qualityRouter } from "./routes/quality.ts";
import { visionModelWarning } from "./providers/imageLimits.ts";
import { generalRateLimit } from "./util/requestLimits.ts";
import { VERSION } from "./version.ts";
const cfg = loadConfig();
// Not a mistake, but a deployment-wide policy whose every consequence is invisible from
// outside: this service has ONE GitHub identity, and whether a stranger may spend it
// depends on a second, unrelated key (see identityWarning). Printed at boot because boot
// is the only place both keys are read together.
const idWarning = identityWarning(githubToken(cfg), apiToken(cfg) !== undefined);
if (idWarning) console.warn(`WARNING: ${idWarning}`);
// A cache TTL nobody can spell is worth saying here, because boot is the only place it
// is observable at all โ the two TTLs differ in price, not in reported tokens.
const ttlWarning = promptCacheTtlWarning(cfg.providers);
if (ttlWarning) console.warn(`WARNING: ${ttlWarning}`);
// And a Bedrock `api` nobody can spell, for the same reason: the fallback works, so the
// only symptom is that the deployment is on the path it was trying to leave.
const apiWarning = bedrockApiWarning(cfg.providers);
if (apiWarning) console.warn(`WARNING: ${apiWarning}`);
// And an override that names no agent, which is the same failure on the one key that
// decides which model runs: the entry is ignored and the call takes the provider's own
// model. What ran is answerable afterwards โ `by_agent.<agent>.models` in diagnostics names
// the model each agent actually used โ but the key that was ignored is nameable only from
// here, because nothing downstream of resolution ever sees it.
const agentKeyWarning = perAgentKeyWarning(cfg.providers.per_agent, cfg.storage.agents_dir);
if (agentKeyWarning) console.warn(`WARNING: ${agentKeyWarning}`);
// What that switch made reachable: a vision model this build has no image limits for.
// Everything still runs, on the conservative defaults โ but the limits it publishes are
// then a guess, and nothing downstream of here can say so (providers/imageLimits.ts).
const visionWarning = visionModelWarning(cfg);
if (visionWarning) console.warn(`WARNING: ${visionWarning}`);
// Ensure the on-disk layout exists, and open the database.
//
// These are the first things that can fail on an otherwise correctly configured deployment, and
// the way they fail is worth catching: the container runs as uid 1000 and compose bind-mounts
// `./data`, which keeps its HOST ownership, so on Linux a `./data` owned by anyone else fails
// here โ at import, before the port is bound. With `restart: unless-stopped` that is a loop, so
// this message is the whole diagnostic an operator gets, and it repeats. Uncaught, they get a
// stack trace naming a path inside a container whose ownership they cannot see from outside.
//
// The store is inside the guard because the layout check alone does not catch the case:
// `mkdirSync(p, { recursive: true })` SUCCEEDS on an existing directory the process cannot
// write, so a `./data` that already holds sessions/ and tmp/ โ which is what `npm start` leaves
// behind before a first `docker compose up` โ passes it and dies one line later. And it dies
// worse: node:sqlite reports `ERR_SQLITE_ERROR`, "unable to open database file", with no errno,
// no path and no uid (measured). Hence `checkWritable` rather than a look at `err.code`: the
// error that needs this message is the one that cannot identify itself.
const writable = (dir: string) => {
try {
accessSync(dir, constants.W_OK);
return true;
} catch {
return false;
}
};
// The path whose permissions decide whether `p` can be created: `p` itself if it is there, and
// otherwise its nearest existing ancestor, because creating it means writing into that ancestor.
// Asking about `p` directly instead would answer ENOENT โ true, and not the reason it failed.
// Terminates: `dirname` reaches a fixed point at the root, which always exists.
const nearestExisting = (p: string): string => {
let at = resolve(p);
while (!existsSync(at)) {
const parent = dirname(at);
if (parent === at) break;
at = parent;
}
return at;
};
// Paths go into commands below that an operator is meant to paste, so one with a space in it has
// to survive the copy: bare when it is plain, single-quoted otherwise (`'\''` is how a single
// quote is escaped inside single quotes).
const shellArg = (p: string) => (/^[A-Za-z0-9_./:@%+=-]+$/.test(p) ? p : `'${p.replaceAll("'", `'\\''`)}'`);
// One directory under two names is one candidate. `resolve` โ which config.ts has already applied
// to all three paths โ collapses `.` and `..`, but not a symlink, so a `database` reached through a
// link to `data_dir` would otherwise be blamed and remedied twice. Identity is therefore the REAL
// path, taken at the nearest existing ancestor because that is the deepest part which has one, with
// whatever does not exist yet appended.
const canonical = (p: string) => {
const at = nearestExisting(p);
const rest = relative(at, resolve(p));
try {
return join(realpathSync.native(at), rest);
} catch {
return join(at, rest);
}
};
const openStorage = (): { store: Store; stale: number } => {
try {
mkdirSync(join(cfg.storage.data_dir, "sessions"), { recursive: true });
mkdirSync(join(cfg.storage.data_dir, "tmp"), { recursive: true });
const store = new Store(cfg.storage.database);
// Clearing the sessions a previous shutdown orphaned is this process's first WRITE, and it is
// in here for the same reason `new Store` is, one step further along: OPENING a database
// proves nothing about writing to it. SQLite opens one it cannot write without complaint and
// raises only when something writes, so a root-owned iris.sqlite bind-mounted into a
// container that drops to an unprivileged uid gets past every check above.
//
// Outside this guard, where it was, it threw a bare `attempt to write a readonly database`
// carrying no errno, path or uid โ past every message below, including the chown that fixes
// it. That cost the UIC deployment eight rolled-back deploys on 2026-09-14, none of which
// named ownership, after the image started dropping root.
return { store, stale: store.failStaleSessions() };
} catch (err) {
const e = err as NodeJS.ErrnoException;
// "use", not "open": a write refused by ownership is this message's commonest cause, and
// saying "cannot open" of a database that opened fine sends the reader to the wrong question.
//
// Both the code AND the message, never one or the other. `e.code ?? e.message`, which is what
// this was, always took the code โ every node:sqlite error carries the same one,
// `ERR_SQLITE_ERROR` โ so the operator got `(ERR_SQLITE_ERROR)` and never `attempt to write a
// readonly database`, the only string that says which SQLite condition this actually was. And
// the ownership branch below prints no stack, so on precisely the failure this guard is for,
// dropping it dropped it everywhere. On an ENOENT or EACCES the code is already the whole
// story and the message repeats it, which is a cheap price for keeping the SQLite case legible.
const detail = e.code ? `${e.code}: ${e.message}` : e.message;
console.error(`FATAL: cannot use this deployment's storage (${detail}).`);
// Which path is at fault, asked directly. Three candidates, not one: the database may sit
// outside data_dir, and the database FILE can be unwritable while both directories are fine
// (a group-writable ./data holding a foreign-owned iris.sqlite).
//
// Each candidate keeps two paths, because they answer different questions. `want` is what the
// config asked for and is what a remedy has to name. `probe` is whose permissions decide it:
// `want` when it is there, and otherwise its nearest existing ancestor, since creating it means
// writing into that ancestor. Dropping absent directories instead โ which is what this did โ
// lost the commonest ownership failure after a mistyped absolute path, a data_dir that does not
// exist AND cannot be created, leaving nothing to report and an empty list of paths printed
// beside a claim that the failure could not be explained.
//
// The database FILE is the one candidate that is right to drop while absent: creating it is a
// write into its directory, which is already above. Same for the two WAL sidecars.
//
// The sidecars are here because this deployment runs in WAL (`PRAGMA journal_mode = WAL`,
// store/db.ts), where a refused write can come from `iris.sqlite-wal` or `-shm` rather than
// from the database, and each is a separate file with its own owner. The sequel this exists to
// stop: a group-writable ./data holding a root-owned set, the operator runs the one-file
// `chown` printed below, and the next boot fails again โ but with all three of the paths above
// now writable, so it lands in the `else` and claims this is not an ownership failure at all.
// A positive claim, and the wrong one, of exactly the kind that branch's comment warns about.
//
// Latent until this round, and no longer: the write that raises on a root-owned set is the
// first one, and only now does it reach this guard rather than dying past the end of it.
//
// Three near-identical lines rather than a filter over a list of the three paths, because the
// property that matters here is which candidates are dropped when absent โ files yes,
// directories never โ and a group filter states that over a bound variable, which says nothing
// about what it ranged over. Spelled out, each check names its own path, and the test that
// enumerates every existence check in this guard can still read which ones they are. (That test
// reads this file as TEXT, so it counts the ones named in a comment too, which is the other
// reason the name is not written here.)
const candidates = [
{ want: cfg.storage.data_dir, kind: "dir" as const },
{ want: dirname(cfg.storage.database), kind: "dir" as const },
...(existsSync(cfg.storage.database) ? [{ want: cfg.storage.database, kind: "file" as const }] : []),
...(existsSync(`${cfg.storage.database}-wal`)
? [{ want: `${cfg.storage.database}-wal`, kind: "file" as const }]
: []),
...(existsSync(`${cfg.storage.database}-shm`)
? [{ want: `${cfg.storage.database}-shm`, kind: "file" as const }]
: []),
]
.map((c) => ({ ...c, probe: nearestExisting(c.want), key: canonical(c.want) }))
.filter((c, i, all) => all.findIndex((o) => o.key === c.key) === i);
const checked = [...new Set(candidates.map((c) => c.probe))];
const unwritable = candidates.filter((c) => !writable(c.probe));
if (unwritable.length > 0) {
const uid = process.getuid?.() ?? 1000;
const gid = process.getgid?.() ?? 1000;
// Names `probe` as well as `want` when they differ, because "cannot write /srv" in answer to a
// configured /srv/iris/data reads like the wrong path otherwise.
const blame = unwritable
.map((c) => (c.probe === c.want ? c.want : `${c.want} (nothing can be created in ${c.probe})`))
.join(" or ");
// `mkdir -p` first for a directory, since the one that cannot be created does not exist yet;
// it is a no-op on the ones that do. And on `want`, never on the ancestor that was probed โ
// `chown -R` on /var/lib to fix /var/lib/iris/data would be a far worse day than this one.
const remedy = unwritable
.map((c) =>
c.kind === "file"
? ` sudo chown ${uid}:${gid} ${shellArg(c.want)}`
: ` sudo mkdir -p ${shellArg(c.want)} && sudo chown -R ${uid}:${gid} ${shellArg(c.want)}`,
)
.join("\n");
// One message, not a branch on whether this is a container. A previous round decided that
// from `/.dockerenv` and `/run/.containerenv`, and those markers answer a question adjacent
// to the one that matters: a `database` inside the image rather than on the mount is a
// container whose path is NOT the host's, and a containerd pod writes neither marker and is
// one whose path is. Getting it wrong either way makes a positive claim about a path this
// code did not look up. Naming the condition instead is true in every case, and shorter.
console.error(
`This process runs as uid ${uid} (gid ${gid}) and cannot write ${blame}.\n` +
`Give it to uid ${uid}:\n` +
`${remedy}\n` +
`In Docker, that path is the one INSIDE the container, and chowning it there dies with the\n` +
`container. If it is bind-mounted โ \`./data\` is, in the shipped docker-compose.yml โ run the\n` +
`same command on the host directory mounted there, whose ownership is the one that carries in.\n` +
`Or run the container as the user that owns it: add \`user: "1234:1234"\` to the iris service in\n` +
`docker-compose.yml, using your own numbers from \`id -u\` and \`id -g\`. They have to be literal โ\n` +
`compose does not expand \`$(id -u)\` in a YAML value.`,
);
} else {
// Says what was checked rather than what the cause is not. "This is not an ownership
// problem" would be a positive claim this code cannot support โ something unreadable, a
// full disk or a corrupt database all land here โ and a wrong one sends the operator away
// from the cause.
console.error(`Every path checked is writable by uid ${process.getuid?.() ?? "?"}: ${checked.join(", ")}.`);
console.error(`So this is not one of the ownership failures this message can explain. The error was:`);
console.error(e.stack ?? String(err));
}
process.exit(1);
}
};
// `stale` comes back from openStorage() rather than being read here, because the call that
// produces it is the guarded first write above.
const { store, stale } = openStorage();
if (stale > 0) console.log(`Marked ${stale} interrupted session(s) as failed on startup.`);
const app = express();
// Whose address `req.ip` is. Off unless a deployment says how many proxies are in front
// of it, because the rate limits below are only per-caller if this is right: unset behind
// Caddy every caller looks like the proxy, and set too permissively every caller can
// claim to be someone new (see normalizeTrustProxy).
//
// The third startup warning comes from here, for either way this key can be wrong: `true`
// is accepted by Express and defeats every per-address limit, because the address then
// comes from a header the client can write (coerced to one hop), and a value Express cannot
// compile would otherwise be a crash naming no config key (trusted as nothing instead).
const proxyWarning = applyTrustProxy(app, cfg.server.trust_proxy);
if (proxyWarning) console.warn(`WARNING: ${proxyWarning}`);
app.use(express.json({ limit: "2mb" }));
// Liveness probe (unauthenticated) โ confirms the service is up and says which build it is.
//
// Registered ABOVE the rate limiter on purpose, and it is the only /v1 route that is: a
// probe that answers 429 reports the deployment as down, which is the opposite of what it
// is for. It also polls from one address (the Dockerfile's HEALTHCHECK runs on the same
// host), so it is precisely the caller a per-address budget would spend itself on.
//
// `version` is package.json's, and it is here rather than only in the boot log because a
// deployed container is read from outside (see version.ts).
app.get("/v1/health", (_req, res) => res.json({ status: "ok", service: "equalify-iris", version: VERSION }));
// How much anyone may ask of this deployment (util/requestLimits.ts). Mounted here โ
// above every route below, below the probe above โ so a flood is refused before it
// reaches a handler, the store, or multer. The run queue bounds pipeline compute, which
// is a later and narrower question: nothing in it stops a polling loop from occupying
// the event loop with synchronous SQLite reads.
app.use("/v1", generalRateLimit(cfg));
// The public tally of pages converted (aggregate-only, no per-session detail). The browser
// app reads it to report how many pages Iris has made accessible, and it is not handed the
// `auth` middleware below, so it still answers where the operator set `server.api_token` โ
// a page count is not something a shared secret should be needed for.
app.use("/v1/stats", statsRouter(store));
// What this deployment accepts for an upload (no user data). Ungated for the same reason as
// the page tally above, plus one of its own: the browser app states the file limits on the
// upload step, and someone deciding whether a scan is small enough should not need the
// deployment's shared token to find out.
app.use("/v1/limits", limitsRouter(cfg));
// The deployment-wide quality tally, read by the weekly quality-report workflow. It carries
// its own guard, `server.quality_token`, and answers 404 until that is set.
//
// It is not handed `auth` either, which is what keeps it answering on a GATED deployment:
// the CI job holds `quality_token` and not `server.api_token` (config.ts's `quality_token`
// argues why they are separate).
app.use("/v1/quality", qualityRouter(store, cfg.server));
// The browser app is the front door, served at the root (unauthenticated; it
// drives the /v1 API itself). no-store so a deploy never serves a stale page.
const demoFile = fileURLToPath(new URL("../public/demo.html", import.meta.url));
app.get("/", (_req, res) => {
res.set("Cache-Control", "no-store");
res.sendFile(demoFile);
});
// Keep the old /demo path working for any shared links.
app.get("/demo", (_req, res) => res.redirect(302, "/"));
// Everything else runs as this deployment's GitHub account, and is refused if that
// account cannot be resolved. If `server.api_token` is set, the caller must also present
// it โ see auth/middleware.ts, which asks those two questions separately.
//
// `auth` is attached PER ROUTE, on the two mounts below and nowhere else. That, and not its
// position in this file, is what leaves /v1/health, /v1/stats, /v1/limits and /v1/quality
// reachable on a gated deployment: nothing stands in front of them because nothing was put
// there, and moving any of those lines below these two would not change it.
//
// Read top to bottom the order looks load-bearing, and for one middleware it is โ the rate
// limiter above is mounted on the whole of `/v1`. So /v1/health being registered above THAT
// is a real decision (see its own comment) and the four mounts sitting above `auth` is not.
const auth = makeAuthMiddleware(store, cfg);
app.use("/v1/me", auth, meRouter(cfg));
app.use("/v1/sessions", auth, sessionsRouter(cfg, store));
const port = cfg.server.port;
app.listen(port, () => {
console.log(`Equalify Iris listening on http://localhost:${port} (base_url: ${cfg.server.base_url})`);
});