CHANGELOG.md
Rama main · réplica de lectura del repositorio original.
# 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 <noreply@<host of APP_URL>>` 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 = <former SERVICE_TOKEN_USER id> 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 <suite name>." 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/<workspace id>`.
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 <OIDC_PROVIDER_NAME>". 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 `<html data-theme="dark">`); 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=<file>`
(`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=<file>` (`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 `<span>`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 `<Link>` 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_<provider>`, 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=<id>&ids=<id,id,...>`, 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 <noreply@cloudcitycomputing.com>`), 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/<hash>.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 = <id> AND provider = 'google';
DELETE FROM sessions WHERE user_id = <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: <ADMIN_USERNAME> / <ADMIN_EMAIL> matches an existing non-admin account (user <id>), 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 = <user_id> AND provider = '<provider>';
DELETE FROM oauth_accounts WHERE id = <id of the link to remove>;
```
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 <SERVICE_TOKEN>` 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=<address>` 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/<file>.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