Submatter/docs/operations/webhook-secret-rotation.md
Randall Stillwell 6f139d6af2 feat(owner-cockpit): forge webhooks + artifacts + copilot (Epic 8/11/12)
- Forge webhook POST /api/webhooks/forge HMAC sha256 verify, marker echodo-convoy → pr_review→merged as automation
- Completion artifacts table 0017, deterministic summary template, gallery /artifacts
- Ops copilot tRPC copilot + context tools (fleet, convoy, backlog) + panel + Draft Task → backlog.createTask
- Doc-writer SOPs: webhook rotation, copilot prompt, published flow examples
- Allow pr_review→merged as automation for webhook

Convoy: .convoys/owner-cockpit-v02

Co-authored-by: orchestrator
2026-08-25 17:49:38 -05:00

3.3 KiB

Webhook secret rotation — FORGE_WEBHOOK_SECRET

One-page rotation SOP for POST /api/webhooks/forge HMAC secret.

Where it lives

  • Coolify (prod): web service env FORGE_WEBHOOK_SECRET in docker/docker-compose.coolify.yml:web.environment → Coolify UI → Environment Variables. Not committed; value lives only in Coolify env store and Forgejo webhook config.
  • Local dev: .env at repo root (FORGE_WEBHOOK_SECRET=...). Copy from .env.example pattern — never commit real value.
  • Consumer: apps/web/app/api/webhooks/forge/route.ts:62 reads process.env.FORGE_WEBHOOK_SECRET and verifies X-Hub-Signature-256 (GitHub) or X-Forgejo-Signature / X-Hub-Signature HMAC-SHA256 with timingSafeEqual. Rate-limit 60/min per IP. Missing secret → 500 webhook_not_configured.

Rotate

  1. Generate new secret (64 hex chars = 32 bytes):

    openssl rand -hex 32
    

    Copy output → new FORGE_WEBHOOK_SECRET.

  2. Update Forgejo / GitHub webhook:

    • Forgejo: Repo → Settings → Webhooks → target webhook → Secret → paste new value → Save.
    • GitHub: Repo → Settings → Webhooks → target webhook → Secret → paste new value → Save / Update webhook.
  3. Update Coolify:

    • Coolify → echodo service web → Environment Variables → set FORGE_WEBHOOK_SECRET=<new-hex> → Save.
    • Redeploy web (Coolify Deploy). Verify docker/docker-compose.coolify.yml not edited directly for prod; Coolify env overlay wins.
  4. For local dev, update .env:

    FORGE_WEBHOOK_SECRET=<new-hex> pnpm --filter @tasks/web dev
    
  5. Keep old secret for 5 min overlap if Forgejo allows dual-secret testing; otherwise coordinate deploy + webhook save within same minute.

Test

Generate signature and POST to local or prod URL. Route accepts sha256=<hex> or bare <hex>.

SECRET="<new-hex>"
BODY='{"action":"closed","pull_request":{"merged":true,"body":"echodo-convoy: 00000000-0000-0000-0000-000000000000","number":123}}'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //')

# Local
curl -i -X POST http://localhost:3000/api/webhooks/forge \
  -H 'Content-Type: application/json' \
  -H "X-Hub-Signature-256: sha256=$SIG" \
  -d "$BODY"

# Forgejo variant (same HMAC, different header)
curl -i -X POST http://localhost:3000/api/webhooks/forge \
  -H 'Content-Type: application/json' \
  -H "X-Forgejo-Signature: sha256=$SIG" \
  -d "$BODY"

# Prod
curl -i -X POST https://echodo.stillwell.cloud/api/webhooks/forge \
  -H 'Content-Type: application/json' \
  -H "X-Hub-Signature-256: sha256=$SIG" \
  -d "$BODY"

Verify

  • Valid HMAC + action=closed + pull_request.merged=true + body contains echodo-convoy: <uuid> matching a convoy in pr_review → 200 {"ok":true} and convoy transitions pr_review → merged (automation, pr#N merged) visible in convoy_events and Mission Control within 5s.
  • Invalid signature → 401 {"ok":false,"error":"invalid_signature"}.
  • Missing echodo-convoy: marker or merged=false or status !== pr_review → 200 {"ok":true} no-op (no leak).
  • Missing FORGE_WEBHOOK_SECRET env → 500 webhook_not_configured.

References

  • Route: apps/web/app/api/webhooks/forge/route.ts
  • State machine: packages/shared/src/convoy-lifecycle/state-machine.ts:90
  • Checklist: docs/operations/live-e2e-checklist.md