chore(deploy): path-scoped /api/* route + production deploy runbook (#125)#133
Merged
Conversation
This was referenced May 16, 2026
79a3129 to
652875d
Compare
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>
652875d to
96c727a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.jsoncRoute topology change:
Mirrors
rafters/apps/registry. Lets shingle keep the apex (rafters.studio/*catchall, marketing) and apps/ctrl takerafters.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:
CF_API_KEY-is-actually-the-account-idnaming gotcha with reflection pointers (019e2d27, 019e2d67)wrangler d1 migrations apply rafters --remote. Inventories all 5 current migrations.inbox@rafters.studio-> the worker, with R2 + D1 verificationcurlsmokeVerification
pnpm typecheckcleanpnpm test27 pass / 5 skip (unchanged)pnpm build(wrangler dry-run) clean -- binding inventory unchangedOut of scope
Test plan
docs/deploy.mdwrangler deployand curlhttps://rafters.studio/api/healthCo-Authored-By: Claude Opus 4.7 (1M context) noreply@anthropic.com