# Build, Test & Ops Map How the repo is laid out, how it runs locally and in production, and exactly what CI enforces. --- ## 1. The dual-root quirk The repo root holds Docker, docs, SQL and Make. The Node application lives one level down in `cloudcodex/`. ``` c2/ <- git root; docker, docs, SQL, Makefile, start.sh ├── scripts/ <- Bash: backup.sh, restore.sh, backup-common.sh (section 4) └── cloudcodex/ <- the npm package; package.json lives HERE ``` **Every `npm` command runs from `cloudcodex/`.** Every `make` target and `docker compose` command runs from the root. This catches out both humans and agents; a `npm test` at the root fails with a missing package.json. The `.env` file lives at the **root**, and `mysql_connect.js:17` reaches up for it with `path.resolve(dirname, '..', '.env')`. Importing `mysql_connect.js` is what loads env for the whole process, so any module that needs env must import it (directly or transitively) before reading `process.env`. ## 2. npm scripts (`package.json:6-21`) | Script | Command | Notes | |---|---|---| | `dev` | `node server.js` | **not** `vite dev`. `vite-express` runs Vite in middleware mode inside the same process. | | `start` | `NODE_ENV=production node server.js` | serves the prebuilt `dist/` | | `build` | `vite build` | frontend only; the backend is not bundled | | `preview` | `vite preview` | | | `lint` | `eslint .` | flat config, whole package | | `test` | `vitest run --project backend --project frontend --project design` | the three default projects; never `integration` | | `test:watch` | `vitest --project backend --project frontend --project design` | | | `test:coverage` | `vitest run --coverage --project backend --project frontend --project design` | v8 provider, enforces thresholds | | `test:integration` | `vitest run --project integration` | opt-in, needs a live MySQL; see section 5 | | `test:backend` / `test:frontend` / `test:design` | `vitest run --project ` | one project at a time | | `migrate` | `node scripts/migrate.js` | applies pending `migrations/*.sql`, records them in `schema_migrations`. One-time adoption first: `-- --adopt-fresh-install` on a database `init.sql` just built, `-- --baseline` on an install that predates the runner. Run it inside the app container on the release compose file (3306 is not published there). See [data-model.md](data-model.md) and `docs/deployment.md`. | | `backfill:doc-images` | `node scripts/backfill-doc-images.js` | once, after the `2026-09-27-who-may-see-doc-images.sql` migration: records a `doc_images` row for every `/doc-images/` image an existing document or version shows, so the authorized handler does not hide them from readers. Idempotent, but it refuses to run over a table that already has rows (run after go-live it would trust every reference saved since) unless `-- --again` or `DOC_IMAGES_PUBLIC=1`. In containers it runs like `migrate`, through `run --rm app`. | `NODE_ENV` matters in six places: CORS localhost allowance (`app.js:272`), where Helmet is mounted (`app.js:330`: the whole app in production, `/api` otherwise), rate-limiter `skip` when `'test'` (`app.js:360`, `app.js:398`, `app.js:412`), the `APP_URL` boot gate (`server.js:64`), the webhook SSRF guard's https-only rule (`webhookTargetOptions`, `services/webhooks.js`), and Vite's dev-vs-prod mode. `.env.example` ships it commented out; `npm run start` and the Docker image set it, and both production compose files pin it (section 4). ## 3. Local development `./start.sh` from the root is the one-shot bootstrap: it checks Docker, Docker Compose, Node and npm, brings up MySQL, installs dependencies, and starts the dev server. On native Linux (not WSL: it checks `/proc/version`) it merges `docker-compose.linux.yml`, which re-declares the bind mounts with the `:Z` SELinux label (`docker-compose.linux.yml:6-8`) and publishes no port. It waits for MySQL with a TCP `mysqladmin ping` inside the container, for up to 180 s, for the same reason as the production healthcheck in section 4. Manual equivalent: ``` docker compose up -d # from the root: MySQL only cd cloudcodex && npm install && npm run dev ``` Make targets (`Makefile`); the first three shell into the running container: | Target | Effect | |---|---| | `make seed` | pipes `seed.sql` in | | `make reset-db` | pipes `init.sql` then `seed.sql` in | | `make db-shell` | interactive `mysql` CLI | | `make backup OUT=` | `scripts/backup.sh ` (section 4, "Backup and restore") | | `make restore IN= [ARGS=...]` | `scripts/restore.sh $(ARGS) ` | The Makefile does `include .env` / `export` at the top, so it needs a populated root `.env`, and it resolves the container via `docker compose ps -q database`. `init.sql`'s `DROP TABLE IF EXISTS` block now covers all 28 tables, including `github_links`, `activity_log`, `watches` and `notifications`, which used to be missing and made `make reset-db` fail partway on a database that already had them. See [data-model.md](data-model.md). ## 4. Docker topologies **Dev** (`docker-compose.yaml`): MySQL only, port 3306 published on 127.0.0.1 (`${DB_BIND:-127.0.0.1}:3306:3306`), data in a bind mount `./db-data/`, `init.sql` mounted into `/docker-entrypoint-initdb.d/`. The app runs on the host. **Prod** (`docker-compose-prod.yml`): MySQL plus the app. **One MySQL release everywhere: `mysql:8.4.11`.** The three compose files, both workflows' service containers and `start.sh`'s pre-pull name that exact tag, so the server CI tests is the server every install runs, and a pull never moves an install to an untested patch. `tests/compose-pins.test.js` fails on a tag without a patch version (`mysql:8`, `mysql:8.4`) and on two files disagreeing, so moving the pin is one commit that changes all seven references. - MySQL uses a **named volume** `db_data`, not the bind mount, and gets a `mysqladmin ping` healthcheck **over TCP to `127.0.0.1`**, with a 300 s `start_period` (`docker-compose-prod.yml:25-39`, and the same block in `docker-compose-release.yml`). Not the socket: on an empty volume the image applies `init.sql` on a temporary server with networking off, which a socket ping answers, so the database read healthy while nothing listened on 3306 and the app exited on the instance lock's `ECONNREFUSED` until the restart policy recovered it (4 restarts per first boot of the prod file, measured 2026-09-28; 0 with the TCP ping). The start period exists because the TCP ping now fails through the whole initialisation, and without it a slow disk would spend the retries and Compose would never start the app. `tests/compose-healthcheck.test.js` pins both files and `start.sh`'s wait loop, which pings the same way. - The app builds from `cloudcodex/Dockerfile`, waits on `condition: service_healthy`, publishes `${APP_BIND:-127.0.0.1}:${PORT:-3000}:${PORT:-3000}`, and takes `env_file: .env`. **The app port is published on 127.0.0.1 in both production files** (GHSA-9fmx-frrf-xxmq). Docker's published ports are a DNAT rule in front of the host firewall, so the old host-less mapping was reachable from anywhere that could route to the machine, and a client reaching the app directly skipped the TLS proxy. Only this machine reaches it now (a browser here, or the reverse proxy); `APP_BIND` (`0.0.0.0`, or one interface's IPv4 address, never `::` or an IPv6 address) exposes it on purpose. `:-` rather than `-`, because `.env.example` ships `APP_BIND=` blank and a blank host address publishes on every interface. A reverse proxy in another container cannot reach the host's loopback: it joins this compose network, proxies to `app:3000`, and is listed in `TRUST_PROXY` by an address pinned with `ipv4_address`. `tests/compose-ports.test.js` parses every compose file at the repo root with js-yaml (a declared devDependency), found by name in both spellings Compose loads, `compose.yaml`/`compose.yml` (and `.override`) and `docker-compose*`, so a new one fails until it has an expectation. Every service in every file must not use `network_mode: host`, and no file may have a top-level `include:`, since an included file's ports merge in unread; both checks are proven on fixtures. For each service it pins the published mappings, reads each one's effective host in both the short syntax and the long one (`host_ip`), resolving `:-` fallbacks as a blank `.env` would, and fails on anything beyond loopback, so moving a pin and its file together to an all-interfaces default still fails. The prod and dev files' database is pinned to `${DB_BIND:-127.0.0.1}:3306:3306`, the release file's to none with `expose: ["3306"]`, and `docker-compose.linux.yml` to none: Compose appends an override's `ports` to the base file's, and the reader's non-vacuity cases prove it sees a flow list, a commented key, a quoted key, a merged anchor, the long syntax and a bare number. It also pins `.env.example`'s `APP_BIND=` and `DB_BIND=` blank. **Both production files pin the default network** to `172.29.0.0/16`, gateway `172.29.0.1` (distinct from any other compose project's default network). Docker presents a proxy on the host to the container as that gateway, and `TRUST_PROXY`'s default names `172.29.0.1/32`, so an unpinned network (Docker's next free subnet, `172.18.0.0/16` or later, or a `192.168` range once about fifteen networks exist) would leave the gateway untrusted and every client behind the proxy in one bucket. The ports test pins the `ipam` block and that its gateway is in the default, and that the app service joins no other network. Upgrading an install created before the pin takes the project `down` before migrating (`docs/deployment.md` "Upgrades"). Measured on a live 0.11.0 install with Compose 5.3.1: when the database container's own configuration is unchanged, the usual stop-then-migrate order makes `run` replace the network and reconnect the database without its `database` alias, so the migration fails with `ENOTFOUND database` and the app then boots unable to reach it (measured before the instance lock, while `/api/oauth/providers` still answered 200; with the lock it exits at boot and restarts); with `down` first it migrates and keeps both volumes. **The prod file's MySQL port is on 127.0.0.1 too**, for the same DNAT reason: it used to publish `3306:3306` on every interface, the database's own authentication the only thing between it and anything that could route to the host. It stays published because `docs/deployment.md` has operators run a mysql client or `npm run migrate` from the host against it; the app itself uses `DB_HOST: database` over the compose network. `DB_BIND` widens it on purpose. The dev file follows suit: its database runs with a development password, often on a laptop on a shared network, and everything that uses it (`npm run dev`, `make`, `start.sh`'s check, a `mysql` client) is on the same machine. `make` goes through `docker exec` and does not use the port at all. - `cloudcodex/Dockerfile` is a **two-stage** build on `node:20-slim`: the build stage runs `npm ci` and `npm run build`, and the runtime stage runs `npm ci --omit=dev`, copies the source, then copies `dist/` across from the build stage. `CMD ["node", "server.js"]`: Node is the container's main process, so `docker stop`'s SIGTERM reaches the graceful shutdown in `server.js` (`request-lifecycle.md` section 7). Under the old `CMD npm run start` the signal reached npm, Node never ran a handler, and Docker's SIGKILL followed. - The runtime stage sets `ENV NODE_ENV=production` rather than relying on the `npm run start` script, because `vite-express` reads it when `server.js` is imported to decide between serving `dist/` and booting a Vite dev server. It is also why the image no longer needs the script at all. Because that `ENV` is now the only thing setting it, both production compose files pin `NODE_ENV: production` under the app's `environment`: Compose lets an `env_file` line `NODE_ENV=` (the `.env.example` of 0.9.0 to 0.11.0 shipped it blank) replace an image `ENV` with an empty value, and `environment` wins over `env_file`. `.env.example` now ships it commented out. `tests/image-lifecycle.test.js` pins both. - A `HEALTHCHECK` probes `GET /readyz` every 10 s (3 s timeout, 20 s start period, 3 retries) with `node -e "fetch(...)"` on `PORT`, because `node:20-slim` has no curl or wget. Healthy means the instance lock is held, `SELECT 1` answers and every file under the `/migrations` mount is recorded, so a fresh volume reads `unhealthy` until the one-time `--adopt-fresh-install`. Measured 2026-09-27 on the release compose file with a local build: `docker stop` returned in 0.45 s with exit 0 and `stopped cleanly on SIGTERM`, and the restarted container reported `healthy` 6 s after `docker start`. Re-measured after the review round with `docker run` of a local build: a stop during the boot's SMTP verify took 0.28 s with exit 0 and `stopped on SIGTERM during boot`, where the previous image ignored the signal (Node as PID 1 with no handler yet) and was SIGKILLed at 10.3 s with exit 137; KILLing the lock's MySQL connection and taking the lock from another session stopped the container with exit 1 about a second later. - The prod and release compose files give the app `stop_grace_period: 20s`, twice the shutdown's own 10 s bound, so it finishes before Docker's SIGKILL. `tests/image-lifecycle.test.js` pins the `CMD`, the `HEALTHCHECK` and the grace period. - `src/` is copied whole rather than dropped in favour of `dist/`: the server imports `src/lib/githubDiff.js` directly. - `cloudcodex/.dockerignore` excludes `node_modules`, `dist`, `tests`, `coverage` and `.env*`. Without it, `COPY . .` shipped the host's 405 MB `node_modules` on top of the tree `npm ci` had just installed, so every dependency was in the image twice. Single-stage with no ignore file produced a **2.86 GB** image; the current one is **679 MB**. - `docker-compose-prod.yml` sets `DB_HOST: database` under the app service's `environment`, overriding `.env`'s `DB_HOST=localhost` (correct for dev, where the app runs on the host, and wrong inside the prod container). `environment` takes precedence over `env_file`. ### Backup and restore (`scripts/`) `scripts/backup.sh ` and `scripts/restore.sh ` (Bash, `set -euo pipefail`, `umask 077`) share `scripts/backup-common.sh`: the archive format (`ARCHIVE_MEMBERS`, `ARCHIVE_FORMAT`), `DUMP_FLAGS`, a copy of the instance lock's name (`INSTANCE_LOCK_NAME_SQL`, pinned equal to `services/instance-lock.js`'s by `tests/scripts/backup-common.test.js`) and two transports behind the same functions (`db_mysql`, `db_query`, `db_dump`, `uploads_out`, `uploads_in`, `app_version`, `target_database`), and two checks both scripts make before they write: `check_confined_user` (the MySQL user holds privileges on the instance's database and nothing else) and, with Compose, `check_compose_file` (the stack was created from the file `COMPOSE_FILE` names): - **Compose** (default): `docker compose` on `COMPOSE_FILE`, defaulting to `docker-compose-release.yml`. The clients run inside the `database` service as its `MYSQL_USER` with `MYSQL_PWD="$MYSQL_PASSWORD"`, never root and never a password on argv, over `--protocol=TCP -h 127.0.0.1` (the temporary init server above is socket-only). The uploads go through a one-off `run --rm --no-deps -T app` streaming `tar` on stdin/stdout, so there is no bind mount and no SELinux label to get right. Both production files share the repo root's project name and the container names, so either file reaches the other's stack; `check_compose_file` reads the `com.docker.compose.project.config_files` label off the `database` container and refuses when its file names differ from `COMPOSE_FILE`'s (names, not paths, so a moved checkout still matches). - **`--local`**: the `mysql`/`mysqldump` on `PATH`, `DB_HOST`/`DB_USER`/ `DB_PASS`/`DB_NAME`, `--protocol=TCP`, and `--uploads DIR` (default `cloudcodex/public`). This is what `tests/integration/backup-restore.test.js` drives. The archive is `database.sql`, `app_public.tar.gz` and a one-key-per-line `manifest.json` (format, `created_at`, `database`, `app_version`, both SHA-256s). `backup.sh` builds it in a `mktemp -d` beside the output, checks the dump's last line is `-- Dump completed`, and publishes it with `ln` (atomic, and a refusal if the output appeared meanwhile); an existing output is refused up front. `restore.sh` does every check before any write (members, types, checksums, uploads types and paths, a line-start scan of the dump for client commands and database switches, the manifest's `app_version` against this install's (`version_is_newer`; a newer backup needs `--allow-newer-backup`, since `npm run migrate` only looks for missing ledger rows and would pass an older app over a newer schema), the target name against the manifest or `--into`, `check_confined_user`, a running `app` service, and rows), then loads in one session that starts with `TAKE_LOCK_SQL` (take the instance lock or fail: the only lock check, so a process holding it stops the load at line 1 and the restore says nothing was written) and, under `--replace` or when every table is empty, drops every table first. It then requires as many tables as the dump has `CREATE TABLE` lines, unpacks the uploads (`--replace` empties only `avatars/` and `doc-images/`), and in Compose runs `npm run migrate` and `up -d` unless `--no-start`. Traps, each found while building it (2026-09-28): - **`--one-database` needs the database as the positional argument.** With `-D ` both MySQL 8.4.11's and MariaDB 10.11's `mysql` skip **every** statement and exit 0, so a restore "succeeds" into an empty database. The scripts never pass `-D`, and the table-count check after the load catches a load that silently did nothing. - **MariaDB's `mysqldump` cannot back up this schema for MySQL.** It writes the value of the generated column `logs.plain_content`, and MySQL refuses every such row (ERROR 3105). `--local` refuses a MariaDB `mysqldump`. - **`tar | grep -q` under `pipefail` can read a hit as a miss** (tar's SIGPIPE wins), so every listing is captured into a variable first. - **The line-start scan does not see a client command later on a line.** `SELECT 1; \T ` passed it and the client wrote `` as whoever ran it. `db_mysql` passes `--binary-mode`, which turns off every client command in piped input except `delimiter` (a dump with triggers needs it) and `charset`, wherever it sits; the shell escape is also turned off by feature detection, `--skip-system-command` (MySQL) or `--sandbox` (MariaDB). - The load's confinement to one schema is the **grant**, not a parser: `--one-database` ignores a qualified `db.table`, and the test proves a `CREATE TABLE .x` in the dump fails with access denied when the restoring user has privileges on its own schema only. - **So the user has to be one the grant confines.** `--local` first took any `DB_USER`, and as root a dump starting `SELECT 1; CREATE DATABASE x;` got past the line-start scan and created `x` (found in review, 2026-09-28). `check_confined_user` reads `SHOW GRANTS` (with `-r`, since batch mode doubles the backslash the MySQL image's `c2\_name` grant spelling carries) and allows only `USAGE ON *.*` and grants on `` ``.* `` or its tables. `SHOW GRANTS` with no `FOR` lists the session's active roles, a mandatory one included, as `GRANT `role`` lines with their privileges (measured on 8.4.11), so a role is refused by the same loop; a separate `CURRENT_ROLE()` check could never fire first, and was removed. CI runs `shellcheck -x scripts/*.sh` (section 6). One thing to get right when deploying: **`init.sql` only executes on a fresh volume.** The MySQL entrypoint skips `/docker-entrypoint-initdb.d/` when the data directory is already initialised. Editing `init.sql` and restarting changes nothing; existing databases need the matching file from `migrations/`, applied with `npm run migrate` (one-time adoption first, see `docs/deployment.md`). No compose file mounts `migrations/` into the **MySQL** container, so it is never applied from inside `make db-shell`; the release and prod compose files mount it into the **app** container (`:ro,z`, because an unlabelled bind mount is unreadable on an SELinux host), which is where the runner runs. Run it with `docker compose ... run --rm app npm run migrate`, not `exec`: on the upgrade that first ships the runner, the already-running container is the old image, with neither the script nor the mount. ## 5. Testing **Vitest 4, four projects** in one config (`vitest.config.js:25-86`). A single `npm test` runs the three default ones, `backend`, `frontend` and `design`; the fourth, `integration`, is opt-in because it needs a MySQL server: | Project | Environment | Setup file | Includes | |---|---|---|---| | `backend` | node | `tests/setup.js` | `tests/routes/`, `tests/middleware/`, `tests/services/`, `tests/helpers/`, `tests/extensions/`, `tests/scripts/`, `tests/*.test.js` | | `frontend` | jsdom + `@vitejs/plugin-react` | `tests/setup.frontend.js` | `tests/src/**` | | `design` | node | none | `tests/design/**/*.test.js` | | `integration` | node | `tests/setup.integration.js`, plus `globalSetup` `tests/integration/global-setup.js` | `tests/integration/**/*.test.js` | Current state: the default run is **155 files, 3578 tests, all passing** (16 files and 281 tests of it are `design`, measured 2026-10-11 on `track/w6-cdx-29-admin-surfaces`); the integration project is **27 files, 297 tests, all passing** (measured the same day in a `mysql:8.4.11` container with Node 22 copied in, the CI root password, and `npm ci` run inside). The older figures were (measured 2026-09-29 on `chore/deps-audit`, the upgraded dependencies, with main merged in after W6-CDX-13, against a stock MySQL 8.4.11 at the server's default isolation and at `READ-COMMITTED`, with MySQL's 8.4.11 clients on `PATH`). The backup file's child-process helpers (`spawn`, `childEnv`, `holdLock`, `startServer`, `signIn`, `freePort`, `killChildren` and the boot `ADMIN`) live in `tests/integration/app-process.js`. The lifecycle, tenancy and grants-sufficient files use `tests/integration/server-child.js` instead (see below), a parallel copy of the same helpers that takes the boot admin as an argument and also opens `/collab`; the two were written on separate branches and have not been folded into one module. `tests/integration/backup-restore.test.js` is the backup and restore drill. It boots `server.js`, saves a document with a pasted PNG and some Unicode through `/api/save-document`, comments on it, stops the server with SIGTERM, writes a Yjs update into `ydoc_state`, and runs `scripts/backup.sh --local` as a MySQL user granted only on the file's schema. It pins the archive (mode 0600, exactly three members, manifest keys and checksums, no MySQL or admin password in any byte, no overwrite, no working files left) and the backup's refusal of root, then each refusal `restore.sh` makes with nothing written: another database's archive, a system schema, a held instance lock (by the load's own first statement), a checksum, an extra member, a line-start switch or client command, a client command after a statement, a user with more than its own database (root with a mid-line `CREATE DATABASE`, a grant elsewhere, a global privilege, a role, a mandatory role), a newer release's backup, and a link in the uploads. The drill proper drops the schema and deletes the image file, restores into an empty scratch schema as a user granted only on it, and requires `html_content` and `ydoc_state` byte-identical, the comment and `doc_images` rows equal, a server booted on the result to answer `/readyz` 200, and the image served to its reader and 404 to an anonymous caller. Then `--replace`, a new install's empty tables restored without it (as a user granted the way the MySQL image grants, with `_` escaped), a dump that names another schema failing on the grant, and `--allow-newer-backup`. `TAKE_LOCK_SQL` is also run directly against a lock holder. **It needs MySQL's `mysql` and `mysqldump` on `PATH`** (the CI runner image has them; a MariaDB client fails the backup by design, see section 4); it passes with the 8.4.11 and the 8.0.45 clients. `tests/scripts/backup-common.test.js` pins the lock name, `version_is_newer`, and `check_compose_file` through a stand-in `docker` on `PATH`. Mutation-checked 2026-09-28, each confirmed landed, then restored. These turn a test red: removing the checksum check, the name check, the dump scan, the row check, `check_confined_user` in either script, its escaped-name spelling, the version refusal, `TAKE_LOCK_SQL`'s line in the load, `--binary-mode`, or `check_compose_file` (the unit test); passing `-D`. The owner-only mode has two guards, `umask 077` and `chmod 600`, and removing either alone stays green; removing both turns the mode test red. `tests/integration/lifecycle.test.js` is the fourth test group that needs real processes rather than a real server alone (the fork, sign-in and `/collab` helpers it shares with `grants-sufficient.test.js` live in `tests/integration/server-child.js`): it forks `tests/integration/lock-holder.js` against the file's schema to prove the instance lock (a second process exits 1 naming the holder's connection id, a SIGKILLed holder frees it within two seconds, two schemas hold their own at once, a 64-character schema name gets an exclusive lock of its own), then boots `server.js` itself (`NODE_ENV=production`, a free port, an `APP_URL` on that port, which production requires, and the boot admin), signs in through `/api/login`, sends one Yjs update over `/collab`, delivers SIGTERM 200 ms later, inside the three-second debounce, and requires exit 0, close code 1001, the edit in `logs.ydoc_state` and the edit served by a restarted server. Its last test boots `server.js`, KILLs the lock's connection the way a MySQL restart would, takes the lock from the test's own connection, and requires the server to exit 1 within 15 s naming that connection and without `stopped cleanly`. `tests/integration/migrate.test.js` adopts a 64-character schema under the runner's lock. Mutation-checked on 2026-09-27: a `flushPendingSaves` that skips every document leaves `ydoc_state` NULL, a lock that ignores GET_LOCK's answer lets the second process in, and a lost lock that never reaches `onSuperseded`, or a `server.js` that ignores it, leaves the server running. **The default run is pinned by name, not by omission.** `test`, `test:watch` and `test:coverage` name `--project backend --project frontend --project design`, because a bare `vitest run` runs every declared project, integration included. `tests/test-projects.test.js` (a backend test) fails if a declared project other than `integration` is missing from `test`, `test:watch` or `test:coverage`, or if `test:integration` runs anything but `integration`. A project added to `vitest.config.js` without joining those scripts turns it red instead of silently never running; `design` (W6-CDX-21) joined them and the guard passed unedited. ### The design project (`tests/design/`) It runs the gates of the vendored design package over `index.html` and `src/`, `.css`, `.js`, `.jsx` and `.html` alike (69 files when it landed). The package is `vendor/cloud-city-design/`, a byte copy of the public Apache-2.0 `Cloud-City-Computing/cloud-city-design` at the commit its `MANIFEST.json` names under `upstream`; ESLint ignores `vendor/`, and `vendor/.gitattributes` keeps git from converting its line endings. **It is never edited here**: a change goes upstream, then a re-vendor PR copies exactly the files the upstream manifest lists. | File | Gate | |---|---| | `vendored.test.js` | every listed file present with its SHA-256, nothing unlisted, a full upstream commit, and the package's own `node:test` suites passing in this copy (run with the TAP reporter named and `NODE_OPTIONS` dropped, so the summary parses on Node 24, whose default reporter is spec) | | `token-discipline.test.js` | literal colours, accent fill shades (`--brand-blue`, `--accent-100` to `-400`, and Codex's `--cx-accent-fill`) in text or edge positions, and suppressed outlines, per bucket, against `ledger.json` | | `z-index.test.js` | every CSS `z-index` and React `zIndex` is a `var(--z-*)` that the vendored `core.css` declares, with a floor of 12 CSS declarations; the shell layers (W6-CDX-40: the sidebar at base, the account menu and the search palette above it and the editor overlays) is read from the real rules | | `standalone.test.js` | no application source (`src`, `routes`, `services`, `middleware`, `scripts`, the root files) names a sibling product, the suite mode, a `SUITE_*` variable outside a removed entry, a `W6-CMD-` tag or the identity provider; the `Cloud City Computing` header and the two cookie names are the allowed forms (CDX-SA-5) | | `dangling.test.js` | a `var()` with no fallback names a property some loaded stylesheet (`src/**/*.css`, the vendored `core.css`) declares, against `dangling-ledger.json` | | `contrast.test.js` | every pair in `pairs.json` clears its minimum in each theme (dark and daylight, W6-CDX-43), and every colour binding in `src/codex.css` is in a pair, named decorative, or a `--cx-deferred-` binding that `deferred-colour.test.js` measures | | `deferred-colour.test.js` | every text colour in the editor, comments, GitHub and read-only sections, measured where it paints (over its own background, composited, or over each surface), at 4.5:1 in both themes; every `--cx-deferred-` binding is declared in both themes and read | | `geometry-fingerprint.test.js` | the SHA-256 of every non-colour declaration in the deferred families, against `geometry-fingerprint.json` (taken before the W6-CDX-43 colour pass; re-taken by CDX-SA-2 when the `.suite-doc-link` rule left, see that file's `_taken`); a layout change there fails (K3) | | `bridge.test.js` | the 22 legacy names (W6-CDX-38 retired the two hover names and the three radius names, which core's values now serve) and the five fallback names in `index.css`'s `:root` are each one `var()` into `codex.css` or `core.css`, resolve through the vendored resolver, and hold no literal (the colour ones resolve to a colour) | | `painted.test.js` | the labels the page actually paints, read from the real `src/index.css` declarations and resolved through the bridge: ink (`--cx-on-accent`) on the accent fill for the default and all eight hues at 4.5:1 or more; white on the accent dark (avatars) at 4.5:1 or more; the danger fill (`--cx-danger-fill`) with the dark ink (`--cx-on-danger`) at 4.5:1 or more at rest, pressed and on the delete hover, and the button hover an inset ring on the same fill with no background declared (W6-CDX-38); the primary hover at 4.5:1 on every hue; the focus ring (`:focus-visible`) at 3:1 on all four surfaces (the two hover steps retired in W6-CDX-38) for the default and eight hues, and no rule in `index.css` removes an outline; each toast label at 4.5:1 on the toast surface; a dialog close control at 4.5:1 on the dialog panel at rest and on hover, on the default and eight hues; the `.gh-merge-dialog__pane` clip draws its ring inside the control | | `presence-palette.test.js` | every presence cursor colour in `services/cursor-colors.js` has a label (white, the ink or black, chosen by `src/lib/avatar-label.js`) that clears 4.5:1, measured through the real tokens | | `tints.test.js` | every translucent tint a label sits on (the CI badges, the filter buttons, the content-match badge, and the Explore, Page Tree and Log Tree rows, forty-five sites, the W6-CDX-29 admin, settings and notification washes among them) is a `--cx-tint-` name in `codex.css`, and each label measures 4.5:1 or more on the tint composited over all four surfaces (W6-CDX-38; the labels on a danger tint step to `--cx-danger-bright`), on the default accent and the eight picker hues; the list cannot shrink below forty-five, and the list is now derived from the stylesheet itself (every rule whose background reads a `--cx-tint-` name, `DEFERRED_BARE` naming the deferred bare washes) | | `code-blocks.test.js` | every highlight.js colour rule (the eight hex literals in `index.css`, which do not cover every class: seven `hljs-*` classes set no colour and inherit the block text, measured too) clears 4.6:1 on the editor code wash (`rgba(0, 0, 0, 0.25)`, read from the real `.editor-container .tiptap pre` declaration) composited over all five surfaces | | `dialogs.test.js` | every dialog wrapper in any file under `src/` carries `role="dialog"` and `aria-modal="true"` in its own markup (seven today), every `close-button` is a `type="button"` button named Close, and `#modal-dimmer` paints `var(--cx-scrim)` with no `backdrop-filter` (seeded, so the scan cannot pass by finding nothing) | | `accents.test.js` | the eight picker hues in `src/tokens/accents.js`, W6-CDX-43 (K7): the contract `STEPS` table, with H substituted, equals the `[data-theme][data-accent]` blocks of `codex.css` in both themes; 64 hue-by-surface-by-theme text pairs at 4.5:1 (the three surfaces and the picked wash); the focus ring at 3:1; on-accent at 4.5:1 on the fill and the fill hover on each fill, at least 15 degrees from the danger, warning, success and violet arcs read from `core.css`, no two closer than 25 degrees, `LEGACY_ACCENT` targets unique keys | | `codex-css.test.js` | `codex.css` declares only `--cx-` custom properties and `color-scheme` (dark and light), under exactly `[data-theme]` (the shared block), the two themes and the two `[data-accent]` forms (W6-CDX-43) and `[data-theme='dark']`, with no radius and no `--cx-surface-3` or `-4` (W6-CDX-38), never a core name; `index.html` sets the attribute; `src/main.jsx` imports core, fonts, codex, index in that order; no font under `public/`; Vite keeps legal comments | | `theme.test.js` | every row of `tests/design/theme.json`, dark and light columns, the theme table restated, equals its declaration in `src/codex.css` as text (W6-CDX-38; the light column, W6-CDX-43) | | `hover-fills.test.js` | no rule in `index.css` reads `--bg-hover` or `--bg-hover-strong`, the bridge declares neither, and the deferred editor, comments and GitHub banners bind their hover to `--cx-surface-2` (Decision 12, W6-CDX-38) | | `fallbacks.test.js` | no `var()` fallback under `src` is a colour literal, and no undeclared name carries a fallback except `--editor-font-size`, which script sets (W6-CDX-38) | | `control-font.test.js` | `.btn`, `button`, `input`, `select` and `textarea` declare `font-family: inherit` on the shell (X4), and the rule excludes the deferred families (comments, the editor and its toolbars, Markdown, Draw.io, Version History, GitHub), which keep the browser font until W6-CDX-43 (K3). The family only: the `font` shorthand also reset size and line height, which grew the top-bar search field (W6-CDX-38) | | `ghost-hover.test.js` | a ghost button's hover background differs from the page's and from a dialog panel's (`.modal-content`), resolved through the dark theme (declaration-level; the live hover is checked in the browser captures) (W6-CDX-38 review) | | `sidebar.test.js` | the sidebar's background is an opaque token (`--cx-surface-1`), with no translucent literal and no opacity (W6-CDX-40; this file was `topbar.test.js`, the top bar being gone) | | `deferred-radius.test.js` | the deferred families read `--cx-deferred-radius-*` (4, 8, 12px) in their banners, never a core radius, until W6-CDX-43 (W6-CDX-38 review) | **The ledger is exact, not a ceiling.** `ledger.json` holds a count per file, and per section banner inside `index.css` (`tests/design/buckets.js`, `banners`; an em dash in a banner title becomes a hyphen in the key). A bucket above its number fails as a regression and one below it fails as an unrecorded fix, so a burndown PR lowers the ledger by exactly what it burned, in the same PR. It started at 745 (646 literal colours, 77 accent positions, 22 outline suppressions) and stood at 679 after W6-CDX-22: the `:root` bridge took its 19 literal colours out, and the dead-CSS burndown and the picker's data file took the rest. It stood at 631 after W6-CDX-23's review (642 after its first pass): the focus rings took all 21 outline suppressions, the button family's literals and label went, and the Toast's six literals went; the Buttons and Focus and Accessibility buckets are deleted. It stands at 510 after W6-CDX-28: the eight content banners (Log Tree, Explore, Page Tree, the search box and results, the Error Boundary and the 404 log) burned to zero, each bridged or Codex-token fallback replacing its literal. It stands at 399 after W6-CDX-29: the admin, settings, notification, activity, watch, archive-access, squad, export and preferences banners burned to zero (68 findings in `index.css`), the five literals in `AccountMenu.jsx` (the Google mark and the swatch check) moved to tokens, and the leftover modal, login, signup and presence banners burned too (20 findings: Modal + Overlay, Login Tabs, Signup Validation Styles and Toast Notifications). What remains is the editor canvas, the comments and the GitHub surface (the roadmap row UI-later), each with a `_later` note in `ledger.json`; Version History, Read-only Document View and Markdown Editor are classified editor-adjacent and wait with the canvas. The file-level buckets in `Editor.jsx`, `GitHubPage.jsx`, `util.jsx` (the print stylesheet), the comment components and the embed picker stay with their surfaces. The label and tint steps are not ledger entries: `tints.test.js` measures them, and the syntax-highlighting literals stay in the Syntax Highlighting bucket, lightened to clear 4.6:1 on the strongest surface, a margin over the 4.5:1 line (`code-blocks.test.js`). `node tests/design/report.mjs [file]` prints the live buckets. In `src/codex.css` (`TOKEN_DEFINITIONS`) the literal rule skips the custom-property declarations (`maskCustomProperties` blanks them first), because those literals are the definitions the contrast gate measures; a literal on any other property there still counts, and `codex-css.test.js` pins that the file declares custom properties and nothing else. Mutation-checked when it landed: a hex in `Toast.jsx`, an `outline: none` or a `color: var(--cx-accent-fill)` in `index.css`, a `var(--nope)`, one byte of the vendored `core.css`, `--cx-text-faint` below 4.5:1, an unpaired colour binding, a non-`--cx-` name, `data-theme` dropped, legal comments stripped, the import order swapped, a fixed dangling reference restored, and one literal removed without lowering the ledger each turned the named test red, as did two added after review: a literal on a plain property in `codex.css`, and `NODE_OPTIONS` passed through to the package suites. A first seed of the hex landed inside `Toast.jsx`'s doc comment and reddened nothing, correctly: the scanner masks comments. `coverage.include` does not reach `tests/` or `vendor/`, so the project adds no coverage threshold. **`tests/namespace-urls.test.js` reads every text file in the repository** (source, tests, docs, SQL, compose files and workflows; not `node_modules/`, `dist/`, `coverage/` or `public/`). Every `xmlns` value must be on its short allowlist of W3C namespaces, and no `http(s)` URL's host may end in a product word (`workspace`, `squad`, `archive`, `log` and their plurals), since no real top-level domain is one. It exists because the organizations-to-workspaces rename was a text substitution that reached inside URLs: ten inline SVG icons declared `www.w3.workspace` as their namespace until 2026-09-28, and React rendered them regardless, so no page looked wrong. The file leaves itself out, because its fixtures are the broken spellings. **The appearance preference contract (W6-CDX-44).** `tests/src/lib/appearance-prefs.test.js` covers every rule of the contract (precedence, the cookie attributes and write order, the domain rule, held writes, clearing, the reconcile, the migration, and both re-apply paths) against a `document.cookie` jar that records each write verbatim. `tests/src/theme-boot.test.js` runs `index.html`'s own script in each precedence case, and the accent cases cover every legacy key. `tests/theme-boot-hash.test.js` (backend project) holds the CSP hash to the script's bytes in `index.html` and in a fresh `vite build`'s `dist/index.html`; `tests/app.test.js` checks the production `script-src` carries it. `tests/src/components/` covers the header's Appearance popover (focus, Tab, Escape, outside press), the shared Appearance groups and the header slot. The design gates run in both themes as before; the deferred-families geometry fingerprint stayed unchanged. Measured 2026-10-12 on `track/w6-cdx-44-theme-contract`: `npm run lint` clean on the changed files; the default run (`backend`, `frontend`, `design`) **209 files, 4745 tests, all passing** (the start of the session: 203 files, 4632). ### The live-MySQL project (`tests/setup.integration.js`) The only tests in the repo that touch a real database. The per-file setup runs before each test file is imported and **does not** mock `mysql_connect.js`: 1. Opens an admin connection from `IT_DB_HOST` (default `127.0.0.1`), `IT_DB_ROOT_USER` (default `root`) and `IT_DB_ROOT_PASSWORD` (required; the setup throws without it). The server must answer on **3306**, because `mysql_connect.js` reads no `DB_PORT`. 2. Creates a throwaway schema `c2_it_<12 hex>`, builds `init.sql` into it over a `multipleStatements` connection (`init.sql` has no `USE`, so it builds into the current schema), and adopts it with the runner's exported `runMigrations({ adoptFreshInstall: true })`. If either step throws, it drops the schema before rethrowing. 3. Sets `DB_HOST`, `DB_USER`, `DB_PASS` and `DB_NAME` to that server and schema, **before** the test file imports any app module, which is what makes the pool in `mysql_connect.js` bind to it. `dotenv` never overrides a variable already set, so a developer's `.env` cannot redirect it. 4. Drops the schema in `afterAll`. The admin helpers (`adminConfig`, `buildSchemaFromInitSql`, `loadInitSql`, `queryVia`, `dropSchema`, `dropUser`, `throwawaySchemaName`) live in `tests/integration/mysql-admin.js`; a test that needs a second schema (as the adoption-refusal test does) builds it with them and drops it in its own `finally`. `queryVia` wraps **one** connection, never a pool, because the runner's advisory lock is per connection. The global teardown drops every `c2_it_` schema, every `c2itrecipe` schema followed by exactly 12 hex digits (`isThrowawaySchema` in `mysql-admin.js` checks the shape, so a developer's own `c2items` survives a run pointed at their server), and every `c2_it_` account, still on the server and fails the run naming them. **Trap: Vitest 4 only logs an error thrown from a globalSetup teardown ("error during close") and exits 0**, so the teardown sets `process.exitCode = 1` before it throws; the throw alone would leave a leak green (found by mutation, 2026-09-25). Because it counts every `c2_it_` schema, two integration runs sharing one server at once would report each other's; give each concurrent run its own server. **The shared-server recipe (W6-CDX-33).** `tests/integration/instance-recipe.js` reads the SQL block under "Several instances on one MySQL server" in `docs/deployment.md` and runs it as root with throwaway names (a `c2itrecipe` schema, since the recipe allows letters and digits only, and `c2_it__app` and `_mig` accounts), then builds the schema as the migration account by the fresh-install path (`buildFresh`) or the upgrade path (`buildUpgraded`: every post-baseline file applied for real, as that account). `tenancy.test.js` does this for two instances and asserts the exact MySQL error for 54 statements from instance A's accounts that reach for B, for the server or for DDL, plus `SHOW DATABASES`, `information_schema`, the process list, `LOAD_FILE`, both accounts' `SHOW GRANTS`, the app account's connection cap (1226 past it) and each instance's lock held at once. It also reads every system view the app account can and pins `information_schema.TABLESPACES_EXTENSIONS` as the only one naming the other instance (MySQL lists every schema's tablespaces to every account), and requires the recipe to say so. Its last two tests demonstrate why the recipe bans `_` in schema names and pin the teardown's name filter. `grants-sufficient.test.js` boots `server.js` as the app account alone and runs a smoke path, a `/collab` edit and a SIGTERM, and fails on any privilege error in the child's log. Access-control section 9 has the boundary; `docs/research/instance-isolation-2026-09-28/` the run, its ten mutations and the recipe driven through the release compose file. `tests/integration/migrate.test.js` holds four tests: a canary that fails if `c2_query` is a mock, adoption recorded every migration file, a second run is a no-op, and adoption refuses a schema missing a post-baseline column (`password_reset_tokens.purpose`). Each was mutation-checked on 2026-09-25: reintroducing the mock fails the canary, skipping the `afterAll` drop fails the teardown, a migration that `ALTER`s a missing table fails the setup, and dropping `--project frontend` from `test` fails the guard. **Trap: the setup never executes a migration file.** `adoptFreshInstall` records every file and applies none (`scripts/migrate.js` `runUnderLock`); its only check on a post-baseline file is `schemaClaims`, which asks whether the table, column or named key the file adds already exists. A file with broken SQL whose objects `init.sql` already has adopts cleanly (reproduced in review, 2026-09-25). `tests/integration/upgrade-path.test.js` is what runs migration SQL: it builds `init.sql` into a second schema, applies the undo statements in `tests/integration/pre-runner-state.js` newest first, records the baseline with `runMigrations({ baseline: true })`, applies every post-baseline file with a plain `runMigrations`, and requires an information_schema fingerprint (columns, indexes, table constraints, checks, foreign keys) equal to the per-file schema's. A second test fails when a post-baseline file has no undo entry or an entry names a file that is gone. Mutation-checked on 2026-09-25: invalid SQL in `2026-09-08-token-purpose.sql` (a parse error), `VARCHAR(64)` for `VARCHAR(32)` and the `CHECK` dropped (fingerprint mismatch), and the undo entry removed (both tests) each turn it red. Its limits: the upgrade runs on empty tables, so a migration's handling of existing rows is not exercised, and the `LEGACY_BASELINE` files are never run. `tests/integration/oauth-one-link-per-provider.test.js` is the first test that runs a migration over rows. It builds an install from the release before `2026-09-25-oauth-one-link-per-provider.sql` (an `init.sql` build with the key dropped and every other file recorded), seeds a double link, and requires the guard's refusal to name the duplicate-finding query and leave the rows, the key, `schema_migrations` and the routine list exactly as they were, then a retry after resolving to apply; a clean install to apply and the key to refuse a second link per provider (`ER_DUP_ENTRY` naming it, the shape `services/identity.js` matches); adoption to refuse a schema missing the key; two Google sign-ins racing for one user to end with one link and one `identity_conflict`; two GitHub links racing through the real initiation and callback routes (`app.js` over supertest, a real session, only `fetch` to GitHub stubbed) to end with one `github_linked=1` and one `link_conflict`; and a GitHub relink to an account another user holds to be refused as `already_linked_other`. Each race is held open deterministically: a transaction takes `SELECT ... FOR UPDATE` on the user's own `users` row (`holdUserRow`, over `holdInTransaction`), every link INSERT needs a shared lock on that row to check its foreign key, and the test waits until `information_schema.INNODB_TRX` shows both waiting. It is a record lock on the parent row, not a gap lock on the child's empty range, because InnoDB takes foreign-key check locks at every isolation level and takes no gap locks at `READ-COMMITTED`, so the file does not depend on the server's isolation setting. Run on 2026-09-25 with the server at `SET GLOBAL transaction_isolation = 'READ-COMMITTED'` and at the default `REPEATABLE-READ`: all six tests pass at both, and removing the `identity_conflict` catch still turns the Google race red at `READ-COMMITTED`. The tests share the file's schema, so each uses GitHub account ids the others do not. **Trap: poll `INNODB_TRX` slower than every 100 ms.** InnoDB refreshes that table only after it has gone 100 ms unread, so a 25 ms loop saw zero waiters for ten seconds while two were waiting. Mutation-checked on 2026-09-25: no guard, the runner's guard cleanup skipped, and the `identity_conflict` catch removed each turn one of them red; the GitHub race and relink tests were red against the callback before its `link_conflict` catch and its holder lookup (a 500 each). `tests/integration/oauth-google-two-factor.test.js` drives Google sign-in through `resolveIdentity` and through the real initiation and callback routes (`google-auth-library` is the one thing stubbed, with `vi.mock`), and requires an account with two-factor on (`totp` and `email` each) and no Google link to be refused as `two_factor_enabled` with no `oauth_accounts` and no `sessions` row; an account with two-factor off to link and sign in through the same flow, which is the anchor that keeps those refusals from passing on a harness that cannot link; an explicit NULL to count as off; and a user linked first who turns two-factor on later to still get a session with no challenge minted (no `password_reset_tokens` or `two_factor_codes` row). It sets the Google client variables in `vi.hoisted`, in its own file, because `routes/oauth.js` reads them at import. Mutation-checked on 2026-09-25: with the refusal removed from `services/identity.js` its four refusal tests are red (the callback redirects to `/` with a session), alongside eight mocked ones: five in `tests/services/identity.test.js` and three across the two route seam files. The interleaves a mocked test cannot hold open live in `tests/integration/google-link-races.js`, a shared module (not itself a test file) that two files run: `oauth-google-two-factor.test.js` at the server's default isolation, and `oauth-google-two-factor-read-committed.test.js` with every connection the app's pool opens set to READ COMMITTED (mysql2's `createPool` is wrapped, through `vi.mock`, to add one `connection` listener that runs `SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED`; that file's first test proves it took on five concurrent connections). Each interleave changes the user row between the seam's email lookup and its link INSERT: a transaction runs the change and holds the row, the seam's lookups are plain reads and go past it, and its link INSERT, the only statement in the ladder that locks the user row, waits. Reaching that lock wait is the proof the lookups already passed. The changes are the app's own enable statements (`totp`, `email`) and an email change, and after the commit each must answer `two_factor_enabled` with no `oauth_accounts` row; an anchor holds the row with a change that touches neither and must link after the same wait. The old unconditional `VALUES` INSERT waited too (its foreign-key check locks the parent row) and then linked, which is how the race was seen; under READ COMMITTED the conditional INSERT without `FOR SHARE` did not wait at all and linked. Mutation-checked on 2026-09-25: `FOR SHARE` removed reddened exactly the three READ COMMITTED refusals while the default-isolation run stayed green; `AND email = ?` removed reddened the email-change interleave in both files and seven mocked pins; the INSERT put back to `VALUES` reddened the default-isolation race tests and six mocked pins; the row check loosened from `!== 1` to `=== 0` reddened two mocked tests; and the check removed entirely reddened the race tests (the seam reported a link it never wrote). Every race file holds the user row through one helper, `holdInTransaction` in `tests/integration/mysql-admin.js` (the C7 races lock it `FOR UPDATE`, these run the change itself), and waits through `waitForLockWaits` beside it, which carries the 100 ms trap described above. A record lock on the user row holds at every isolation level, so all of them pass with the server at `READ-COMMITTED` as well. `tests/integration/update-account-sessions.test.js` proves the update-account session rotation on a real server, where the route tests can only prove the SQL text. Each test seeds a user with two sessions minted by `generateSessionToken` (two sign-ins, so two devices), drives `POST /api/update-account` over supertest, and reads `sessions` back: a password change and a current-password email change each leave exactly one row, the digest of the token the caller was handed, with both old tokens answering 401 on a `requireAuth` route; a wrong current password leaves the hash and both sessions untouched; the password-less code flow writes an `email_change` row carrying `new_email`, sends the code to the old address, and after `/update-account/confirm-email` leaves one session. Mail is the one module stubbed (`vi.mock` of `services/email.js`), so the code is read back off the call. A last test inserts rows that break `chk_password_reset_tokens_new_email` both ways and requires error 3819. Mutation-checked on 2026-09-25: accepting a wrong current password, keeping the caller's session (`AND id != ?`), handing back the old token, and keeping the caller's session in the confirm step each turn a live test red. `tests/integration/sessions.test.js` proves one hashed session per sign-in (W6-CDX-2) on a real server: two sign-ins make two rows and deleting one by its digest leaves the other valid, no stored id equals a raw token, JavaScript's `hashSessionToken` equals MySQL's `SHA2(?, 256)`, an insert that omits `auth_provider` fails with `ER_NO_DEFAULT_FOR_FIELD` and one naming `oidc` fails the CHECK. Its last test runs `migrations/2026-09-27-session-per-sign-in.sql` for real in the file's own schema: it takes the column and CHECK off and un-records the file, inserts a raw-token row, an upper-case-hex raw token (only case separates it from a digest) and an existing digest, runs `runMigrations`, and requires both raw tokens to sign in through the real `validateAndAutoLogin`, the digest to be untouched, and the file's `UPDATE` run again to change zero rows. Mutation-checked on 2026-09-27: dropping the `UPDATE`, the `'c'` flag or the `DROP DEFAULT`, or the CHECK from `init.sql`, each turns a live test red. `tests/integration/remove-sibling-connection.test.js` proves the 0.15.0 removal migration on a real server: it applies to the pre-migration schema (expiring revoked invitations, deleting the env webhook rows, dropping the two columns), its undo returns them, a fresh build adopts it, and the adoption is refused on a schema that still has the columns. The reconciliation read it replaced has no integration test any more. Three files prove the outbound-events outbox (W6-CDX-13). `tests/integration/webhooks-schema.test.js` proves the tables' constraints: the `source` and `status` CHECKs, that only an `env` subscription goes without a stored secret, both unique keys, and the cascades from a subscription and from an event to their deliveries. `tests/integration/webhooks-emit.test.js` drives each of the eight emitted actions through its real route with a real session and reads exactly one `webhook_events` row and one `webhook_deliveries` row per matching subscription (made through the admin API, so its SQL runs too); a coalesced `log.update`, a `comment.create`, a subscription for another workspace or for other event types, a delete naming a log outside its archive, and no subscription at all each write nothing for that subscription or event; a subscription disabled in the database since the cache loaded gets no delivery, and an event it leaves with none is rolled back; the stored body is byte-stable and is the envelope its row describes (its `occurred_at` read back in UTC); a stored 30,000-character title is emitted as 255 code points; and with a test-created trigger that `SIGNAL`s on every outbox insert, a save still answers 200, writes its activity row and notifies its watcher. The env-subscription integration file (`webhooks-env.test.js`, boots through each env state) was removed in CDX-SA-3 with the env subscription. Mutation-checked on 2026-09-28: letting the emit throw out of `doLogActivity` turns the trigger test red (no watcher notification); the unit suites catch dropping the title bound, the workspace match, the no-subscription early return, the SQL re-check, the queue drop on a retarget, the IPv4-in-IPv6 check, the every-address check and the admin gate. `tests/integration/webhooks-worker.test.js` (W6-CDX-14) runs the delivery worker against a real receiver on 127.0.0.1 and reads the outbox back: the body is byte-identical to the stored row and its signature verifies; event 3 is not received before event 2; two workers on one database send each of 50 deliveries exactly once; a lapsed lease writes nothing after another worker delivers; 410, 422, a 302 (not followed) and a receiver that never answers (`ETIMEDOUT` after ten seconds) each land their outcome; the backoff gaps read back from `paused_until` are 10, 20, 40, 2560 and 3600 seconds; a 73-hour event is dead-lettered and never sent; a name that now resolves to a private address is not sent to; the connect goes to the approved address; the prune keeps what the rules keep; and 20 saves with a hanging subscriber and a worker ticking beside them are measured against 20 without one. The in-process jobs are now three: the activity prune, the session prune, and the delivery worker with its prune. `tests/integration/admin-sync.test.js` proves the boot admin sync never promotes (GHSA-w8q3-r34w-3pjh), which only a real server can: which row the lookup returns first, and how `LOWER(name)` and the email collation compare, are MySQL's. Each test creates the admin with a real first-boot `ensureAdminUser()`, lets the admin give up its name, its email or both, has a member (own password, two sessions) take the freed name or address, and boots again: the member is still not an admin with its row and sessions unchanged, the admin's row is unchanged, the call returns `null`, and exactly one refusal line is logged. All four shapes run with the member's row older and newer than the admin's, because a lookup that read only the first row passes one order and fails the other. A last test proves `.env` still resets an existing admin's email and password. Before the fix, five of the eight takeovers promoted the member, two crashed on the `users.email` unique key and one synced the admin. Mutation-checked on 2026-09-25: removing the refusal turns 8 of 9 red (five with the member's password and email overwritten, two on the unique-key crash, one with the admin synced), and checking only the first matched row turns the two two-row cases red in the admin-older order. Tests mirror the source tree: ``` routes/foo.js -> tests/routes/foo.test.js routes/helpers/foo.js -> tests/helpers/foo.test.js services/foo.js -> tests/services/foo.test.js middleware/foo.js -> tests/middleware/foo.test.js src/** -> tests/src/** ``` ### The backend mock surface (`tests/setup.js`) Four global `vi.mock` calls apply to **every** backend test: - `../mysql_connect.js`: `c2_query` returns `[]`, `generateSessionToken` returns `'mock-session-token'`, `validateAndAutoLogin` returns `null`, `touchSession` and `endPool` no-op, and `openConnection` rejects, so no backend test can reach a real server by accident. - `../services/email.js`: `sendEmail` and `verifyEmailConnection` stubbed. - `sharp`: a chainable stub with `resize`/`webp`/`toFile`. - `fs/promises`: **only `mkdir` and `unlink`.** That last one is a sharp edge. `routes/helpers/images.js` also calls `fs.stat`, `fs.writeFile` and `fs.readFile`, which the global mock does not provide, so a test touching those paths must supply its own mock. `tests/helpers/images.test.js` does. Because `c2_query` is a mock returning `[]` by default, **most backend tests assert against a queue of `mockResolvedValueOnce` calls in the exact order the handler issues queries.** Adding a query to a handler, even a harmless one, shifts that queue and breaks tests that were passing for the right reason. When a test breaks after a route change, check the mock ordering before assuming the change is wrong. Helpers in `tests/helpers.js`: `TEST_USER`, `TEST_USER_2`, `mockAuthenticated(user)`, `mockUnauthenticated()`, `resetMocks()`. Note there is **no `ADMIN_USER`** despite the root `CLAUDE.md` listing one; admin tests pass `mockAuthenticated({ ...TEST_USER, is_admin: true })` explicitly. `tests/setup.frontend.js` adds jest-dom matchers and, after each test, runs Testing Library `cleanup()`, clears `localStorage` and `sessionStorage`, and empties `document.body`. ### Coverage thresholds `vitest.config.js:116-210`. The global floor is deliberately low because `src/pages/` and `src/extensions/` are untested by policy: ``` lines 43 statements 40 branches 33 functions 26 ``` Above that sit **48 per-glob thresholds** (this map and the root `CLAUDE.md` both used to say 26, which was a miscount). The 30th, `services/identity.js`, arrived with the identity seam; the 31st, `services/session-token.js` (95 on all four), with hashed sessions; the 32nd, `routes/doc-images-serve.js` (95 lines, 92 branches), with the authorized image handler; the 33rd to 35th, `routes/health.js`, `services/shutdown.js` and `services/instance-lock.js`, with W6-CDX-31; the 36th, `env-contract.js`, with the configuration contract; the 37th, `services/session-cookie.js` (95 on all four), with the `__Host-` session cookie (W6-CDX-3); and the 38th to 40th, `routes/webhooks.js` (97 lines), `services/webhooks.js` (95 lines, 90 branches) and `services/webhook-target.js` (95 lines, 88 branches), with the outbound-events outbox (W6-CDX-13). The 41st, `services/webhook-worker.js`, with the delivery worker (W6-CDX-14), is measured in the backend project alone, where the timeout branch and the live-MySQL paths are not counted. Review follow-ups (2026-10-11), each with a test: `tests/integration/webhooks-order.test.js` holds the order lock from a second connection and shows an emit waits and takes an id above the one committed under it (mutation-checked: removing the lock turns both emit tests red), and checks that overlapping emits never leave a gap in the visible ids; `tests/integration/webhooks-cap.test.js` runs the 50-subscription cap through the admin API at the limit; `tests/integration/webhooks-worker.test.js` gained a late success that cannot clear another worker's live backoff, a lease renewed after a four-second DNS check, and a subscription beyond the twentieth being served. The unit suite gained the round-robin starvation test, the expired-then-next continuation, the query deadline, the skipped-tick log, the renewal before the send, and the UTC-only SQL assertion; `tests/services/webhook-target.test.js` gained the AWS IPv6 metadata refusal under `WEBHOOK_ALLOW_PRIVATE_TARGETS=1` and the bounded DNS lookup; `tests/services/shutdown.test.js` gained the worker stop running first. The security-critical and well-covered modules are ratcheted high: | Glob | lines | |---|---| | `routes/documents.js` | 95 | | `routes/notifications.js` | 95 | | `routes/comments.js` | 92 | | `routes/admin.js`, `routes/archives.js` | 90 | | `services/notifications.js` | 90 | | `services/identity.js` | 95 | | `routes/doc-images-serve.js` | 95 | | `routes/helpers/**` | 88 | | `routes/auth.js`, `routes/squads.js`, `routes/watches.js`, `mysql_connect.js` | 85 | | `middleware/**` | 80 | | `services/collab.js` | 65 (raised from 25 in the gap-fix pass) | | `src/util.jsx` | 65 | Plus `services/email.js` and `email-templates.js` at 95, `app.js` at 75 lines but only 5 branches, `env-contract.js` at 100 (it is data only, so importing it covers all of it), `src/editorUtils.js` and `src/userPrefs.js` at 95, `scripts/**` at 82, and five per-hook thresholds. `coverage.include` is an allowlist, so a directory absent from it is **invisible to coverage rather than under-covered**. `scripts/**/*.js` was added to it when the migration runner landed; anything new outside `routes/`, `middleware/`, `services/`, `scripts/`, `src/` and the four named root files needs the same treatment or it silently counts for nothing. **The practical consequence:** adding an uncovered branch to a high-threshold file fails CI even though every test passes. Write the test with the code. When you raise real coverage, ratchet the threshold up in the same PR; the comment at `vitest.config.js:110-115` explains the "achieved minus a small buffer" policy. ## 6. CI `.github/workflows/ci.yml`, on push to `main` and on every pull request. Ubuntu, Node 20, npm cache keyed on `cloudcodex/package-lock.json`, working directory `cloudcodex`: ``` npm ci -> npm audit --omit=dev --audit-level=moderate -> npm audit (advisory only) -> npm run lint -> shellcheck -x scripts/*.sh -> npm test -> npm run test:integration -> npm run test:coverage -> npm run build ``` The ShellCheck step runs from the repository root (`working-directory: .`) with the runner image's own `shellcheck`; the Bash scripts are the only code no other step reads. **The dependency gate** is the step named `Dependency audit (production, blocking)`, first after `npm ci`, so a finding fails in seconds under its own name. It fails on any advisory of moderate severity or worse against a package `npm ci --omit=dev` would install, which is the tree the Docker image ships. Dev tools are excluded so that an advisory in a lint or test dependency does not turn every PR red; the next step, `Dependency audit (all, advisory only)`, runs a plain `npm audit` with `continue-on-error: true`, which leaves a red mark on that step and a green job. Before the gate, `npm audit` ran nowhere, and 19 production findings had accumulated by 2026-09-28. What it does and does not catch: - It turns red **without a code change** when an advisory is published against a package already in the lockfile. The fix is an upgrade (`npm audit fix`, or a major read against its changelog), never a lower `--audit-level`. - It needs the registry: an npm outage fails the step, which a re-run clears. - `release.yml` does not run it, so a tag cut from a commit that predates an advisory still publishes. The job carries a `mysql:8.4.11` **service container** (root password `ci-root-password`, published on 3306, health-checked with `mysqladmin ping`), and the `Integration tests (live MySQL)` step runs `npm run test:integration` against it with `IT_DB_HOST=127.0.0.1`. The password is not a secret: it guards an ephemeral container that lives only as long as the job. Both sit **inside** the existing job, so the check `main` already requires covers the live-MySQL project with no branch-protection change. The job is named `Lint, test and build`, and that name (not the job id `test`) is the check run context. Renaming the job renames the check, and would break the gate until branch protection is updated to match. **Whether that check is actually required on `main` is repository configuration, not repository content, and it is applied by hand.** Until someone runs the `PUT` on `/branches/main/protection`, `main` has no `required_status_checks` key at all and a red run is mergeable on one approval. Verify rather than assume: ``` gh api repos/Cloud-City-Computing/c2/branches/main/protection/required_status_checks ``` A 404 means no status check is required yet. `npm run build` is the newest step and the reason for the job's name: lint and the suite never exercise the production Vite build, so a bad import or a dependency missing from `vite.config.js`'s `manualChunks` used to pass CI and reach `main`. The build emits a "Circular chunk" warning about the vendor chunks and still exits 0, so that warning is not a gate failure. Coverage is uploaded as an artifact with 14-day retention, `if: always()`. That `if` makes the upload run even when an earlier step failed; it does not affect the job's own pass or fail conclusion. **There are no pre-commit hooks.** Running lint and tests locally is on you. `npm ci` means the lockfile must be committed and current. On **push** the filter is still `main` only, so work on a side branch does not burn CI until it becomes a pull request. On **pull_request** there is no branch filter: every PR runs, whatever its base. That matters for stacked PRs, whose base is another feature branch. Under the old `pull_request: branches: [main]` filter they reported no checks at all, and a required status check that never reports blocks a merge permanently rather than failing it. ## 6b. Releases `.github/workflows/release.yml`, triggered by pushing a `v*` tag. Two jobs: 1. **verify** re-runs `npm ci`, `npm run lint`, `npm test`, `npm run test:integration` (against the same `mysql:8.4.11` service CI uses) **and `npm run test:coverage`**. The integration step means a tag cannot publish an image whose `init.sql` does not build on MySQL 8.4, or whose post-baseline migrations do not upgrade a pre-runner schema to exactly what `init.sql` builds (section 5). A tag is not evidence the commit is green, because tags can point at any commit and `ci.yml` only runs on `main`. The coverage run is not optional padding: the 48 per-glob thresholds are CI's real gate, so omitting it would make the release path weaker than the thing it claims to be re-proving. 2. **publish** needs `verify`, then builds `./cloudcodex` with buildx and pushes `ghcr.io/cloud-city-computing/cloud-codex` at both the bare version and `:latest`, with `packages: write` and the GITHUB_TOKEN. Deliberate details, each of which is load-bearing: - The image is named for the **product**, not the repository. `c2` is a legacy codename and a string people type into `docker pull` is user-facing. The owner is spelled out in lowercase because ghcr.io rejects the mixed-case `Cloud-City-Computing` that `github.repository_owner` would give. - A guard step fails the build when the tag does not match `cloudcodex/package.json`'s version **or** the default pinned in `docker-compose-release.yml`. A published image that misreports its own version is worse than no image, and a stale compose default means the documented `docker compose up` silently runs an older release than the README describes. - **The tag name reaches that guard through `env:`, never through a `${{ }}` expansion inside `run:`.** A tag is attacker-choosable and `v1.0.0$(id)` is a valid, pushable ref, so interpolating it into the script body would be command execution in a job holding `packages: write` and a ghcr.io login. - The trigger is `v[0-9]+.[0-9]+.[0-9]+`, not `v*`, so a prerelease tag cannot start a publish. - **`:latest` is only moved when the tag is the highest version tag in the repo.** A hotfix on an older line (`v0.9.1` cut after `v1.0.0` shipped) is normal, and republishing `:latest` from it would downgrade everyone tracking that tag onto a database a newer release has already migrated. The job checks `git tag --sort=-v:refname` and, when it is not the newest, publishes the version tag alone and says so with a `::notice::`. **Known gap: `linux/amd64` only.** arm64 would mean cross-building `sharp` and `bcrypt` under QEMU, which is slow and fails in ways that only appear at runtime. Apple Silicon runs the amd64 image under Docker Desktop's emulation. **One-time setup the workflow cannot do for you: make the package public.** A container package first created by Actions is **private**, whatever the repository's visibility, and nothing in `release.yml` can change that. The publish job goes green and an anonymous `docker pull` still answers `unauthorized`, which is the worst possible failure here because the whole point of the image is that a stranger can run it. Confirmed after the 0.9.0 publish. Fix it once, in the web UI. Straight to the page: https://github.com/orgs/Cloud-City-Computing/packages/container/cloud-codex/settings **Danger Zone → Change visibility → Public.** While you are there, "Manage Actions access" → add the `c2` repository with Write, so future publishes keep working if the default token scope tightens. **This cannot be scripted.** There is no REST endpoint: `GET` on the package answers 403 asking for `read:packages`, while `PATCH .../packages/container/ cloud-codex -f visibility=public` answers **404**, because the route does not exist. No token scope changes that. It is a click, once per package, forever. Verify from a logged-out client rather than trusting the workflow: ``` docker logout ghcr.io docker pull ghcr.io/cloud-city-computing/cloud-codex:0.14.0 ``` `docker-compose-release.yml` consumes the published image instead of building, pinned to `${CLOUDCODEX_VERSION:-0.14.0}` so an evaluator's install does not move under them on the next publish. It also differs from `docker-compose-prod.yml` in not publishing 3306 at all: the app reaches MySQL over the compose network, and Docker's published ports are a DNAT rule that sits in front of the host firewall, so publishing it on every interface of a VPS exposes the database to the internet past a `ufw deny`. The prod file publishes it on 127.0.0.1 only, for a client on the host. **Both** compose files mount a named volume `app_public` at `/app/public`. Uploaded avatars and extracted document images live only there: `routes/helpers/images.js` writes the file and then replaces the base64 data URI in `html_content` with a `/doc-images/` URL, so after extraction the file on disk is the sole copy. Without the volume, `docker compose pull && up -d` recreates the container and destroys every one of them while the database keeps pointing at them. ## 7. Shippability checklist Before calling a change done: 1. `npm run lint` clean, no new warnings. 2. `npm test` green. A schema or migration change also needs `npm run test:integration` green against a live MySQL, and a new migration file needs its undo in `tests/integration/pre-runner-state.js`, or the upgrade-path test is red. That test runs on empty tables: a migration that rewrites existing rows needs its own seeded test. 3. New env vars in `.env.example` with a comment, and an entry in `cloudcodex/env-contract.js` (see `request-lifecycle.md` section 1). 4. New heavy frontend deps added to `manualChunks` in `vite.config.js`. 5. New SQL in **both** `migrations/` and `init.sql`. Never add the new file to `LEGACY_BASELINE` in `scripts/migrate.js`; that list is closed. 6. New admin-visible behaviour documented in the relevant `docs/*.md`, and any architectural change reflected in the matching `docs/maps/` file. 7. UI changes verified in a browser at desktop and mobile widths. 8. No `console.log`, no commented-out code, no `.only`/`.skip` in tests, no unlinked `TODO`/`FIXME`. --- ## Related - [data-model.md](data-model.md) for the `init.sql` and migration contract. - [request-lifecycle.md](request-lifecycle.md) for what `app.js` exposes to Supertest. - [open-questions.md](open-questions.md) for the ops issues above that read as defects.