# Cloud Codex — Agent Guide > Self-hosted, real-time collaborative documentation platform. > Confluence/Notion alternative for teams that need to own their data. > © Cloud City Computing, LLC. ## What this product is **Cloud Codex** is a multi-user web app for creating, editing, and organizing team documentation. Differentiating capabilities: - **Real-time collaborative editing** via Yjs CRDT — multiple users edit the same document simultaneously with conflict-free merging, not last-write-wins. - **Hierarchical organization**: Workspaces → Squads → Archives → Logs (documents). - **Layered access control** (admin → user grants → creator → workspace owner → squad role → squad grants → workspace-wide flag). - **Invite-only registration** (no public signup; admin issues invite tokens). - **GitHub integration** as a live API proxy: browse repos, edit files, manage branches/PRs, link documents to GitHub files. No webhooks, no background sync. - **Self-hosted**: Docker Compose for dev and prod. MySQL 8 + Node 22. The "c2" repo name is a legacy codename — refer to the product as **Cloud Codex** in user-facing strings, comments, and commit messages. ## Repository layout (the dual-root quirk) The repo root holds Docker, docs, and SQL. The Node application lives one level down in `cloudcodex/`. **Run all `npm` commands from `cloudcodex/`.** ``` c2/ ← repo root (Docker, docs, SQL, Make) ├── docker-compose.yaml ← dev: MySQL only ├── docker-compose-prod.yml ← prod: MySQL + app ├── docker-compose.linux.yml ← native-Linux :Z override for the dev file (not WSL) ├── Makefile ← seed, reset-db, db-shell, backup, restore ├── start.sh ← one-shot dev bootstrap ├── scripts/ ← backup.sh, restore.sh (Bash; docs/deployment.md, Backups) ├── init.sql / seed.sql ← schema + sample data ├── migrations/ ← incremental SQL migrations ├── docs/ ← human-facing architecture docs │ └── maps/ ← file:line-cited deep maps, READ THESE FIRST ├── .github/workflows/ci.yml ← audit, lint, test, coverage, build; push to main + every PR └── cloudcodex/ ← the Node application ├── app.js ← Express app (middleware, route mounting) ├── server.js ← entry point (verifies SMTP + admin, WS attach) ├── mysql_connect.js ← DB pool, sessions, c2_query() ├── vite.config.js ← code-splitting strategy (read before adding deps) ├── vitest.config.js ← four projects + 48 per-glob coverage thresholds ├── eslint.config.js ← strict flat config ├── routes/ ← API endpoints │ ├── helpers/ ← shared.js, ownership.js, images.js, │ │ activity.js, mentions.js, REUSE THESE │ ├── auth.js, documents.js, archives.js, workspaces.js, squads.js, │ ├── comments.js, search.js, favorites.js, admin.js, │ ├── oauth.js, github.js, avatars.js, doc-images.js, │ ├── doc-images-serve.js (the /doc-images mount), upload.js, │ ├── notifications.js, activity.js, watches.js ├── middleware/ ← auth.js (requireAuth, requireAdmin), permissions.js, │ forwarded-for.js (the untrusted-proxy warning) ├── services/ ← collab.js (Yjs WS), user-channel.js (inbox WS), │ notifications.js, email.js, email-templates.js ├── src/ ← React frontend │ ├── App.jsx ← router + lazy-loaded pages │ ├── pages/ ← full-page components │ ├── components/ ← reusable UI (+ components/github/ pickers) │ ├── extensions/ ← custom Tiptap nodes (Mention, GitHub embeds) │ ├── hooks/ ← useCollab, usePresence, useClickOutside, │ │ useGitHubStatus.jsx, useGitHubLink, │ │ useNotificationChannel │ ├── lib/githubDiff.js ← diff3 merge, imported by the BACKEND too │ ├── util.jsx ← apiFetch + API wrappers — USE THIS for new calls │ ├── userPrefs.js ← localStorage prefs + theme constants │ ├── editorUtils.js ← editor-specific helpers │ └── codex.css ← --cx- theme bindings over the vendored primitives ├── vendor/cloud-city-design/ ← vendored design package, pinned by checksum; NEVER edit here ├── public/ ← uploaded avatars, document images └── tests/ ← Vitest + Supertest, mirrors the source tree ``` ## Deep maps: read before changing anything cross-cutting `docs/maps/` holds `file:line`-cited maps of every subsystem. They exist so you do not re-derive the wiring by re-reading the tree, and they carry the traps this file only summarises. Start at [docs/maps/README.md](docs/maps/README.md). | Map | Read it when | |---|---| | `request-lifecycle.md` | boot order, middleware stack, sessions, WS upgrades, error handling, health probes, the instance lock, shutdown | | `access-control.md` | **any permission work**, the 7-clause fragments and the four other permission systems | | `documents-and-collab.md` | the editor, Yjs, dual-state storage, versions | | `github-integration.md` | anything under `routes/github.js` | | `notifications-and-activity.md` | notifications, activity log, watches, mentions | | `data-model.md` | schema changes, migrations, the ACL columns | | `frontend-architecture.md` | routing, chunks, hooks, `util.jsx`, prefs | | `build-test-and-ops.md` | Docker, npm scripts, Vitest projects, coverage thresholds, CI | | `open-questions.md` | **before trusting any single claim**, known dead paths and suspected defects | `docs/*.md` remains the human-facing product documentation (features, deployment, API contracts). `docs/maps/` is the mechanism layer. ## Application areas (what already exists) Before writing new code in any of these areas, **read the existing files**. Most "new feature" work is extension, not greenfield. ### Auth & accounts: `routes/auth.js`, `routes/oauth.js`, `routes/oidc.js`, `middleware/auth.js` Email+password login, signup via invite token, password reset, two-factor (email OTP and TOTP with QR), session tokens (DB-backed, one row per sign-in, stored only as a SHA-256 digest), Google Workspace SSO, OIDC sign-in with any standard issuer, one per instance (`services/oidc.js`, `OIDC_*`, keyed on `(issuer, subject)` in `user_identities`, `returnTo` by `services/return-to.js`; setup and provider notes in `docs/sign-in.md`; `AUTH_PROVIDERS=oidc` is OIDC-only mode), GitHub OAuth (token AES-256-GCM encrypted at rest). Use `requireAuth` and `requireAdmin` from `middleware/auth.js` on any new protected route. **Never bind a raw session token into a `sessions` query**: hash it with `hashSessionToken` in `services/session-token.js` (`validateAndAutoLogin` and `touchSession` already do). The cookie is `__Host-sessionToken` on https (`sessionToken` over plain http); read it only through `extractSessionToken` on the server and `getSessionTokenFromCookie` on the client, write it only through `setSessionCookie`, and never give it a `Domain` (`services/session-cookie.js`). Match cookie names exactly: strip only ASCII space and tab (`COOKIE_OWS`), never `trim()`. An `/api` write authenticated by that cookie alone needs an accepted `Origin` (`requireOriginForCookieWrites` in `app.js`). **External sign-in** decides its local user in one seam, `resolveIdentity(claims, policy)` in `services/identity.js`: a provider route verifies the protocol and hands over claims plus a policy, and gets back a user id or a named refusal. Google and OIDC are its callers (the `oidc` branch is `resolveOidcIdentity`). Do not write a second account ladder in a route. OIDC never creates an account. `parseAuthProviders()` in the same file validates `AUTH_PROVIDERS` at boot (unset means today's set). **Sign-out** (W6-CDX-6): `POST /api/logout` returns an `endSessionUrl` for an OIDC session, carrying the ID token sealed by `services/id-token-seal.js` as `id_token_hint`. `POST /api/auth/oidc/backchannel-logout` (`routes/oidc.js`, OIDC only) ends sessions by sid or subject, bounded by the token's `iat`, and closes their sockets with 4004 (`closeSocketsForSessions` in `services/collab.js` and `services/user-channel.js`). Maps: `docs/maps/request-lifecycle.md`, section 3. **No machine credential**: every route is reached by a session (the bearer header or the cookie). A script signs in as a user. ### Org hierarchy: `routes/workspaces.js`, `routes/squads.js`, `routes/archives.js` Workspaces own squads; squads own archives; archives own logs. Squad members have roles (member/admin/owner) and per-member permission flags (read/write/create_log/create_archive/manage_members/delete_version/publish). Squad invitations have a pending/accepted/declined lifecycle. **All access checks go through `routes/helpers/ownership.js` — do not re-derive permission SQL inline.** The **archive is the ACL boundary**; per-document permissions do not exist. `docs/maps/access-control.md` has the full resolution table. ### Documents & editor: `routes/documents.js`, `src/pages/Editor.jsx`, `services/collab.js` Logs (documents) have **dual-state storage**: `html_content` (human-readable, written by REST saves and exports) and `ydoc_state` BLOB (binary CRDT state for live sync restore), plus `markdown_content` for lossless GitHub round-tripping. The Yjs WebSocket server in `services/collab.js` debounces saves on a 3-second window, and that autosave writes **only** the blob. `html_content` updates on an explicit save or publish, so search, export and GitHub push all read the last explicitly-saved HTML. Any code path that writes `html_content` from outside the editor **must set `ydoc_state = NULL`** or live editors will resurrect the old content. Editor uses Tiptap 3 + ProseMirror with Lowlight syntax highlighting, draw.io diagrams, resizable images, mentions, and GitHub code/issue embeds. Full protocol in `docs/maps/documents-and-collab.md`. ### Notifications: `services/notifications.js`, `routes/notifications.js`, `services/user-channel.js` One funnel for every user-facing alert. `createNotification()` self-suppresses actor-equals-recipient, coalesces the same (recipient, type, resource) inside a 60-second window, persists, then fire-and-forgets a WebSocket push and an email. Six types (`mention`, `comment_on_my_doc`, `watched_comment`, `watched_publish`, `watched_log_update`, `squad_invite`) with per-user email preferences in `users.notification_prefs`. The user-scoped WS (`/notifications-ws`) is push-only and keyed by user, not by document. ### Activity & watches: `routes/helpers/activity.js`, `routes/activity.js`, `routes/watches.js` `logActivity()` is fire-and-forget and **does four things**: writes the `activity_log` row, emits an outbound webhook event (below), auto-enrols the actor as a watcher, and fans notifications out to every other watcher. Adding a `logActivity` call to a route therefore starts sending people email. `log.update` events coalesce on a 5-minute window (distinct from the notification funnel's 60 seconds). Watching an archive cascades to its documents. Retention is 365 days, pruned in-process daily. ### Outbound webhooks: `services/webhooks.js`, `services/webhook-target.js`, `routes/webhooks.js` `emitEvent`, called once inside `logActivity` after the activity row, writes one outbox event (`webhook_events`, the exact body bytes) and its deliveries for eight document and archive actions, **only when a cached subscription matches**, so an install with none adds no query to any change. It never throws. Every receiver URL goes through `checkWebhookTarget` (the SSRF guard); do not build a second check. A subscription's secret is never stored; it is returned only once, on creation or rotation. Contract: `docs/api/webhooks.md`; mechanism: `docs/maps/notifications-and-activity.md` section 2a. The delivery worker (`services/webhook-worker.js`, W6-CDX-14) sends them from an in-process tick every two seconds: one head per subscription, leased, in order; the mechanism is in `docs/maps/notifications-and-activity.md` section 2b. ### Mentions: `routes/helpers/mentions.js`, `src/extensions/Mention.jsx` `` nodes, diffed old-vs-new HTML so only newly added mentions notify. Mentions inside `
`, ``, `