# Changelog All notable changes to Cloud Codex are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Versions before 1.0.0 make no stability promise about the database schema. Schema changes ship as a file in [`migrations/`](migrations/), applied with `npm run migrate` from `cloudcodex/`, and `init.sql` runs only when MySQL initialises an empty data directory. ## [Unreleased] ### Changed - **The default From address names this instance's host (standalone review, PR 6).** A blank `SMTP_FROM` now sends as `Cloud Codex >` instead of a vendor's domain, so mail is not sent as a domain the instance does not own (SPF and DMARC would fail). **Upgrade note:** an install that sent mail from the old default must set `SMTP_FROM` to a sender on its own domain, or its mail now carries its own host. An `APP_URL` on an IPv6 address falls back to `localhost`, so set `SMTP_FROM` there too. One trailing dot on the host (`codex.example.test.`) is dropped, since a dot after the domain is not part of an address. ### Fixed - **The adoption line for a dropped column no longer says "already in the schema".** `--adopt-fresh-install` now prints a column that a later migration removes as absent, as a later file removes it, and never as present. The recorded adoption is unchanged. ### Documentation - **Stability.** [docs/stability.md](docs/stability.md) states what 1.x promises and lists the breaking changes since 0.12.0 with their upgrade notes. - **Keycloak is verified, Entra is not.** The Keycloak section of [docs/sign-in.md](docs/sign-in.md) is verified live on Keycloak 26.4.7, including the back-channel URL and the `APP_URL` reachability it needs. The Microsoft Entra ID section now says Entra sends no standard `email_verified` claim, so a sign-in from an Entra tenant is refused until that is decided. - **Provider-neutral wording.** The sample issuer and the comments that named one provider or a sibling product now say "the issuer" or "a sibling deployment". ## [0.15.0] - 2026-10-14 Codex becomes a standalone product: suite mode, the connection to a sibling product and the issuer-specific behaviour are removed, and sign-in works with any standard OIDC issuer. Upgrading from 0.14.0 runs one migration; read the upgrade notes below (and docs/deployment.md#upgrades) before you upgrade, especially if you ran Codex beside another product. ### Added - **Provider-neutral OIDC (CDX-SA-4).** Codex signs in through any standard OIDC issuer. Its sign-in, sign-out and back-channel paths are tested against a second issuer shape (identity claims in the ID token, no `sid`, no end-session endpoint). Setup for the redirect and logout URIs, the settings, OIDC-only mode and the provider notes is in [`docs/sign-in.md`](docs/sign-in.md). - **Removed variables are named at boot (CDX-SA-2).** A variable the server no longer reads prints one line at startup, naming it and what to do, and the server starts as it would without it. - **Migration `2026-10-17-remove-sibling-connection.sql` (CDX-SA-3)**, with its undo in the file header. The migration runner's `--adopt-fresh-install` learns drop claims, so a fresh 0.15.0 build is adopted and a schema that still has the removed columns is refused. ### Changed - **"Hosted mode" is "OIDC-only mode" (CDX-SA-4).** The name changes in logs, comments and docs. The behaviour does not: with `AUTH_PROVIDERS` set and no `local`, local sign-in, Google and the email or password change are refused as before. The boot log line reads `OIDC-only boot:`, and the purge-failure line reads `OIDC-only session purge failed`. An administrator's two-factor reset still works on an OIDC-only instance. - **OIDC claims merge (CDX-SA-4).** The profile fields (name) merge per field: userinfo overrides the ID token's validated claims where it returns them. `email` and `email_verified` are one unit and never merge. When userinfo returns an email, that email and its verification come from userinfo, and if the ID token carries the same address (case-insensitive), the ID token's verification stands unless userinfo says `email_verified: false`; for a different address, userinfo must say `email_verified: true`. Without a userinfo email, the ID token pair is used. A userinfo `sub` other than the ID token's is refused as `userinfo_subject` (OIDC Core 5.3.2). A token response with no access token is refused, as before, because openid-client requires one. - **Refusal wording (CDX-SA-4).** The `AUTH_PROVIDERS` refusals say "OIDC-only mode" where they said "hosted mode". Scripts that match the text should match `OIDC-only mode` instead. - **`GET /api/search` and `GET /api/browse` require a session (CDX-SA-3).** A bearer token must be a signed-in session; the former service token answers `401` like any unknown token. - **The admin webhook routes manage admin subscriptions only (CDX-SA-3).** An id that is not an admin subscription answers `404`, the list has no `managed` field, and redelivery is refused for any delivery whose subscription is not an admin one with `404`. Events are queued and sent for admin subscriptions only: a row of any other source is never sent. - **`--adopt-fresh-install` refuses while an env webhook subscription exists (CDX-SA-3).** The migration that deletes those rows would otherwise be recorded as applied without running. - **An unmatched `/api` path is a JSON 404 in every mode (CDX-SA-5).** An `/api` request that no route answers gets `404` with `{ "success": false, "message": "Not found" }`. Before, a production build answered an unknown GET with the app's page and `200`, so a removed route looked alive. A removed GET route now answers `404`. ### Removed - **The identity spec and plan (CDX-SA-4).** `docs/specs/2026-09-24-suite-identity.md` and its plan are retired; the identity track is recorded in the roadmap. Nothing in the running product changes. - **The sibling-product connection (CDX-SA-3).** Codex no longer has a machine credential or a connection to a sibling product. - `SERVICE_TOKEN`, `SERVICE_TOKEN_USER`, `MACHINE_OIDC_AUDIENCE` and `MACHINE_OIDC_SUBJECTS` are read for nothing. Unset them. A script signs in as a user and sends that session. - `WEBHOOK_URL`, `WEBHOOK_SECRET` and `WEBHOOK_WORKSPACE_ID` are read for nothing. Create the same subscription in Admin, Webhooks, give the receiver the new secret the console shows once, then unset them. The migration deletes the old environment subscription and its queued deliveries. - `GET /api/workspaces/:workspaceId/reader-check`, `GET /api/documents/state`, `PUT /api/machine/members`, `DELETE /api/machine/members` and `DELETE /api/machine/members/:email` answer `404`. - The membership sync's admin grant and revoke columns, `user_invitations.grants_admin` and `user_invitations.revoked_at`, are dropped by the migration. The migration expires two kinds of open invitation explicitly: the ones the sync revoked, and the ones it made for a workspace owner (`grants_admin` set). An owner who still needs access is invited again by an admin. Other open invitations the sync created stay open until they expire, and then admit a plain member, never an admin. Find them with `SELECT id, email FROM user_invitations WHERE invited_by = AND accepted = FALSE;` and revoke them in Admin, Invitations. - **Upgrade step: review Admin, Users after upgrading from a suite install.** Accounts the sync made instance admins keep that role; no column records which ones, so the migration cannot demote them. Demote any that should not be admin. - Rolling back to 0.14.0 runs the migration's undo first (the header of the migration file). The undo restores the two columns with their defaults; the values are not restored, and the deleted env subscription is not restored (a 0.14.0 started after the undo recreates it from `WEBHOOK_URL` and `WEBHOOK_SECRET`, if those are still set). - The upgrade steps are in [docs/deployment.md](docs/deployment.md#upgrades). - **The suite mode, the OIDC account link and the shared theme cookie (CDX-SA-2).** Codex no longer has a suite mode: the suite cluster in the sidebar, the Linked tasks link on a document, the hidden Workspaces page and the suite copy on the sign-in screen are gone. Workspaces is always in the sidebar, and `/workspaces` always renders its page. - `SUITE_COMMAND_WORKSPACE_URL`, `SUITE_NAME` and `SUITE_PREFERENCE_DOMAIN` are read for nothing. Unset them. - `GET /api/suite` and `GET /api/suite/links` answer 404. - **The issuer's account link (CDX-SA-2).** `OIDC_ACCOUNT_URL` is read for nothing. Unset it. An account that signs in through an OIDC issuer is editable in Codex: its name, email, password and two-factor are not read-only, and `GET /api/2fa/status` no longer answers `managed_by: 'issuer'` (it answers `'oidc'` for an OIDC session, `null` otherwise). The `managed_by_issuer` refusal is gone. Where local sign-in is on, such an account keeps its password and two-factor routes; to make the issuer the only sign-in door, set `AUTH_PROVIDERS=oidc`. **Upgrade note:** on an install that ran suite mode, accounts linked to an identity provider now accept a local password and local two-factor wherever local sign-in is on. A password reset sent by email reaches them without the provider's second factor. Set `AUTH_PROVIDERS=oidc` to keep sign-in provider-only. While `SUITE_COMMAND_WORKSPACE_URL` is still set, the boot logs how many accounts this affects. - **The shared theme cookie (CDX-SA-2).** The theme and the accent are per host. A choice is written host-only and no longer shared across a parent domain. On the next change, and on Match system, the cookie name is also expired on this host and on each parent domain, so an older shared choice, including one an install with the preference domain set to its own host wrote, cannot shadow the new one. ### Fixed - **Presence shows each user once (CDX-SA-5).** A user with two tabs open on a document appeared twice in the presence avatars and in the editor's collaborator list, and React logged a duplicate-key error. Each list now keeps one entry per user. - **The phone navigation strip no longer runs under the account avatar (CDX-SA-5).** At 390 px the last navigation item was drawn under the avatar. The strip now ends at its column and scrolls sideways as before. ## [0.14.0] - 2026-10-12 The Wave 6 release. Cloud Codex can run as one instance beside others under a supervisor and behind a TLS proxy, signs people in through Cloud City ID (OIDC), signs them out of the issuer too, and sends outbound webhooks. It also brings the suite shell, the light theme and the suite preference contract. Upgrading from 0.13.0 takes one step before the new image starts: run the migrations (see Migration below). ### Added - **Membership sync endpoints (W6-CDX-9).** `PUT /api/machine/members` (`{ email, role, reactivate? }`) and `DELETE /api/machine/members/:email`, machine-only, idempotent, rate-limited on their own 300-a-minute bucket (a `429` is retryable). An address with no account gets an invitation that names the seeded `General` squad with write access, and grants instance admin for `admin` (`user_invitations.grants_admin`, migration `2026-10-15-invitation-grants-admin.sql`). An account gets its `is_admin` set; a removal deactivates it, deletes its sessions, closes its sockets and keeps its authorship. The boot admin's row and the machine principal's own are refused (409). An address that matches two accounts is refused. A sync that sends the person's issuer and subject matches on the identity alone, and removal by subject is supported. An existing account is placed in `General` with write access, once. A removed member is re-admitted only with `reactivate: true`. A removed invitation stays removed (`user_invitations.revoked_at`, migration `2026-10-16-invitation-revoked.sql`). The app refuses to start while this release's columns are missing, and the sync answers 503 while the boot admin row does not exist. See [api/machine-members.md](docs/api/machine-members.md). - **Hosted mode: OIDC is the only human sign-in (W6-CDX-8).** `AUTH_PROVIDERS=oidc` (a list without `local`) refuses sign-up, password login, password reset, two-factor, the username check and the local email change on the server, with 404 (and 403 for an email or password change through the account update). Google sign-in is 404 when the list leaves it out. The boot admin is matched by `ADMIN_EMAIL` alone and created with no password; `ADMIN_USERNAME` and `ADMIN_PASSWORD` are optional there, and the seeded workspace is named from the admin's row. A boot that sets `AUTH_PROVIDERS` without `oidc`, or sets the Google variables, exits with a sentence naming the variable. - **Invitations bind on a verified email (W6-CDX-8).** An OIDC sign-in whose verified email matches an open invitation creates the account with that invitation's squad and flags, in one transaction. With no invitation it is `no_account`: the issuer's own account never admits anyone. - **Deactivated users (W6-CDX-8).** `users.deactivated_at` (migration `2026-10-12-user-deactivation.sql`) ends a user's sessions and refuses every sign-in path as `account_deactivated`, and password login as a 403, before any write. Nothing sets it yet: an operator sets it by hand. - **The signed-out deep link bounces to sign-in once on an OIDC-only instance (W6-CDX-8).** It goes to `/api/auth/oidc/start` with the link as `returnTo`, once per tab, and an error return renders the landing. `GET /api/oauth/providers` reports `local`. - **The suite front door: deep links survive sign-in (W6-CDX-26).** In suite mode a signed-out deep link goes once per tab to the sign-in, even when local login is also on. Without OIDC the Login modal opens, and a successful sign-in returns to the link. The sign-in modal leads with the suite's name (or `OIDC_PROVIDER_NAME` when set), with local login secondary. `/?signedOut=1` says "You're signed out of ." in suite mode, and a local sign-out lands there. Every target goes through the one validator the server uses. - **Suite mode: Linked tasks from a document.** In suite mode a document's meta line has a "Linked tasks" link to Cloud Command's page for that document. It is a same-tab link built from the Cloud Command workspace URL, and it is absent outside suite mode. `GET /api/suite/links` supplies the URL to a signed-in page. - **Suite mode: a suite cluster.** Setting `SUITE_COMMAND_WORKSPACE_URL` (with `SUITE_NAME`) turns it on. A signed-in user sees a disclosure at the head of the sidebar that lists Cloud Codex, Cloud Command, and All workspaces. `GET /api/suite` answers the name, and `GET /api/suite/links` (session required) answers the Cloud Command URL. A bad pair stops the boot, and so does a URL that is not exactly an origin plus `/w/`. With suite mode off the sidebar shows the product mark. The chrome sections are on the design tokens, and every `z-index` is on the shared scale. - **Machine JWTs from Cloud City ID (W6-CDX-7).** A client-credentials access token from the issuer now authenticates the machine routes (`GET /api/search`, `GET /api/browse`, `GET /api/documents/state`, and the reader check). It is verified with `jose` against the issuer's cached key set: the issuer and this instance's own project id as `aud`, asymmetric algorithms only, 60 seconds of skew, and the `sub` on the `MACHINE_OIDC_SUBJECTS` allowlist. It needs `OIDC_ISSUER_URL`, `MACHINE_OIDC_AUDIENCE` and `MACHINE_OIDC_SUBJECTS`; the static `SERVICE_TOKEN` is unchanged. An issuer whose keys cannot be read answers 503, never 401. - **The outbound webhook delivery worker.** An in-process worker, started by `server.js`, sends the queued events every two seconds: per subscription, in event order, one lease at a time, signed with `X-Codex-Signature-256`, with the send-time SSRF check and a 10-second timeout. A `2xx` delivers, `410` disables the subscription, `422` dead-letters that one event, and anything else retries on a backoff of 10 seconds doubling to one hour. An event older than 72 hours is dead-lettered. A daily prune removes finished deliveries and unreferenced events older than 30 days. Two admin endpoints list a subscription's last 50 deliveries and redeliver a dead one (`docs/api/admin.md`). An instance holds at most 50 webhook subscriptions. Subscriptions are served round-robin, so one with a backlog cannot starve the others. Outbox ids are allocated in commit order. A receiver that rejects the signature stalls its subscription until fixed or expired, and is never dropped silently. - **Sign-out that propagates (W6-CDX-6).** Signing out of Codex ends the issuer's session too when the account signed in through OIDC: `POST /api/logout` answers an `endSessionUrl` that carries the stored ID token as `id_token_hint`, and the browser goes there after the local sign-out. The ID token is kept sealed (AES-256-GCM, keyed from `OIDC_CLIENT_SECRET`), never in the clear. `POST /api/auth/oidc/backchannel-logout` receives the issuer's back-channel logout: it verifies the logout token with its keys (asymmetric algorithms only), ends the sessions it names by sid or by subject, and answers 400 for an invalid or replayed token and 503 when the keys are unavailable, so the issuer retries. Every delete is bounded by the token's `iat`, so a replay cannot end a session opened after the logout. Sockets of an ended session close with 4004. A sign-out never fails because the issuer is unreachable: it ends locally and says why in the log. Migration `2026-10-13-session-id-token.sql` adds `sessions.oidc_id_token`. - **OIDC sign-in through one issuer per instance (W6-CDX-5).** Set `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID` and `OIDC_CLIENT_SECRET` (with `APP_URL`) and the sign-in screen offers "Sign in with ". Identities are keyed on `(issuer, subject)` in the new `user_identities` table; an account is linked by a verified email only when it has no second factor, and OIDC never creates an account. Sessions record their identity and the issuer's sid. The reader check accepts an optional `subject`. See `docs/security.md` ("OIDC Sign-in") for what the issuer is trusted for. - **Webhooks in the admin console.** Admin, then Webhooks, lists each subscription as Delivering, Stalled or Off. A stalled one says how many attempts failed in a row, the receiver's last answer (a `401` means the signature was rejected) and when the next try is. It creates a subscription, whose secret is shown once with a copy button, turns one on or off, rotates a secret, deletes one, and lists its recent deliveries with Redeliver on a dead one. Every change asks first. The Cancel button of a confirmation now takes focus when it opens. The admin console stacks its sidebar above the content on a narrow screen. Its times are UTC whatever the server's zone. A failed action is toasted, not dropped, and a 404 reloads the list; create and rotate pause while a secret is on screen. - **The shared Cloud City design package, vendored, and a design gate.** `cloudcodex/vendor/cloud-city-design/` is a byte copy of the public Apache-2.0 [cloud-city-design](https://github.com/Cloud-City-Computing/cloud-city-design) package at commit `2d52baa` (0.2.0): its primitives (`core.css`), the Inter and Poppins faces under the SIL Open Font License, and its gates, pinned by a SHA-256 `MANIFEST.json`. It builds offline with the image, and the fonts are fingerprinted into `dist/assets/`, never `public/`. A new Vitest project, `design`, runs in `npm test` and `npm run test:coverage` (alone: `npm run test:design`): the vendored copy must match its manifest, literal colours, accent fill shades in text positions and suppressed focus outlines may not exceed an exemption ledger that starts at 745 and only shrinks (`node tests/design/report.mjs` prints it), every `var()` with no fallback must name a declared property, and Codex's new colour bindings must clear their contrast minimums. `src/codex.css` holds those bindings (`--cx-` names on ``); nothing uses them yet, so the interface does not change. The built stylesheet now keeps licence comments, so the package's notice heads it. No migration and no new setting. - **Backup and restore as one command each.** `make backup OUT=` (`scripts/backup.sh`) writes one archive holding a `mysqldump` of the database, migration ledger included, the uploads volume (avatars and document images) and a manifest with a checksum of each. It is readable by its owner only, never written over an existing file, and holds no password; it does hold everything the database does, so keep it as carefully. `make restore IN=` (`scripts/restore.sh`) restores into a stopped stack and starts it, after refusing an archive that is damaged or altered, is a backup of a differently named database (unless `--into` names this one), was taken on a newer release than this install runs (unless `--allow-newer-backup`), or would land on a database that holds data (unless `--replace`), a running app, or a database whose instance lock is held. A new stack's empty tables need no flag. Both run through Docker Compose (`COMPOSE_FILE`, default `docker-compose-release.yml`, and a stack created from another compose file is refused), or with `--local` through MySQL's `mysql` and `mysqldump` clients for an install outside Docker, and both refuse a MySQL user granted anything beyond the app's database, root included. See "Backups" in `docs/deployment.md`, which replaces the manual recipe that was there. No migration and no new setting. - **Several instances on one MySQL server.** `docs/deployment.md` has a recipe for it: per instance, one schema with an opaque name of letters and digits only, an app account holding `SELECT`, `INSERT`, `UPDATE` and `DELETE` on that schema (capped at 15 connections), and a migration account holding every privilege on that schema without `GRANT OPTION`, which builds the schema and runs `npm run migrate`. Neither holds anything global. A small compose override (`shared-mysql.yml`, in the recipe) points the app and the migration runner at the shared server instead of the bundled database. The integration suite runs the recipe as written: 54 statements reaching for the other instance, for the server, or for DDL the app does not need all fail with MySQL's privilege error, the app runs on the app account alone, and each instance holds its own single-writer lock. The grant hides another instance's rows, not its name: MySQL lists every schema's name and table names to every account (`information_schema.TABLESPACES_EXTENSIONS`), which the recipe states and the suite pins. Nothing changes for an install with a MySQL server of its own. - **Outbound webhooks, recorded.** Cloud Codex can now tell a receiver you run when a document is saved, published, restored, renamed, moved or deleted, or an archive is renamed or deleted: each such change writes one JSON event (`codex.event.v1`, documented in `docs/api/webhooks.md`) to an outbox, with one delivery per matching subscription. Titles and names are cut to 255 characters, so no event exceeds 4 KiB, and an event carries ids, that title or name, and the actor's user id and name, never an email or any content. **Nothing is sent yet**: the delivery worker ships in a later release. Off by default: with no subscription nothing is written and no query is added to any request or change (the subscription list is re-read once a minute, and at boot). One subscription can be declared in the environment (`WEBHOOK_URL`, `WEBHOOK_SECRET` of at least 32 characters, optional `WEBHOOK_WORKSPACE_ID`), reconciled at every boot, its secret never stored; instance admins manage more under `/api/admin/webhooks` (list, create, rotate the secret, enable or disable, delete), where a secret is shown once. Every receiver URL must be `https` in production and resolve only to public addresses: loopback and private ones need `WEBHOOK_ALLOW_PRIVATE_TARGETS=1`, and link-local and cloud-metadata ones are refused always. An admin subscription's secret is stored in the database in plaintext, so a database dump can sign events to that receiver; the env subscription avoids it. A failure to write the outbox never affects the change that caused it. Needs the webhooks migration (see Migration). ### Changed - **The theme switch and the suite preference (W6-CDX-44, A7).** An Appearance popover sits in every page header: Theme (System, Light, Dark) and the accents, the same as Cloud Command's; the Account page's Appearance section shows the same choices. The choice is stored in `cc_suite_theme` and the accent in `cc_suite_accent`, as a cookie shared across a suite's hosts (`SUITE_PREFERENCE_DOMAIN`, optional, honoured in suite mode only) and in localStorage, the suite preference contract that Cloud Command also implements. A stored Codex accent moves into the contract once. A pre-paint script in `index.html` applies the theme and accent before first paint; its SHA-256 is allowed in `script-src`. `GET /api/suite` answers `preferenceDomain`. - **Settings and admin (W6-CDX-42, A5).** In suite mode an account signed in through the issuer has its name, email, password and two-factor read-only on the Account page, "Managed by" the provider with a link to `OIDC_ACCOUNT_URL` (a new optional setting). The server refuses the same writes for that account with `403 managed_by_issuer`, so nothing changes. Density and the sidebar default are removed; a stored value is dropped on load. The admin console is a tab row in the page header with no emoji, member rows for users and invitations, and Revoke confirms first. Not found is a standalone card, and the first-run list is left-aligned with accent links. The Workspaces page takes the shared page header. - **The suite shell is sidebar-led (W6-CDX-40, A3).** There is no top bar, no copyright footer and no bottom tab bar. One sidebar holds the suite cluster (or the product mark outside suite mode), the workspace slot, a "Search or jump to" palette on Ctrl K (documents and archives), the navigation with an unread count on Notifications, an Archives group and the account footer (avatar, name menu, sign-out icon). On phones the sidebar is a two-row strip. Workspaces is hidden in suite mode. Pages that were rebuilt take a shared header with a title, a primary action and a tab row. - **Controls on the suite pattern (W6-CDX-39, A2).** Buttons are 30 px high on `--radius-md`, 13 px at weight 500, with no letter-spacing (the small size stays 26 px). The primary's hover steps to the lighter accent. One menu primitive (`Menu.jsx`, a `role="menu"` popup with arrow-key, Home, End and Escape handling) replaces the native selects of the Explore sort and the invite dialog's squad and role. The export and GitHub sync menus take its look. Tabs on Activity and Notifications sit 24 px apart, with the active tab in the primary text and a 2 px accent underline. Activity and notification placeholders are avatars on the suite tints. Badges and the role chip are at least 12 px, and the role chip is a pill. Modal panels use a strong hairline and `--radius-lg`. The menu's popup lives in its own node, so an account panel dismissed before it opens does not hide it; inside a dialog it stays in the dialog. Its items are radio items with the visible choice in the trigger's name; a letter jumps to the next match. Icon-only buttons are 30 px with a 44 px hit area, and the destructive confirmations name what is lost and what stays (the workspace delete no longer says its archives go: they stay, with no squad). - **The dark neutrals and the hover model follow the suite pattern (W6-CDX-38).** The dark surfaces, text tiers, accent, borders, scrim and shadows take the suite pattern's values, held to `tests/design/suite-theme.json`. The two hover fills retire: a hover is the panel surface on a sunken row, or a stronger hairline on a card. Core's radii apply. Buttons and native fields take the page's typeface, and native controls paint dark (`color-scheme`). The danger button is a bright fill with the dark ink, and its hover is an inset ring. Undeclared colour fallbacks are gone; the GitHub banner's `--accent` fallback now paints the picked accent, not a violet. - **Node 22.** The image (`node:22-slim`), CI and the release workflow run Node 22, and `package.json` declares `engines` `>=22`. openid-client 6 and jose 6 need it. - **The bare root goes to the sign-in where Cloud City ID is the only sign-in (W6-CDX-29).** A signed-out visit to `/` on a hosted instance (OIDC the only provider) redirects once per tab to `/api/auth/oidc/start`, and a signed-out visit after a sign-out, or after an error return, still shows the landing. An instance with local sign-in, or no OIDC, keeps its sign-in screen on `/`. Deep links are unchanged. - **The admin, settings and social surfaces read the tokens (W6-CDX-29).** The admin dashboard, settings layout, notification bell and pages, activity item, watch toggle, archive access, squad members, export dropdown and preferences banners in `src/index.css` take their colours from the Codex tokens, and the Account swatches are drawn from `src/tokens/accents.js`. Most values are unchanged (the accent, the surfaces and the status colours land on the same token), and the translucent washes keep their exact alpha. The visible differences: the owner role badge is an accent fill with a dark label, where it was a white label on the pale accent (which read below 4.5:1); the archive access badges take the Codex info, warning, violet and muted labels, and their amber and blue washes take the warning and accent tints; a label on a wash that sank below 4.5:1 steps one shade lighter (the accent-hover step, or a bright success, warning or text step). Captured under `docs/research/w6-cdx-29-admin-surfaces-2026-10-11/`. The Google "G" and the swatch check mark move to tokens with no visible change. - **The leftover modal, login, signup and presence surfaces read the tokens (W6-CDX-29).** The modal scrim, the modal and textarea focus rings, the close control, the login tab, the signup availability and password-rule hints, the Welcome subtitle and checkbox, and the presence avatars, dot and tooltip in `src/index.css` take their colours from the Codex tokens (20 findings). The visible differences: the success green of the signup hints and the rule checks is the Codex success step, a little paler than the old literal; the modal scrim is the Codex scrim, a blue-grey dark instead of pure black; the presence tooltip's shadow is the Codex popover shadow, a little tighter. Found and fixed in the same pass: PR 6 recorded the Login-modal bucket as burned to zero, yet the ledger still held Login Tabs (1) and Signup Validation Styles (3). - **Presence avatars label their initial for the cursor colour (W6-CDX-29 review).** The initial on a presence avatar was white on every colour, about 2:1 on the orange and the green. Each colour now takes white, the dark ink, or black, whichever clears 4.5:1 (`src/lib/avatar-label.js`, the palette in `services/cursor-colors.js`), and every colour is measured in `tests/design/presence-palette.test.js`. Two cursor colours (`#e91e63`, `#607d8b`) take black, since neither white nor the ink clears 4.5:1 on them. - **Other rewrites that are not same-value (W6-CDX-29 review).** The info badge's label is the fixed info step, so it no longer follows the picked accent. The presence tooltip paints the panel step and the presence avatar's ring the page surface, where both had painted an undeclared fallback. The Welcome checkbox's accent follows the picked accent. The notification bell's action label and three shell labels (the suite link on hover, the active sidebar link, the account button on hover) step one shade lighter on their wash, for contrast. The dialog scrim is the shared Codex scrim, so every dialog repaints, including the deferred GitHub and editor dialogs. Each is recorded in the research folder's review-round section. - **The content surfaces read the tokens.** The Log Tree, Explore, Page Tree, search, top-bar search, Error Boundary and 404 banners in `src/index.css` take their colours from the Codex tokens instead of literals, and the ledger goes from 631 findings to 510. Labels on a translucent tint are measured over every surface: where a tint would sink a label below 4.5:1, the label steps one shade lighter. Highlight.js colours that sat on the 4.5:1 line lift a step, measured on the code wash. Components whose colours change: GitHub sync banners and menu (the info edge now uses the info token, not the picked accent), the reconnect banner, the releases panel, failing code and issue embeds, the merge dialog, the PR discussion and doc link, the Error Boundary, the Log Tree, Page Tree and Explore rows and badges, CI badges and code blocks. - **Every dialog is a real dialog.** Each modal is a `role="dialog"` with `aria-modal="true"`, named from its heading. Focus moves into it on open, Tab is trapped inside it, Escape closes it where it can be dismissed, and focus returns to whatever opened it, even when that element has since unmounted. The page behind it is inert and does not scroll. The 22 close controls that were ``s are buttons named Close, reachable from the keyboard. The modal backdrop is a flat scrim token rather than a blur. The GitHub modals and the image crop, which were bare `div` overlays, are now dialogs too, and the first-run welcome is one; it is not dismissed by Escape, since Get started is its exit. A listener that used to be added on every open is now installed once. - **One focus ring, buttons without the lift, and a Toast that can be dismissed from the keyboard.** Every focus ring is the global `:focus-visible` outline on the shared focus token, and no rule removes an outline any more. The primary button lost its brightness filter, lift and glow, and the danger label reads a token, and a pressed step follows the disabled and hover states. The Toast's dismiss is a real button, reachable by Tab and operable with Enter or Space, at least 24 by 24 pixels, and the whole toast is no longer a click target. A toast holds while it is hovered or focused, and an error is announced as an alert. The Toast is the suite's shared toast primitive. - **The palette now resolves from the shared tokens, and the accent picker has eight hues.** The legacy colour names keep their names but point at Codex's bindings of the vendored package (a legacy-alias bridge in `index.css`), so the surfaces, text, status colours and focus ring read one source. Codex's own radii, shadows and layout sizes moved to `codex.css` at their old values. Fifty-seven CSS rules that no markup could match were deleted. Four accent hues moved off the status colours and were renamed (rose is now Nova, amber is now Solar, emerald is now Aurora, violet is now Nebula); blue, cyan, pink and lime keep their names. Nothing was removed: the picker still offers eight choices, and a saved rose, amber, emerald or violet loads as its successor. The default accent is now the package's blue step, a little lighter than before, and the surfaces take the package's blue-grey tint. Labels on the accent fill are now a dark ink (white on the pale fill failed 4.5:1), and the danger button is a deeper red so its white label passes. - The GitHub page's linked file path and its file picker, which asked for a `--font-mono` nothing declared and fell back to `'SF Mono', monospace`, now get the design package's monospace stack (`ui-monospace, SFMono-Regular, Menlo, Consolas, monospace`). On Linux both stacks render the same face (checked by rendering each). On macOS and Windows the new stack may pick Menlo or Consolas where the old one used the browser's generic monospace face; that was not checked. - **Running from source needs Node.js 20.9 or newer.** sharp 0.35 requires 20.9 and nodemailer 10 requires 20 (see Security). The published image is unchanged on this point: it is `node:20-slim`, which is Node 20.20. - **In the editor, Tab at the start of a paragraph that directly follows a list moves that paragraph into the list's last item.** This is Tiptap 3.30's list keymap, which the editor loads through StarterKit; inside a list item Tab still nests the item as before. - **Uploads (avatars, document images and imports):** a file of exactly the size limit (5 MB for an avatar, 10 MB otherwise) is now accepted rather than refused, a file sent under an unexpected form field is refused with `Unexpected file field` (was `Unexpected field`), and characters a browser escapes in a file name, such as a `"` sent as `%22`, are decoded, which reaches the title of an imported document. - **CI fails on any moderate or worse advisory in a production dependency** (`npm audit --omit=dev --audit-level=moderate`, the first step after `npm ci`), and reports the whole tree's audit without failing. Before this, `npm audit` ran nowhere. See `docs/maps/build-test-and-ops.md`, section 6. ### Fixed - **The suite shell's review round (W6-CDX-40).** The signed-out landing has a Sign in control; the search palette is a dialog in the stack, with Ctrl K off while a modal is open; the phone strip collapses the archives and names its account button; the unread count follows the server after a read, and a `read` broadcast happens only for a change; the Notifications list updates live; the sidebar keeps its last count and archives across a page change; the Sidebar Default control is gone; the squad, invitation and workspace-delete actions are on the Admin page in suite mode. - **The first toast after a page load is shown (W6-CDX-39).** A toast raised before the container mounted was dropped. It is now queued and drawn on mount. - **Admin error toasts keep the server's message (W6-CDX-39).** A failed invitation revoke, permission save or member save shows the server's message when the error is an API error (it has a status); a network failure shows the fallback copy. A 403 keeps its "Permission denied" prefix. - **The toast stack sits bottom right (W6-CDX-39)**, where no navigation can sit under it, and `.toast--info` has a rule. The message is the primary text colour; the status is on the icon and the edge. - **Outbound events: `occurred_at` follows the order of `sequence`.** `emitEvent` read the event's instant before it waited for the order lock, so two concurrent emits could commit with `occurred_at` running backwards against `sequence`. The instant is now read once the lock is held, inside the transaction, so for one instance a higher `sequence` never carries an earlier `occurred_at`. The API reference states both guarantees: `sequence` is the authoritative order, and `occurred_at` is non-decreasing in it while the host clock does not step back. - **Five rules that named a colour nothing defines now render, and a sixth that never applied is removed.** A `var()` that names an undeclared property makes its declaration invalid at computed-value time, so the property computes as `unset`: transparent for a background, inherited for a colour. The "Sign in with Google" button now has its border and background, the "or" divider above it its two lines, the Linked Accounts rows in Account settings their border and background, the active tab in Manage Archive Access its accent underline, and a draw.io diagram's delete button turns red on hover instead of transparent with a white edge. The avatar placeholder's background named such a property and was already transparent; the declaration is deleted, with no visible change. - **A failing comments request answers JSON.** `routes/comments.js` was the one router without the shared error handler, so a database error there answered Express's default HTML error page, and the error was not logged in the project's format. It now answers `500` with `{ "success": false, "message": "An internal server error occurred" }` like every other route. - Deleting a document through `DELETE /api/archives/:archiveId/logs/:logId` with an id that is not in that archive removes nothing, as before, and now also records nothing: it used to add a "deleted" entry to the archive's activity feed for a document that still exists. ### Security - **Production dependencies with published advisories are upgraded.** Each package below, old version to new, with the advisories it closes: - ws 8.20.0 to 8.22.0, the library under both WebSockets (`/collab` and `/notifications-ws`): [GHSA-96hv-2xvq-fx4p](https://github.com/advisories/GHSA-96hv-2xvq-fx4p), [GHSA-58qx-3vcg-4xpx](https://github.com/advisories/GHSA-58qx-3vcg-4xpx). - multer 2.1.1 to 2.4.0 (every upload route): [GHSA-535w-7cp7-47q4](https://github.com/advisories/GHSA-535w-7cp7-47q4), [GHSA-72gw-mp4g-v24j](https://github.com/advisories/GHSA-72gw-mp4g-v24j), [GHSA-wc9g-mqfw-jrwm](https://github.com/advisories/GHSA-wc9g-mqfw-jrwm), [GHSA-3p4h-7m6x-2hcm](https://github.com/advisories/GHSA-3p4h-7m6x-2hcm), [GHSA-qvfw-j98x-7q72](https://github.com/advisories/GHSA-qvfw-j98x-7q72). - mysql2 3.17.2 to 3.24.4: [GHSA-3f6p-5ww8-9rcr](https://github.com/advisories/GHSA-3f6p-5ww8-9rcr) (a server could downgrade the connection to `mysql_clear_password` and read the database password), [GHSA-rgwj-5xj2-c3m3](https://github.com/advisories/GHSA-rgwj-5xj2-c3m3). Password sign-in to MySQL 8.4's default `caching_sha2_password` is unchanged. - nodemailer 8.0.4 to 10.0.12 (two majors; no 8.x release fixes these): [GHSA-2x7j-588g-ccc2](https://github.com/advisories/GHSA-2x7j-588g-ccc2), [GHSA-p6gq-j5cr-w38f](https://github.com/advisories/GHSA-p6gq-j5cr-w38f), [GHSA-268h-hp4c-crq3](https://github.com/advisories/GHSA-268h-hp4c-crq3), [GHSA-6vj9-mwq6-2f5v](https://github.com/advisories/GHSA-6vj9-mwq6-2f5v), [GHSA-8m3c-c648-2xjj](https://github.com/advisories/GHSA-8m3c-c648-2xjj), [GHSA-cc9r-2j5m-2m83](https://github.com/advisories/GHSA-cc9r-2j5m-2m83), [GHSA-r7g4-qg5f-qqm2](https://github.com/advisories/GHSA-r7g4-qg5f-qqm2), [GHSA-vvjj-xcjg-gr5g](https://github.com/advisories/GHSA-vvjj-xcjg-gr5g), [GHSA-wmmp-3585-3rmp](https://github.com/advisories/GHSA-wmmp-3585-3rmp), [GHSA-wqvq-jvpq-h66f](https://github.com/advisories/GHSA-wqvq-jvpq-h66f). Codex's SMTP settings and message fields need no change. - sharp 0.34.5 to 0.35.5, with libvips 8.17.3 to 8.18.7 (a major; no 0.34 release fixes these): [GHSA-f88m-g3jw-g9cj](https://github.com/advisories/GHSA-f88m-g3jw-g9cj), [GHSA-rgj7-g3m4-5g8c](https://github.com/advisories/GHSA-rgj7-g3m4-5g8c). Avatars and document images are still webp at quality 85; PNG, JPEG, GIF and WebP inputs came out at the same dimensions and byte length on both versions. - dompurify 3.3.3 to 3.4.16, the sanitizer in the browser and, through isomorphic-dompurify, on the server. Most of these concern options Codex does not set (`IN_PLACE`, hooks, `CUSTOM_ELEMENT_HANDLING`, `SAFE_FOR_TEMPLATES`, Trusted Types): [GHSA-39q2-94rc-95cp](https://github.com/advisories/GHSA-39q2-94rc-95cp), [GHSA-55q2-fjhq-7xh7](https://github.com/advisories/GHSA-55q2-fjhq-7xh7), [GHSA-76mc-f452-cxcm](https://github.com/advisories/GHSA-76mc-f452-cxcm), [GHSA-cmwh-pvxp-8882](https://github.com/advisories/GHSA-cmwh-pvxp-8882), [GHSA-crv5-9vww-q3g8](https://github.com/advisories/GHSA-crv5-9vww-q3g8), [GHSA-h7mw-gpvr-xq4m](https://github.com/advisories/GHSA-h7mw-gpvr-xq4m), [GHSA-hpcv-96wg-7vj8](https://github.com/advisories/GHSA-hpcv-96wg-7vj8), [GHSA-r47g-fvhr-h676](https://github.com/advisories/GHSA-r47g-fvhr-h676), [GHSA-rp9w-3fw7-7cwq](https://github.com/advisories/GHSA-rp9w-3fw7-7cwq), [GHSA-v9jr-rg53-9pgp](https://github.com/advisories/GHSA-v9jr-rg53-9pgp), [GHSA-c2j3-45gr-mqc4](https://github.com/advisories/GHSA-c2j3-45gr-mqc4), [GHSA-gvmj-g25r-r7wr](https://github.com/advisories/GHSA-gvmj-g25r-r7wr), [GHSA-vxr8-fq34-vvx9](https://github.com/advisories/GHSA-vxr8-fq34-vvx9), [GHSA-x4vx-rjvf-j5p4](https://github.com/advisories/GHSA-x4vx-rjvf-j5p4). - react-router and react-router-dom 7.13.0 to 7.18.4. Most of these apply to framework mode, server rendering or React Server Components, which Codex does not use; GHSA-wrjc-x8rr-h8h6 (a backslash in a `` or `useNavigate` target) is the one that reaches declarative routing like Codex's: [GHSA-49rj-9fvp-4h2h](https://github.com/advisories/GHSA-49rj-9fvp-4h2h), [GHSA-8646-j5j9-6r62](https://github.com/advisories/GHSA-8646-j5j9-6r62), [GHSA-8x6r-g9mw-2r78](https://github.com/advisories/GHSA-8x6r-g9mw-2r78), [GHSA-chx6-hx7r-mcp5](https://github.com/advisories/GHSA-chx6-hx7r-mcp5), [GHSA-qwww-vcr4-c8h2](https://github.com/advisories/GHSA-qwww-vcr4-c8h2), [GHSA-rxv8-25v2-qmq8](https://github.com/advisories/GHSA-rxv8-25v2-qmq8), [GHSA-2j2x-hqr9-3h42](https://github.com/advisories/GHSA-2j2x-hqr9-3h42), [GHSA-337j-9hxr-rhxg](https://github.com/advisories/GHSA-337j-9hxr-rhxg), [GHSA-f22v-gfqf-p8f3](https://github.com/advisories/GHSA-f22v-gfqf-p8f3), [GHSA-h8fp-f39c-q6mh](https://github.com/advisories/GHSA-h8fp-f39c-q6mh), [GHSA-wrjc-x8rr-h8h6](https://github.com/advisories/GHSA-wrjc-x8rr-h8h6), [GHSA-84g9-w2xq-vcv6](https://github.com/advisories/GHSA-84g9-w2xq-vcv6). - express-rate-limit 8.3.1 to 8.7.0, and with it ip-address 10.1.0 to 10.7.2: [GHSA-mwp4-54f8-5fhr](https://github.com/advisories/GHSA-mwp4-54f8-5fhr), [GHSA-rpw4-54j3-4h4q](https://github.com/advisories/GHSA-rpw4-54j3-4h4q), [GHSA-v2v4-37r5-5v8g](https://github.com/advisories/GHSA-v2v4-37r5-5v8g). - @tiptap/core and @tiptap/pm 3.22.3 to 3.31.3, and every other @tiptap package to the same version so the editor's packages stay in step: [GHSA-j95f-988m-3j2f](https://github.com/advisories/GHSA-j95f-988m-3j2f), [GHSA-cp6q-959q-f8rh](https://github.com/advisories/GHSA-cp6q-959q-f8rh). - @xmldom/xmldom 0.8.12 to 0.8.15, which parses DOCX imports (through mammoth): [GHSA-27p8-2357-5qqv](https://github.com/advisories/GHSA-27p8-2357-5qqv), [GHSA-2v35-w6hq-6mfw](https://github.com/advisories/GHSA-2v35-w6hq-6mfw), [GHSA-4w3w-2rp5-g8jm](https://github.com/advisories/GHSA-4w3w-2rp5-g8jm), [GHSA-8344-3jmq-59r6](https://github.com/advisories/GHSA-8344-3jmq-59r6), [GHSA-93r5-fhx6-vmg9](https://github.com/advisories/GHSA-93r5-fhx6-vmg9), [GHSA-965w-775f-mr7g](https://github.com/advisories/GHSA-965w-775f-mr7g), [GHSA-c7q8-3ch8-vqpv](https://github.com/advisories/GHSA-c7q8-3ch8-vqpv), [GHSA-f6ww-3ggp-fr8h](https://github.com/advisories/GHSA-f6ww-3ggp-fr8h), [GHSA-j759-j44w-7fr8](https://github.com/advisories/GHSA-j759-j44w-7fr8), [GHSA-w2rr-34g9-rvrj](https://github.com/advisories/GHSA-w2rr-34g9-rvrj), [GHSA-x4fp-j954-r2f4](https://github.com/advisories/GHSA-x4fp-j954-r2f4), [GHSA-x6wf-f3px-wcqx](https://github.com/advisories/GHSA-x6wf-f3px-wcqx), [GHSA-6gmq-8vp8-gcm6](https://github.com/advisories/GHSA-6gmq-8vp8-gcm6), [GHSA-6h8r-xr42-gp59](https://github.com/advisories/GHSA-6h8r-xr42-gp59). - undici 7.25.0 to 7.30.0, under the server-side sanitizer's jsdom: [GHSA-4cwx-7wf7-3272](https://github.com/advisories/GHSA-4cwx-7wf7-3272), [GHSA-hm92-r4w5-c3mj](https://github.com/advisories/GHSA-hm92-r4w5-c3mj), [GHSA-vmh5-mc38-953g](https://github.com/advisories/GHSA-vmh5-mc38-953g), [GHSA-vxpw-j846-p89q](https://github.com/advisories/GHSA-vxpw-j846-p89q), [GHSA-8xcm-r25x-g524](https://github.com/advisories/GHSA-8xcm-r25x-g524), [GHSA-jr45-8vmc-qm54](https://github.com/advisories/GHSA-jr45-8vmc-qm54), [GHSA-m8rv-5g2x-5cg5](https://github.com/advisories/GHSA-m8rv-5g2x-5cg5), [GHSA-p88m-4jfj-68fv](https://github.com/advisories/GHSA-p88m-4jfj-68fv), [GHSA-pr7r-676h-xcf6](https://github.com/advisories/GHSA-pr7r-676h-xcf6), [GHSA-v3r7-h72x-cjcm](https://github.com/advisories/GHSA-v3r7-h72x-cjcm), [GHSA-35p6-xmwp-9g52](https://github.com/advisories/GHSA-35p6-xmwp-9g52), [GHSA-g8m3-5g58-fq7m](https://github.com/advisories/GHSA-g8m3-5g58-fq7m). - Express's qs 6.15.0 to 6.16.0 and body-parser 2.2.2 to 2.3.0: [GHSA-4mjr-xmp4-gh2g](https://github.com/advisories/GHSA-4mjr-xmp4-gh2g), [GHSA-q8mj-m7cp-5q26](https://github.com/advisories/GHSA-q8mj-m7cp-5q26), [GHSA-x5fp-wj9c-mxmx](https://github.com/advisories/GHSA-x5fp-wj9c-mxmx), [GHSA-v422-hmwv-36x6](https://github.com/advisories/GHSA-v422-hmwv-36x6). - markdown-it and linkify-it leave the tree, because @tiptap/pm 3.31 no longer depends on prosemirror-markdown: [GHSA-6v5v-wf23-fmfq](https://github.com/advisories/GHSA-6v5v-wf23-fmfq), [GHSA-22p9-wv53-3rq4](https://github.com/advisories/GHSA-22p9-wv53-3rq4), [GHSA-v245-v573-v5vm](https://github.com/advisories/GHSA-v245-v573-v5vm). - **Build and test tooling with published advisories is upgraded too:** vite 7.3.1 to 7.3.6, with esbuild 0.27.3 to 0.28.2, and postcss 8.5.6 to 8.5.28 with its nanoid 3.3.11 to 3.3.19; vitest and @vitest/coverage-v8 4.1.5 to 4.1.11; js-yaml 4.1.1 to 4.3.2; @babel/core 7.29.0 to 7.29.7; @humanfs/node 0.16.7 to 0.16.8; baseline-browser-mapping 2.9.19 to 2.11.26; brace-expansion 1.1.13 to 1.1.21; browserslist 4.28.1 to 4.29.2; form-data 4.0.5 to 4.0.6. They close [GHSA-fx2h-pf6j-xcff](https://github.com/advisories/GHSA-fx2h-pf6j-xcff), [GHSA-p9ff-h696-f583](https://github.com/advisories/GHSA-p9ff-h696-f583), [GHSA-v2wj-q39q-566r](https://github.com/advisories/GHSA-v2wj-q39q-566r), [GHSA-4w7w-66w2-5vf9](https://github.com/advisories/GHSA-4w7w-66w2-5vf9), [GHSA-v6wh-96g9-6wx3](https://github.com/advisories/GHSA-v6wh-96g9-6wx3), [GHSA-g7r4-m6w7-qqqr](https://github.com/advisories/GHSA-g7r4-m6w7-qqqr), [GHSA-6g55-p6wh-862q](https://github.com/advisories/GHSA-6g55-p6wh-862q), [GHSA-r28c-9q8g-f849](https://github.com/advisories/GHSA-r28c-9q8g-f849), [GHSA-fxqj-rqcc-2cmp](https://github.com/advisories/GHSA-fxqj-rqcc-2cmp), [GHSA-qx2v-qp2m-jg93](https://github.com/advisories/GHSA-qx2v-qp2m-jg93), [GHSA-28wg-ghj8-5hjv](https://github.com/advisories/GHSA-28wg-ghj8-5hjv), [GHSA-2v37-7h3g-55p8](https://github.com/advisories/GHSA-2v37-7h3g-55p8), [GHSA-xwg4-73v4-xw9w](https://github.com/advisories/GHSA-xwg4-73v4-xw9w), [GHSA-82fw-gwwq-j7x9](https://github.com/advisories/GHSA-82fw-gwwq-j7x9), [GHSA-2883-xcg3-v3hh](https://github.com/advisories/GHSA-2883-xcg3-v3hh), [GHSA-52cp-r559-cp3m](https://github.com/advisories/GHSA-52cp-r559-cp3m), [GHSA-5p4m-2wfm-xmqj](https://github.com/advisories/GHSA-5p4m-2wfm-xmqj), [GHSA-h67p-54hq-rp68](https://github.com/advisories/GHSA-h67p-54hq-rp68), [GHSA-4x5r-pxfx-6jf8](https://github.com/advisories/GHSA-4x5r-pxfx-6jf8), [GHSA-p498-v437-472g](https://github.com/advisories/GHSA-p498-v437-472g), [GHSA-w5vr-8v7q-w6rv](https://github.com/advisories/GHSA-w5vr-8v7q-w6rv), [GHSA-3jxr-9vmj-r5cp](https://github.com/advisories/GHSA-3jxr-9vmj-r5cp), [GHSA-mh99-v99m-4gvg](https://github.com/advisories/GHSA-mh99-v99m-4gvg), [GHSA-rgw5-rvv9-x895](https://github.com/advisories/GHSA-rgw5-rvv9-x895), [GHSA-73wf-gq98-2v4g](https://github.com/advisories/GHSA-73wf-gq98-2v4g), [GHSA-c83g-rgw3-j3cx](https://github.com/advisories/GHSA-c83g-rgw3-j3cx), [GHSA-hmw2-7cc7-3qxx](https://github.com/advisories/GHSA-hmw2-7cc7-3qxx). ### Migration Upgrading from 0.13.0 takes one step before the new image starts: `npm run migrate` (in containers, `docker compose -f docker-compose-release.yml run --rm app npm run migrate`). Nine migrations are pending, all additive; none drops data. The runner applies them in this order. Undo them in reverse order, with the statement given for each (each file's header says the same). 1. **[`2026-09-28-webhooks.sql`](migrations/2026-09-28-webhooks.sql)** adds `webhook_subscriptions`, `webhook_events` and `webhook_deliveries`. Until it is applied the new image logs `webhook env subscription reconcile failed` at boot and `webhook subscriptions load failed` once a minute, and otherwise emits nothing. Undo: `DROP TABLE webhook_deliveries, webhook_events, webhook_subscriptions;` 2. **[`2026-10-10-webhook-sequence-lock.sql`](migrations/2026-10-10-webhook-sequence-lock.sql)** adds the one-row `webhook_sequence_lock` that orders outbound events; the first emit creates the row. Undo: `DROP TABLE webhook_sequence_lock;` 3. **[`2026-10-11-user-identities.sql`](migrations/2026-10-11-user-identities.sql)** adds the `user_identities` table, `sessions.identity_id` and `sessions.provider_sid`, and accepts `auth_provider = 'oidc'`. Existing sessions keep `local` or `google`. Undo, on an install with no OIDC sessions: `ALTER TABLE sessions DROP FOREIGN KEY fk_sessions_identity, DROP INDEX idx_sessions_provider_sid, DROP COLUMN provider_sid, DROP COLUMN identity_id, DROP CHECK chk_sessions_identity_pairing, DROP CHECK chk_sessions_auth_provider, ADD CONSTRAINT chk_sessions_auth_provider CHECK (auth_provider IN ('local', 'google'));` then `DROP TABLE user_identities;` 4. **[`2026-10-12-identity-last-signin-email.sql`](migrations/2026-10-12-identity-last-signin-email.sql)** adds `user_identities.email_at_last_signin`, which records the issuer's email and authorizes nothing. Undo: `ALTER TABLE user_identities DROP COLUMN email_at_last_signin;` 5. **[`2026-10-12-user-deactivation.sql`](migrations/2026-10-12-user-deactivation.sql)** adds `users.deactivated_at`; NULL means active. Undo: `ALTER TABLE users DROP COLUMN deactivated_at;` 6. **[`2026-10-13-session-id-token.sql`](migrations/2026-10-13-session-id-token.sql)** adds `sessions.oidc_id_token`, the sealed ID token a sign-out sends back to the issuer. Undo: `ALTER TABLE sessions DROP CHECK chk_sessions_id_token_oidc_only, DROP COLUMN oidc_id_token;` 7. **[`2026-10-14-session-rotation.sql`](migrations/2026-10-14-session-rotation.sql)** adds `sessions.signed_in_at` (backfilled from `created_at`) and `sessions.rotated_from`. Undo: `ALTER TABLE sessions DROP INDEX idx_sessions_rotated_from, DROP COLUMN rotated_from, DROP COLUMN signed_in_at;` 8. **[`2026-10-15-invitation-grants-admin.sql`](migrations/2026-10-15-invitation-grants-admin.sql)** adds `user_invitations.grants_admin`, default false, so every existing invitation admits a plain member. Undo: `ALTER TABLE user_invitations DROP COLUMN grants_admin;` 9. **[`2026-10-16-invitation-revoked.sql`](migrations/2026-10-16-invitation-revoked.sql)** adds `user_invitations.revoked_at`; NULL means not revoked. Undo: `ALTER TABLE user_invitations DROP COLUMN revoked_at;` The app refuses to start while `user_invitations.grants_admin`, `user_invitations.revoked_at`, `users.deactivated_at` or `user_identities.email_at_last_signin` is missing (`cloudcodex/services/release-schema.js`). The other migrations are not checked at boot, so run `npm run migrate` before starting the new image, never after. On an install that `init.sql` builds fresh, `npm run migrate -- --adopt-fresh-install` checks each file's objects against the schema before it records the file. ### Breaking changes and upgrade notes - **Hosted mode is opt-in.** `AUTH_PROVIDERS` without `local` refuses every local sign-in, sign-up, password reset and Google sign-in on the server, and the boot admin is then matched by `ADMIN_EMAIL` alone. A boot that sets `AUTH_PROVIDERS` without `oidc`, or sets the Google variables, exits with a sentence naming the variable. - **Cookies.** On https the session cookie is `__Host-sessionToken` (see 0.13.0; `LEGACY_SESSION_COOKIE`, default 1, keeps the older name working). A write authenticated by the session cookie alone must carry an accepted `Origin`. The theme and accent preferences are `cc_suite_theme` and `cc_suite_accent`, and `SUITE_PREFERENCE_DOMAIN` shares them across a suite's hosts, in suite mode only. - **Sidebar and layout.** The top bar, the copyright footer and the bottom tab bar are gone. The density control and the sidebar default are removed, and a stored value is dropped on load. - **Suite-managed accounts.** In suite mode an account signed in through the issuer has its name, email, password and two-factor managed by the issuer. The server refuses those writes for that account with `403 managed_by_issuer`. - **Sign-out** of an OIDC session returns an `endSessionUrl` for the issuer's logout. ### Environment variables added `OIDC_ISSUER_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_PROVIDER_NAME`, `OIDC_LINK_BY_VERIFIED_EMAIL`, `OIDC_SESSION_TTL_HOURS`, `OIDC_ACCOUNT_URL`, `AUTH_PROVIDERS`, `MACHINE_OIDC_AUDIENCE`, `MACHINE_OIDC_SUBJECTS`, `SUITE_COMMAND_WORKSPACE_URL`, `SUITE_NAME`, `SUITE_PREFERENCE_DOMAIN`, `WEBHOOK_URL`, `WEBHOOK_SECRET`, `WEBHOOK_WORKSPACE_ID`, `WEBHOOK_ALLOW_PRIVATE_TARGETS`. `cloudcodex/env-contract.js` lists each one with its behaviour; `.env.example` has them. ## [0.13.0] - 2026-09-28 The cookie-hardening release. One security fix: on an https instance, the cookie that ties a Google sign-in or a GitHub link to the browser that started it is now a `__Host-` cookie, read only under that exact name ([GHSA-xq3x-556x-fr4q](https://github.com/Cloud-City-Computing/c2/security/advisories/GHSA-xq3x-556x-fr4q)). On https the session cookie becomes `__Host-sessionToken` under the same rule, and a write authenticated by the session cookie alone must now carry an accepted `Origin`. Alongside them: a first boot on an empty volume that no longer restarts the app, a clean boot log and browser console, and self-hosting documentation that matches the code. **Upgrading from 0.12.0 applies no migration and needs nothing run, and signed-in browsers stay signed in. A script that posts to `/api` with only a session cookie now needs an `Origin` header or the bearer header; see Migration below.** ### Added - `LEGACY_SESSION_COOKIE`: whether a lone `sessionToken` cookie, the name the session cookie had before it became `__Host-sessionToken` on https (see Security), still signs its holder in. Unset keeps it on; exactly `0` turns it off, for an https instance that shares its domain with hosts you do not control. ### Fixed - **Signing in over plain `http` to an address other than `localhost` keeps its session.** The page wrote the session cookie with `Secure` whatever the scheme, and a browser drops a `Secure` cookie set over plain `http`, so the sign-in reloaded signed out. The page now marks it `Secure` on https only, and the Google callback decides `Secure` from `APP_URL`'s scheme (as its state cookie already did) rather than from `NODE_ENV`. - **A first boot on an empty volume no longer restarts the app** (listed as a known gap in 0.12.0's release notes). The MySQL healthcheck in both production compose files pinged over the socket, which the image's temporary initialisation server (networking off, while it runs `init.sql`) answers, so the database reported healthy while nothing listened on 3306. The app then exited with `Could not open a MySQL connection for the instance lock: connect ECONNREFUSED` until `restart: unless-stopped` brought it back (the 0.11.0 image, which has no instance lock, started instead without its admin: `admin user sync failed: ... ECONNREFUSED`). The check now pings `127.0.0.1` over TCP, which only the real server answers, with a 300-second start period so a slow initialisation is not marked unhealthy, and `start.sh` waits the same way (for up to three minutes instead of one). Measured on fresh volumes: `docker-compose-prod.yml` restarted the app 4 times in each of two boots before and 0 after, and `docker-compose-release.yml` built locally 6 before and 0 after. A test pins the check in both files and in `start.sh`. - **The app's inline SVG icons declare the real SVG namespace.** The rename from organizations to workspaces had reached inside a URL, so the sidebar's icons and the search and explore boxes' declared `www.w3.workspace` where `www.w3.org` belongs. React drew them anyway, which is why it went unnoticed, but the markup was not valid SVG. A test now fails on any XML namespace outside a short list of W3C ones, and on any URL whose host ends in a product word (workspace, squad, archive, log). - **The container's log no longer opens with dotenv's banner.** `mysql_connect.js` and `services/email.js` each call `dotenv.config()`, and dotenv 17 printed `injecting env (0) from ../.env` and an advert for each call on every boot, because the image has no `.env`. Both calls are quiet now and still read the same file. - **No more refused GitHub requests in the browser console.** A signed-out visitor's landing page asked `GET /api/github/status` and got a 401, and every document view by a user with no GitHub account linked asked `GET /api/github/link/:id` and got a 403. The layout now asks for the GitHub status only once its sign-in check has found a user, and the editor asks for a document's link only once that status says an account is linked. The server refuses both exactly as before. The standalone editor route (`/editor/:id`) now renders inside the layout the way the archive view's embedded editor already did, so it also waits for the sign-in check. - **The self-hosting documentation matches the code.** `docs/deployment.md`, `README.md` and `docs/architecture.md` now all say SMTP is optional and what running without it turns off (emailed invitations, which become a link to copy, password reset, email two-factor codes and notification emails). Both first-install snippets in `docs/deployment.md` now include the one-time `npm run migrate -- --adopt-fresh-install`. Its "Stop every writer first" section appeared twice and is now one. The environment table in `docs/getting-started.md` lists every variable in `env-contract.js` it lacked (`PORT`, `C2_INSTANCE_LOCK`, `DOC_IMAGES_PUBLIC`, `SERVICE_TOKEN`, `SERVICE_TOKEN_USER`), marks `ADMIN_USERNAME` required, and describes `NODE_ENV`'s effect on CORS as `app.js` implements it, and a test now fails when the table misses an entry or contradicts its kind. - **README's release quick start reaches a ready instance.** It ran compose in the foreground and stopped there, so a reader who followed it alone never ran the one-time `npm run migrate -- --adopt-fresh-install`: `/readyz` stayed 503 `migrations` and the container unhealthy. It now matches the 0.12.0 release notes' "Run it" block (`up -d`, then the adopt step, with `APP_URL` named among what to fill in), and both first-install blocks in `docs/deployment.md` name `APP_URL` too. A test fails when any documented first install on a production compose file leaves out one of the three. - **Running without SMTP no longer reads as an error in the boot log.** The documented mail-off mode printed `✖ Email disabled: SMTP_HOST, SMTP_USER or SMTP_PASS not set`, the same glyph as a boot that cannot start. It now prints a report line styled like the trusted-proxy one, `✔ Email off (...)`, naming what is unavailable. A configured SMTP server that fails verification still prints `✖ Email disabled: SMTP connection failed`, since that is a fault. `initMail()` now also returns `configured`, which is how the two are told apart. - **The rate-limit tables list every limiter.** `docs/deployment.md` and `docs/security.md` left out the one on `GET /api/documents/state`, 120 requests per 15 minutes counted before authentication, and `security.md`'s "Search" row now says it is the user search it limits. - **The configuration contract describes `NODE_ENV` correctly.** Its entry in `cloudcodex/env-contract.js` said production "arms the rate limiters", but they are on in every mode except `test`. It now lists what production actually changes: the built app, the security headers on every response, `APP_URL` required, and no localhost origins. ### Security - **On https the OAuth state cookies are `__Host-oauth_state_`, read only under that exact name** ([GHSA-xq3x-556x-fr4q](https://github.com/Cloud-City-Computing/c2/security/advisories/GHSA-xq3x-556x-fr4q)). The cookie that ties a Google sign-in or a GitHub link to the browser that started it is now `__Host-oauth_state_google` or `__Host-oauth_state_github` when `APP_URL` is https: Secure, `Path=/` and no `Domain`, so no other host under the same domain can set it, and the callback reads the state under that name only. The protection needs https. A browser cannot hold a `__Host-` cookie over plain http, so there the names stay `oauth_state_google` and `oauth_state_github` at `Path=/api/oauth`, and an instance served that way stays exposed to scripts on hosts under its domain. - **On https the session cookie is `__Host-sessionToken`, and a write authenticated by that cookie alone must carry an accepted `Origin`.** Any host under the same registrable domain could set a `sessionToken` cookie for the whole domain with a longer path, which a browser sends first, and the server and the page both took the first `sessionToken` they found, so a sibling host could sign a visitor in as someone else. A browser refuses a `__Host-` cookie with a `Domain`, so no other host can set this one; it wins over a legacy `sessionToken` wherever the two sit, on the server and in the page, and no writer sets `Domain`. Every cookie reader matches the name exactly, stripping only the ASCII space and tab between cookies, so a cookie whose name merely looks like `__Host-sessionToken` is never read as it. What an operator sees: sessions under the old `sessionToken` name keep working while `LEGACY_SESSION_COOKIE` is on (the default, `1`), and each is upgraded to `__Host-sessionToken` on its browser's first visit over https, with no new sign-in. (The page asks the server to confirm the old cookie before moving it, waits at most five seconds for the answer, and asks once per tab.) Over plain http the cookie keeps the old name, the only one a browser can hold there. Separately, an `/api` `POST`, `PUT`, `PATCH` or `DELETE` that carries the session cookie and no bearer header is refused with `403` unless its `Origin` is one the CORS rule accepts; CORS admitted a request with no `Origin` at all, and `SameSite=Strict` treats sibling hosts as the same site. The app's own requests, which send a bearer header or an `Origin`, and every server-to-server caller are unaffected. Both WebSockets already refused an upgrade with no `Origin` or a sibling's; tests now pin it. ### Migration **The cookie renames need nothing run.** Browsers that hold the older `sessionToken` stay signed in and move to `__Host-sessionToken` on their next visit over https. An https instance that sets `LEGACY_SESSION_COOKIE=0` signs those browsers out instead, once. A script that posts to `/api` with only a session cookie now needs an `Origin` header or the bearer header. On an https instance, a Google sign-in or GitHub link that was in progress while you upgraded fails once with `invalid_state` and works when started again. ## [0.12.0] - 2026-09-28 The hosting-readiness release. One security fix: anyone who could reach the app's port directly could choose their own address in `X-Forwarded-For` and step around every rate limiter, including the sign-in and two-factor limit ([GHSA-9fmx-frrf-xxmq](https://github.com/Cloud-City-Computing/c2/security/advisories/GHSA-9fmx-frrf-xxmq)). `TRUST_PROXY` now names the proxies it believes by address, the app port is published on `127.0.0.1`, and the production compose files pin their network. Alongside it: session tokens stored only as a digest, with one session per sign-in; document images served only to people who can read the document; `/healthz` and `/readyz`; a container that stops cleanly; one process per database; a reconciliation read for Cloud Command; and the configuration contract, with `APP_URL` required in production. **Upgrading from 0.11.0 is a breaking change for an install reached directly from another machine, or behind a proxy that does not connect from `127.0.0.1`, `::1` or `172.29.0.1`; it takes the project `down` before migrating, applies two migrations and runs one backfill. See below and Migration.** **Upgrading: a production instance now refuses to start without `APP_URL`.** Check that `.env` sets it to the address people use before pulling. `.env.example` ships `http://localhost:3000`, which boots but now prints a warning in production, since emailed links would open only on the server itself. MySQL is now pinned to `mysql:8.4.11`: an install whose cached `mysql:8` is older pulls it and upgrades its data directory in place on first start, so back the database up first. This release also has two migrations and a backfill; see Migration below. **Upgrading, BREAKING: the app port is published on `127.0.0.1`, and `X-Forwarded-For` is believed only from a proxy named by address.** An install reached directly on port 3000 from another machine stops answering there: put it behind a TLS-terminating proxy, or set `APP_BIND=0.0.0.0` in `.env` to expose it on purpose (an IPv4 address, never `::`), with `TRUST_PROXY=false` when nothing is in front of it. `docker-compose-prod.yml` publishes MySQL on `127.0.0.1` too, so a database client on another machine needs `DB_BIND` or an SSH tunnel. `TRUST_PROXY` now defaults to `127.0.0.1/32, ::1/128, 172.29.0.1/32`, which covers nginx or Caddy on the same host in front of either compose file and nothing else (an install run without Docker sets `127.0.0.1/32, ::1/128`): **a proxy running as another container, a load balancer, or the image run with `docker run` behind a proxy is no longer believed until you list its address** (worked values in "Rate limiters", `docs/deployment.md`); until then every client behind it shares one rate-limit bucket, and the server logs a warning naming it. For a single nginx, set `proxy_set_header X-Forwarded-For $remote_addr;`. The production compose files now pin their network to `172.29.0.0/16`, so **this upgrade takes the project down before migrating**: ```bash docker compose -f docker-compose-release.yml pull app docker compose -f docker-compose-release.yml down # never down -v docker compose -f docker-compose-release.yml run --rm app npm run migrate docker compose -f docker-compose-release.yml run --rm app npm run backfill:doc-images docker compose -f docker-compose-release.yml up -d ``` The usual stop-the-app-then-migrate order makes Compose replace the network under a running database and reconnect it without its `database` alias, so the migration and then the app cannot find it (see "Upgrades", `docs/deployment.md`). The backfill runs once, on an install that already has documents, between the migrations and the start (see Migration below). ### Added - `GET /api/documents/state?workspaceId=&ids=`, a reconciliation read for Cloud Command: for up to 100 document ids it returns the id, title, archive and last update of each one the caller can read in that workspace. An id that is deleted, unreadable, in another workspace or in a system archive is simply absent, and the answer does not say which. It is the third route the service token (`SERVICE_TOKEN`) reaches, beside `GET /api/search` and `GET /api/browse`, and like them it acts with the service user's ordinary, never-admin access. Rate-limited to 120 requests per 15 minutes. No migration and no new setting. - **`GET /healthz` and `GET /readyz`.** `/healthz` answers `{"ok":true}` while the process serves HTTP and touches nothing. `/readyz` answers `{"ready":true}`, or 503 with one reason: `shutting_down`, `lock`, `database` (`SELECT 1` failed or took over two seconds) or `migrations` (a file in `migrations/` is not applied, or the database was never adopted). Neither carries a version, a count or a name. However many probes arrive at once, `/readyz` has at most one `SELECT 1` and one migrations read out. The image has a Docker `HEALTHCHECK` on `/readyz`. See "Health checks" in `docs/deployment.md`. - **One process per database.** At boot the app takes a MySQL lock named for its schema and holds it until it stops, so a second process pointed at the same database refuses to start and names the connection that holds it, instead of both keeping their own diverging copy of every open document. `C2_INSTANCE_LOCK=0` turns it off. Instances on different schemas of one server, and `npm run migrate`, never contend with it. If MySQL restarts, the app takes the lock back within a second; if another process got it first, the app that lost it stops and exits 1 rather than keep serving beside it. - `cloudcodex/env-contract.js`, the configuration contract: every environment variable the server reads, whether it is required, required in production, defaulted (and to what) or optional, whether linking an instance to its workspace supplies it, and why. It is data only, so a paired product can pin a copy. A test parses the whole server and fails on a variable read without an entry, an entry nothing reads, or one missing from `.env.example`, and every stated default is checked against what the code does when the variable is unset or blank. - `TRUST_PROXY`, Express's `trust proxy` setting, which decides the address the rate limiters count: a list of addresses and CIDRs (subnet names such as `loopback` are accepted too), or `false`. Unset is the address list described under Security, and a value Express cannot parse stops the boot with a sentence naming the variable. - `DB_POOL_SIZE`, the MySQL pool's connection limit. Unset keeps today's 10; anything but a whole number from 1 to 100 stops the boot. ### Changed - `POST /api/doc-images/upload` needs a `logId` form field naming the document the images go into, and write access to it: without one it answers `400`, without access `403`, and nothing is processed either way. The editor sends it. `DOC_IMAGES_PUBLIC` is new in `.env.example` (see Security). - **The container stops cleanly.** The image runs `node server.js` instead of `npm run start`, so `docker stop` reaches the app. On SIGTERM or SIGINT it reports `shutting_down`, writes every open document's not-yet-saved live edits to the database (previously the last three seconds were lost), closes both WebSockets with code 1001, releases the lock and the pool, and exits 0, within ten seconds or exits 1. It says `stopped cleanly` only when every pending document was written. A stop signal during startup, or a second Ctrl-C, ends it at once. The production compose files give it `stop_grace_period: 20s`. - **Both production compose files pin `NODE_ENV=production`**, and `.env.example` ships it commented out. The image now runs `node server.js` directly, so its own `ENV` is all that sets it, and a blank `NODE_ENV=` line in `.env` (the `.env.example` of 0.9.0 to 0.11.0 has one) would have replaced it with an empty value and started the container in development mode. An install that runs the image some other way should delete that line from its `.env`. - **The stop and the probes need nothing run on upgrade**, and no migration of their own. Two things read differently: a brand-new install reports `unhealthy` until its one-time `npm run migrate -- --adopt-fresh-install`, which was already the documented first-run step (the log says so once), and a container started without the `./migrations` mount the compose files provide reports `migrations`, because it cannot check. - **`APP_URL` is required in production.** With `NODE_ENV=production` (the Docker image and `npm run start`) the server exits at boot when it is unset, blank or not an `http`/`https` URL, instead of emailing invitation, password-reset and notification links that point at `http://localhost:3000`. One on `localhost`, `127.x.x.x`, `[::1]` or a `.localhost` name still boots, with a warning. Development keeps the default. - MySQL is pinned to `mysql:8.4.11` in every compose file, both workflows and `start.sh`, instead of the floating `mysql:8`. Both tags resolve to the same image today, so an install that pulled `mysql:8` recently already has it. Compose does not re-pull a tag it has cached, so an older install may still run an earlier 8.4 (8.4.8 shipped before): it pulls 8.4.11, and MySQL upgrades the data directory in place on first start, so back it up first. A test fails on a floating tag or on two files disagreeing. - **Both production compose files publish the app port on `127.0.0.1`** (`${APP_BIND:-127.0.0.1}:${PORT:-3000}:${PORT:-3000}`), not on every interface. A browser or reverse proxy on the same machine reaches `http://localhost:3000` as before; another machine no longer does, and neither does a proxy in another container that used the host's address (join it to the compose network and proxy to `app:3000` instead). Production belongs behind a TLS-terminating reverse proxy; to expose the port on purpose, set `APP_BIND` in `.env` to `0.0.0.0` or one interface's IPv4 address (never `::` or an IPv6 address), and with nothing in front of it `TRUST_PROXY=false`. `docker-compose-prod.yml` also publishes MySQL as `${DB_BIND:-127.0.0.1}:3306:3306` instead of `3306:3306`: a mysql client or `npm run migrate` on the host still reaches it, and anything else needs `DB_BIND` set on purpose (the release file publishes no database port). The development file, `docker-compose.yaml`, does the same for its MySQL, which runs with a development password: the dev server, `make` and a `mysql` client on the same machine reach it as before, and nothing else on the network does. A test reads every compose file at the root with a YAML parser, pins every mapping, fails on any default beyond loopback in either port syntax, and checks that `docker-compose.linux.yml` publishes nothing, however it is spelled. - **Both production compose files pin their default network** to `172.29.0.0/16`, gateway `172.29.0.1` (Cloud Command uses `172.28.0.0/16`), so the address a proxy on the host arrives from is one `TRUST_PROXY`'s default can name. An existing install's network is replaced by the `down` and `up -d` the upgrade note above describes. ### Fixed - **Deleting an archive is recorded in the activity log.** The route looked up the archive's workspace only after deleting the row that led to it, so every `archive.delete` event was dropped. It now reads the workspace and squad first. An archive with no squad has no workspace and stays unrecorded, as before. - **Renaming or moving a document in the archive tree is recorded.** `PUT /api/archives/:archiveId/logs/:logId` now logs `log.rename` when the title changes and `log.move`, with the previous and new parent, when the parent changes; re-sending the stored values logs nothing, and neither event notifies watchers. It also applies the title rules of `PUT /api/document/:logId/title` (required, at most 255 characters, the same 400 responses), where it used to accept any length and answer a non-string title with a 500, and it answers 404 for a document that is not in the archive instead of reporting success. - **The archive tree no longer loses documents to a bad parent.** The same route wrote any `parent_id` it was given. A document put under itself or under one of its own descendants dropped out of the tree with everything below it. Each is now a 400. A document also could be moved, created (`POST /api/archives/:archiveId/logs`) or uploaded under a document in another archive; all three routes now refuse that with a 400. Moves in one archive now take turns, so two opposite moves sent at once can no longer both pass the check and leave two documents each under the other. - **`PUT /api/document/:logId/title` answers a title that is not a string with a 400**, as the tree route now does, where it used to fail with a 500. - **A fresh install on an SELinux-enforcing host gets its schema.** `docker-compose-release.yml` and `docker-compose-prod.yml` mounted `init.sql` read-only with no SELinux relabel, so on Fedora, RHEL and their relatives the MySQL container could not read it, the first boot's initialisation failed, and MySQL came up with no tables ("Table 'c2.users' doesn't exist"). Both now mount it `:ro,z`, as `migrations/` already was, and a test pins a label on every host bind mount in the two files. An install that already hit this starts again from an empty data directory; see `docs/troubleshooting.md`. - **The migration lock fits any schema name.** `npm run migrate` against a schema whose name is longer than 45 characters failed with MySQL's `User-level lock name ... should not exceed 64 characters`. Such a schema now gets a lock named by a digest; every shorter name keeps the lock it had, so an older runner and this one still exclude each other. - **A blank `SMTP_FROM` sends from the default address.** `.env.example` ships `SMTP_FROM=` blank, and a blank value was used as the From, so an install that turned email on from it sent every email with an empty From. Blank now behaves as unset (`Cloud Codex `), and so do a blank `SMTP_PORT` (587), `DB_HOST` (`localhost`) and `DB_NAME` (`c2`), in the server and in `npm run migrate`. ### Security - **The rate limiters can no longer be walked around by choosing your own address** ([GHSA-9fmx-frrf-xxmq](https://github.com/Cloud-City-Computing/c2/security/advisories/GHSA-9fmx-frrf-xxmq)). Express's `trust proxy` was `1`, so every limiter keyed on the rightmost `X-Forwarded-For` entry of any request that carried one, and both production compose files published the app port on every interface, where Docker's DNAT rule sits in front of the host firewall. Anyone who could reach port 3000 directly could send a new address with each request and get a fresh bucket every time: unlimited password and two-factor guessing past the sign-in limit of 20 per 15 minutes, and unlimited user search. The address recorded against each session came from the same header. Now: - `trust proxy` defaults to `127.0.0.1/32, ::1/128, 172.29.0.1/32`: loopback, and the gateway of the network the production compose files now pin, which is where Docker presents a proxy on the host. Every other peer is counted by its own address, whatever `X-Forwarded-For` it sends: a public client, a machine on the same LAN, VPN or VPC, and a sibling container. A range is not trusted by default, because a client inside a trusted range, behind a proxy that appends the header, could name its own address with the left entry. The default bridge's gateway, `172.17.0.1`, is not trusted either: `docker run -p PORT:PORT` publishes on IPv6 too, that bridge is IPv4-only, and every IPv6 client arrives as its gateway. An IPv4-only Docker network presents every IPv6 client of an all-interfaces or IPv6 publish as its gateway, which is also why `APP_BIND` must be an IPv4 address. - `TRUST_PROXY` takes a list of addresses and CIDRs, subnet names, or `false`. A hop count, `true`, or a range wider than an IPv4 /8, wider than an IPv6 /16 outside `fc00::/7` and `fe80::/10`, or holding more than an IPv4 /8 of the IPv4-mapped `::ffff:0:0/96` (all of it is every IPv4 client) stops the boot with a sentence saying why, unless `TRUST_PROXY_ALLOW_HOP_COUNT=true` says you accept that any client able to reach the port can choose its own address. Those are the thresholds: a public /8, or an IPv6 /16 to /31, is accepted. An entry not written in standard notation always stops the boot, since Express reads `010.0.0.0/8` as octal, public `8.0.0.0/8`. - A peer `TRUST_PROXY` does not name that sends `X-Forwarded-For` is logged, once per address (an IPv6 peer once per /64) and for at most 32 of them, so a proxy left out of the list shows up in the log instead of silently putting every client in one bucket. Once listening, the server also prints what it trusts: `✔ Trusting proxies (the default): ...`, or `(from TRUST_PROXY)`. - The app port is published on `127.0.0.1` (Changed, above), and the prod file's MySQL port is no longer published on every interface. **If you run behind a proxy, check two things:** that `TRUST_PROXY` names the address it connects from (nginx or Caddy on the same host needs nothing; a proxy container, a load balancer or `docker run` must be listed), and that the proxy **sets** `X-Forwarded-For` (for nginx alone, `$remote_addr`; appending is also safe, and is what nginx behind a load balancer should do, as long as the address it appends is the client's own and not one `TRUST_PROXY` trusts). A proxy that sets nothing lets each client choose its own address. Every process on the host that reaches the published port, and with `APP_BIND` widened every container on the host, arrives as the gateway and is trusted too; the loopback bind is what keeps that to this machine. "Rate limiters" in `docs/deployment.md` has a check to run from outside, with a forged `X-Forwarded-For`, and says what each result means. - **Session tokens are stored only as a SHA-256 digest, and every sign-in is its own session.** `sessions.id` held the raw token, so a copy of the table (a backup, a dump) was a list of working sign-ins; it now holds `hashSessionToken(token)`, and every lookup and delete hashes the presented token first. Each sign-in also gets a row of its own: a second device used to be handed the first device's token, so signing out anywhere signed out everywhere, and a sign-in after a password change was handed the caller's fresh token. Now `POST /api/logout` signs out only the device that sent it; a password reset, and an email or password change, still sign out every device. Each row records the flow that minted it (`sessions.auth_provider`, `local` or `google`), and the replacement an email or password change hands the caller keeps the tag of the session it replaces. Expired sessions are deleted daily. - **Document images are served only to people who can read the document.** `/doc-images/.webp` was a public static mount, cached `public` for 30 days, so anyone with an image's address could fetch it. It now serves an image to its uploader and to users who can read a document that holds it, cached `private` for a day; everyone else, signed in or not, gets the same empty `404`. A reference is recorded for a document only by the write that adds it, from a writer who can see the image, so pasting another document's image URL into a document you can write grants nothing, and neither does a later save, publish or restore of that document by someone who can see it. Export inlines only the images the exporting user can see. `DOC_IMAGES_PUBLIC=1` restores the old public mount. Avatars stay public. - **In production the security headers cover the whole app, not only `/api`.** The single-page app's HTML, its built assets and the `/avatars` and `/doc-images` files now carry the Content-Security-Policy, including `frame-ancestors 'none'`, and `X-Frame-Options: DENY`. The policy also allows `https:` images (documents hold remote images, and a linked GitHub account's avatar is remote), turns off Helmet's `upgrade-insecure-requests` so an install served over plain `http` still loads, and sends `Cross-Origin-Opener-Policy: same-origin-allow-popups` so the draw.io editor popup can still answer the page. Development keeps the `/api`-only scope for the Vite dev server. Exporting a document as PDF now prints its window from the page rather than from a script written into the window, which the policy would block. ### Migration **The session migration,** [`migrations/2026-09-27-session-per-sign-in.sql`](migrations/2026-09-27-session-per-sign-in.sql), adds `sessions.auth_provider` (existing rows become `local`, then the default is dropped) with `CHECK (auth_provider IN ('local', 'google'))`, and hashes every stored session id in place, so nobody is signed out by the upgrade. **Stop every writer, apply it, then start the new image**, from `cloudcodex/`: ```sh npm run migrate ``` In containers, `docker compose ... run --rm app npm run migrate` with the app stopped. The schema is incompatible with the app in both directions: the old image against it fails every sign-in (error 1364) and cannot find any existing session, and the new image against the old schema fails every sign-in (error 1054) and cannot find any either. The hash step is idempotent (it skips any id that is already a lowercase hex digest), so a re-run changes nothing it already changed; if a run is interrupted partway, drop the column (`ALTER TABLE sessions DROP COLUMN auth_provider;`) and run it again. There is no rollback of the hash: going back to an older image means dropping the column and every user signing in again. On an install that `init.sql` builds fresh, `--adopt-fresh-install` checks that `auth_provider` is already there before it records the file. **The document-images migration,** [`migrations/2026-09-27-who-may-see-doc-images.sql`](migrations/2026-09-27-who-may-see-doc-images.sql), adds the `doc_images` table: which documents hold which image. Apply it with `npm run migrate` as usual. **Then run the backfill once, before starting the new image,** or every image in an existing document is hidden from its readers: ```bash docker compose -f docker-compose-release.yml run --rm app npm run backfill:doc-images # built from source: docker compose -f docker-compose-prod.yml run --rm app npm run backfill:doc-images # dev: cd cloudcodex && npm run backfill:doc-images ``` It records every image that a document's current HTML or any of its versions shows, and prints how many. It trusts every reference already stored, so run it once, before the new image serves anyone, and not again after go-live: it refuses to run over a table that already has rows, and `npm run backfill:doc-images -- --again` is only for rerunning an interrupted first run. If the app has to start before it runs, set `DOC_IMAGES_PUBLIC=1` for that window (the backfill then runs without `--again`) and unset it after. A fresh install needs neither. See `docs/deployment.md`, "The document-images backfill, once". ## [0.11.0] - 2026-09-27 The account-security release. Three security fixes: Google sign-in no longer links an account that has two-factor authentication on ([GHSA-6q9j-5qr9-7f2p](https://github.com/Cloud-City-Computing/c2/security/advisories/GHSA-6q9j-5qr9-7f2p)), the boot admin sync no longer promotes an existing account ([GHSA-w8q3-r34w-3pjh](https://github.com/Cloud-City-Computing/c2/security/advisories/GHSA-w8q3-r34w-3pjh)), and changing an account's email or password now needs its current password and signs every device out. Google sign-in also no longer attaches a second Google account to a user, which a database key now enforces for every provider. Alongside them: an identity seam that sign-in methods resolve through, and `AUTH_PROVIDERS` to choose which ones an instance offers. **Upgrading from 0.10.0 applies two migrations with `npm run migrate`, and the first refuses on an install that already holds a double link; see Migration below.** ### Added - `AUTH_PROVIDERS`, an optional comma list of the sign-in methods an instance offers (`local`, `google`), validated at boot. Unset keeps today's set: local always, Google when it is configured. A value naming an unknown provider, leaving out `local`, listing an unconfigured Google, or leaving out a configured one stops the boot with a sentence naming the variable. ### Changed - `npm run migrate` reports a migration that refuses on purpose (a guard in the file raising `SIGNAL`) as a refusal: it leads with the guard's own message, says the file stays pending, and drops the guard procedure the file created, instead of warning that the database may be partially migrated. And `--adopt-fresh-install` now checks a key a newer migration adds against `information_schema`, as it already did for tables and columns. - The account page says what happened after linking GitHub: the Linked Accounts panel shows "GitHub account linked." or the reason a link was refused (cancelled, expired, already linked to another user, and so on). It used to show nothing either way. - **`POST /api/update-account` needs `currentPassword` for an email or password change.** Without it the request is a 400, with a wrong one a 401, and nothing is written either way; a name change needs nothing extra, and an `email` equal to the one on file is not a change. An email or password change now answers `{ success: true, token }`: every session of the user, the caller's included, has been deleted, so a client must store the returned token in place of the one it sent. For an account with no password, an email change answers `{ success: true, requires_email_code: true, confirmToken }` and completes at the new `POST /api/update-account/confirm-email` with `{ confirmToken, code }`. Both routes now share the sign-in rate limit (20 requests per 15 minutes per IP). See `docs/api/auth.md`. ### Fixed - The account page's **Update Info** button never saved anything: the panel sent no session token in the request body, so every submit came back "Error updating account: Token and userId are required". It now sends the token, and only the fields that changed. - The 6-digit code row (the account page's email confirmation and the two-factor setup and disable steps) no longer pushes its Cancel button out of view on a phone-width screen. - The Linked Accounts panel told an account with no password to "Set a password in Account Info above", which has never been possible; it now points at "Forgot Password?" on the sign-in screen. - Relinking GitHub to an account that another user already has linked answered with a server error. It is now refused as `already_linked_other`, as a first link always was, and the account page says so. ### Security - **Google sign-in no longer links an account that has two-factor authentication on** ([GHSA-6q9j-5qr9-7f2p](https://github.com/Cloud-City-Computing/c2/security/advisories/GHSA-6q9j-5qr9-7f2p)). Signing in with Google for the email of an existing account with no Google account linked yet linked the two and signed in, without asking for that account's two-factor code. An account with two-factor on (authenticator app or email code alike) is now never linked by email: the sign-in is refused as `/?oauth_error=two_factor_enabled` with nothing written, and the sign-in form says to sign in with the password and code instead. A Google account that is already linked signs in as before, without the local code, even after its user turns two-factor on: once linked, Google's own sign-in, its MFA included, governs the account. Links made before this release are kept, and the database cannot tell one the owner made from one made without their second factor. `docs/troubleshooting.md` has a query listing every Google link made by email. Ask each owner whether they linked Google themselves; where that cannot be confirmed, delete the link (the owner can link again deliberately) and the account's session: ```sql DELETE FROM oauth_accounts WHERE user_id = AND provider = 'google'; DELETE FROM sessions WHERE user_id = ; ``` The session goes too because an account has one session, shared by every sign-in to it, so removing the link alone leaves whoever used it signed in. A password reset through Forgot password also ends every session, and so, from this release, does an email or password change (below), so either does the same job as the second statement. Then have the owner confirm that the account's email address, password and two-factor setting are theirs: before this release a session alone was enough to change the first two. - **The boot admin sync no longer promotes an existing account** ([GHSA-w8q3-r34w-3pjh](https://github.com/Cloud-City-Computing/c2/security/advisories/GHSA-w8q3-r34w-3pjh)). At every boot the server makes sure the admin named in `.env` exists. It looked for an account whose name was `ADMIN_USERNAME` or whose email was `ADMIN_EMAIL`, took whichever it found first, made it an admin, and replaced its password and email with `ADMIN_PASSWORD` and `ADMIN_EMAIL`. So an ordinary member who held that name or that address became the instance admin at the next restart, and any session they already had was an admin session from then on. The simplest way there was for a member to change their own name or email to the one the admin had just given up. Boot now does one of three things and logs one line saying which, never the password: it **creates** the admin when no account matches; it **syncs** an account that is already an admin, whose email and password `.env` still resets at every boot, as before; and when a matching account is not an admin it **refuses**, changing nothing (not that account, and not the admin's either), and logs: ``` admin sync: / matches an existing non-admin account (user ), refusing to promote it. Promote it in the admin console if that is intended. ``` If that line appears, decide whose name or address it is. If that account is meant to be the admin, promote it in the admin console (Users, click its **User** badge); from the next boot on it is synced like any admin, so its email and password become `ADMIN_EMAIL` and `ADMIN_PASSWORD`. If it is not, set `ADMIN_USERNAME` and `ADMIN_EMAIL` to the admin's current name and address, or to a name and address no account uses to have boot create a fresh admin, and restart. If you ran an earlier release, look in the Users panel for an admin you did not make: revoke it there (boot no longer puts it back), and treat that account as one that had admin access. - **Changing an account's email or password now needs its current password, and signs every other device out.** Before, anyone holding a signed-in session could change the account's email and password with nothing else, and a password change deleted every session except the caller's own. Sessions are one per account today, so the caller's session is the same one every other device holds: a stolen session survived the owner changing their password, and could itself change the email and take the account over. Now the account page asks for the current password when the email field is changed (there is still no password field on that page; passwords change through "Forgot Password?"), a wrong one is refused with "Your current password is incorrect.", and after the change every device signed in to the account is signed out while the one that made the change carries on with a new session. The old address gets an email saying the address was changed. An account created by an external sign-in has no password to give, so its email change is confirmed with a 6-digit code sent to its current address; when this instance cannot send email, that change is refused with a sentence saying why. Such an account still sets a first password only through Forgot Password. A name change is unaffected. - **Google sign-in no longer attaches a second Google account to a user.** A Google account not yet linked to anyone was linked to whichever user held its verified email, even when that user already had a different Google account linked, so a reassigned Workspace address or a deleted and recreated Google account signed straight into the previous owner's account. That case is now refused as `/?oauth_error=identity_conflict` with nothing written, and the sign-in form says so. Relinking is by hand; see `docs/troubleshooting.md`. A double link made before this release is not undone automatically: the migration below refuses to run until an operator resolves it. - **The same rule is now a database key.** `oauth_accounts` gains `UNIQUE (user_id, provider)`, so two different Google accounts signing in for one user at the same instant can no longer both be linked. The one that lands second gets the same `identity_conflict` answer instead of an error. The same holds for GitHub linking: of two GitHub accounts linking one user at once, the second is refused as `/account?github_error=link_conflict` instead of a 500. ### Migration **The one-link-per-provider migration,** [`migrations/2026-09-25-oauth-one-link-per-provider.sql`](migrations/2026-09-25-oauth-one-link-per-provider.sql), adds `UNIQUE KEY uq_oauth_user_provider (user_id, provider)` to `oauth_accounts`. Apply it with `npm run migrate` from `cloudcodex/`, not by piping it into the `mysql` client, which splits the file's guard procedure on its semicolons. Stopping writers is not required: new code against the old schema only carries a catch that never fires, and old code against the new schema gets an error, not a second row, where it would have written a double link. **It refuses, and deletes nothing, on an install where some user already holds two links to one provider.** The refusal names the query that lists every offending `(user_id, provider)` pair, which you can run yourself before upgrading: ```sql SELECT user_id, provider FROM oauth_accounts GROUP BY user_id, provider HAVING COUNT(*) > 1 ``` To resolve a pair it lists, take a dump, look at the rows, and decide which link the person really signs in with: ```sql SELECT id, provider_user_id, provider_email, provider_username, created_at FROM oauth_accounts WHERE user_id = AND provider = ''; DELETE FROM oauth_accounts WHERE id = ; ``` Then run `npm run migrate` again; the refused file is still pending and applies once the query returns no rows. Deleting a Google link stops that Google account signing in to the user; deleting a GitHub link drops its stored token, and the user relinks GitHub from the account menu. A refused run leaves the schema as it found it: nothing is recorded, and the runner drops the throwaway guard procedure (`migration_guard_oauth_one_link_per_provider`) the file creates. **The email-change token migration,** [`migrations/2026-09-25-token-purpose-email-change.sql`](migrations/2026-09-25-token-purpose-email-change.sql), adds `password_reset_tokens.new_email`, widens the `purpose` `CHECK` to include `email_change`, and adds `chk_password_reset_tokens_new_email`, which requires an address on every `email_change` row and refuses one on any other. Apply it with `npm run migrate` from `cloudcodex/`. Stopping writers is not required: old code never names the column and mints only rows both constraints accept, and new code against the old schema fails only an email change on an account with no password (a 500 until the file is applied). Rows already in the table satisfy both constraints, so it applies with rows present. On an install that `init.sql` builds fresh, `--adopt-fresh-install` checks that `new_email` is already there before it records the file. ## [0.10.0] - 2026-09-25 The security and infrastructure release. Everything since 0.9.0 closes a defect in the shipped image or makes an install upgradable: four places where one workspace could reach into another, a credential-flow bug, a GitHub account link that a second person could complete, GitHub link routes that checked nothing, a migration runner so an upgrade can actually be applied, and the first machine interfaces a paired product (Cloud Command) reads through. **Upgrading from 0.9.0 needs one extra step; see Migration below.** ### Added - A database migration runner (`cloudcodex/scripts/migrate.js`, `npm run migrate`). It applies pending `migrations/*.sql` in lexicographic order, records each in a new `schema_migrations` table with its sha256, and refuses to run when an already-applied file has been edited. Concurrent runs are serialised by a MySQL advisory lock. Each database takes one adoption command first: `npm run migrate -- --baseline` for a database that already existed before this release (the usual case, and the one an upgrade is in), or `npm run migrate -- --adopt-fresh-install` for one `init.sql` has just built. The second records every file without running any, so it refuses unless the live schema already contains what each post-baseline file adds, and it prints the exact list before adopting it. Both adoption modes write their rows in one transaction. MySQL implicitly commits DDL, so a failed migration reports that the database may be partially migrated rather than claiming a rollback, except for the duplicate-object errors, which say the schema already has the change and how to record it. The release and prod compose files now mount `migrations/` into the app container (`:ro,z`, so the mount is readable on an SELinux host), which is where the runner runs when MySQL is not published to the host: `docker compose ... run --rm app npm run migrate`, in a one-off container, after stopping the app. - **A scoped service token, for a machine that needs to read your documents.** Set `SERVICE_TOKEN` (at least 32 characters) and `SERVICE_TOKEN_USER` (the email of an existing, non-admin user) and a caller presenting `Authorization: Bearer ` can read `GET /api/search` and `GET /api/browse`, and nothing else. Set neither and nothing changes: there is no new authentication path on an install that does not opt in. The token acts as that user through the ordinary archive ACLs, so you widen or narrow what a machine sees by changing that user's squad membership and archive grants, not by editing permission code. An admin `SERVICE_TOKEN_USER` is refused outright, because an admin principal matches every archive in the install. Rotating the secret is immediate and needs no database change, and any caller still holding the old value starts getting 401s. See [`docs/security.md`](docs/security.md) and `.env.example`. - **A workspace reader check, for the product the service token serves.** `GET /api/workspaces/:workspaceId/reader-check?email=
` answers `{ "canRead": true | false }` to the service token and to nothing else: a new `requireMachine` guard refuses a signed-in session with the same 401 an anonymous caller gets, because the answer is about someone else's access. "Can read" means an admin, the workspace owner, or a member of any squad in that workspace. An unknown email, an unauthorised user and a workspace that does not exist all answer `false` identically, and the route shares the login rate limit. Cloud Command asks it before letting an admin map a workspace. An install without `SERVICE_TOKEN` is unaffected. ### Changed - CI builds the production frontend as well as linting, testing and checking coverage, and that check is required on `main`, so a red run can no longer merge. ### Fixed - **Documents over 64 KiB save.** `logs.html_content`, the `plain_content` generated from it and `versions.html_content` were `TEXT`, so a save between 64 KiB and the application's own 2 MiB ceiling failed with an opaque 500 and the edit was lost. All three are `MEDIUMTEXT` ([`migrations/widen_log_content.sql`](migrations/widen_log_content.sql)). - **Squad-to-GitHub-team sync no longer removes members past the first page.** Both routes fetched one page of team members and treated everyone beyond it as removed. They paginate now, and a truncated listing removes nobody. - **The `email_squad_invite` preference is honoured.** Squad invitation emails were sent whatever it said. - **Committing a file through the browser no longer marks its linked documents clean.** `PUT /api/github/contents/*` set every linked document's merge base to the new commit, so the next push from any of them silently overwrote the change. It now records the new remote version and marks them `remote_ahead`. - **Document titles are reachable by keyboard** in the browse grid, the archives page, the editor's page tree and the search dropdown, which closes the 0.9.0 known gap. - **Glyph-only controls have names.** Twelve controls announced as their glyph (a star, a plus, a cross) rather than their purpose; each now names what it does and to which document, and the version history row is a real button. - `docs/deployment.md` documented applying migrations with `source /var/lib/mysql/migrations/.sql` inside `make db-shell`. No compose file mounts `migrations/` into the MySQL container, so that path does not exist there and the documented upgrade path could not work. - The upgrade procedure now stops the app before migrating and runs the runner in a one-off container. `docker compose exec app` runs inside the container that is already running, which on the upgrade that first ships the runner is the old image: no `migrate` script, and no `/migrations` mount, because `docker compose pull` does not recreate a container. ### Security - **OAuth state is bound to the browser that started the flow** ([GHSA-34pj-8475-rqwf](https://github.com/Cloud-City-Computing/c2/security/advisories/GHSA-34pj-8475-rqwf)). The GitHub link callback trusted a state value for whichever browser completed it, so a link one user started could be completed by another, and the second person's GitHub token was stored against the first person's Codex account. Each initiation now sets a short-lived httpOnly, SameSite=Lax cookie holding the state, one per provider, and each callback refuses, before any token exchange or write, unless the completing browser presents the same value. A refused attempt still consumes the state. Google sign-in gets the same binding. - **Typed `password_reset_tokens.purpose`.** Four flows mint into that table (password reset, the 2FA login challenge, TOTP enrolment, the 2FA-disable confirmation) and no reader constrained which flow minted the row it found. `POST /api/login` returns its 2FA challenge token to the caller, and `POST /api/reset-password` accepted it. Impact was a persistent password rewrite plus a full session wipe of the victim, so lockout and an integrity defect: reset-password issues no session and does not clear `two_factor_method`, and the caller must already hold the victim's password to reach the challenge. Not account takeover. - **`POST /api/logout` terminates the server-side session.** It read `req.body.token`, which no client sends, so every logout was a 400 the caller swallowed and no `sessions` row was ever deleted. It now resolves the token the same way `requireAuth` does. - **The workspace is a tenant boundary.** Four surfaces read a global permission flag as "may act in any workspace": squad creation let any account make itself a squad owner inside any workspace; `POST /api/archives` let any account plant an archive in any squad; the archive access-grant route wrote any user or squad id into an archive's grants; and `GET /api/users/search` returned every account and its email address across the install. All four now check workspace membership, and answer 404 where a 403 would confirm that a workspace exists. - **GitHub link routes check document access.** `GET`, `PUT` and `DELETE /api/github/link/:logId` checked nothing, so any user could read another document's GitHub binding, repoint that document's next push into a repository of their own, or delete the binding. They now require read, write and write access respectively. - **A read-only collaborator can no longer rename a document** through the collaborative-editing socket, the one mutating message that was not gated on write access. - **Pull-request discussions are scoped to the pull request.** A PR opened as a document is granted through a hidden archive of its own, and the session route asks GitHub whether the caller can see the pull request before creating one. ### Removed - The `squad_permissions` table and its two routes. Nothing enforced them, so a value saved there changed no behaviour ([`migrations/drop_squad_permissions.sql`](migrations/drop_squad_permissions.sql)). ### Migration **Upgrading from 0.9.0: one extra step.** Two files on the runner's pre-runner list, `widen_log_content.sql` and `drop_squad_permissions.sql`, shipped after 0.9.0 was tagged, so a database that has only ever run 0.9.0 does not have them, but `npm run migrate -- --baseline` records them as applied without running them. After `--baseline`, and before starting the new image, apply both by hand. Both are idempotent, so running them on a database that already has them is harmless: ```bash for f in widen_log_content drop_squad_permissions; do docker compose -f docker-compose-release.yml exec -T database \ sh -c 'exec mysql -u root -p"$MYSQL_ROOT_PASSWORD" "$MYSQL_DATABASE"' \ < "migrations/$f.sql" done ``` Without the first, documents over 64 KiB still fail to save on the upgraded install. **The token-purpose migration,** [`migrations/2026-09-08-token-purpose.sql`](migrations/2026-09-08-token-purpose.sql): **stop every writer, apply, then start the new image**, and note there is no rollback. The new column is `VARCHAR(32) NOT NULL` with a `CHECK` constraint and no `DEFAULT`, which makes the schema incompatible with the application in both directions, and the migration deletes existing token rows because they cannot be classified after the fact: in-flight password resets and 2FA challenges must be restarted. Apply it with the new runner (`npm run migrate`), which lands in the same release. Full reasoning is in the migration header and in [`docs/deployment.md`](docs/deployment.md). ## [0.9.0] - 2026-08-08 The first release since the March alpha. Five months of work, most of it aimed at the gap between "this looks interesting" and "this is running on my server": the app now boots without SMTP configured, lands a new admin inside a seeded workspace, and publishes a container image so evaluating it does not require a build toolchain. ### Added - **Published container image.** `ghcr.io/cloud-city-computing/cloud-codex` is built and pushed on every version tag, and [`docker-compose-release.yml`](docker-compose-release.yml) runs a release without compiling anything locally. - **First-run experience.** A guided welcome that every user reaches, including the admin synced from `.env` at boot, backed by a new `GET /api/first-run`. It creates nothing, it points at the squad, archive and document the user already has, or at their pending squad invitations if they are in no squad yet. - **Invitations that land somewhere.** `POST /api/admin/invitations` can carry a `squadId`, a role and permission flags, so an invited user joins that squad in the same transaction that creates their account. - **Mail-optional boot and a non-empty first boot.** `initMail()` degrades instead of exiting when no SMTP is configured, and `bootstrapInstance()` seeds a workspace so a fresh admin does not land on an empty screen. - **Admin-side two-factor reset**, for the account that has lost its device. - **Notifications, activity feed, and watches.** One funnel for every user-facing alert, with per-user email preferences, a push-only user-scoped WebSocket, coalescing windows, and watches that cascade from an archive to its documents. - **GitHub integration**, as a live API proxy with no webhooks and no background sync: repository browsing, file add / delete / rename / move, commit history drill-down and diff view, pull requests, bidirectional document sync with a local three-way merge, live code embeds, and manual squad-to-team sync. - **Draw.io diagrams** in documents, searchable by label, alongside syntax highlighting, resizable images, mentions, and inline comments. - **Google Workspace SSO** and GitHub OAuth, with tokens encrypted at rest. - **Admin console** for workspaces, users, invitations, squads, permission flags, and live presence telemetry. - **Deep documentation maps** in [`docs/maps/`](docs/maps/), each citing `file:line`, plus an adoption roadmap in [`docs/specs/`](docs/specs/). - **Test suite and coverage gates.** Backend Vitest + Supertest and frontend Vitest + jsdom as separate projects, with per-glob coverage thresholds enforced in CI. - **Mobile CSS** across the interface. - `CODE_OF_CONDUCT.md`, and this changelog. - An application-wide error boundary, so a render failure is recoverable rather than a blank page. ### Changed - **Workspace ownership is a foreign key.** `workspaces.owner` was a TEXT column holding an email address with no constraint, so changing your email silently lost ownership and a deleted user's address could be inherited by a later signup. It is now `workspaces.owner_id`, an `INT` FK to `users(id)` with `ON DELETE SET NULL`. - **The production image is a multi-stage build**, 2.86 GB to 679 MB. The previous single-stage image shipped devDependencies and, lacking a `.dockerignore`, a second copy of `node_modules` carried in from the host. - **The editor is Tiptap 3** on ProseMirror, replacing the previous WYSIWYG. - **Collaborative editing uses native Yjs binary sync** for conflict-free merges. - **Vocabulary settled** on Workspaces, Squads, Archives and Logs, and a commitment that a day-one user meets three levels: Squad, Archive, Log. The welcome screen now says three rather than four, matching that. - Documentation reorganised around per-area API contracts and architecture docs. ### Fixed - **Navigating away from an open editor no longer blanks the application.** The collaborative cursor and comment overlays were rendered into DOM that ProseMirror owns, so tearing the editor down threw during unmount and, with no error boundary anywhere, took down the whole page until a manual reload. - **The app has an error boundary.** A render error now shows a recoverable message instead of an empty window. - **Same-origin API requests work in production.** A self-hosted instance following `.env.example` rejected its own browser's writes with a 500, which made logging in impossible. - **`PORT` is honoured** through the app, Docker, and `start.sh`, and the boot log no longer reports a successful start when the bind actually failed. - **Account creation is rejected when the invitation was revoked mid-signup.** - Admin invitations send working default permissions instead of a no-op role. - The welcome flow awaits its completion request before navigating away. - Failing API endpoints return real HTTP status codes rather than a bare 200. - `.env.example` and the README agree on variable naming. ### Removed - **`POST /api/setup`** and its unused `setupWorkspace` frontend wrapper. It had no callers, and its only behaviour was creating an archive with a `NULL` `squad_id`, an orphaned archive that only its creator could ever reach. - The `workspaces.owner` TEXT column, replaced by `owner_id`. ### Known gaps - The published image is `linux/amd64` only. Apple Silicon runs it under Docker Desktop's emulation. - The `LICENSE` is a bespoke source-available licence, so GitHub reports it as "Other". This is deliberate, not an oversight. - Documents edited only over the collaborative WebSocket have a stale `html_content` until an explicit save, which affects search, exports, and GitHub pushes. See [`docs/maps/documents-and-collab.md`](docs/maps/documents-and-collab.md). - Document titles in list views are click-only and cannot be reached by keyboard. ## [0.1.0-alpha] - 2026-03-27 Initial public pre-release. [Unreleased]: https://github.com/Cloud-City-Computing/c2/compare/v0.14.0...HEAD [0.14.0]: https://github.com/Cloud-City-Computing/c2/compare/v0.13.0...v0.14.0 [0.13.0]: https://github.com/Cloud-City-Computing/c2/compare/v0.12.0...v0.13.0 [0.12.0]: https://github.com/Cloud-City-Computing/c2/compare/v0.11.0...v0.12.0 [0.11.0]: https://github.com/Cloud-City-Computing/c2/compare/v0.10.0...v0.11.0 [0.10.0]: https://github.com/Cloud-City-Computing/c2/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/Cloud-City-Computing/c2/compare/alpharelease...v0.9.0 [0.1.0-alpha]: https://github.com/Cloud-City-Computing/c2/releases/tag/alpharelease