cloud api: GitHub token endpoint with a sealed per-principal ledger, revoke and audit

Merged
TE
12d ago
+787-11

Leo, 2026-10-24, on the "cmux lanes" GitHub App ask: "can this be productionized?" This is PR 1 of the plan in leo-workspace research/2026-10-24-github-app-broker-product.md: the Cloud API's GitHub token endpoint, coded exactly to the gh-app-product contract that PR 2 (lane-gh-broker minter) and PR 3 (cmux github credential / cmux github gh) build against.

Endpoints (backend/apps/api/src/integrations/github-token-route.ts, routed in index.ts)

  • POST /v1/integrations/github/token {team, repo, permissions, label?} → {token, expires_at, repo, permissions, token_id}. Callers: a vm install for its own live machine (vmGate's checks through CloudDO cloud.vm.self.get), a daemon/cli/mac install, or a member's session (same selectTeam + ssoGate + principalForOwner path as /v1). Refused: agent claims, team-vm, ios, web. Permission keys are contents, pull_requests, issues, workflows, checks, actions, statuses, metadata, values read|write, non-empty (an empty map would mean "everything" to GitHub). Errors as the contract lists (400 request.invalid, 401 auth.required, 403 auth.forbidden/policy.denied, 404 integration.not_found, 409 needs_reauth, 429 rate_limited + retry_after, 503 integration.unavailable).
  • POST /v1/integrations/github/token/revoke {team, install?} → {revoked: N}; another install's tokens need team.manage (the personal team's own user counts).
  • GET /v1/integrations/github/status?team= → {configured, app_slug, connection: {id, account, status, repos, allow_workflows} | null}.

Mint (integrations/github-tokens.ts, in the team's ConnectionDO): picks an active GitHub connection the principal may use whose account is the repo's owner and whose repo scope + allowlist include the repo (githubRepoAllowed), then asks GitHub for {repositories: [name], permissions} exactly. workflows: write also needs the new team policy key github.allowWorkflows (default false; slice field allow_workflows). A GitHub answer that grants less than asked is revoked and refused, never handed out downgraded. GitHub 404/suspended → 409 and the connection goes needs_reauth; 422/403 → policy.denied; rate limits → 429.

Ledger = cache: each live token is a ConnectionDO row sealed under INTEGRATIONS_KEK with AAD over {team, principal, install, repo, permissions, token_id, expires_at}; reused for the same (principal, installation, repo, permissions) while >15 min remain, never across principals; dropped at expiry. Install principals register with their UserDO's socket registry, so install.revoke (and a machine delete, which revokes the VM install) calls ConnectionDO.closeInstall, which revokes that install's tokens with DELETE /installation/token; failed DELETEs retry from the alarm until expiry. New mints are rate limited per principal (burst 60, 1/s, memory).

Audit: each mint commits integration.github.token_minted in ConnectionDO, whose outbox appends team.github.token_minted to the team's audit chain (team.audit.list): {at, team, principal_kind, user, install, machine?, label?, repo, permissions, token_id, expires_at}. Never the token; a test checks the ledger, audit and errors for the token text.

github.issue.comment keeps its whole-installation token path unchanged (its in-memory cache never serves callers). Generated files (catalog, clients/ts/cloud/src/generated.ts, cmux-cloud-wire generated) change only because of the new policy key. No client code, setup-github or GitHub App configuration is touched.

Risk: medium. New routes and a new ConnectionDO table; existing integration ops untouched except the TeamIntegrationPolicy.github.allow_workflows optional field (the slice adds it only when on, so existing teams' slice hashes do not change). Overlap check: #16776, #24846, #24849 touch the same files for other features; none mints tokens.

Tests: backend/apps/api/test/github-token.test.ts (narrowed body checked, per-principal reuse, policy/owner/allowlist/workflows refusals, 404 → needs_reauth, 429, revoke route, install revoke → DELETE, VM own-team only and machine delete → DELETE, ios refused, status, audit).

Review: independent Codex (gpt-6-astra, xhigh) SHIP at 58800f49bcb8d891b6f628030180ac42a13afee8 after two CHANGES rounds (mint/revoke race, post-await rechecks, durable refuse, unopenable rows kept).

🤖 Generated with Claude Code

Agent session: cosmic-gray


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.