Repository Connections
GitHub App installs, manual PAT hosts, and the one-connection-per-project bind.
An organization reaches repositories through two connection kinds: a remembered GitHub App installation (github.com) or a manual connection — a Git host plus an encrypted personal access token — for GitHub, GitLab, Gitea, or Forgejo. Both feed one repository-listing surface. A project binds to exactly one connection at import, re-checked for access at import time and again at dispatch; the GitHub App path mints a short-lived per-run token scoped to the single repository, while the manual path uses the stored PAT.
GitHub-side installation lifecycle changes flow back through
signature-verified webhooks: uninstall and suspension flip the stored
connection between active, revoked, and suspended. Dispatch against a
non-active connection answers 422 with the machine-readable code
REPO_CONNECTION_REVOKED before any run or task row exists.
How to test it
| Suite | Proves |
|---|---|
pnpm vp run @firops/api#test — github-app-installations.test.ts | The connect flow's session, nonce, expiry, and installation-selection gates over real HTTP |
pnpm vp run @firops/api#test — repository-credentials.test.ts | Installation rows sync, invalidate, and re-point projects without breaking rows that active runs still hold |
pnpm vp run @firops/api#test — opencode-provider-credentials.integration.test.ts and the dispatch suites | Dispatch validates the repository credential before enqueueing |
pnpm vp run @firops/api#test — webhooks.github.integration.test.ts | Signed lifecycle deliveries mutate connection state idempotently, forged signatures die with 401 and no state change, and dispatch is blocked and restored accordingly |
Manual smoke: Manage → Credentials → Repository — connect the GitHub App, add a manual host, then import a repository in New project.
Decisions
- An org can only remember an installation the connecting user controls — the connect flow checks the authorizing user's own installation list, so one org can never import an app-global installation that belongs to someone else.
- The connect flow is stateful and single-use — each attempt is a server-side session with a hashed nonce and a 15-minute expiry that transitions exactly once; a replayed or forged callback dies on the session, not on heuristics.
- One active manual connection per organization + provider + host — saving again replaces the stored token instead of accumulating duplicates, so "which token does this host use" always has one answer.
- Rotation and deletion are refused while anything depends on the
connection — a rotate that would break an attached project's access, or a
delete while projects or active runs still reference the connection, answers
409with the dependents; the guard runs atomically with the write. - A project stores the connection reference, never a token — runs resolve credentials through the connection at run time, so rotating a PAT never requires touching projects.
- Prefer the GitHub App path where possible — a per-run token scoped to one repository with write access to exactly contents, pull requests, and issues beats a long-lived personal access token.
- Installation webhook handlers are set-state on GitHub's numeric installation ids, never toggle — replayed or reordered deliveries converge on the same connection state, and account or repository renames do not change the installation identity.
- Signature verification reads the raw delivery bytes before any parsing — a constant-time HMAC compare against the shared webhook secret; unsigned or forged deliveries answer 401 and change nothing.
- Dispatch against a dead connection fails with a typed code, not a failed
run —
422 REPO_CONNECTION_REVOKEDat run creation with reinstall copy, so the UI can point at the fix instead of surfacing a mid-run credential error.
Next: Runner images — the container a run needs before it can be claimed.