# GitHub Integration Map `routes/github.js` is about 2,350 lines and about 45 endpoints, the largest single file in the backend. It grew in four labelled phases (`P0` sync, `P1` embeds, `P2` PR-as-document, `P3` CI and team sync) whose section banners are still in the file, and it is the most volatile file in the repo, so anchor on route paths and function names rather than line numbers. The core stance: **live API proxy, no webhooks, no background sync, no mirror.** Every GitHub read is a request made in the user's name with the user's token at the moment they ask. --- ## 1. Auth and the `req.gh` helper Tokens are stored encrypted in `oauth_accounts.encrypted_token`. The crypto lives in `routes/oauth.js:51-84` (`TOKEN_CIPHER` to `decryptToken`): - AES-256-GCM, 12-byte IV, 16-byte auth tag. - The key is `scryptSync(GITHUB_CLIENT_SECRET, 'cloudcodex-oauth-token', 32)` (`getTokenEncryptionKey`, `oauth.js:59`). **Rotating `GITHUB_CLIENT_SECRET` renders every stored token undecryptable**; there is no key-version field and no re-encryption path. Every user has to re-link. - Stored as `ivHex:tagHex:ciphertextHex` (`encryptToken`, `oauth.js:70`). - `encryptToken`/`decryptToken` return `null` when the secret is unset, which is how the app degrades gracefully with GitHub unconfigured. `requireGitHub` (`github.js:111-122`) decrypts the caller's token, 403s with a "link your GitHub account" message when absent, and attaches `req.gh(path, options)`, a closure over `githubFetch` that also forwards `req.user.id`. `router.use('/github', requireAuth, asyncHandler(requireGitHub))` (`github.js:125`) applies that to every `/api/github/*` route. Note the two routes that live **outside** the `/github` prefix, `/api/logs/by-github-ref` (`github.js:1956`) and the two `/api/squads/:squadId/github-team/*` routes (`github.js:2168`, `github.js:2248`), which therefore attach `requireAuth` and `requireGitHub` individually. ### Token revocation detection `githubFetch` (`github.js:70-104`) inspects failures. On a 401, or a 403 whose message mentions credentials, it fire-and-forgets `UPDATE oauth_accounts SET token_status = 'revoked'` (`github.js:88-95`). The message check matters: GitHub also returns 403 for rate limits and missing scopes, and flipping the status on those would produce spurious "re-link your account" prompts. The frontend reads this through `GET /api/github/status` (`oauth.js:605`) and the `useGitHubStatus` hook, which is what hides GitHub UI affordances for unlinked users. The client asks that route only for a signed-in user, and asks the gated routes (the editor's `GET /api/github/link/:logId`) only once it reads `connected`; see `frontend-architecture.md` section 3. ### This router does NOT use the shared error handler `github.js:2340-2347` installs its own terminal handler that forwards `err.status` when it is a plausible 4xx/5xx and prefers `err.ghBody.message`, so GitHub's own wording reaches the client. `githubFetch` is what attaches `.status` and `.ghBody` (`github.js:97-100`). **Do not replace this with `errorHandler` from `shared.js`.** That handler hardcodes 500, so every "file not found on that branch" and every rate-limit response would become an opaque server error. ## 2. Document to file linking `github_links` (`init.sql:339-358`) is `UNIQUE (log_id)`, so a document links to at most one file. Columns that carry the sync state: `file_sha` (last observed remote blob sha), `base_sha` (the merge base), `last_pulled_at`, `last_pushed_at`, `sync_status`. Three plain CRUD routes: `GET`, `PUT`, `DELETE /api/github/link/:logId` (`github.js:927`, `958`, `990`). The `PUT` is an upsert keyed on the unique `log_id`, setting `base_sha = file_sha` at link time. **All three check log access**: `GET` needs read, `PUT` and `DELETE` need **write**, because the row `PUT` writes is where `/push` reads its target repo, branch and path from. Until 2026-08-09 they checked nothing at all beyond `requireAuth` plus `requireGitHub`, which is B4 in [open-questions.md](open-questions.md) and was live-confirmed before the fix. The four sync routes below go through `loadLinkAndLog` (`github.js:1012-1028`), which applies the same fragments. ### The sync state machine `classifySync({ remoteSha, baseSha, localChanged })` (`github.js:1085-1091`): ``` remoteChanged = remoteSha && baseSha && remoteSha !== baseSha localChanged = log.updated_at > max(last_pulled_at, last_pushed_at) !remote && !local -> clean remote && !local -> remote_ahead !remote && local -> local_ahead remote && local -> diverged ``` The schema's `sync_status` enum also has `conflict` (`init.sql:350`), which `classifySync` never returns; it exists for a manual-resolution state that the current code expresses as a 409 response instead. Two heuristics to know about: - **`localChanged` is deliberately conservative** (`github.js:1113-1121`). It compares `logs.updated_at` against the last sync, and `updated_at` is bumped by CRDT autosaves too, so idle-but-open documents drift to `local_ahead`. The code notes this: a false positive only offers a Pull the user does not need. - **A link that has never been pulled or pushed reports `localChanged = false`** (`lastSyncTs === 0` short-circuit, `github.js:1121`). `GET /api/github/link/:logId/status` (`github.js:1098`) also *persists* what it observed into `file_sha` and `sync_status` as a fire-and-forget write (`github.js:1128-1133`), so sidebars and banners can read cached state without re-hitting GitHub. ### Pull (`github.js:1158`) Three strategies, validated against an allow-list (`github.js:1165`): | Strategy | Behaviour | |---|---| | `overwrite_local` | remote markdown rendered to HTML, written over the doc, `ydoc_state = NULL`, both shas advanced, `sync_status = 'clean'` | | `merge` | fetch the base blob by `base_sha`, run `diff3Merge(local, base, remote)`; conflict yields **409** with `conflicts`, `merged_with_markers`, `ours`, `theirs`; success writes the merged doc | | `preview` | identical merge computation, returns `merged_markdown`, writes nothing | `diff3Merge` is local, in `src/lib/githubDiff.js:122` (an LCS-based three-way merge, no external dependency). It is one of the few backend modules imported from `src/`. Every write path here nulls `ydoc_state` (`github.js:1184`, `1226`, `1408`) so live editors re-initialise their CRDT from the new HTML. That is mandatory for any external content writer; see [documents-and-collab.md](documents-and-collab.md). All three write paths also call `broadcastToDoc(logId, {type:'github-pulled'})` (`github.js:1193`, `1235`, `1417`) to tell open editors. ### Push (`github.js:1247`) `branch_strategy: 'direct'` commits straight to the linked branch using `base_sha` as the parent. `'pr'` first creates `codex/-` off the linked branch (`github.js:1280-1294`), pushes there, then opens a PR. On a GitHub 409 or 422, meaning the remote moved between the status check and the push, it flips `sync_status = 'diverged'` and returns 409 with a "pull first" message (`github.js:1317-1332`). Sha bookkeeping differs by strategy (`github.js:1351-1365`): a direct push advances both `file_sha` and `base_sha`; a PR push advances only `base_sha`, because the linked branch on disk is unchanged. The content pushed is `localMarkdown(row)` (`github.js:1035-1043`), which **prefers `markdown_content` over `html_content`**. A document edited in rich-text mode sets `markdown_content` to `null`, at which point the HTML is round-tripped through turndown; a document with a stale `markdown_content` pushes the stale markdown. ### Resolve (`github.js:1384`) Persists a user-merged markdown after a conflict. Guards on the client's `base_sha` matching the stored one (409 if the base moved underneath), writes the doc, and sets `sync_status = 'local_ahead'`, since the user still has to push. ## 3. Live code embeds (P1) `GET /api/github/embed/code` (`github.js:1466`) serves the Tiptap `GitHubCodeEmbed` node. It fetches the file, optionally slices a line range, and returns content plus a `language` guess from `EXT_TO_LANG` (`github.js:1424-1438`). The response is cached in a module-level LRU capped at 500 entries (`github.js:1444-1459`), **keyed by `${req.user.id}:${owner}:${repo}:${ref}:${path}`** (`github.js:1475`). The user id in the key is the point: private-repo content must never leak across users through a shared cache. Any change to that key must keep the user id. The cache stores the *full* file and slices per request, so two embeds of different ranges in the same file cost one fetch. There is a second, separate TTL cache for CI and release reads, `ciCache`, 60 seconds, 200 entries (`github.js:1981-2004`). ### The dead back-link table `github_embed_refs` (`init.sql:309-323`, created by `migrations/p1_github_embeds.sql`) is intended to answer "which documents embed this file / issue / PR". `GET /api/logs/by-github-ref` (`github.js:1956`) reads it, correctly gated by the read fragment. **Nothing writes it.** There is no `INSERT INTO github_embed_refs` anywhere in the repo. The endpoint therefore always returns an empty list in practice, and its test (`tests/routes/github.test.js:1818`) passes because the DB is mocked. Recorded in [open-questions.md](open-questions.md). ## 4. Archive as repo (P1) `archive_repos` (`init.sql:269-284`) binds an archive to a repo, with a `docs_path` prefix (default `docs`) and `auto_link_imports`. Managed through `GET`/`POST`/`DELETE /api/archives/:archiveId/repos` (`archives.js:711`, `743`, `792`), all gated by `isArchiveOwner`. `bulkImportArchiveRepo` (`github.js:1521-1616`) backs **both** `POST .../import` and `POST .../refresh` (`github.js:1618-1619`); they are the same handler. It walks the tree recursively, keeps only markdown blobs under the prefix (`listMarkdownFilesUnder`, `github.js:1523-1535`), skips paths already linked, and creates a log plus a `github_links` row per new file. It yields to the event loop between chunks with `await new Promise(r => setImmediate(r))` (`github.js:1612`) so a large repo does not block the single process. Because already-linked files are reported as `skipped` rather than re-fetched, `/refresh` is a no-op on a fully-synced archive: it imports newly-added files only, and does not pull content changes into existing docs. ## 5. PR as document (P2) The cleverest and most fragile part. A pull request gets a **virtual log** so the existing comment routes and collab WebSocket work on it unchanged. Two things have to be true for this to work, and until 2026-08-09 neither was. **The session must be authorised by GitHub.** `GET .../pulls/:number/session` now fetches the PR through the caller's own token before doing anything else. GitHub is the only authority on who may see a PR, and this is the only place that asks; a caller who cannot see it gets GitHub's 404 forwarded by this router's error handler. Before, the route took `owner/repo/number` from the URL and trusted them, which mattered the moment the grant below became real. **The grant must land on an archive, and on one archive per PR.** `ensurePrArchive(owner, repo, prNumber)` find-or-creates a hidden archive named `__c2_github_pr_sessions__:owner/repo#N` with `system = TRUE`, `squad_id NULL`, `created_by NULL` and empty ACLs. `getOrCreatePrSession` creates the log in it, records a `github_pr_sessions` row (unique on owner+repo+pr_number), and appends the caller's id to **that archive's** `read_access` / `write_access`, which is clause 2 of the shared fragment. One archive per PR is the whole point: the archive is the ACL boundary, so a single shared archive would make opening any PR session grant access to every other PR's session. A session created before this change is moved into its per-PR archive on next open. A concurrent first open of the same PR is handled by treating `uq_pr_session`'s duplicate-key error as "the other request won": this request deletes the log it just created and adopts theirs. **The grant carries write, so every archive-scoped surface must exclude `system` archives.** See the `excludeSystemArchives()` rule in [access-control.md](access-control.md). Without it, opening a session on any public PR was enough to list, search, delete documents in, and enumerate the members of that hidden archive. The old code appended the caller's id to the **log's** ACL columns, which are read by no query in the codebase (A1). Live-confirmed 2026-08-09: the grant was written and a non-admin still got 403 on the comment routes while an admin got 200, i.e. the feature was admin-only. Also live-confirmed after the fix: the granted user reads and comments, and two users holding different PR sessions each get 403 on the other's. **`logs.read_access` remains dead**, deliberately; this fix routes around it rather than reviving it. The rest of the PR surface (`github.js:1761-1856`) proxies review comments, review submission, and issue search straight through, with no local mirror. ## 6. Squad to GitHub Team sync (P3) `squads.github_org` / `github_team_slug` / `team_sync_at` (`init.sql:188-193`, unique on the org+slug pair) bind a squad to a GitHub Team. `GET /api/squads/:squadId/github-team/preview` (`github.js:2168`) and `POST .../sync` (`github.js:2248`), both behind `userCanManageSquad`. Identity matching is `LOWER(oauth_accounts.provider_username)` against the GitHub login (`github.js:2195`, `github.js:2272`), so **a Codex user who has not linked GitHub is invisible to sync** and appears in the `unmatched` list. Adds insert with `role='member', can_read=TRUE, can_write=FALSE` and `ON DUPLICATE KEY UPDATE can_read = TRUE` (`github.js:2302-2304`). Removals skip anyone with `role = 'owner'` (`github.js:2318-2323`) so a bootstrapping admin cannot lock themselves out. Sync is **manual and one-directional**: GitHub is the source, Codex is the target, and nothing runs it on a schedule. `team_sync_at` records the last run. Membership fetch goes through `fetchTeamMemberLogins`, which follows pagination to a 20-page (2,000 member) cap and returns `{ logins, complete }`. **A truncated listing disables removals**, in both the preview and the sync, and surfaces as `members_complete: false` in the response: a login on a page that was never fetched looks exactly like a member who left, and the removal pass deletes them. Until 2026-08-09 the fetch was a single `per_page=100` call with no loop, so a team over 100 members silently under-reported and the sync deleted the overflow (B5 in [open-questions.md](open-questions.md)). ## 7. Frontend surface `src/pages/GitHubPage.jsx` (2652 lines) is the repo browser. It carries the repo's only load-bearing `eslint-disable` lines for `react-hooks/exhaustive-deps` and a legacy `no-alert` disable; both are deliberate, leave them. The API wrappers all live in `src/util.jsx:132-180`. `useGitHubStatus.jsx` gates UI on whether the user has linked an account; `useGitHubLink.js` drives the per-document sync banner (`GitHubSyncBanner.jsx`) and merge dialog (`GitHubMergeDialog.jsx`). Picker modals live under `src/components/github/`. --- ## Related - [access-control.md](access-control.md) for why the PR-session ACL write is inert. - [documents-and-collab.md](documents-and-collab.md) for the `ydoc_state = NULL` contract every writer here obeys. - [data-model.md](data-model.md) for the five GitHub tables.