Skip to main content

GitHub App integration (link a project to a repository, pull on push)

A ReelBolt project can be linked to a GitHub repository, so that a git push updates the project without anybody clicking anything. The owner of the GitHub account creates and installs the GitHub App himself — ReelBolt never creates an app, never asks for a personal access token, and never writes to a repository. This document is the developer and operator reference for that integration: what it does, what "pull" means, what the owner must configure, and the security rules the implementation is built around.

The code lives in inference/src/ReelBolt.Inference.Api/Services/GitHub/ (the app client, the signature verifier, the link service, the pull service and the delivery worker) and Controllers/GitHubWebhookController.cs + Controllers/ProjectGitHubController.cs. The feature is inert until it is configured: with no app id, no private key or no webhook secret, the webhook answers 503 and linking a repository is refused.

What the integration does, in one paragraph​

An owner links one of his projects to one repository. From then on, every push to that repository's linked branch is delivered to ReelBolt as a signed webhook, verified, and materialized into the project's file library. He can also pull on demand. Nothing else changes: the repository is a source of files for the project, not a place ReelBolt deploys to, and ReelBolt has read-only access.

What "pull" means for a ReelBolt project​

A pull makes the project's GitHub-sourced files equal the repository tree at one commit. It is a reconciliation, not an import:

In the pushWhat happens in the project
A path that is newA project file is created (category userFiles, path relative to the repository or to the link's path prefix).
A path whose content changedThe existing project file is rewritten in place. Its id stays the same, so nothing that references it breaks.
A path that is gone from the treeThe project file and its stored object are deleted.
A path whose content is byte-identicalNothing at all: no download, no upload.
A path a person uploaded or an agent wroteNothing. A pushed file never takes a path that already belongs to someone else's file, and it is counted as skipped.

Only rows a pull wrote are ever reconciled. Every one carries project_files.source_kind = 'github' and remembers the git blob SHA it was decoded from in project_files.source_revision; a row written by an upload or an agent has neither, and a pull cannot see it, rewrite it or delete it — whatever it is called.

Two directories are never pulled: .git/ and node_modules/. Everything else is, including binary assets. Files under the linked pathPrefix keep their path relative to that prefix, so a project linked to video/ shows scene.tsx, not video/scene.tsx.

Pulled files are deliberately not queued for the LLM summarizer or the embedder. A push arrives with nobody watching, and a bill the owner never asked for is not a side effect of installing an app; summary_status is set to Done (the same idiom the media-upload path uses for "nothing to summarize") and indexing_status to NotIndexed. Use POST /api/v1/projects/{id}/files/reindex when semantic search over a repository is actually wanted.

Unlinking a repository removes the link and nothing else: the files a previous pull wrote stay. Silently deleting a customer's source tree because he detached a repository would be the more surprising of the two behaviours.

Security: the signature is the only credential​

The webhook endpoint is anonymous by construction — GitHub is the caller, so there is no session and no bearer token. The only credential is the X-Hub-Signature-256 header: an HMAC-SHA256 over the exact bytes of the request body, keyed with the app's webhook secret. It is verified before anything is written, and a request that fails is refused and leaves no trace — not a delivery row, not a job, not a log line containing the body.

Three properties are not negotiable and are enforced in code:

  • Constant-time comparison. The digest is compared with CryptographicOperations.FixedTimeEquals. A byte-wise comparison would return as soon as it found a differing byte, letting an attacker who can time the endpoint recover the correct HMAC one byte at a time and then forge any delivery — including a push that overwrites a customer's project.
  • No secret means no acceptance. If the webhook secret is not configured the endpoint answers 503, not 200. There is no "skip verification when unconfigured" branch, because an endpoint that cannot verify must not accept anything. The same is true of a missing header, a wrong scheme (sha1=…), a malformed or wrong-length digest, or a body that changed after signing.
  • No secret is logged. The webhook secret, the app private key and the installation access tokens never appear in a log line. The rejected-body path logs the fact of the rejection, not the body, which is attacker-controlled text.

What a verified delivery can reach is bounded by the same principle: the payload names a repository, and the project is the one that repository is linked to — resolved by GitHub's numeric repository id. There is no field in a webhook body that names a ReelBolt project, a user or an organization, so a verified push has no way to touch a project other than its own.

Creating a link requires two independent things to be true, and they are checked in this order:

  1. The caller may edit the project. Every action on /api/v1/projects/{projectId}/github goes through the same IProjectAccessService check as the rest of the project API — View to read the link, Edit to create or remove one. A project in another organization is 404, never 403.
  2. The app is really installed on that repository. ReelBolt asks GitHub, as the app (GET /repos/{owner}/{repo}/installation), which installation covers the repository. A caller cannot assert this, and a repository the app cannot see has no installation, so it is refused with 404. The repository id, its default branch and the installation id all come from GitHub's answer, never from the request body.

There is a third rule, which is the cross-tenant one. GitHub can tell ReelBolt that the app is installed on a repository, but it cannot tell ReelBolt which organization installed it. Without a rule here, any user who may edit any project could link a colleague's private repository — GitHub would happily deliver its pushes, because the app is installed on it — and read its contents out of his own project. So the first organization to link a repository under an installation claims that installation (github_app_installations), and every later link under the same installation from a different organization is answered 404, the same answer as a repository that does not exist. Never a 403, which would confirm the repository and the installation are there.

The trust chain for a delivery is therefore: a valid signature (GitHub sent this) → a repository id (in a signed body) → at most one link row (created only by an authorized editor, under an installation the organization owns) → one project. Each link is a single hop; none of them can be skipped.

Connecting a GitHub account (how the repository list is possible)​

The integration above authenticates as the App. That is the right credential for pulling a linked repository, and it is structurally unable to answer the question the dashboard actually asks first: which repositories may this person choose from? An installation token has no notion of a person, and it sees only what the App was installed on.

So there is a second, different token: a user-to-server access token, obtained from the same GitHub App through its OAuth flow. It represents the person, not the installation.

Why the App needs client credentials, and what the owner had to change​

The App may have been created without them, because an earlier instruction said to leave the callback fields empty — ReelBolt identifies people through its own session, so OAuth looked unnecessary. That instruction was wrong for this feature and has been corrected in docs/github-app-setup.md. The owner must:

  1. Enable user authorization — fill in the App's Callback URL with https://<host>/api/v1/github/oauth/callback. Without a callback URL there is nowhere for GitHub to send the browser and no flow at all.
  2. Generate a client secret, and note the client id (Iv1.… — not the App ID).
  3. Subscribe to the github_app_authorization event, so a revocation is reported to ReelBolt.
  4. Optionally (recommended) opt in to expiring user tokens, so a leaked token is worth eight hours rather than forever.
  5. Make the App installable on any account, so it can reach a customer's private repositories.

The permissions do not change. A GitHub App requests no scopes; its permissions come from its registration, and this one has contents: read and metadata: read. A user access token is the intersection of the App's permissions and the person's own access — so connecting an account widens nothing. It can read file contents and metadata of repositories that both the App and the person could already read, and nothing else.

The flow​

POST /api/v1/github/account/connect (session required)
│ mints a single-use state row, sets an HttpOnly binding cookie named
│ reelbolt_github_oauth (SameSite=Lax, Path=/api/v1/github, HttpOnly),
│ and answers {"authorizeUrl": "https://github.com/login/oauth/authorize?…"}
▼
browser → github.com/login/oauth/authorize?client_id=…&redirect_uri=…&state=…&
code_challenge=…&code_challenge_method=S256&scope=offline_access
▼
GET /api/v1/github/oauth/callback?code=…&state=… (anonymous)
│ verifies all three defences, exchanges the code server-to-server
│ with the client secret, calls GET /user, stores the token encrypted
▼
302 /app/projects/{projectId}?tab=repository&github=connected

Three independent defences, all required before anything is exchanged:

DefenceWhat it stops
state, stored as a SHA-256 and spendable exactly onceA forged or replayed callback. A signed-only state would prove we minted it but could be replayed by anyone who saw it; a consumed row cannot.
A second random value in an HttpOnly cookie, stored as a SHA-256, checked with CryptographicOperations.FixedTimeEqualsSomebody who learned the state — a shared screen, a leaked referrer — but is not the browser that started the flow. A wrong cookie deliberately does not consume the ticket, so this is a check rather than a denial of service.
PKCE (S256), verifier stored encryptedAn intercepted authorization code. GitHub supports PKCE for Apps and this is the documented reason to use it.

Whose account is connected is read out of our own state row, never out of the callback. The user id and the organization are written at connect and read back at callback, so a valid code intercepted on its way back cannot be aimed at somebody else's session. Every answer is a redirect to a fixed relative path built from a Guid we stored and an outcome from an enum — nothing from the query string reaches the Location header, so the callback is not an open redirect.

Where the token is stored, and how​

QuestionAnswer
Wheregithub_account_links.protected_access_token / protected_refresh_token.
HowEncrypted at rest with ISecretProtector — ASP.NET Core Data Protection, the same dpkeys ring the inference-provider keys use, shared by both services. The column holds ciphertext; a plaintext token there would be a credential that reads a customer's private repositories.
Scoped toOne row per (user_id, organization_id), unique. The token can read the person's private repositories, so which workspace may spend it is a decision rather than a detail: a connection made in one workspace is invisible to another, and a colleague in the same team cannot borrow it. Connecting the same GitHub account in two workspaces is the intended shape, two rows.
Never leakedNo endpoint returns it, no DTO has a field for it, and no log line contains it. GitHubAccountService.GetUserTokenAsync is private and is the only place plaintext exists; it goes to GitHub and nowhere else. ProseMessage-style guards keep a code out of a sentence; the token never enters a message at all.
ExpiryWith the App opted in to expiring tokens, an access token lasts 8 hours and a refresh token 6 months. Reaching for an expired token refreshes it, persists the rotated pair, and spends the new one; a refused refresh (expired, or revoked) revokes the row and answers 409 github_account_revoked so the person is told to reconnect rather than shown a 401 from GitHub.
A lost key ringIf Data Protection cannot decrypt what it wrote (a rotated dpkeys volume), the connection is revoked and the answer is 409, never a fallback to the ciphertext. Same fail-closed direction as the inference-provider keys.

Revocation​

GitHub sends a github_app_authorization delivery with action: "revoked" and the account in sender.id whenever somebody revokes ReelBolt in their GitHub settings, and a GitHub App cannot unsubscribe from it. That is the documented signal, and GitHubDeliveryProcessor.PlanAuthorizationAsync handles it:

  • The row is looked up by GitHub's numeric account id, never the login, which the person may have changed.
  • Every connection that account holds is revoked, across every workspace. The person revoked ReelBolt, not one workspace's view of it.
  • The ciphertext columns are overwritten with empty, so "revoked" means the credential is gone rather than ignored.
  • Nothing touches project_github_links. A revoked OAuth grant is about a person; pulls use the App's installation token, which the event does not affect. Detaching a team's repository because one member revoked their personal authorization would be a silent outage — so this deliberately does not.

Disconnecting from the dashboard (DELETE /api/v1/github/account) does the same thing, recorded as disconnected; a revoked row is kept rather than deleted so the UI can say why a connection stopped working. Reconnecting clears the revocation.

The two pickers​

EndpointWhat it answers
GET /api/v1/projects/{id}/github/repositoriesThe repositories the caller may link, from GET /user/installations then GET /user/installations/{installation_id}/repositories — with the caller's own token.
GET /api/v1/projects/{id}/github/tree?repositoryId=&path=One directory, resolved by walking down from the branch head.

The picker cannot show a repository the person cannot access, and not because a filter is applied to it: the list is GitHub's answer, and that token is the intersection described above. Two rules sit on top:

  • A repository this workspace already follows is marked as taken, so the person does not walk into a 409.
  • A repository another workspace follows is absent, not annotated. The link endpoint answers 404 there, and an entry saying "taken" would say out loud what that 404 exists to hide — the same non-oracle rule the rest of the tenancy boundary uses.

The folder browser never accepts a tree SHA from the client. It takes a repository id and a relative path, checks the repository is one the person can use, and then descends one level per request from the branch head. That matters: the installation token can read every repository the App was installed on, so a caller-supplied SHA would let somebody who may choose from repository A enumerate repository B's file names. A path that is not a folder is 404 folder_not_found; a repository that is not the person's is 404 repository_not_found, the same answer a repository that does not exist gets.

A repository with no commits has no tree, which GitHub answers 404 for; the browser returns an empty root rather than an error, because "nothing to pull yet" is the truth.

The picker does not have its own write path. Choosing a repository sends its owner/name to the same PUT that the by-name form uses, and the server verifies it against GitHub exactly as before. One write path, one set of checks.

One tightening comes with the account, and only when there is one. If the caller has a working GitHub connection for this workspace, GitHubLinkService also requires the repository to be one that account can use, and refuses it 404 otherwise — the same set the picker lists, so a link that contradicts the picker is impossible. When the caller has no connection, or a connection GitHub will no longer honour, the check is skipped and the pre-existing installation rule governs, so a broken connection never becomes a new way for linking to fail. The residual gap is real: a caller who never connects GitHub is still bounded only by the installation claim, not by their own GitHub access. Closing it outright would require every linker to connect an account, which a deployment with no App client credentials cannot offer.

GitHub retries deliveries: idempotency and ordering​

GitHub retries, redelivers and can deliver out of order. Every one of those cases is handled explicitly:

  • Redelivery. X-GitHub-Delivery is GitHub's idempotency key. It is unique-indexed on github_webhook_deliveries, and a second delivery of the same id is answered 200 with {"outcome":"duplicate"} and does no work. A duplicate is a 200 and not a 409 on purpose: GitHub retries any non-2xx.
  • The same commit twice. The link remembers last_pulled_commit. A push that names it is settled as unchanged without contacting GitHub at all.
  • Out of order. A push whose head_commit.timestamp is older than the last successful pull is dropped as stale. The project mirrors one commit, so applying a late retry of an older push would move it backwards.
  • Per file, too. Because each row remembers its blob SHA, a pull that does run only downloads and stores what actually changed. That is what makes "a redelivered webhook must not duplicate work" a fact about the data rather than a hope about timing.
  • A failure is bounded. A delivery is attempted GitHub:MaxDeliveryAttempts times and then parked with outcome=failed and the reason on the row. The next push to the same branch is a fresh delivery and repairs the project, because reconciliation is by content and not by delivery.
  • Slow work does not block the acknowledgement. A repository can be large and GitHub abandons a delivery whose endpoint is slow, so the endpoint writes the delivery and answers 202 queued in milliseconds; GitHubPushSyncService performs the pull. A restart between the two loses nothing: the row is durable and still unprocessed when the next sweep runs.

Create the GitHub App (what the owner must do)​

This is done by the owner of the GitHub account, in his own account. ReelBolt cannot and does not create an app, request a personal access token, or ask for one.

Option A — the web form​

  1. Go to https://github.com/settings/apps/new (or https://github.com/organizations/<ORG>/settings/apps/new for an organization-owned app).
  2. Fill in:
    • GitHub App name: anything unique, e.g. ReelBolt Sync.
    • Homepage URL: any URL he controls. It is only shown on the app's page.
    • Webhook: Active, and Webhook URL = https://<his-reelbolt-host>/api/v1/github/webhook (see "Where the webhook URL points" below).
    • Webhook secret: generate one and paste it — openssl rand -hex 32. It must equal GITHUB_WEBHOOK_SECRET on the deployment.
  3. Repository permissions — exactly two, and no organization permissions:
    PermissionAccessWhy
    ContentsRead-onlyRead the commit tree and the file contents it names.
    MetadataRead-onlyMandatory; GitHub adds it automatically. Used to resolve the repository and its installation.
    No other permission is needed or asked for. In particular there is **no write permission of any
    kind**: ReelBolt never pushes, never opens a pull request and never changes a repository.
  4. Subscribe to events: tick Push, and nothing else. (GitHub sends a ping when the webhook is first configured; ReelBolt acknowledges it and does nothing, which is the intended answer.)
  5. Identifying and authorizing users — the App's Callback URL must be https://<his-reelbolt-host>/api/v1/github/oauth/callback. Leave Request user authorization (OAuth) during installation unticked, and opt in to Expire user authorization tokens. This section is what makes the repository picker possible; see "Connecting a GitHub account" above. (An App created before this feature exists probably has it empty — the owner must go back and fill it in, generate a client secret, and subscribe to the github_app_authorization event.)
  6. Subscribe to events: Push and GitHub App authorization. The second is how a revocation reaches ReelBolt; a GitHub App cannot unsubscribe from it.
  7. Where can this GitHub App be installed? Any account. ReelBolt is offered to other people, and an App restricted to one account cannot reach a customer's private repositories.
  8. Create GitHub App.
  9. On the app's page, Generate a private key. A .pem file downloads. Its contents are the GITHUB_APP_PRIVATE_KEY secret. Keep it out of the repository.
  10. Note the App ID at the top of the page. That is GITHUB_APP_ID — not the Iv1.… client id, which is a different value in the Client secrets section.
  11. Generate a client secret there, and note the Client ID. They are GITHUB_APP_CLIENT_SECRET and GITHUB_APP_CLIENT_ID.

Option B — from a manifest​

A manifest creates the app in one POST and hands back the secrets. Use a random state that he keeps and compares:

STATE=$(openssl rand -hex 16)
cat > /tmp/reelbolt-app.json <<'JSON'
{
"name": "ReelBolt Sync",
"url": "https://reelbolt.example.com",
"hook_attributes": {
"url": "https://reelbolt.example.com/api/v1/github/webhook",
"active": true
},
"callback_urls": ["https://reelbolt.example.com/api/v1/github/oauth/callback"],
"request_oauth_on_installation": false,
"public": true,
"default_permissions": {
"contents": "read",
"metadata": "read"
},
"default_events": ["push", "github_app_authorization"]
}
JSON
echo "open https://github.com/settings/apps/new?state=$STATE and paste the JSON above"

The manifest flow needs a page that posts the JSON as a form field named manifest to https://github.com/settings/apps/new?state=<state> (that is what the "Create GitHub App from manifest" button on GitHub's own settings page does). GitHub then redirects back with ?code=…, and that single-use code exchanges for the credentials — including the webhook secret GitHub generated — at:

curl -s -X POST "https://api.github.com/app-manifests/$CODE/conversions"

The response contains id (GITHUB_APP_ID), slug (GITHUB_APP_SLUG), pem (GITHUB_APP_PRIVATE_KEY), webhook_secret (GITHUB_WEBHOOK_SECRET) and client_id / client_secret (GITHUB_APP_CLIENT_ID / GITHUB_APP_CLIENT_SECRET). Store all of them and delete the code; it is single-use. Note that public: true and the callback_urls entry are what make connecting an account work at all.

Where the webhook URL points​

https://<the host that serves ReelBolt>/api/v1/github/webhook — the Inference API behind the nginx reverse proxy. nginx/locations.conf carries an exact-match location for that path, and it is deliberately shaped like the Paddle webhook block next to it:

location = /api/v1/github/webhook {
client_max_body_size 2m;
proxy_set_header Authorization "";
auth_basic off;
proxy_pass $inference_api;
}

There is no cookie-to-Authorization translation ($auth_header) because GitHub has no session, and any client-supplied Authorization header is blanked. The body is forwarded untouched — no buffering or rewriting — because the signature covers its exact bytes. A push payload is JSON prose about commits and the endpoint refuses anything over 2 MB.

Install the app on a repository​

After the app exists, the owner installs it himself:

  1. On the app's page, click Install App (or use the install URL https://github.com/apps/<app-slug>/installations/new).
  2. Choose the account, then Only select repositories and pick the repository (or repositories) the app may read. "All repositories" works too and means every repository he owns matching that account.
  3. Install. GitHub may ask him to authenticate again first; that is his own login, not ReelBolt's.
  4. The install is what makes GET /repos/{owner}/{repo}/installation succeed for that repository. Until it is done, linking the repository answers 404 github_app_not_installed — ReelBolt refuses links it cannot verify, rather than accepting one that would silently never receive a push.

To stop pulling a repository, either unlink it in ReelBolt (the files already pulled stay) or suspend or uninstall the app on GitHub (pushes simply stop arriving).

Configure the deployment: environment variables and secrets​

.env variableConfig keySecretMeaning
GITHUB_APP_IDGitHub__AppIdnoThe App ID from the app's settings page.
GITHUB_APP_SLUGGitHub__AppSlugnoThe app slug, used only to build the install URL. Optional.
GITHUB_APP_PRIVATE_KEYGitHub__PrivateKeyPemyesThe full PEM of the generated private key. A literal \n is accepted as a newline, because that is what a pasted multi-line PEM becomes in a single-line variable.
(a mounted file)GitHub__PrivateKeyPathyesPath to the PEM instead of pasting it. Read once, lazily.
GITHUB_WEBHOOK_SECRETGitHub__WebhookSecretyesThe webhook secret. This is the only credential the webhook endpoint trusts.
GITHUB_APP_CLIENT_IDGitHub__ClientIdno (but required for account linking)The App's Iv1.… client id. Not the App ID.
GITHUB_APP_CLIENT_SECRETGitHub__ClientSecretyesThe client secret, generated under "Client secrets". Makes ReelBolt a confidential OAuth client, so the code exchange happens server-to-server and the secret never reaches the browser.
GITHUB_APP_OAUTH_CALLBACK_URLGitHub__OAuthCallbackUrlno (but required for account linking)https://<host>/api/v1/github/oauth/callback. Must equal one of the App's registered Callback URLs exactly. Configured rather than derived from the request, because a redirect target taken from a Host header is a redirect target an attacker chooses.

All three of app id, private key and webhook secret must be present. With any of them missing the endpoint answers 503 github_not_configured and every link is refused 503 github_app_not_configured; nothing is half-enabled.

The three OAuth values are a second, independent all-or-nothing set. With any one of them missing, POST /api/v1/github/account/connect and the picker endpoints answer 503 github_oauth_not_configured, the dashboard explains instead of offering a button that cannot work, and linking by owner/name keeps working — a deployment with a half-configured App is degraded, not broken.

Tuning keys (optional, with their defaults):

Config keyDefaultMeaning
GitHub__ApiBaseUrlhttps://api.github.comREST base; change it for a GitHub Enterprise install.
GitHub__MaxFilesPerPull2000Over this, the whole pull is refused rather than half-applied.
GitHub__MaxFileBytes52428800 (50 MB)Larger files are skipped and counted.
GitHub__MaxTotalBytes536870912 (512 MB)Declared tree over this refuses the pull.
GitHub__WorkerIntervalSeconds15How often the delivery worker looks for work.
GitHub__MaxDeliveryAttempts5Attempts before a delivery is parked as failed.
GitHub__OAuthAuthorizeUrlhttps://github.com/login/oauth/authorizeWhere the browser is sent to authorize. Overridable for GitHub Enterprise; there is no reason to change it on github.com.
GitHub__OAuthTokenUrlhttps://github.com/login/oauth/access_tokenWhere the code is exchanged. A different host from ApiBaseUrl: the OAuth endpoints live on github.com, not api.github.com.
GitHub__OAuthStateTtlSeconds600How long a started authorization stays completable.
GitHub__MaxRepositoriesListed500Ceiling on the repositories one picker response carries, so a person with thousands cannot make one dashboard request unbounded work.

docker-compose.yml passes the four credentials through to the inference service. In the cloud deployment they are secrets, and belong in the same store as JWT_SIGNING_KEY and PADDLE_API_KEY — see docs/secrets.md.

The API​

All project routes require an authenticated session and go through the project access check.

MethodPathPermissionAnswers
POST/api/v1/github/webhooknone (HMAC signature)503 unconfigured, 401 missing/invalid signature, 400 not JSON or no event header, 200 settled (ignored_event, duplicate, no_link, ignored_ref, installation_mismatch, unchanged), 202 queued.
GET/api/v1/projects/{projectId}/githubViewThe link, or 404 not_linked.
PUT/api/v1/projects/{projectId}/githubEditBody {"repository":"owner/name","branch":null,"pathPrefix":null}. repository also accepts a github.com URL. 404 repository_not_found / github_app_not_installed / branch_not_found, 409 project_already_linked / repository_already_linked, 400 invalid_repository / invalid_branch / invalid_path_prefix, 503 github_app_not_configured.
DELETE/api/v1/projects/{projectId}/githubEdit204, or 404 not_linked. Files stay.
POST/api/v1/projects/{projectId}/github/pullEditPulls the branch head now. 200 {"status":"pulled"|"unchanged","commit":…,"written":…,"deleted":…,"unchanged":…,"skipped":…}, or 502 github_error.
GET/api/v1/projects/{projectId}/github/repositoriesEditThe repository picker. 200 {"repositories":[…],"truncated":…,"installUrl":…,"accountLogin":…}, or 409 github_account_not_connected / github_account_revoked / github_account_expired / github_account_unreadable, 502 github_error, 503 github_oauth_not_configured.
GET/api/v1/projects/{projectId}/github/tree?repositoryId=&path=EditOne directory (path empty = the root). 200 {"path":…,"parentPath":…,"entries":[{"name","path","sha","isDirectory"}],"truncated":…}, 404 folder_not_found / repository_not_found, 502 github_error.
GET/api/v1/github/accountsessionWhether this workspace has a working GitHub connection. 200 {"connected","login","avatarUrl","canConnect","revokedReason","connectedAt","installUrl"}. Carries no token.
POST/api/v1/github/account/connectsessionStarts the OAuth flow. Body {"projectId":"…"} or {}. 200 {"authorizeUrl":"…"} and sets the HttpOnly binding cookie, or 503 github_oauth_not_configured.
DELETE/api/v1/github/accountsessionForgets the connection and destroys the stored credential. 204, or 404 not_connected for a caller with no row at all. Idempotent for an already-revoked row.
GET/api/v1/github/oauth/callbacknone (see below)Completes the flow and redirects to a fixed relative path. Anonymous by construction: the caller is a browser navigation from github.com, the person is identified by our own single-use state row, and the request is authenticated by PKCE plus the binding cookie.

The callback is deliberately not an API for clients. It is the one endpoint reached by a top-level navigation, so it answers 302 and never JSON, and its Location is built only from a Guid read out of our own ticket and an outcome from an enum — nothing from the query string.

Data model​

TableOwned byNotes
project_github_linksInference APIOne row per project (unique project_id) and per repository (unique repository_id). No tenant query filter: it is a child of projects, reached through the project access check or by the webhook from a signature-verified repository id.
github_app_installationsInference APIWhich organization owns an installation (unique installation_id). No tenant filter, and that is the point — seeing another organization's claim is what makes the refusal possible.
github_webhook_deliveriesInference APIThe idempotency ledger and the work queue in one table. Unique delivery_id; a null processed_at means the pull worker still owes it work. Organization-less: the row is written before the payload's owner is known.
project_files.source_kind / source_revisionInference API'github' marks a pulled row and source_revision holds its blob SHA. Null for uploads and agent writes, which is what keeps a pull away from them.
github_account_linksInference APIOne row per (user_id, organization_id) (unique), holding that person's encrypted user access token and refresh token. Indexed on github_user_id so the revocation webhook, which has no tenant, can find every row for the account that revoked. No tenant query filter: the row is never looked up by an id a caller supplied, only by the session's own (user id, active organization) pair — and the webhook, which has neither, could not find it through a filter.
github_oauth_statesInference APISingle-use OAuth tickets: the SHA-256 of the state, the SHA-256 of the browser-binding cookie value, the encrypted PKCE verifier, and the (user, organization, project) the flow was started for. Consumed by writing consumed_at, so a replay finds no live row. Expired and spent rows are swept when the same person starts another flow.

Deleting a project cascades to its link. Deleting a link leaves the pulled files. Revoking or disconnecting a GitHub account touches only github_account_links — never a project link, never a file.

Troubleshooting​

SymptomLikely cause
503 github_not_configured on the webhookGITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY and GITHUB_WEBHOOK_SECRET are not all set on the inference service.
401 invalid_signature for a real deliveryGITHUB_WEBHOOK_SECRET does not match the secret on the app. A proxy that rewrites the body would do it too; the nginx location above does not.
Linking answers 404 github_app_not_installedThe app is not installed on that repository (or is installed on a different account), or another organization already claimed that installation.
Linking answers 503 github_app_not_configuredThe app id or private key is missing, or the PEM is not a readable private key.
A push is accepted but nothing changesCheck github_webhook_deliveries.outcome: ignored_ref means the push was to another branch, no_link means the repository is not linked, installation_mismatch means the delivery came from a different installation, stale means a newer commit had already been applied.
A push settles as failederror on the delivery row says why (a GitHub 404 on the tree, a revoked installation, a limit). The next push retries the work.
Files are missing from the projectThey may exceed MaxFileBytes, sit under .git/ or node_modules/, lie outside the link's pathPrefix, or already exist as a file a person uploaded — a pull never takes a path somebody else owns.
The dashboard offers no "Connect GitHub account" buttonGITHUB_APP_CLIENT_ID, GITHUB_APP_CLIENT_SECRET or GITHUB_APP_OAUTH_CALLBACK_URL is missing on the inference service, so canConnect is false. The tab says so and keeps the by-name form.
GitHub says "The redirect_uri MUST match the registered callback URL"GITHUB_APP_OAUTH_CALLBACK_URL and the App's registered Callback URL differ. They must match byte for byte, including scheme, host and trailing path.
The callback lands back on the page as ?github=state_mismatchThe binding cookie did not come back. Usually the flow was completed in a different browser or profile, or the App's callback URL changed host (the cookie is Secure when the callback URL is https, and Path=/api/v1/github). Start again from the project page.
The picker lists fewer repositories than the person ownsExpected. The App can only read what it was installed on, and the user token is that intersected with the person's own access. Use the "Add another on GitHub" link (for a selected install) or "Install the ReelBolt App".
The picker lists nothing at all, but the account is connectedThe App is not installed on any account that person can reach, or the installation covers no repositories they can see. The picker says this rather than showing a bare empty list.
Connecting succeeds, then the next page load says the connection is revokedGitHub refused a token refresh, or the dpkeys volume changed under the service so the stored ciphertext could not be decrypted. Both revoke the row deliberately and ask the person to reconnect; the second one logs Could not decrypt a GitHub OAuth code verifier or revokes with decryption_failed.
A revocation webhook arrives but nothing changesThe delivery settles as authorization_unknown when no github_account_links row carries that sender.id. Check github_webhook_deliveries.outcome; authorization_revoked means rows were revoked.
Behind a staging auth_basic front door, the callback stallsThe browser must be able to reach /api/v1/github/oauth/callback with its cached basic-auth credentials; the generic location /api/v1/ block covers it. A proxy that rewrites the response or blocks the Set-Cookie on connect breaks the binding cookie, and the callback then reports state_mismatch.

What is deliberately not done​

  • ReelBolt never writes to GitHub. The app asks for contents: read and nothing else: no commits, no branches, no pull requests. A push is an input, not an output.
  • No personal access tokens, ever. The only credential is the app's own key, which the owner generates in his own account and can revoke at any time without touching ReelBolt.
  • Pulled files are not summarized or embedded (see "What "pull" means" above). Indexing a repository is a deliberate act, not a side effect of a push.
  • One repository per project, and one project per repository. A repository linked by two projects would make "which project does this push update" ambiguous.
  • No force-push or history handling beyond the commit. The pull materializes the tree at the pushed commit; it does not try to interpret the commits in between.
  • The picker does not change what ReelBolt may read. Connecting an account grants no new permission: a GitHub App requests no scopes, this App has contents: read and metadata: read, and a user token is that intersected with the person's own access. The account connection exists to enumerate, not to widen.
  • No personal access tokens, and no second OAuth app. The user token comes from the same GitHub App, through its own OAuth flow — one registration, one permission list, one thing to revoke.
  • The by-name link path is not removed. It is the only path on a deployment whose App has no client credentials, and it is what works when somebody's account connection is broken. It is demoted in the UI, not deleted, and the picker does not get its own write path: a chosen repository is still linked by its owner/name through the same verified PUT.
  • The folder browser walks the tree one level at a time rather than fetching one recursive listing. A recursive listing is far cheaper for a small repository and refuses to work for a large one, where GitHub truncates the tree — and the whole point of the browser is that it works for the repositories the picker can reach.
  • A repository the App was never installed on cannot be listed, only linked to an install page. No code can change this: GitHub will not enumerate a repository to a token that cannot read it, and the UI says so rather than showing a list that quietly omits it.