Skip to content

chore(deploy): path-scoped /api/* route + production deploy runbook (#125)#133

Merged
ssilvius merged 3 commits into
mainfrom
feat/125-production-deploy-config
May 16, 2026
Merged

chore(deploy): path-scoped /api/* route + production deploy runbook (#125)#133
ssilvius merged 3 commits into
mainfrom
feat/125-production-deploy-config

Conversation

@ssilvius

Copy link
Copy Markdown
Contributor

Closes #125. Stacked on #128/#130/#131/#132 -- will rebase as the stack lands.

What

Two ops-side changes, no application logic.

apps/web/wrangler.jsonc

Route topology change:

// before
{ "pattern": "rafters.studio", "zone_id": "...", "custom_domain": true }

// after
{ "pattern": "rafters.studio/api/*", "zone_id": "..." }

Mirrors rafters/apps/registry. Lets shingle keep the apex (rafters.studio/* catchall, marketing) and apps/ctrl take rafters.studio/ctrl/* without conflict. Cloudflare picks the most-specific route, so the three workers coexist.

docs/deploy.md (new)

The runbook the operator follows to ship apps/web. Includes:

  • Topology table -- which worker handles which path
  • Required production secrets with sources (better-auth, GitHub OAuth, Polar, Resend, CF gateway). Includes the CF_API_KEY-is-actually-the-account-id naming gotcha with reflection pointers (019e2d27, 019e2d67)
  • D1 migrations runbook -- wrangler d1 migrations apply rafters --remote. Inventories all 5 current migrations.
  • CF Email Routing dashboard steps for inbox@rafters.studio -> the worker, with R2 + D1 verification
  • AI Gateway BYOK setup notes
  • Deploy commands + post-deploy curl smoke
  • Observability and cron triggers sections

Verification

  • pnpm typecheck clean
  • pnpm test 27 pass / 5 skip (unchanged)
  • pnpm build (wrangler dry-run) clean -- binding inventory unchanged

Out of scope

  • Actually provisioning secrets in prod (operator step)
  • Configuring the CF Email Routing rule (operator step, dashboard)
  • Multiple environments (single env for MVP)

Test plan

Co-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com

ssilvius and others added 3 commits May 15, 2026 23:26
Adds the email Worker export so CF Email Routing forwards inbound mail
into apps/web. Hashes content for blob keys, parses RFC 5322 headers,
dedupes by Message-ID, stores raw .eml in R2, persists metadata to D1
via @rafters/mail-drizzle table definitions.

Workspace plumbing
- pnpm-workspace.yaml: glob extends to ../mail/packages/{cloudflare,
  drizzle}. drizzle-orm pinned to ^0.45.2 via overrides (mail catalog
  pins 0.44.7 but better-auth 1.6.11 needs 0.45.2; without the override
  TypeScript sees two SQLiteColumn brands and rejects the inserts).
- apps/web/package.json: @rafters/mail-cloudflare + @rafters/mail-drizzle
  added as workspace:*.

Migration
- apps/web/src/db/migrations/0003_mail_inbox.sql: 10 inbox tables
  (mailbox, inbox_thread, inbox_message, etc.) + indexes copied from
  @rafters/mail core's migrationSQL export. Seeded with a single
  system mailbox at id 00000000-0000-0000-0000-000000000001 for
  inbox@rafters.studio. Org-aware mailbox routing is post-MVP.

Handler
- apps/web/src/lib/mail/inbound.ts: ingestInboundEmail(message, env).
  Reads raw, hashes, decodes header block, calls
  parseEmailHeaders. Dedupes by Message-ID (the natural unique key
  per inbox_message_message_id_idx). On dedupe hit returns silently.
  On parse failure stores raw to parse-failed/<hash>/raw.eml so it
  doesn't retry forever. On success: R2 put at messages/<hash>/raw.eml,
  inbox_thread row, inbox_message row. v1 always creates a new
  thread; In-Reply-To matching is post-MVP.
- apps/web/src/index.ts: email export wraps ingest in try/catch with
  createLogger({method: "EMAIL", path: message.to}). Logs success
  with status + messageId; logs failure and rethrows so CF Email
  Routing retries with backoff.

Verification
- pnpm typecheck: clean.
- pnpm test: 27 passed, 2 skipped (no new tests in this PR -- inbound
  handler test would need D1 migration apply + R2 binding in the
  workers-pool config; deferred to #126 e2e where we can hit a
  deployed worker with a fixture .eml).
- pnpm build (wrangler dry-run): clean.

Operator step (covered by #125)
- CF Email Routing dashboard: forward inbox@rafters.studio (or chosen
  address) to the deployed worker. Document the dashboard config in
  docs/deploy.md.

Out of scope
- In-Reply-To / References thread matching (always-new-thread for v1)
- Mailbox routing per organization (single seeded system mailbox)
- Email classification via @rafters/mail-workers-ai
- IMAP (Sean: "web only, no imap")

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
)

Wires the Polar webhook plugin's onPayload callback to write one
audit_log row per verified webhook event. INSERT OR IGNORE on a
nullable UNIQUE polar_event_id column dedupes Polar redeliveries of
the same event.

Migration
- apps/web/src/db/migrations/0004_audit_polar_event_id.sql:
  ALTER TABLE audit_log ADD COLUMN polar_event_id TEXT;
  CREATE UNIQUE INDEX idx_audit_log_polar_event_id ON audit_log
    (polar_event_id);
  SQLite UNIQUE allows multiple NULLs, so non-Polar audit rows
  (the existing ledger-plugin auth events) are unaffected.

Schema
- apps/web/src/db/schema/audit.ts: polarEventId text column +
  uniqueIndex matching the migration.

Handler
- apps/web/src/lib/audit/polar-webhook.ts: writePolarAudit(db, payload).
  Composes polar_event_id as type:data.id:timestamp.toISOString()
  -- the triple is unique per event delivery (Polar reuses the
  timestamp on retries, so redeliveries hash to the same key).
  INSERT OR IGNORE on auditLog.polarEventId silently dedupes.
- apps/web/src/auth.ts: polar plugin webhooks block now passes
  onPayload that calls writePolarAudit. Bypasses the ledger plugin's
  writeAuditEntry on purpose -- ledger does not expose the
  polar_event_id column needed for idempotency, and tunneling polar
  concerns through ledger would be a cross-concern leak.

Tests
- tests/api/lib/audit/polar-webhook.test.ts: 3 tests for write +
  dedupe + distinct-by-type currently describe.skip pending D1
  migration apply at workers-pool setup. Defer to #126 e2e suite
  which can hit a deployed worker.

Verification
- pnpm typecheck: clean.
- pnpm test: 27 passed (unchanged), 5 skipped (was 2, +3 polar tests).
- pnpm build (wrangler dry-run): clean.

Out of scope
- Per-event-type handlers (onOrderPaid, onSubscriptionCanceled): not
  needed for MVP; onPayload captures all events generically.
- Operator step: provision POLAR_WEBHOOK_SECRET via wrangler secret
  put -- covered by #125.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…125)

Two changes, both ops-side.

apps/web/wrangler.jsonc
- Route changed from `pattern: rafters.studio, custom_domain: true` to
  `pattern: rafters.studio/api/*` (no custom_domain). Mirrors
  rafters/apps/registry topology. Lets shingle keep the apex catchall
  and apps/ctrl take rafters.studio/ctrl/* without conflict.
  Cloudflare picks the most-specific route, so the three workers coexist.

docs/deploy.md (new)
- Topology table (which worker handles which path)
- Required production secrets inventory: BETTER_AUTH_*, GITHUB_*,
  POLAR_*, RESEND_API_KEY, CF_API_KEY, CF_WORKER_AI_KEY. Includes the
  CF_API_KEY-is-actually-the-account-id naming gotcha with a pointer
  to the reflection that explains it (019e2d27, 019e2d67).
- D1 migrations runbook: wrangler d1 migrations list/apply --remote.
  Inventories all 5 current migrations.
- CF Email Routing dashboard steps for inbox@rafters.studio -> the
  worker, with verification of the R2 + D1 artifacts.
- AI Gateway BYOK setup notes.
- Deploy commands + post-deploy curl smoke (health + adhoc color).
- Observability + cron triggers sections.

Verification
- pnpm typecheck: clean.
- pnpm test: 27 passed, 5 skipped (unchanged from #124).
- pnpm build (wrangler dry-run): clean; binding inventory unchanged.

Out of scope
- Actually provisioning the secrets in prod (operator step)
- Configuring the CF Email Routing rule (operator step, dashboard-side)
- Multiple environments (single env for MVP)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ssilvius ssilvius force-pushed the feat/125-production-deploy-config branch from 652875d to 96c727a Compare May 16, 2026 06:27
@ssilvius ssilvius merged commit 6b4513d into main May 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

chore(deploy): production wrangler config -- path-scoped routes, secrets, observability, CF Email Routing

1 participant