Skip to content

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

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.

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).

# 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.
# 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.
# Gap Location Severity
M1 LeaseDO Go bridge not wired main.go:481-487acquireLeaseViaLeaseDO 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 planning

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)

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 encryption

Every 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.

  1. Token encryption on create. CreateConnection generates a per-connection DEK, encrypts the forge token with it, stores enc:v1:<iv_base64>:<ct_base64> in token_encrypted. The DEK itself is encrypted with the master key and stored in a new encrypted_dek column (or the existing token_nonce column is repurposed for the full envelope).

  2. Token decryption on read. Any code path that reads token_encrypted from Supabase (engine tick, forge client construction) decrypts through the envelope. Legacy raw-base64 tokens are transparently accepted (backward compat) with a deprecation log line.

  3. 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 existing RedactTokenValue/RedactBearerToken pattern from internal/api/handler.go).

  4. Token rotation is atomic. UpdateConnection (token rotation endpoint) encrypts the new token with the existing DEK, atomically swaps the token_encrypted value in a single UPDATE. No window where both old and new are valid.

  5. Master key absence is handled. If CREDENTIAL_MASTER_KEY is not configured, encrypt/decrypt returns a clear error. No silent fallback.

  6. 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.

  1. Add encrypted_dek and dek_nonce columns to forge_connections (migration).
  2. Wire pkg/crypto.Decryptor into internal/api/handler.go — handler constructor takes a *crypto.Decryptor.
  3. Implement encryptToken() and decryptToken() helper methods on the handler.
  4. Fix CreateConnection to encrypt before storage.
  5. Fix engine tick path (internal/engine/engine.go, internal/cron/cron.go) to decrypt when constructing the forge client.
  6. Audit: rg -n 'token_encrypted\|TokenEncrypted\|bot_token\|BotToken\|forge.*token\|decrypt\|encrypt' --type go — verify every read site decrypts, every write site encrypts.
  7. Audit: rg -n 'log\.\|slog\.\|fmt\.Sprintf\|fmt\.Printf' --type go in diff — verify no token data in log calls.
  • internal/api/handler.go — wire encryption into CreateConnection, add decrypt helper
  • internal/engine/engine.go — decrypt token before constructing forge client
  • internal/cron/cron.go — decrypt token before tick
  • internal/forge/forge.go — ensure token is never logged
  • supabase/migrations/008_envelope_encryption.sql — new columns
  • internal/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)

Two auth systems exist partially:

  • site/index.html has Supabase magic-link auth (sb.auth.signInWithOtp) wired with the real Supabase project URL + publishable key.
  • internal/auth/auth.go has a GitHub OAuth implementation that’s wrong for this product (gondolier serves Forgejo/Gitea users, not GitHub users).
  • Both use STUB_JWT_SECRET for 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).

  1. 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.

  2. Real JWT secret. SESSION_SECRET is 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.

  3. 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/me returns user profile → logout clears cookie.

  4. 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.

  5. User account creation. First login creates user_accounts row via Supabase GoTrue webhook or manual upsert on first session validation.

  6. Organization auto-creation. First login creates a default organization for the user (or prompts to create/join one).

  7. Replace GitHub OAuth code. internal/auth/auth.go rewritten 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.

  8. Tests. Unit tests for: Supabase JWT validation, session expiry. Integration test: full magic-link flow with a test email.

  1. Configure Supabase GoTrue: set SITE_URL to https://gondolier.dev, enable email provider (Resend), set rate limits.
  2. Store SUPABASE_JWT_SECRET (the signing key from Supabase dashboard) in OpenBao (secret/data/gondolier/backend).
  3. Rewrite internal/auth/auth.go to validate Supabase JWTs.
  4. Wire the real secrets into the Worker entrypoint.
  5. Test end-to-end magic-link flow with a real browser.
  6. Keep site/index.html Supabase magic-link auth (already working with real Supabase credentials). Remove the GitHub OAuth redirect path.
  • internal/auth/auth.go — replace GitHub OAuth with Supabase JWT validation
  • cmd/gondolier/container/main.go — wire auth handler with real secrets
  • index.mjs — if still in use, wire auth
  • site/index.html — verify magic-link flow, remove any GitHub references
  • scripts/fetch-openbao-secrets.sh — add SUPABASE_JWT_SECRET
  • CI workflows (.forgejo/workflows/deploy.yaml)

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)

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.go stores config in memory; Supabase has the real data
  • main.go creates 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.mjs has working acquire/release but Go can’t call it
  1. 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.

  2. 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).

  3. Engine cache. mq.Engine instances 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.

  4. Cron reconciliation works. Every 5 minutes, the cron trigger:

    1. Queries managed_repos with active connections
    2. For each repo: acquire lease → load config → decrypt token → construct engine → Reconcile(ctx) → write audit log → release lease
    3. Max 50 repos per tick (rate-limited batch)
    4. Any single-repo failure skips that repo and continues
  5. Webhook path works. POST /api/v1/webhooks/forgejo → verify HMAC → look up tenant → forward to LeaseDO → process event → write audit log.

  6. Tests. Unit: cron handler with mock DB, mock lease, mock engine. Integration: end-to-end cron tick with real Supabase, real forge API.

  1. Decide deployment model: pure JS worker (keep index.mjs), pure Go worker (finish main.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.

  2. Implement DB-backed config loader in internal/scheduler/.

  3. Wire cron handler to use it.

  4. Wire webhook handler to use real HMAC secret from DB.

  5. Test end-to-end with a test Forgejo instance.

  • cmd/gondolier/container/main.go — production entrypoint
  • internal/scheduler/scheduler.go — actual implementation
  • internal/cron/cron.go — DB integration
  • internal/worker/webhook.go — HMAC secret from DB
  • internal/lease/lease.go — DO fetch bridge or D1-based lease
  • shunt-container.mjs — container binding wrapper
  • wrangler.toml — may need container binding config

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)

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.

  1. Stripe Go SDK integrated. go.mod includes github.com/stripe/stripe-go/v81.

  2. 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
  3. 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.

  4. 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/..." }
  5. 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 billing table (tier, status, period dates)
    • On invoice.payment_failed: notify tenant, start grace period
  6. Stripe test mode. All flows work in Stripe test mode with test cards. Production mode is a config flip (different secret key + price IDs).

  7. Tests. Unit: webhook signature verification, event routing. Integration: full checkout→webhook→tier-update flow with Stripe test mode.

  1. Create Stripe account, configure products/prices in test mode.
  2. Add stripe-go dependency, implement checkout/portal/webhook handlers.
  3. Store STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET in OpenBao/Cloudflare Secrets.
  4. Add Stripe price IDs to tier config (per-environment: test vs prod).
  5. Run through full checkout flow with test card 4242 4242 4242 4242.
  • internal/billing/billing.go — full implementation
  • cmd/gondolier/container/main.go — wire billing handler
  • internal/organization/organization.go — add Stripe customer ID management
  • scripts/fetch-openbao-secrets.sh — add Stripe keys

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)

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).”

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.

  1. PR merge counting. Engine writes pr_landed audit log events. A PRCountThisMonth() query counts these per organization.

  2. 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.

  3. Usage visible in dashboard. Settings page shows: current tier, repos used / limit, PRs merged this month / limit.

  4. 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).

  5. Tier change on Stripe events. Webhook handler updates organizations.tier and billing.tier on subscription changes.

  6. Tests. Unit: counter queries, limit checks. Integration: create repos up to limit, verify rejection at limit+1.

  1. Choose metering model and document the decision.
  2. Add pr_count_this_month and repo_count queries to the org DB interface.
  3. Wire limit checks into repo creation and engine tick paths.
  4. Add usage display to dashboard.
  5. Add grace period logic (configurable: 0 days = hard enforcement).
  • internal/organization/organization.go — add counting methods, limit checks
  • internal/engine/engine.go — emit pr_landed audit events
  • internal/api/handler.go — enforce repo limit on create
  • internal/dashboard/dashboard.go — display usage
  • internal/billing/billing.go — tier update on Stripe events

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)

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.

  1. Dashboard home shows real data: active queue count, PRs merged today, queue success rate, recent activity (last 10 audit log entries).

  2. Connections page lists forge connections from Supabase with: instance URL, bot login, connection age, “Add Connection” button that opens a form.

  3. Repos page lists managed repos with: slug, base branch, status context, merge style, last reconcile status. “Add Repo” button with form.

  4. Queue page shows per-repo queue status: current batch (if active), staging branch SHA, CI status, pending PR count.

  5. Settings page shows: organization name, tier, billing status (Stripe portal link), API key management (generate/revoke).

  6. 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.

  7. Empty states. Each page has a distinct empty state with a CTA (“Connect your first forge”, “Add your first repo”).

  1. Add JS fetch logic to dashboard templates (or build as a SPA if the template approach is too limiting).
  2. Ensure all API endpoints return the data the dashboard needs.
  3. Add auth middleware to dashboard routes.
  4. Style consistently with the sell site (IBM Plex Mono, amber/green palette).
  • internal/dashboard/dashboard.go — fetch real data from DB
  • templates/dashboard.html — add JS for API calls
  • templates/dashboard_page.html, connections.html, repos.html, queue.html, settings.html — real content
  • site/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)

  • Workers [observability] enabled in wrangler.toml — provides Workers invocation logs, duration, CPU time in Cloudflare dashboard.
  • slog used 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 tail for ad-hoc log viewing.
  1. Structured logging. Every log line includes: tenant_id (when in tenant context), repo_slug, request_id (generated at edge). Use slog groups for related fields.

  2. 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.
  3. Key metrics tracked:

    • gondolier.engine.tick.duration — histogram of reconcile durations
    • gondolier.engine.tick.errors — counter of failed ticks
    • gondolier.queue.prs.merged — counter of PRs landed
    • gondolier.queue.prs.bounced — counter of PRs bounced
    • gondolier.queue.batch.size — histogram of batch sizes
    • gondolier.api.request.duration — histogram of API request latency
    • gondolier.api.request.errors — counter of 4xx/5xx responses
    • gondolier.tenant.active — gauge of active tenants
  4. Health check endpoint. GET /healthz returns 200 if: Supabase reachable, KMS (Cloudflare Secrets) accessible. GET /readyz returns 200 if all dependencies healthy.

  5. 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
  6. Dashboard. Cloudflare Workers Analytics dashboard bookmarked. Optional: Grafana dashboard if metrics are shipped to Prometheus-compatible store.

  1. Add request_id generation middleware (or use CF cf-ray header).
  2. Add slog groups for tenant/repo context.
  3. Configure Logpush to R2 (Cloudflare dashboard, zero code).
  4. Add metric emission via slog with a metric=true attribute (parse from logs) or use Cloudflare Workers Analytics Engine (writeDataPoint() in JS).
  5. Set up Cloudflare notification alerts for error rate thresholds.
  • internal/api/handler.go — add request_id, tenant logging context
  • internal/engine/engine.go — add metric log lines
  • internal/cron/cron.go — add metric log lines
  • cmd/gondolier/container/main.go — middleware chain
  • wrangler.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)

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.

  1. PostHog snippet on sell site. Track: page views, “Sign in” clicks, “View pricing” clicks, “Docs” clicks, pricing tier interest.

  2. PostHog snippet on dashboard. Track: page views, feature usage (connections created, repos added, queue status viewed), upgrade funnel (pricing page → checkout → return).

  3. Server-side events (optional). Track: tenant created, first repo added, first PR merged — sent from Worker to PostHog API.

  4. Feature flags (future). PostHog feature flags for gradual rollouts.

  5. GDPR compliance. Cookie consent banner if targeting EU users. PostHog cloud is GDPR-compliant; self-hosted option available.

  1. Create PostHog project, get API key + instance URL.
  2. Add PostHog snippet to site/index.html and dashboard templates.
  3. Store POSTHOG_API_KEY in Cloudflare Secrets if doing server-side events.
  4. Add window.posthog.capture() calls at key interaction points.
  • site/index.html — PostHog snippet
  • templates/dashboard.html — PostHog snippet
  • (Optional) internal/api/handler.go — server-side event capture

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.

  1. 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: RedactTokenValue exists but not used everywhere
  2. Webhook integrity. Verify:

    • HMAC secret stored securely (same envelope encryption as token)
    • Constant-time comparison used (currently: == in VerifyHMACBUG)
    • 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
  3. Input validation. Add validation for:

    • instance_url: must be valid HTTPS URL, no localhost/private IPs
    • repo_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
  4. 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
  5. Deployment security.

    • Verify Cloudflare Secrets are not in wrangler.toml (they aren’t) ✓
    • Verify .env is 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
  6. Remediation plan. For each finding: severity, exploit scenario, fix, verification test.

  1. Manual code review: trace credential path, webhook path, API path.
  2. Run gosec ./... if available.
  3. Run rg -n 'token\|secret\|key\|password\|credential' --type go and review every match.
  4. Write a security-audit report in docs/security-audit-2026-08.md.
  5. File blocking findings as P0 issues.
  • docs/security-audit-2026-08.md — audit report
  • internal/worker/webhook.go — fix constant-time comparison, add replay protection
  • internal/api/handler.go — add input validation
  • (Potentially) any file with findings

Blocks: Nothing Parallel with: Everything Complexity: Low-Medium Risk: Low

  • 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.dev referenced but doesn’t exist.
  • DNS: gondolier.dev points to Worker. api.gondolier.dev not configured.
  1. Staging environment. A separate Cloudflare Workers environment (staging) with its own Supabase database (or shared with staging_ prefix on tables). Deployed on PR to main; production deployed on merge to main.

  2. 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.

  3. 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.

  4. Secrets rotation runbook. Step-by-step for: rotating CREDENTIAL_MASTER_KEY (requires all tenants to re-enroll), rotating SUPABASE_SERVICE_ROLE_KEY, rotating individual tenant webhook secrets.

  5. 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.

  6. CI/CD hardening. Canary deploy (10% traffic → 50% → 100%), automatic rollback on error rate spike. (Nice-to-have; manual rollback via wrangler versions rollback is sufficient for launch.)

  1. Create staging environment in Cloudflare Dashboard.
  2. Configure wrangler.toml with [env.staging].
  3. Write runbooks as markdown in docs/operations/.
  4. Deploy status page (can be as simple as a static HTML file on Pages).
  • docs/operations/backup-restore.md
  • docs/operations/secret-rotation.md
  • docs/operations/on-call.md
  • docs/operations/incident-response.md
  • wrangler.toml — add staging environment
  • .forgejo/workflows/deploy.yaml — staging deploy
  • New: status page (static HTML)

Blocks: Nothing Parallel with: Everything Complexity: Low-Medium Risk: Low (static content)

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).

  1. 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.

  2. 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.

  3. Dashboard linked. Authenticated users on sell site see “Dashboard” in nav instead of “Sign in”.

  4. 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
  5. Mobile responsive. Already responsive; verify on 320px width.

  6. Performance. Lighthouse > 90. Static HTML + single font, should be trivial.

  1. Update site/index.html: verify magic-link flow works with production Supabase config, add dashboard link for authenticated users.
  2. Add guided onboarding flow (can be part of dashboard, linked from sell site).
  3. Deploy updated sell site to Pages.
  • site/index.html — auth flow, onboarding links
  • site/_headers — CSP, cache headers
  • site/_routes.json — if needed

Blocks: Nothing Parallel with: Everything Complexity: Low Risk: Low

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”).

  1. Astro site builds and deploys. docs-site/ builds with npm run build and deploys to Cloudflare Pages at docs.gondolier.dev.

  2. 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
  3. Search. Client-side search (Pagefind or similar, zero infra).

  4. “Edit this page” links. Each page links to its source in the repo.

  1. Set up Astro project in docs-site/ with a docs theme (Starlight).
  2. Convert existing markdown docs to Astro content collections.
  3. Write remaining content pages.
  4. Deploy to Cloudflare Pages.
  • docs-site/ — full Astro project
  • docs/ — may reorganize into docs-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

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.

  1. Resend email integration. POST /api/v1/tenants/:id/repos/:rid accepts notification_email. On bounce, gondolier sends an email via Resend API with: PR title, PR number, bounce reason, link to PR.

  2. Email templates. Plain text and HTML versions. Styled consistently with sell site.

  3. Slack/Discord webhooks. Accept Slack/Discord incoming webhook URL as a notification target. On bounce, POST a formatted message to the webhook.

  4. Notification preferences. Per-repo config: which channels to notify (webhook, email, Slack, Discord).

  1. Create Resend account, verify gondolier.dev domain.
  2. Add RESEND_API_KEY to Cloudflare Secrets.
  3. Implement EmailNotifier in internal/notify/.
  4. Implement SlackNotifier in internal/notify/.
  5. Wire notification preferences into the API and DB.
  • internal/notify/notify.go — email, Slack notifiers
  • internal/api/handler.go — add notification_email to CreateRepo
  • supabase/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

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.

  1. 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.

  2. API throughput. Load test: GET /api/v1/tenants/:id/queue at 10, 100, 1000 concurrent requests. Document latency distribution.

  3. Webhook throughput. Load test: POST /api/v1/webhooks/forgejo at sustained rates. Document HMAC verification overhead.

  4. Supabase connection pooling. Verify connection pool settings are appropriate for Workers (stateless, many short-lived connections).

  5. Cost projection. At 10, 100, 1000 tenants: estimated Cloudflare Workers CPU-ms, KV reads/writes, DO requests, Supabase egress. Verify within free tier limits.

  6. 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.

  1. Write a load-test script (Go or k6) that exercises the API and webhook endpoints.
  2. Run against staging environment with a test Forgejo instance.
  3. Measure with Cloudflare Workers Analytics + wrangler tail.
  4. Write capacity report in docs/operations/capacity-planning.md.
  • New: load test scripts
  • docs/operations/capacity-planning.md

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 testing
  1. Immediate (Tier 1): Dispatch WS-A and WS-B in parallel. These unblock everything else.

  2. 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.

  3. After Tier 2/3 stabilizes: Dispatch WS-K, WS-L, WS-M, WS-N in parallel. Or defer to post-launch.

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
  1. Metering model: PR-count or CI-runs-saved? (Recommendation: PR-count for launch.)
  2. Pricing: Are the tiers in site/index.html ($0 / $29 / $99 / Custom) final?
  3. 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).
  4. Deployment model: Are we committed to the Container binding (Go engine in a container, JS API surface)? Or reverting to pure JS?
  5. PostHog vs alternative: Is PostHog the final choice for analytics (same as pokomplete)?
  6. Log aggregator: Preference between CF Logpush→R2 (free, zero-code) and a paid service like Axiom?
  7. GDPR / data residency: Do we need to worry about this at launch?