Skip to content

feat(app): hosted multi-tenant GitHub App relay (PRD M3) - #110

Merged
bamr87 merged 1 commit into
mainfrom
feat/github-app-multi-tenant-relay
Aug 20, 2026
Merged

feat(app): hosted multi-tenant GitHub App relay (PRD M3)#110
bamr87 merged 1 commit into
mainfrom
feat/github-app-multi-tenant-relay

Conversation

@bamr87

Copy link
Copy Markdown
Owner

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.js into ten modules under app/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, fire repository_dispatch. It holds no code, no credentials, and no model traffic — app/DATA-HANDLING.md is the verifiable inventory, and tests/test_app.py asserts 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_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.

Org policy lives in 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. It is subtractive by construction — evaluatePolicy can only return allowed: 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 a githubai.yml means the same thing in both languages.

How verified

$ cd app/relay && npm test
ℹ tests 68 ℹ pass 68 ℹ fail 0
$ python3 -m pytest -q
53 passed in 0.54s
$ python3 tools/unwrap-prose.py --check
All markdown prose already unwrapped.
$ shellcheck setup/install.sh # clean

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 relay job 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_call inputs, load-config outputs, config schema keys, template paths, dispatch types, and label names are all untouched. template/workflows/claude-dispatch.yml is unchanged; template/githubai-org.yml is additive.

Three things a relay operator (not a framework consumer) should know:

  • app/relay/worker.js is gone, replaced by src/index.js; wrangler.toml points at the new entry. Anyone running the old scaffold redeploys — there was no state to migrate, and there is no published deployment.
  • Manifest permissions were reducedissues and pull_requests dropped from write to read, since the relay only ever fires repository_dispatch. Reducing permissions does not require installations to re-accept.
  • The relay now has state (D1), so a deploy needs wrangler d1 create + schema.sql before it works. app/OPERATIONS.md is the runbook; /health returns 503 when config or the database is missing rather than accepting webhooks it cannot dispatch.

Two deliberate omissions, both noted in app/MARKETPLACE.md: no claude-dispatch.yml in this repo (it would be inert until a relay is deployed), and no plan-tier enforcement (RATE_LIMIT_PER_MINUTE is global — gating on installation.plan is meaningless until pricing exists).

🤖 Generated with Claude Code

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>
@bamr87
bamr87 merged commit 3407e57 into mainAug 20, 2026
6 of 7 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@bamr87