Skip to content

Hosted shunt — architecture

Status: Active design. Approved decisions are listed first. Open questions at the end are for future work, not blockers.

gondolier is a hosted, multi-tenant version of shunt — the batch-then-bisect merge queue for Forgejo/Gitea. It manages tenant forge connections, encrypted credentials, and queue execution while reusing the shunt engine’s stage → gate → bisect mechanics unchanged.

Layer Choice Rationale
Compute Cloudflare Workers (Go/WASM) Free tier: 10M req/day, 100 CPU sec/day. Serverless, scales to zero. Same platform as pokomplete.
Database Supabase PostgreSQL Free tier 500MB. Managed. Auth integration. Same as pokomplete.
Secrets Cloudflare Secrets (master key) Serverless KMS. Zero infra cost. ~$0.
Encryption Envelope AES-GCM (Go crypto) Per-tenant DEK encrypted by master key. Never stored in plaintext.
Auth API keys per tenant Simplest. No auth infra to build. Supabase SSO v2.
Scheduler Workers cron + Durable Objects Serverless scheduler. Per-tenant lease via DO.
Email Resend Same stack as pokomplete. Generous free tier.
Analytics PostHog Same stack.
Queue state Cloudflare KV Fast transient storage. Engine state re-derivable from forge API.

Why not self-hosted: laputacloudco’s portfolio runs on managed services. Self-hosted OpenBao is for personal infra secrets only — not acceptable for customer data. We’re cost-sensitive until revenue covers infra costs.

The core algorithm and forge mechanics are unaffected by hosting:

  • Batch-then-bisect merge queue: N ready PRs land in one CI run when green; a failing batch bisects in log₂(N) runs to isolate the bad PR(s).
  • No bot commands — a PR joins the queue via the forge’s native “merge when checks succeed”.
  • Landing goes through the forge’s own merge endpoint after setting a required commit status; branch protection (required status + push allow-list) is the actual enforcement point, not shunt’s own state.
  • Queue membership and batch state are largely re-derivable from the forge API each cycle. This statelessness is the main asset carried into a hosted design — in-flight batches survive a crash or restart without a WAL.

Everything below is about turning “one process, one forge, one bot token” into “many tenants, many forges, isolated credentials,” by importing github.com/rbtr/shunt/internal/engine unchanged and providing a Workers I/O abstraction layer.

Unit of isolation is (tenant_id, forge_instance). A tenant may point at more than one forge instance (self-hosted + a secondary), and a forge instance is never shared for credential purposes across tenants even if two tenants happen to point at the same URL (e.g. two customers on the same Codeberg-alike).

tenant
├─ forge_connection (instance URL, bot identity, encrypted token)
│ ├─ managed_repo (owner/repo, base branch, status context, merge style)
│ └─ managed_repo ...
└─ forge_connection ...

managed_repo is the direct analog of what manager.Manager builds today from SHUNT_TOPIC discovery or SHUNT_REPO, just persisted instead of recomputed from env vars at startup.

Forgejo/Gitea act as OAuth2 providers (RFC 6749 Authorization Code Grant, PKCE, refresh tokens, OIDC discovery). Onboarding:

  1. Tenant creates or designates a bot account on their forge instance.
  2. Auth exchange — two paths:
    • OAuth2 authorization code grant, where scoped. On shared public instances like Codeberg, we register one OAuth2 application ourselves (/user/settings/applications, done once by laputacloudco), and every tenant on that instance authorizes through a standard “Sign in with Codeberg”-style consent redirect — no token ever changes hands. On self-hosted tenant instances, someone registers the OAuth2 application there once (/admin/applications, or automated via POST /api/v1/user/applications/oauth2 given one initial admin-scoped setup token) — a one-time cost per instance, not per tenant or repo.
    • Scoped Personal Access Token paste, as a fallback where OAuth doesn’t help (see caveat) or where the tenant prefers not to register an OAuth application at all.
  3. Whichever path, shunt encrypts and stores the resulting credential, then calls EnsureBranchProtection on each opted-in repo.
  4. Tenant adds the mq/**-scoped gate workflow to their repo (unchanged — runs on the tenant’s own Actions runners, so hosted shunt never needs CI compute).

Caveat, verified against current docs: Forgejo’s OAuth2 provider has no scopes implemented yet — a token obtained via OAuth “will have administrative rights” on the authorizing account, full stop (source: forgejo.org/docs/latest/user/oauth2-provider/). That’s broader than a repo-scoped PAT, not narrower — so on Forgejo/Codeberg specifically, OAuth today is a UX win but a blast-radius regression versus a scoped PAT. Gitea 1.23+ has since shipped granular OAuth2 scopes (read:repository, write:repository, etc.), so on Gitea-hosted tenants OAuth is a strict improvement — better UX and a tighter, revocable, refreshable grant.

Net design: default to OAuth2 only where it is genuinely scoped (Gitea 1.23+, and any future Forgejo release that ships OAuth2 scopes); fall back to scoped-PAT paste everywhere else, including Codeberg and self-hosted Forgejo today. Surface which path a given instance gets, and why, rather than presenting OAuth as uniformly safer than it currently is.

Supabase PostgreSQL. Three categories:

  • Tenant config (durable, low write volume): tenants, forge connections, managed repos, per-repo settings. The only state that must never be lost.
  • Encrypted credentials: forge tokens (PAT or OAuth access+refresh token pairs), envelope-encrypted with a KMS-backed key (Cloudflare Secrets master key → per-tenant DEK), never logged, never returned plaintext.
  • Operational/audit log (durable, append-only, disposable): batch history, bisection steps, land/bounce events — useful for dashboard/support but not required for correctness.

Workers cron trigger fires every 5 minutes. Each invocation processes one tenant’s repos sequentially:

  1. Acquire a per-(tenant, repo, base) lease via Durable Object (short-TTL).
  2. Fetch tenant config + encrypted token from Supabase (REST, service role).
  3. Decrypt token from Cloudflare Secrets (master key → DEK → token).
  4. Run engine tick (imported from github.com/rbtr/shunt/internal/engine).
  5. Update queue state in KV if needed.
  6. Write result to audit log in Supabase.
  7. Release lease.

If a tick exceeds 30s Workers timeout, the result is partial — the next cron tick retries and the engine re-derives from forge API (the re-derivation property handles this gracefully). This is acceptable because engine state isn’t persisted; it’s re-derived from the forge each cycle.

A per-repo lease (Durable Object) ensures at most one tick per repo at a time, avoiding double-staging without global coordination.

  • A per-(tenant, forge_instance) limiter via Cloudflare KV (sliding window).
  • Backoff on 429s/5xx scoped to the failing connection only.
  • One tenant’s noise cannot degrade another tenant’s queue latency or burn another tenant’s forge API rate limit.

REST API on Workers (net/http routing). Operations:

  • POST /api/v1/tenants — create tenant
  • GET /api/v1/tenants — list tenants
  • GET /api/v1/tenants/:id — get tenant
  • PUT /api/v1/tenants/:id — update tenant
  • DELETE /api/v1/tenants/:id — delete tenant
  • POST /api/v1/tenants/:id/connections — add forge connection
  • GET /api/v1/tenants/:id/connections — list connections
  • PUT /api/v1/tenants/:id/connections/:cid — update connection (token rotation)
  • DELETE /api/v1/tenants/:id/connections/:cid — remove connection
  • POST /api/v1/tenants/:id/connections/:cid/repos — add managed repo
  • GET /api/v1/tenants/:id/connections/:cid/repos — list managed repos
  • DELETE /api/v1/tenants/:id/connections/:cid/repos/:rid — remove managed repo
  • GET /api/v1/tenants/:id/queue — get queue state
  • GET /api/v1/tenants/:id/repos/:rid/batch — get current batch
  • GET /api/v1/tenants/:id/audit — get audit log (paginated)

All endpoints require X-API-Key header (per-tenant API key stored in Supabase).

Cloudflare Secret (master key, ~512 bit)
↓ AES-GCM
Per-tenant DEK (256 bit, stored encrypted in Supabase)
↓ AES-GCM
Forge token (PAT string or OAuth access/refresh pair, stored as JSON blob)
  • Master key stored in Cloudflare Secrets (set by operator, never in repo).
  • Per-tenant DEK: random 256-bit key, encrypted with master key, nonce stored alongside encrypted DEK in Supabase.
  • Forge token: encrypted with DEK, nonce stored alongside in Supabase.
  • Decryption: master key (Secrets) → DEK (DB) → token (DB). Done in-memory during engine tick only. Never logged, never returned by API.
  • Token rotation: encrypt new token with same DEK, atomically swap in Supabase. Old token invalid at next tick. No overlap window.

Tenant-provided webhook URL on repo config. POST JSON on bounce:

{
"event": "bounce",
"tenant_id": "...",
"repo": "owner/repo",
"pr_number": 42,
"pr_title": "fix: something broken",
"pr_head_sha": "abc123",
"reason": "status check failed: ci/build",
"timestamp": "2026-08-03T12:00:00Z"
}

v2: Slack/Discord incoming webhooks (just POST to different URL), email (Resend).

  • REST API — tenant management, queue inspection, bounce notification.
  • Web UI — v2. Same operations via HTML, no separate auth system.
  • Notifications — webhook on bounce (v1), Slack/Discord/email (v2).
  • Tenant forge tokens are the highest-value secret — compromise gives push access + merge rights on tenant repos. Envelope encryption at rest; decrypt only in-memory in the worker tick; never log.
  • Per-tenant isolation: a worker tick holds at most one tenant’s decrypted token in memory. KV rate limits are per-(tenant, forge_instance).
  • Audit log records credential access (without raw values), tenant changes, and forge API errors.

Natural metering unit: CI runs saved = (PRs merged) - (gate workflow triggers) per billing period. Directly defensible “here’s what you paid for” number.

A self-hosted deployment moves to hosted without changing forge-side state (branch protection, merge-queue status context, gate workflow are all forge-side artifacts). Migration is “point config at hosted scheduler instead of local process.”

Import tool reads old env vars (SHUNT_REPO, SHUNT_TOPIC, SHUNT_TOKEN) and creates equivalent tenant/repo/connection records via the API.

  • Pricing model details (per-repo vs. per-merged-PR vs. per-batch).
  • Whether to offer managed bot-account provisioning flow (requires org admin scope from tenant) vs. requiring tenants to bring their own bot account.
  • Multi-region: whether tenants care about data residency for the audit log.
  • Web UI framework: Go templates (simple, one binary) vs. separate SPA (separate deploy, but richer UX).
  • Billing integration (Stripe): when do we add it?