๐Ÿ“ฆ EqualifyEverything / equalify-iris

๐Ÿ“„ index.ts ยท 283 lines
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
283import 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 => {
  try {
    mkdirSync(join(cfg.storage.data_dir, "sessions"), { recursive: true });
    mkdirSync(join(cfg.storage.data_dir, "tmp"), { recursive: true });
    return new Store(cfg.storage.database);
  } catch (err) {
    const e = err as NodeJS.ErrnoException;
    console.error(`FATAL: cannot open this deployment's storage (${e.code ?? e.message}).`);
    // 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.
    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 }] : []),
    ]
      .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);
  }
};

const store = openStorage();
// Clear sessions orphaned by a previous shutdown (their in-process run is gone).
const stale = store.failStaleSessions();
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})`);
});