@forwardimpact/svcghbridge
v0.1.6
Published
GitHub Discussions bridge — relay messages between GitHub Discussion threads and the Kata agent team.
Maintainers
Readme
GitHub Discussions Bridge
GitHub Discussions bridge — relay messages between GitHub Discussion threads and the Kata agent team.
For the trust model when this bridge runs as the hosted Forward Impact service vs the customer's self-hosted deployment, see TRUST.md.
For configuring the GitHub server App this bridge uses (self-hosted holds
the key here; hosted custodies it in services/ghserver), see
services/ghserver § github-app.md.
Prerequisites
- The Kata Agent Team GitHub App with
discussions: writepermission and webhook subscriptions fordiscussionanddiscussion_commentevents (see the kata-setup skill for initial App creation). - An installation of that App on the target repository.
- The
ghuserservice running and reachable (provides per-user GitHub tokens for dispatch). Each user who triggers a dispatch must have linked their GitHub account through the OAuth flow — the bridge posts a link prompt on the discussion when a link is missing.
The App installation token is still used for posting replies, reactions,
and declined-dispatch notices — only the workflow_dispatch call uses
the per-user token.
Dependencies
| Service | Why |
| --- | --- |
| bridge | Canonical discussion and origin store (gRPC) |
| ghuser | Per-user GitHub token for workflow_dispatch |
Discussion state is owned by services/bridge; the bridge talks to it
over gRPC and keeps no on-disk discussion state of its own. Operators
upgrading from a bridge that predates this service can safely delete
legacy data/bridges/ghbridge/ files; they expire under their existing
24-hour TTL regardless.
Tenancy mode
SERVICE_GHBRIDGE_TENANCY_MODE selects the deployment shape:
single(default, self-hosted) — the bridge reads the App private key in process, mints installation tokens from the staticapp_installation_id, and threads the literal tenant iddefaultthrough everyservices/bridgeRPC via aDefaultTenantResolver. Per-user OAuth (services/ghuser) supplies theworkflow_dispatchcredential.multi(hosted) — the bridge holds no App key. It resolves the tenant per inbound webhook from the delivery's repository (resolveByRepo), mints repo-scoped tokens throughservices/ghserverfor the reply/reaction path, and scopes every store RPC by the resolved tenant. Theworkflow_dispatchcredential is the dispatching user's per-user OAuth token (services/ghuser), the same per-user path as single-tenant.installation.created/installation.repositories_addeddeliveries onboard repositories into the registry (services/tenancy) withstate = active.
Multi-tenant dependencies
| Service | Why |
| --- | --- |
| services/tenancy | Tenant registry — resolves a delivery's repo to a tenant and records onboarding upserts |
| services/ghuser | Per-user GitHub token for workflow_dispatch (the dispatch credential in both modes) |
| services/ghserver | Mints repo-scoped App installation tokens for replies and reactions (the bridge never holds the App key) |
Deferred: installation.repositories_removed revoke
Onboarding upserts on installation.created /
installation.repositories_added. The revoke path — rotating a tenant from
active to revoked on installation.repositories_removed or a full
uninstall — is not handled here. A partial uninstall leaves the active row
in place until the revoke path ships. Self-hosted (single) deployments are
unaffected.
Documented limitation: multi-tenant elapsed-recess re-arm on restart
In single mode, the bridge re-arms time-based (elapsed-trigger) recesses at
startup: ResumeScheduler.rearm() reads the open recesses for the one tenant
(default) and re-schedules each. In multi mode there is no single tenant at
boot, and the registry does not expose a cross-tenant enumeration of open
recesses, so rearm() returns nothing. The consequence: a hosted bridge that
restarts while an elapsed recess is pending does not fire that recess on a
timer. Instead, multi-tenant elapsed-trigger recesses re-arm lazily on the
next inbound activity on the thread (the resume lifecycle runs through
processInbound). missing_input recesses are unaffected — they resume on the
next reply regardless of restart. Self-hosted (single) re-arm behaviour is
unchanged.
Configuration
Loaded via createServiceConfig("ghbridge")):
| Env var | Purpose |
| --- | --- |
| SERVICE_GHBRIDGE_URL | Listen URL (default http://localhost:3013) |
| SERVICE_GHBRIDGE_GITHUB_REPO | owner/repo target |
| SERVICE_GHBRIDGE_CALLBACK_BASE_URL | Public URL the workflow POSTs callbacks to |
| SERVICE_GHUSER_URL | gRPC address of the ghuser service |
| SERVICE_GHBRIDGE_APP_ID | Kata App numeric id |
| SERVICE_GHBRIDGE_APP_PRIVATE_KEY | PEM contents (see § Private key format) |
| SERVICE_GHBRIDGE_APP_INSTALLATION_ID | Installation id for the target repo |
| SERVICE_GHBRIDGE_APP_WEBHOOK_SECRET | Shared secret for X-Hub-Signature-256 verification |
| SERVICE_GHBRIDGE_TENANCY_MODE | single (default) or multi — see § Tenancy mode |
Private key format
The PEM file must be entered as a single line with literal \n replacing
each line break, wrapped in double quotes:
SERVICE_GHBRIDGE_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIE...\n...\n-----END RSA PRIVATE KEY-----"Convert a .pem file to this format:
awk 'NR>1{printf "\\n"}{printf "%s",$0}' path/to/your-key.pemPaste the output between double quotes after the =.
Service supervision
If you supervise ghbridge via fit-rc, list bridge ahead of the bridge
entries in init.services so createClient('bridge', …) resolves at startup.
Running
Add ghtunnel and ghbridge to config/config.json under
init.services — see config/CLAUDE.md for the
entry format. List the tunnel before the bridge so that restarting the
bridge does not cycle the tunnel (declaration order determines restart
scope).
Start both services:
bunx fit-rc startThe tunnel uses a quick trycloudflare.com hostname that changes on
every restart. After starting, check the tunnel log for the assigned URL:
cat data/logs/ghtunnel/current | grep trycloudflare.comGitHub App webhook configuration
In the App settings (github.com/organizations/<org>/settings/apps/<app>):
- Under Webhook, check Active.
- Set Webhook URL to
https://<tunnel-domain>/api/webhook. - Set Secret to a shared value and save the same value as
SERVICE_GHBRIDGE_APP_WEBHOOK_SECRETin.env. - Under Permissions & events → Subscribe to events, check Discussions and Discussion comments.
- Save changes.
Set SERVICE_GHBRIDGE_CALLBACK_BASE_URL in .env to the tunnel domain
(without any path), then restart only the bridge:
bunx fit-rc restart ghbridgeThe tunnel keeps its hostname across bridge restarts.
Corporate network considerations
The bridge must be able to reach api.github.com to dispatch workflows
and post GraphQL replies. If you are on a corporate VPN with tenant
restrictions, disconnect before starting.
Smoke test
Open a new GitHub Discussion in the configured repository. The bridge:
- Verifies the
X-Hub-Signature-256against the webhook secret. - Saves a discussion record to
services/bridgekeyed by the discussion'snode_id. - Dispatches
kata-dispatch.ymlviaworkflow_dispatch. - Adds an "EYES" reaction to the discussion as a progress indicator.
The bridge then waits for the workflow's callback. When it arrives:
- If
verdict: "adjourned"— eachreplyinpayload.repliesbecomes a threaded comment viaaddDiscussionComment. The RFC is closed. - If
verdict: "recessed"— the bridge persists the trigger and re-dispatches the workflow withresume_contextwhen the trigger fires. - If
verdict: "failed"— the summary is posted to the thread so the human sees the failure surface; no re-dispatch.
