feat(app): hosted multi-tenant GitHub App relay (PRD M3) - #110
Merged
Conversation
Takes app mode from a stateless routing sketch to the hosted app the PRD
describes: installation lifecycle, per-org policy, an activity dashboard,
and marketplace readiness. The trust model is unchanged — Claude still runs
only in each repository's own Actions with that repository's own token, and
the relay's App credential does exactly one thing: fire repository_dispatch.
The relay grows from one 200-line worker.js into ten modules under
app/relay/src/, still with zero npm dependencies and no build step.
Routing parity fix
------------------
App mode was quietly more permissive than Actions mode. A reusable workflow
reached through repository_dispatch has no issue or PR in github.event, so
the `if:` conditions that skip claude:skip, drafts, forks and bot-authored
issues on direct events could never fire. routing.js now applies those gates
before dispatching, and both suites assert the parity.
Multi-tenancy
-------------
- installation / installation_repositories events maintain tenant records
(create, suspend, unsuspend, repo add/remove, delete); every query is
scoped by installation id, and uninstall deletes the tenant immediately.
- D1 for durable state behind a store interface with an in-memory twin, so
every module is testable without Cloudflare.
- Webhook deduplication by delivery id, released on dispatch failure so a
transient error stays redeliverable from the App's Advanced tab.
- Per-installation rate limiting; nightly cron enforces 30-day retention.
Org policy
----------
Read from each org's own .github repo at .github/githubai-org.yml, in the
schema repos already use, so the service stores no configuration a customer
cannot see and revert in git. Subtractive by construction: evaluatePolicy
can only return allowed:false, never enable an area a repo disabled and
never change who may authorize work. A document that stops parsing keeps
enforcing the last one that parsed — failing open would turn a typo into a
silent policy bypass, failing closed into an org-wide outage.
Parsing it in a dependency-free Worker needs yaml.js, a deliberately small
YAML subset that throws on anything it cannot represent faithfully. Its
tests assert byte-identical results with PyYAML on this repo's real configs,
so a githubai.yml means the same thing in both languages.
Dashboard
---------
Read-only, GitHub sign-in, no scripts, strict CSP, entitlement from a signed
8-hour cookie. The user token used to learn installation ids is discarded
immediately and never stored. Deliberately narrow: it answers what the
router did with your events and why, and nothing else — the PRD's "no web UI
beyond GitHub" still holds.
Marketplace
-----------
Public manifest with least privilege (contents:write only because the
dispatches endpoint requires it; issues and pull_requests dropped to read),
marketplace_purchase plan handling, and self-service App registration behind
SETUP_TOKEN. Remaining work is a deployment, privacy policy, terms, verified
publisher status and pricing — business decisions, tracked in MARKETPLACE.md.
Tests: 68 Node (routing, policy fallback, sessions, tenant isolation, the
whole webhook pipeline) plus 53 Python; CI gains a relay job. The relay gets
real behavioural tests because it is the one component no repo dogfoods.
Docs: new app/{OPERATIONS,DATA-HANDLING,MARKETPLACE}.md; README, PRD,
CLAUDE.md, architecture, security and configuration updated. Corrects the
architecture doc's "the relay holds no state", which this change makes false.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What & why
Takes app mode from a stateless routing sketch to the hosted GitHub App the PRD describes as M3: installation lifecycle, per-org policy, a read-only activity dashboard, and marketplace readiness. The relay grows from one 200-line
worker.jsinto ten modules underapp/relay/src/, still with zero npm dependencies and no build step.The trust model is unchanged, and that is the point. Claude still runs only inside each repository's own Actions with that repository's own
CLAUDE_CODE_OAUTH_TOKEN; the relay's App credential does exactly one thing, firerepository_dispatch. It holds no code, no credentials, and no model traffic —app/DATA-HANDLING.mdis the verifiable inventory, andtests/test_app.pyasserts the schema has no column that could hold a token.Routing parity fix (the one behavioral bug found). App mode was quietly more permissive than Actions mode: a reusable workflow reached through
repository_dispatchhas no issue or PR ingithub.event, so theif:conditions that skipclaude:skip, drafts, forks, and bot-authored issues on direct events could never fire.routing.jsnow applies those gates before dispatching, and both suites assert the parity.Org policy lives in each org's own
.githubrepo at.github/githubai-org.yml, in the schema repos already use, so the service stores no configuration a customer cannot see and revert in git. It is subtractive by construction —evaluatePolicycan only returnallowed: false, never enable an area a repo disabled, never change who may authorize work. A document that stops parsing keeps enforcing the last one that parsed: failing open would turn a typo into a silent policy bypass, failing closed into an org-wide outage.Parsing YAML in a dependency-free Worker needs
src/yaml.js, a deliberately small subset that throws on anything it cannot represent faithfully (anchors, aliases, merge keys, tags, multi-doc). Its tests assert byte-identical results with PyYAML on this repo's real config files, so agithubai.ymlmeans the same thing in both languages.How verified
The relay gets real behavioral tests because it is the one component no repository dogfoods: routing parity, policy caching and last-known-good fallback, session/signature handling, tenant isolation (a session for one installation cannot see another's activity), HTML escaping of tenant data, and the full webhook pipeline including duplicate, suspended, policy-blocked, rate-limited, and failed-dispatch paths. CI gains a
relayjob on Node 22.Not verified end-to-end against a live GitHub App — no relay is deployed yet. The GitHub API surface (
github.js) is exercised only through fakes plus a real WebCrypto round trip for signatures and sessions.Risk & compatibility
No breaking changes to the framework's public API. Workflow names,
workflow_callinputs, load-config outputs, config schema keys, template paths, dispatch types, and label names are all untouched.template/workflows/claude-dispatch.ymlis unchanged;template/githubai-org.ymlis additive.Three things a relay operator (not a framework consumer) should know:
app/relay/worker.jsis gone, replaced bysrc/index.js;wrangler.tomlpoints at the new entry. Anyone running the old scaffold redeploys — there was no state to migrate, and there is no published deployment.issuesandpull_requestsdropped fromwritetoread, since the relay only ever firesrepository_dispatch. Reducing permissions does not require installations to re-accept.wrangler d1 create+schema.sqlbefore it works.app/OPERATIONS.mdis the runbook;/healthreturns 503 when config or the database is missing rather than accepting webhooks it cannot dispatch.Two deliberate omissions, both noted in
app/MARKETPLACE.md: noclaude-dispatch.ymlin this repo (it would be inert until a relay is deployed), and no plan-tier enforcement (RATE_LIMIT_PER_MINUTEis global — gating oninstallation.planis meaningless until pricing exists).🤖 Generated with Claude Code