gondolier — Production Readiness Roadmap
gondolier — Production Readiness Roadmap
Section titled “gondolier — Production Readiness Roadmap”Status: Active. This is the canonical go-live checklist. Last updated: 2026-08-10
Purpose
Section titled “Purpose”The core shunt engine is proven. The gap between “engine works” and “service is safe to charge money for” is ~13 TODO-bearing stubs, an unaudited credential path, and missing operational visibility. This document ranks every gap by severity, estimates complexity, annotates parallelizability, and provides dispatch-ready acceptance criteria and methodology.
Gap inventory
Section titled “Gap inventory”13 in-code TODO markers, 5 stub packages, 3 hardcoded secrets, 0 telemetry
integration, and an encryption pipeline that isn’t wired in the API handler
(raw tokens stored as bytes with a TODO comment).
Critical (go-live blockers)
Section titled “Critical (go-live blockers)”| # | Gap | Location | Severity |
|---|---|---|---|
| C1 | Token encryption not wired in API | internal/api/handler.go:443 — // TODO: encrypt token with envelope encryption — raw token stored as []byte(req.Token) |
Data loss class. Every connection created via the API stores the forge token in plaintext in Supabase. |
| C2 | STUB_JWT_SECRET in production | STUB_JWT_SECRET is generated at deploy time from OpenBao but labeled “replace before production auth” |
Auth bypass class. Stub secrets break session integrity. |
| C3 | STUB_JWT_SECRET / no real auth | STUB_JWT_SECRET generated at deploy time; no real Supabase Auth integration with production credentials |
No real auth. Dashboard login stub allows any session; no user can actually sign in. |
| C4 | Engine not wired to real repo data | main.go uses in-memory map[string]repoConfig; cron iterates an always-empty map in production |
Engine never runs. No tenant repo gets reconciled. |
| C5 | Workers cold-start data loss | main.go globals (repoConfigs, engineCache) are in-memory maps that vanish on cold start |
State loss class. Even if populated, configs disappear on next cold start. |
| C6 | No real auth for dashboard/API | Supabase Auth not wired with production credentials; Bearer auth uses STUB_JWT_SECRET |
No access control. API keys only; no real user sessions. |
High (should block go-live)
Section titled “High (should block go-live)”| # | Gap | Location | Severity |
|---|---|---|---|
| H1 | Security audit of credential path | All pkg/crypto/, internal/api/handler.go, internal/forge/, log lines |
Compliance class. Must verify tokens never appear in logs, errors, API responses. |
| H2 | No Stripe billing integration | internal/billing/billing.go — 3 TODO stubs |
Revenue block. Cannot charge customers. |
| H3 | No billing metering | No code counts PR merges, tracks tier limits, or enforces quotas | Revenue leakage. Free tier = unlimited. |
| H4 | Dashboard is hardcoded HTML | internal/dashboard/dashboard.go — all content is static template.HTML strings, no DB reads |
UX block. Dashboard shows zeros always. |
| H5 | No structured logging / log aggregation | slog used locally; Workers [observability] enabled but no log shipping, no retention, no alerting |
Ops blind. Can’t debug production issues. |
| H6 | No monitoring or alerting | No health checks beyond /healthz, no metrics, no paging |
Ops blind. Service can fail silently. |
| H7 | No PostHog / frontend telemetry | Zero analytics code; design.md mentions PostHog but nothing implemented | Product blind. Can’t measure conversion or usage. |
| H8 | Forgejo webhook secret not wired | WebhookSecretForTenant() in Go returns "" always |
Spoofable webhooks. HMAC verification effectively disabled in Go worker. |
| H9 | Input validation missing at API surface | No validation on instance_url, repo_slug, bot_login — raw strings accepted |
Injection risk. Malformed URLs, oversized slugs. |
| H10 | Database backup/restore not documented | Supabase has backups on paid plans; no documented restore procedure | Disaster recovery gap. |
Medium (acceptable post-launch)
Section titled “Medium (acceptable post-launch)”| # | Gap | Location | Severity |
|---|---|---|---|
| M1 | LeaseDO Go bridge not wired | main.go:481-487 — acquireLeaseViaLeaseDO returns fmt.Errorf("LeaseDO not wired in Go worker") |
Go worker can’t coordinate with JS LeaseDO. |
| M2 | Webhook event routing incomplete | handleWebhook in main.go decodes but doesn’t act on events |
Webhooks accepted but discarded. |
| M3 | Sell site uses Supabase anon key inline | site/index.html has hardcoded Supabase URL + anon key |
Keys in source; rotation requires redeploy. |
| M4 | Documentation site (Astro) not built | docs-site/astro.config.mjs exists but no built site |
Docs are markdown files only. |
| M5 | Email notifications (Resend) not wired | No Resend integration | Bounce emails not sent. |
| M6 | Slack/Discord notifications not wired | No integration code | Bounce notifications HTTP-webhook only. |
| M7 | Status page not set up | status.gondolier.dev referenced in footer but doesn’t exist |
No public status communication. |
| M8 | CI/CD only runs on PR to main | .forgejo/workflows/ci.yaml — no staging environment, no canary deploy |
All deploys go straight to prod. |
| M9 | No load testing | No perf baseline | Unknown capacity limits. |
| M10 | No tenant data export / offboarding | No API for tenant to export their data | GDPR/data-portability gap. |
Prioritized workstreams with parallizability
Section titled “Prioritized workstreams with parallizability”Each workstream can be dispatched to a subagent fleet. Workstreams are ordered by dependency chain, but within a tier, all streams are parallelizable.
Tier 1: Foundation (MUST complete before Tier 2) ├─ WS-A: Token encryption fix + credential audit └─ WS-B: Production auth (Supabase Auth + JWT)
Tier 2: Core features (parallel after Tier 1) ├─ WS-C: Engine production wiring (DB-backed config, LeaseDO bridge) ├─ WS-D: Stripe billing integration ├─ WS-E: Metering & tier enforcement └─ WS-F: Dashboard real-data wiring
Tier 3: Operations (parallel with Tier 2) ├─ WS-G: Observability (logs, metrics, alerting) ├─ WS-H: PostHog / frontend telemetry ├─ WS-I: Security audit (credential path, input validation, webhook HMAC) └─ WS-J: Infrastructure hardening (backups, CI/CD staging, status page)
Tier 4: Polish (post-launch acceptable) ├─ WS-K: Website UX polish ├─ WS-L: Documentation site (Astro) ├─ WS-M: Notifications (Resend, Slack/Discord) └─ WS-N: Load testing & capacity planningAuth layers sidebar
Section titled “Auth layers sidebar”There are two completely different auth flows in gondolier. Confusing them leads to wrong implementation choices.
| Dashboard login | Forge connection | |
|---|---|---|
| Who | Human user → gondolier | gondolier → tenant’s forge instance |
| Provider | Supabase GoTrue (magic-link) | Forgejo/Gitea OAuth2 or PAT paste |
| Where | site/index.html + internal/auth/auth.go |
internal/forge/ + internal/forgejo/ |
| Goal | Prove who you are so you can manage your org | Get a token so gondolier can stage/check/land PRs |
| Self-hosted issue | N/A — Supabase is our infra | Per-instance OAuth app registration; PAT paste fallback |
Why not Forgejo OAuth for dashboard login? Each self-hosted Forgejo instance is its own OAuth2 provider. Using “Sign in with Forgejo” for the gondolier dashboard would require registering gondolier as an OAuth app on every tenant’s instance before they can log in — chicken-and-egg. Shared instances (Codeberg) reuse a single OAuth app registration, but that doesn’t scale to arbitrary self-hosted instances.
Why not GitHub OAuth? It privileges one forge over others. gondolier serves Forgejo/Gitea users — some may not have GitHub accounts at all.
Workstream A: Token encryption fix + credential audit
Section titled “Workstream A: Token encryption fix + credential audit”Blocks: WS-C, WS-D, WS-E (all need encrypted credential storage) Parallel with: WS-B Complexity: Medium Risk: High (touches the highest-value secret path)
Current state
Section titled “Current state”pkg/crypto/crypto.go has a complete envelope encryption implementation
(EncryptToken, DecryptToken, GenerateDEK, EncryptDEK, DecryptDEK).
It is tested (tests/unit/crypto/crypto_test.go). But it is NOT wired into
the API handler. internal/api/handler.go:443 does:
TokenEncrypted: []byte(req.Token), // placeholder — needs encryptionEvery forge connection created via the API stores the raw PAT/OAuth token in
the token_encrypted BYTEA column. The enc:v1: prefix format described in
docs/SECURITY.md is not produced anywhere in the Go code.
Acceptance criteria
Section titled “Acceptance criteria”-
Token encryption on create.
CreateConnectiongenerates a per-connection DEK, encrypts the forge token with it, storesenc:v1:<iv_base64>:<ct_base64>intoken_encrypted. The DEK itself is encrypted with the master key and stored in a newencrypted_dekcolumn (or the existingtoken_noncecolumn is repurposed for the full envelope). -
Token decryption on read. Any code path that reads
token_encryptedfrom Supabase (engine tick, forge client construction) decrypts through the envelope. Legacy raw-base64 tokens are transparently accepted (backward compat) with a deprecation log line. -
No plaintext token in logs, errors, or API responses. Audit every
log.*,fmt.*,slog.*call site in the diff. Redact tokens in errors (use the existingRedactTokenValue/RedactBearerTokenpattern frominternal/api/handler.go). -
Token rotation is atomic.
UpdateConnection(token rotation endpoint) encrypts the new token with the existing DEK, atomically swaps thetoken_encryptedvalue in a single UPDATE. No window where both old and new are valid. -
Master key absence is handled. If
CREDENTIAL_MASTER_KEYis not configured, encrypt/decrypt returns a clear error. No silent fallback. -
Tests. Unit tests for: encrypt→decrypt round-trip, legacy raw-base64 backward compat, wrong key → error, wrong nonce → error, rotate→old token invalid. Integration test: create connection via API, verify stored value is
enc:v1:prefixed, verify engine tick can decrypt.
Methodology
Section titled “Methodology”- Add
encrypted_dekanddek_noncecolumns toforge_connections(migration). - Wire
pkg/crypto.Decryptorintointernal/api/handler.go— handler constructor takes a*crypto.Decryptor. - Implement
encryptToken()anddecryptToken()helper methods on the handler. - Fix
CreateConnectionto encrypt before storage. - Fix engine tick path (
internal/engine/engine.go,internal/cron/cron.go) to decrypt when constructing the forge client. - Audit:
rg -n 'token_encrypted\|TokenEncrypted\|bot_token\|BotToken\|forge.*token\|decrypt\|encrypt' --type go— verify every read site decrypts, every write site encrypts. - Audit:
rg -n 'log\.\|slog\.\|fmt\.Sprintf\|fmt\.Printf' --type goin diff — verify no token data in log calls.
Files touched
Section titled “Files touched”internal/api/handler.go— wire encryption into CreateConnection, add decrypt helperinternal/engine/engine.go— decrypt token before constructing forge clientinternal/cron/cron.go— decrypt token before tickinternal/forge/forge.go— ensure token is never loggedsupabase/migrations/008_envelope_encryption.sql— new columnsinternal/db/db.go— update ForgeConnection model
Workstream B: Production auth (Supabase Auth + JWT)
Section titled “Workstream B: Production auth (Supabase Auth + JWT)”Blocks: WS-F (dashboard needs real auth) Parallel with: WS-A Complexity: Medium Risk: Medium (well-documented flow; misconfiguration = broken login)
Current state
Section titled “Current state”Two auth systems exist partially:
site/index.htmlhas Supabase magic-link auth (sb.auth.signInWithOtp) wired with the real Supabase project URL + publishable key.internal/auth/auth.gohas a GitHub OAuth implementation that’s wrong for this product (gondolier serves Forgejo/Gitea users, not GitHub users).- Both use
STUB_JWT_SECRETfor JWT signing — not a production secret.
Decision: Use Supabase Auth for the dashboard login. It’s forge-agnostic
(it doesn’t privilege one forge provider over another), already has a real
Supabase project, and the magic-link flow is already in the sell site. The
GitHub OAuth code in internal/auth/auth.go should be replaced with Supabase
JWT session validation.
Note: this is separate from the forge-connection OAuth2 flow (Forgejo/Gitea
OAuth or PAT paste, described in docs/design.md) which authenticates gondolier
TO the tenant’s forge instance. That flow is part of WS-C (engine wiring).
Acceptance criteria
Section titled “Acceptance criteria”-
Supabase Auth configured for production. Supabase project has GoTrue configured with a real site URL (
https://gondolier.dev), email provider (Resend), and appropriate rate limits. -
Real JWT secret.
SESSION_SECRETis a 32+ random byte value stored in OpenBao/Cloudflare Secrets, provisioned once by the operator, never auto-generated. Used for both Supabase JWT validation and gondolier’s own session cookies. -
Login flow works end-to-end. “Sign in” on sell site → enter email → magic link sent via Resend → click link → Supabase sets session cookie → redirect to dashboard →
/api/v1/mereturns user profile → logout clears cookie. -
Session validation. Dashboard pages reject expired/missing sessions, redirect to login. API Bearer auth validates the Supabase JWT signature and expiry against the Supabase JWT secret.
-
User account creation. First login creates
user_accountsrow via Supabase GoTrue webhook or manual upsert on first session validation. -
Organization auto-creation. First login creates a default organization for the user (or prompts to create/join one).
-
Replace GitHub OAuth code.
internal/auth/auth.gorewritten to validate Supabase-issued JWTs (HS256, signed with Supabase JWT secret) instead of running its own GitHub OAuth flow. The magic-link flow stays in the client-side JS on the sell site; the Go code only validates the resulting session. -
Tests. Unit tests for: Supabase JWT validation, session expiry. Integration test: full magic-link flow with a test email.
Methodology
Section titled “Methodology”- Configure Supabase GoTrue: set
SITE_URLtohttps://gondolier.dev, enable email provider (Resend), set rate limits. - Store
SUPABASE_JWT_SECRET(the signing key from Supabase dashboard) in OpenBao (secret/data/gondolier/backend). - Rewrite
internal/auth/auth.goto validate Supabase JWTs. - Wire the real secrets into the Worker entrypoint.
- Test end-to-end magic-link flow with a real browser.
- Keep
site/index.htmlSupabase magic-link auth (already working with real Supabase credentials). Remove the GitHub OAuth redirect path.
Files touched
Section titled “Files touched”internal/auth/auth.go— replace GitHub OAuth with Supabase JWT validationcmd/gondolier/container/main.go— wire auth handler with real secretsindex.mjs— if still in use, wire authsite/index.html— verify magic-link flow, remove any GitHub referencesscripts/fetch-openbao-secrets.sh— addSUPABASE_JWT_SECRET- CI workflows (
.forgejo/workflows/deploy.yaml)
Workstream C: Engine production wiring
Section titled “Workstream C: Engine production wiring”Blocks: WS-E (metering needs engine running), WS-F (dashboard needs queue data) Parallel with: WS-D (after WS-A complete) Complexity: High Risk: High (touches engine, scheduler, cron, lease coordination)
Current state
Section titled “Current state”main.go is labeled “experimental.” It uses in-memory map[string]repoConfig
and map[string]*mq.Engine — both vanish on Workers cold start. The cron handler
(internal/cron/cron.go) has a proper stub but main.go’s handleCron doesn’t
use it; it iterates its own in-memory map. The cmd/gondolier/container/main.go
is a separate entrypoint for the container-based deployment.
Key disconnects:
main.gostores config in memory; Supabase has the real datamain.gocreates engines but never populates configs from DB- LeaseDO is a JS Durable Object; Go worker has no fetch bridge to it
- The JS
lease-do.mjshas working acquire/release but Go can’t call it
Acceptance criteria
Section titled “Acceptance criteria”-
DB-backed config loading. Cron handler queries Supabase for active repos, loads forge connections, decrypts tokens, and runs engine ticks. No in-memory config maps.
-
LeaseDO bridge. Go worker can acquire/release leases via the JS LeaseDO Durable Object. Either: (a) Go makes
fetch()calls to the DO’s HTTP endpoint (https://lease-do.gondolier.workers.dev/...), or (b) the container-based deployment uses a Go-native lease (D1 + alarm-based TTL). -
Engine cache.
mq.Engineinstances are cached per (tenant, repo) with a TTL. Cache is populated on first tick and evicted after N minutes of inactivity. This preserves the engine’s in-memory active-batch state between reconciles. -
Cron reconciliation works. Every 5 minutes, the cron trigger:
- Queries
managed_reposwith active connections - For each repo: acquire lease → load config → decrypt token → construct
engine →
Reconcile(ctx)→ write audit log → release lease - Max 50 repos per tick (rate-limited batch)
- Any single-repo failure skips that repo and continues
- Queries
-
Webhook path works.
POST /api/v1/webhooks/forgejo→ verify HMAC → look up tenant → forward to LeaseDO → process event → write audit log. -
Tests. Unit: cron handler with mock DB, mock lease, mock engine. Integration: end-to-end cron tick with real Supabase, real forge API.
Methodology
Section titled “Methodology”-
Decide deployment model: pure JS worker (keep
index.mjs), pure Go worker (finishmain.go), or hybrid (Go for engine, JS for LeaseDO/API surface). Recommendation: Hybrid — Go in a Container binding for the engine (cmd/gondolier/container/main.go), JS for API surface and LeaseDO. This avoids the Go→JS LeaseDO bridge problem entirely because the container communicates with the JS worker via internal fetch. -
Implement DB-backed config loader in
internal/scheduler/. -
Wire cron handler to use it.
-
Wire webhook handler to use real HMAC secret from DB.
-
Test end-to-end with a test Forgejo instance.
Files touched
Section titled “Files touched”cmd/gondolier/container/main.go— production entrypointinternal/scheduler/scheduler.go— actual implementationinternal/cron/cron.go— DB integrationinternal/worker/webhook.go— HMAC secret from DBinternal/lease/lease.go— DO fetch bridge or D1-based leaseshunt-container.mjs— container binding wrapperwrangler.toml— may need container binding config
Workstream D: Stripe billing integration
Section titled “Workstream D: Stripe billing integration”Blocks: WS-E (metering needs billing tiers defined) Parallel with: WS-C (after WS-A) Complexity: Medium Risk: Medium (Stripe API is stable; testing requires Stripe test mode)
Current state
Section titled “Current state”internal/billing/billing.go has tier definitions and stub handlers. Three TODOs:
create checkout session, create portal session, verify webhook signature.
No Stripe SDK dependency in go.mod. supabase/migrations/003_billing.sql has
the billing table with RLS policies.
Acceptance criteria
Section titled “Acceptance criteria”-
Stripe Go SDK integrated.
go.modincludesgithub.com/stripe/stripe-go/v81. -
Checkout flow.
POST /api/v1/billing/checkout:- Accepts
{ organization_id, tier } - Looks up or creates Stripe Customer (linked to organization)
- Creates a Stripe Checkout Session for the tier’s price ID
- Returns
{ url: "https://checkout.stripe.com/..." } - Dashboard redirects user to Stripe
- Accepts
-
Success/return flow. After Stripe checkout completes, user lands on
https://app.gondolier.dev/settings/billing?session_id={...}. Dashboard verifies the session and shows success. -
Customer portal.
POST /api/v1/billing/portal:- Looks up organization’s Stripe Customer ID
- Creates a billing portal session
- Returns
{ url: "https://billing.stripe.com/..." }
-
Webhook handler.
POST /stripe/webhook:- Verifies Stripe webhook signature (using webhook secret from Cloudflare Secrets)
- Handles events:
checkout.session.completed,customer.subscription.created,customer.subscription.updated,customer.subscription.deleted,invoice.payment_failed - Updates
billingtable (tier, status, period dates) - On
invoice.payment_failed: notify tenant, start grace period
-
Stripe test mode. All flows work in Stripe test mode with test cards. Production mode is a config flip (different secret key + price IDs).
-
Tests. Unit: webhook signature verification, event routing. Integration: full checkout→webhook→tier-update flow with Stripe test mode.
Methodology
Section titled “Methodology”- Create Stripe account, configure products/prices in test mode.
- Add
stripe-godependency, implement checkout/portal/webhook handlers. - Store
STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRETin OpenBao/Cloudflare Secrets. - Add Stripe price IDs to tier config (per-environment: test vs prod).
- Run through full checkout flow with test card
4242 4242 4242 4242.
Files touched
Section titled “Files touched”internal/billing/billing.go— full implementationcmd/gondolier/container/main.go— wire billing handlerinternal/organization/organization.go— add Stripe customer ID managementscripts/fetch-openbao-secrets.sh— add Stripe keys
Workstream E: Metering & tier enforcement
Section titled “Workstream E: Metering & tier enforcement”Blocks: Nothing (can go live without enforcement, but revenue leaks) Parallel with: WS-D (uses tier definitions from D) Complexity: Medium Risk: Low (read-only counters; limits are soft enforcement)
Current state
Section titled “Current state”No metering code exists. Tier limits are defined in internal/organization/organization.go
(TierLimits) but never checked. RecordPRMerge is a DB interface method with no
implementation. The billing metering unit proposed in design.md is “CI runs saved =
(PRs merged) - (gate workflow triggers).”
Open question: how to meter
Section titled “Open question: how to meter”Two defensible models:
Model 1: PR merges per billing period (simpler). Count pr_landed audit log
events per organization per calendar month. Enforce tier limit (Free: 10/mo,
Pro: 100/mo, Team: 500/mo, Enterprise: unlimited). Over-limit repos pause
queue processing until upgrade or next period.
Model 2: CI runs saved (design.md proposal). Count (PRs merged) - (gate workflow triggers) per billing period. More directly maps to value delivered,
but harder to explain and requires tracking gate workflow invocations (which
happen on the tenant’s CI, not ours — we only see status updates).
Recommendation: Model 1 for launch. Switch to Model 2 only if customers push back on PR-count pricing.
Acceptance criteria
Section titled “Acceptance criteria”-
PR merge counting. Engine writes
pr_landedaudit log events. APRCountThisMonth()query counts these per organization. -
Tier limit enforcement. On repo creation: check org’s repo count against tier limit, reject if at max. On engine tick: check org’s monthly PR count against tier limit, skip repo (with audit log) if over limit.
-
Usage visible in dashboard. Settings page shows: current tier, repos used / limit, PRs merged this month / limit.
-
Upgrade prompt. When a limit is hit, dashboard shows an upgrade CTA. Engine continues processing for a 3-day grace period after limit is exceeded (to avoid service interruption during upgrade).
-
Tier change on Stripe events. Webhook handler updates
organizations.tierandbilling.tieron subscription changes. -
Tests. Unit: counter queries, limit checks. Integration: create repos up to limit, verify rejection at limit+1.
Methodology
Section titled “Methodology”- Choose metering model and document the decision.
- Add
pr_count_this_monthandrepo_countqueries to the org DB interface. - Wire limit checks into repo creation and engine tick paths.
- Add usage display to dashboard.
- Add grace period logic (configurable: 0 days = hard enforcement).
Files touched
Section titled “Files touched”internal/organization/organization.go— add counting methods, limit checksinternal/engine/engine.go— emitpr_landedaudit eventsinternal/api/handler.go— enforce repo limit on createinternal/dashboard/dashboard.go— display usageinternal/billing/billing.go— tier update on Stripe events
Workstream F: Dashboard real-data wiring
Section titled “Workstream F: Dashboard real-data wiring”Blocks: Nothing (UX is standalone) Parallel with: WS-C (needs C for queue data, B for auth) Complexity: Medium Risk: Low (read-only display; can’t corrupt state)
Current state
Section titled “Current state”internal/dashboard/dashboard.go serves hardcoded HTML. Every page shows
static placeholder text (“No active queues”, “No connections yet”, “0 PRs
merged today”). The template rendering works but no data is fetched from
Supabase. templates/dashboard.html has the base layout with navigation.
Acceptance criteria
Section titled “Acceptance criteria”-
Dashboard home shows real data: active queue count, PRs merged today, queue success rate, recent activity (last 10 audit log entries).
-
Connections page lists forge connections from Supabase with: instance URL, bot login, connection age, “Add Connection” button that opens a form.
-
Repos page lists managed repos with: slug, base branch, status context, merge style, last reconcile status. “Add Repo” button with form.
-
Queue page shows per-repo queue status: current batch (if active), staging branch SHA, CI status, pending PR count.
-
Settings page shows: organization name, tier, billing status (Stripe portal link), API key management (generate/revoke).
-
All data fetched via API. Dashboard pages make
fetch()calls to/api/v1/...endpoints authenticated with the session cookie or Bearer token. No direct Supabase queries from the browser. -
Empty states. Each page has a distinct empty state with a CTA (“Connect your first forge”, “Add your first repo”).
Methodology
Section titled “Methodology”- Add JS fetch logic to dashboard templates (or build as a SPA if the template approach is too limiting).
- Ensure all API endpoints return the data the dashboard needs.
- Add auth middleware to dashboard routes.
- Style consistently with the sell site (IBM Plex Mono, amber/green palette).
Files touched
Section titled “Files touched”internal/dashboard/dashboard.go— fetch real data from DBtemplates/dashboard.html— add JS for API callstemplates/dashboard_page.html,connections.html,repos.html,queue.html,settings.html— real contentsite/dashboard.html— separate Pages-deployed dashboard (may unify)
Workstream G: Observability (logs, metrics, alerting)
Section titled “Workstream G: Observability (logs, metrics, alerting)”Blocks: Nothing (additive) Parallel with: Everything Complexity: Medium Risk: Low (additive; can’t break engine)
Current state
Section titled “Current state”- Workers
[observability]enabled inwrangler.toml— provides Workers invocation logs, duration, CPU time in Cloudflare dashboard. slogused in Go packages — structured logging with levels.- No log shipping to external aggregator.
- No metrics (request counts, error rates, queue latency, PR throughput).
- No alerting.
wrangler tailfor ad-hoc log viewing.
Acceptance criteria
Section titled “Acceptance criteria”-
Structured logging. Every log line includes:
tenant_id(when in tenant context),repo_slug,request_id(generated at edge). Usesloggroups for related fields. -
Log shipping. Logs shipped to an external aggregator. Options:
- Cloudflare Logpush → S3/R2 + query with Athena (zero-code, CF native)
- Axiom / Baselime (Workers-native log drains, paid)
- Tailscale + Loki (self-hosted, free)
- Recommendation: Start with Logpush to R2 + occasional Athena queries. Zero cost, zero code change. Add Axiom when revenue justifies it.
-
Key metrics tracked:
gondolier.engine.tick.duration— histogram of reconcile durationsgondolier.engine.tick.errors— counter of failed ticksgondolier.queue.prs.merged— counter of PRs landedgondolier.queue.prs.bounced— counter of PRs bouncedgondolier.queue.batch.size— histogram of batch sizesgondolier.api.request.duration— histogram of API request latencygondolier.api.request.errors— counter of 4xx/5xx responsesgondolier.tenant.active— gauge of active tenants
-
Health check endpoint.
GET /healthzreturns 200 if: Supabase reachable, KMS (Cloudflare Secrets) accessible.GET /readyzreturns 200 if all dependencies healthy. -
Alerting. At minimum:
- Engine error rate > 10% for any tenant → alert
- API 5xx rate > 1% → alert
- Supabase unreachable for > 2 minutes → alert
- No successful ticks in > 15 minutes → alert
-
Dashboard. Cloudflare Workers Analytics dashboard bookmarked. Optional: Grafana dashboard if metrics are shipped to Prometheus-compatible store.
Methodology
Section titled “Methodology”- Add
request_idgeneration middleware (or use CFcf-rayheader). - Add
sloggroups for tenant/repo context. - Configure Logpush to R2 (Cloudflare dashboard, zero code).
- Add metric emission via
slogwith ametric=trueattribute (parse from logs) or use Cloudflare Workers Analytics Engine (writeDataPoint()in JS). - Set up Cloudflare notification alerts for error rate thresholds.
Files touched
Section titled “Files touched”internal/api/handler.go— add request_id, tenant logging contextinternal/engine/engine.go— add metric log linesinternal/cron/cron.go— add metric log linescmd/gondolier/container/main.go— middleware chainwrangler.toml— Logpush config (if declarable)
Workstream H: PostHog / frontend telemetry
Section titled “Workstream H: PostHog / frontend telemetry”Blocks: Nothing (additive) Parallel with: Everything Complexity: Low Risk: Low (client-side only)
Current state
Section titled “Current state”Zero analytics. docs/design.md mentions PostHog as the analytics stack
(“same stack as pokomplete”). No PostHog snippet or SDK in the sell site
or dashboard.
Acceptance criteria
Section titled “Acceptance criteria”-
PostHog snippet on sell site. Track: page views, “Sign in” clicks, “View pricing” clicks, “Docs” clicks, pricing tier interest.
-
PostHog snippet on dashboard. Track: page views, feature usage (connections created, repos added, queue status viewed), upgrade funnel (pricing page → checkout → return).
-
Server-side events (optional). Track: tenant created, first repo added, first PR merged — sent from Worker to PostHog API.
-
Feature flags (future). PostHog feature flags for gradual rollouts.
-
GDPR compliance. Cookie consent banner if targeting EU users. PostHog cloud is GDPR-compliant; self-hosted option available.
Methodology
Section titled “Methodology”- Create PostHog project, get API key + instance URL.
- Add PostHog snippet to
site/index.htmland dashboard templates. - Store
POSTHOG_API_KEYin Cloudflare Secrets if doing server-side events. - Add
window.posthog.capture()calls at key interaction points.
Files touched
Section titled “Files touched”site/index.html— PostHog snippettemplates/dashboard.html— PostHog snippet- (Optional)
internal/api/handler.go— server-side event capture
Workstream I: Security audit
Section titled “Workstream I: Security audit”Blocks: Nothing (findings may create new tasks) Parallel with: Everything Complexity: Medium Risk: High (findings may block go-live)
Full credential-path audit, webhook integrity, input validation, multi-tenant isolation, and deployment security.
Acceptance criteria
Section titled “Acceptance criteria”-
Credential path audit. Trace every line of code that touches a forge token from API ingestion to engine use to memory release. Verify:
- Token encrypted at rest (Supabase) — currently broken, see WS-A
- Token never logged — needs verification
- Token never returned by API — currently true: GET endpoints exclude token
- Token cleared from memory after tick — currently: GC handles this, but explicit zeroing would be better
- Token never appears in error messages — partially done:
RedactTokenValueexists but not used everywhere
-
Webhook integrity. Verify:
- HMAC secret stored securely (same envelope encryption as token)
- Constant-time comparison used (currently:
==inVerifyHMAC— BUG) - Replay protection (timestamp check or nonce) — not implemented
- Tenant lookup before HMAC verification (avoid timing oracle) — currently: tenant lookup happens before HMAC check, which leaks “repo managed” status
-
Input validation. Add validation for:
instance_url: must be valid HTTPS URL, no localhost/private IPsrepo_slug: must match^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$bot_login: must match^[a-zA-Z0-9_.-]+$base_branch: must match^[a-zA-Z0-9_.-/]+$status_context: must match^[a-zA-Z0-9_.-/]+$- API key: minimum 32 chars
- Request body size limits on all endpoints
-
Multi-tenant isolation. Verify:
- Every DB query includes
WHERE tenant_id = $1(or admin bypass) - Engine tick holds at most one tenant’s decrypted token in memory
- Durable Object keyed by
(tenant_id, repo_slug)— no cross-tenant access - Rate limiter keyed by
(tenant_id, forge_host)— no cross-tenant noise
- Every DB query includes
-
Deployment security.
- Verify Cloudflare Secrets are not in
wrangler.toml(they aren’t) ✓ - Verify
.envis in.gitignore✓ - Verify no secrets in migration SQL files
- Verify CI doesn’t log secrets (OpenBao +
::add-mask::is used) ✓ - Verify Forgejo Actions OIDC role has least privilege
- Verify Cloudflare Secrets are not in
-
Remediation plan. For each finding: severity, exploit scenario, fix, verification test.
Methodology
Section titled “Methodology”- Manual code review: trace credential path, webhook path, API path.
- Run
gosec ./...if available. - Run
rg -n 'token\|secret\|key\|password\|credential' --type goand review every match. - Write a security-audit report in
docs/security-audit-2026-08.md. - File blocking findings as P0 issues.
Files touched
Section titled “Files touched”docs/security-audit-2026-08.md— audit reportinternal/worker/webhook.go— fix constant-time comparison, add replay protectioninternal/api/handler.go— add input validation- (Potentially) any file with findings
Workstream J: Infrastructure hardening
Section titled “Workstream J: Infrastructure hardening”Blocks: Nothing Parallel with: Everything Complexity: Low-Medium Risk: Low
Current state
Section titled “Current state”- CI/CD:
.forgejo/workflows/ci.yaml(PR vet/test/build),deploy.yaml(deploy on merge to main). No staging environment, no canary deploy. - Database: Supabase managed, backups on paid plan. No restore procedure documented.
- Status page:
status.gondolier.devreferenced but doesn’t exist. - DNS:
gondolier.devpoints to Worker.api.gondolier.devnot configured.
Acceptance criteria
Section titled “Acceptance criteria”-
Staging environment. A separate Cloudflare Workers environment (
staging) with its own Supabase database (or shared withstaging_prefix on tables). Deployed on PR to main; production deployed on merge to main. -
Database backup/restore documented. Step-by-step runbook for: taking a manual backup, restoring from Supabase backup, verifying restore integrity. Test the restore procedure against a fresh Supabase project.
-
Status page. A static page at
status.gondolier.dev(Cloudflare Pages) showing: API health, queue processing health, recent incidents. Update manually or via Cloudflare notification webhook → status page API. -
Secrets rotation runbook. Step-by-step for: rotating
CREDENTIAL_MASTER_KEY(requires all tenants to re-enroll), rotatingSUPABASE_SERVICE_ROLE_KEY, rotating individual tenant webhook secrets. -
On-call runbook. What to do when: worker errors spike, Supabase is down, a tenant reports their queue is stuck, a security incident is suspected.
-
CI/CD hardening. Canary deploy (10% traffic → 50% → 100%), automatic rollback on error rate spike. (Nice-to-have; manual rollback via
wrangler versions rollbackis sufficient for launch.)
Methodology
Section titled “Methodology”- Create staging environment in Cloudflare Dashboard.
- Configure
wrangler.tomlwith[env.staging]. - Write runbooks as markdown in
docs/operations/. - Deploy status page (can be as simple as a static HTML file on Pages).
Files touched
Section titled “Files touched”docs/operations/backup-restore.mddocs/operations/secret-rotation.mddocs/operations/on-call.mddocs/operations/incident-response.mdwrangler.toml— add staging environment.forgejo/workflows/deploy.yaml— staging deploy- New: status page (static HTML)
Workstream K: Website UX polish
Section titled “Workstream K: Website UX polish”Blocks: Nothing Parallel with: Everything Complexity: Low-Medium Risk: Low (static content)
Current state
Section titled “Current state”site/index.html is a single-page sell site: hero, algorithm visualization,
feature list, pricing table, Supabase magic-link auth modal. Clean monospace
aesthetic. Dashboard is a separate Pages deployment, not linked from sell site.
The magic-link auth already uses the real Supabase project (URL + publishable key are hardcoded — see workstream I for that finding). The flow works but needs production Supabase GoTrue configuration (email provider, site URL).
Acceptance criteria
Section titled “Acceptance criteria”-
Unified auth flow. “Sign in” on sell site uses Supabase magic-link. Post-login redirects to dashboard. The Supabase session cookie is valid for both the sell site and the dashboard API.
-
Post-signup flow. After first login: prompt to create an organization, then prompt to connect a forge, then prompt to add a repo. Guided onboarding.
-
Dashboard linked. Authenticated users on sell site see “Dashboard” in nav instead of “Sign in”.
-
Content completeness.
- Hero: clear value prop (“Zero-infra merge queue for Forgejo & Gitea”)
- How it works: 3 steps (connect forge → add repos → relax)
- Social proof: “Built on shunt, the batch-then-bisect engine”
- Pricing: 4 tiers with clear limits
- Footer: docs, status, source (shunt repo), contact
-
Mobile responsive. Already responsive; verify on 320px width.
-
Performance. Lighthouse > 90. Static HTML + single font, should be trivial.
Methodology
Section titled “Methodology”- Update
site/index.html: verify magic-link flow works with production Supabase config, add dashboard link for authenticated users. - Add guided onboarding flow (can be part of dashboard, linked from sell site).
- Deploy updated sell site to Pages.
Files touched
Section titled “Files touched”site/index.html— auth flow, onboarding linkssite/_headers— CSP, cache headerssite/_routes.json— if needed
Workstream L: Documentation site (Astro)
Section titled “Workstream L: Documentation site (Astro)”Blocks: Nothing Parallel with: Everything Complexity: Low Risk: Low
Current state
Section titled “Current state”7 markdown docs in docs/ are the authoritative reference. docs-site/astro.config.mjs
exists but no built site. Docs are accessible at docs.gondolier.dev only as
pre-built HTML (per ROADMAP.md: “Docs: ✅ Pre-built HTML in docs/; Astro
presentation layer pending”).
Acceptance criteria
Section titled “Acceptance criteria”-
Astro site builds and deploys.
docs-site/builds withnpm run buildand deploys to Cloudflare Pages atdocs.gondolier.dev. -
Content structure (from MVP_PLAN.md):
- Getting started: installation, hosted setup, first queue
- Concepts: merge queue, staging branch, bisect, automerge
- Reference: API reference, config reference, webhooks
- Admin: branch protection, gate workflow, bot setup
- Billing: pricing, FAQ, upgrade
- Troubleshooting: staging fails, CI not registered, rate limits
-
Search. Client-side search (Pagefind or similar, zero infra).
-
“Edit this page” links. Each page links to its source in the repo.
Methodology
Section titled “Methodology”- Set up Astro project in
docs-site/with a docs theme (Starlight). - Convert existing markdown docs to Astro content collections.
- Write remaining content pages.
- Deploy to Cloudflare Pages.
Files touched
Section titled “Files touched”docs-site/— full Astro projectdocs/— may reorganize intodocs-site/src/content/docs/
Workstream M: Notifications (Resend, Slack/Discord)
Section titled “Workstream M: Notifications (Resend, Slack/Discord)”Blocks: Nothing Parallel with: Everything Complexity: Low Risk: Low
Current state
Section titled “Current state”internal/notify/notify.go has a Notifier that POSTs bounce events to a
tenant-provided webhook URL. No Resend (email) integration, no Slack/Discord
integration. This is sufficient for v1 (HTTP webhooks) but email is expected.
Acceptance criteria
Section titled “Acceptance criteria”-
Resend email integration.
POST /api/v1/tenants/:id/repos/:ridacceptsnotification_email. On bounce, gondolier sends an email via Resend API with: PR title, PR number, bounce reason, link to PR. -
Email templates. Plain text and HTML versions. Styled consistently with sell site.
-
Slack/Discord webhooks. Accept Slack/Discord incoming webhook URL as a notification target. On bounce, POST a formatted message to the webhook.
-
Notification preferences. Per-repo config: which channels to notify (webhook, email, Slack, Discord).
Methodology
Section titled “Methodology”- Create Resend account, verify
gondolier.devdomain. - Add
RESEND_API_KEYto Cloudflare Secrets. - Implement
EmailNotifierininternal/notify/. - Implement
SlackNotifierininternal/notify/. - Wire notification preferences into the API and DB.
Files touched
Section titled “Files touched”internal/notify/notify.go— email, Slack notifiersinternal/api/handler.go— add notification_email to CreateReposupabase/migrations/— add notification preferences columns
Workstream N: Load testing & capacity planning
Section titled “Workstream N: Load testing & capacity planning”Blocks: Nothing Parallel with: Everything Complexity: Medium Risk: Low
Current state
Section titled “Current state”No performance baseline. Unknown: max repos per cron tick within 30s timeout, max webhook throughput, Supabase query latency under load, Workers cold start impact on API latency.
Acceptance criteria
Section titled “Acceptance criteria”-
Engine tick capacity. Benchmark: how many repos can a single cron tick process in 30s with a real (or mocked) forge API. Document the ceiling.
-
API throughput. Load test:
GET /api/v1/tenants/:id/queueat 10, 100, 1000 concurrent requests. Document latency distribution. -
Webhook throughput. Load test:
POST /api/v1/webhooks/forgejoat sustained rates. Document HMAC verification overhead. -
Supabase connection pooling. Verify connection pool settings are appropriate for Workers (stateless, many short-lived connections).
-
Cost projection. At 10, 100, 1000 tenants: estimated Cloudflare Workers CPU-ms, KV reads/writes, DO requests, Supabase egress. Verify within free tier limits.
-
Scaling plan. Document: when to move from free tier to paid, which services become bottlenecks first, how to shard tenants across multiple Workers if needed.
Methodology
Section titled “Methodology”- Write a load-test script (Go or k6) that exercises the API and webhook endpoints.
- Run against staging environment with a test Forgejo instance.
- Measure with Cloudflare Workers Analytics +
wrangler tail. - Write capacity report in
docs/operations/capacity-planning.md.
Files touched
Section titled “Files touched”- New: load test scripts
docs/operations/capacity-planning.md
Dependency graph
Section titled “Dependency graph”Tier 1 (no deps) WS-A: Token encryption ─────────────────────────┐ WS-B: Production auth ──────────────────────────┤ │Tier 2 (depends on Tier 1) │ WS-C: Engine production wiring ◄─────────────────┤ WS-D: Stripe billing ◄───────────────────────────┤ WS-E: Metering ◄─────────────────────────────────┤ WS-F: Dashboard real data ◄──────────────────────┤ │Tier 3 (parallel with Tier 2, no deps) │ WS-G: Observability ◄────────────────────────────┤ WS-H: PostHog telemetry ◄────────────────────────┤ WS-I: Security audit ◄───────────────────────────┤ WS-J: Infrastructure hardening ◄─────────────────┤ │Tier 4 (no deps) │ WS-K: Website UX ◄───────────────────────────────┘ WS-L: Documentation site WS-M: Notifications WS-N: Load testingDispatch strategy
Section titled “Dispatch strategy”-
Immediate (Tier 1): Dispatch WS-A and WS-B in parallel. These unblock everything else.
-
After Tier 1 completes: Dispatch WS-C, WS-D, WS-E, WS-F, WS-G, WS-H, WS-I, WS-J all in parallel. That’s 9 workstreams — a subagent fleet of 9.
-
After Tier 2/3 stabilizes: Dispatch WS-K, WS-L, WS-M, WS-N in parallel. Or defer to post-launch.
Risk matrix
Section titled “Risk matrix”| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Token plaintext in DB | Certain (current state) | Critical | WS-A |
| Stub secrets in production | Certain | High | WS-B |
| Cold start wipes engine state | Certain if unaddressed | High | WS-C |
| Stripe integration delays revenue | Medium | Medium | WS-D — stub in place, can launch without |
| Webhook HMAC timing oracle | Medium | Medium | WS-I — fix constant-time compare |
| Workers timeout during tick | Medium | Low | Partial tick, retry next cycle (by design) |
| Supabase outage | Low | High | Re-derivable state (by design) |
| Key rotation forces re-enroll | Low | Medium | Document procedure in WS-J |
Open questions for product owner
Section titled “Open questions for product owner”- Metering model: PR-count or CI-runs-saved? (Recommendation: PR-count for launch.)
- Pricing: Are the tiers in
site/index.html($0 / $29 / $99 / Custom) final? - Dashboard auth provider: Staying with Supabase Auth (magic-link) for the dashboard, or considering Forgejo/Codeberg OAuth as a second option? The advantage of Supabase Auth is forge-agnosticism — it doesn’t privilege any single forge provider. Forgejo OAuth would be more aligned with the audience but Forgejo currently lacks scoped tokens (full admin access).
- Deployment model: Are we committed to the Container binding (Go engine in a container, JS API surface)? Or reverting to pure JS?
- PostHog vs alternative: Is PostHog the final choice for analytics (same as pokomplete)?
- Log aggregator: Preference between CF Logpush→R2 (free, zero-code) and a paid service like Axiom?
- GDPR / data residency: Do we need to worry about this at launch?