feat(email): add org_email_domains claim/verify/remove - #367
Conversation
Phase 5 PR 6. Adds the per-org sending-domain table and the admin flow to claim, verify, and remove a domain through Resend's Domains API. No outbound email switches to the domain yet — that is PR 7. Changes: - migration 20260819000000_org_email_domains: table with restrictive isolation policy + admin-only permissive policy; column-level GRANTs (authenticated: SELECT, INSERT(domain), DELETE; no UPDATE on any column; anon nothing); unique-per-org index; domain_shape CHECK - POST /api/admin/email-domain: service-role claim, org anchored on the caller's own RLS-scoped profile; Resend domains.create; rollback on provider failure; 409 on a second claim - POST /api/admin/email-domain/verify: service-role domains.verify + get, persists status/dns_records/verified_at/last_checked_at on (id, org_id) - DELETE /api/admin/email-domain: request-scoped client only (RLS + grant bounded), no service role - /admin/settings/email admin page (claim form, status badge, DNS records, Verify/Remove) linked from /admin/settings - pgTAP: org_email_domains_suite (isolation, non-admin, grant matrix, constraints); tenancy_leak_suite fixture row for the new table - service-role inventory: two new rows, heading 19 -> 21 sites - database.types.ts: org_email_domains only Closes #363
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Dependency Review✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.Snapshot WarningsEnsure that dependencies are being submitted on PR branches and consider enabling retry-on-snapshot-warnings. See the documentation for more information and troubleshooting advice. Scanned FilesNone |
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
🔍 Comprehensive PR ReviewPR: #367 SummaryThe tenancy/RLS layer on Verdict:
🟠 High Issues1. Resend domain orphaned, unlogged, and unrecoverable if the post-create DB update fails📍 The claim route's insert-failure branch rolls back correctly (deletes the row, scoped to View fixif (updateError || !saved) {
console.error(
"email-domain create: scoped update failed (org=%s, id=%s, resend_domain_id=%s):",
orgId, inserted.id, rd.id, updateError,
);
const { error: resendCleanupError } = await getResend().domains.remove(rd.id);
if (resendCleanupError) {
console.error("email-domain create: Resend cleanup after failed update also failed (org=%s, resend_domain_id=%s):", orgId, rd.id, resendCleanupError);
}
const { error: rollbackError } = await service
.from("org_email_domains").delete().eq("id", inserted.id).eq("org_id", orgId);
if (rollbackError) {
console.error("email-domain create: rollback delete after failed update also failed (org=%s, id=%s):", orgId, inserted.id, rollbackError);
}
return NextResponse.json({ error: "Failed to save domain. Please try again." }, { status: 500 });
}2. No top-level
|
| Issue | Location | Suggestion |
|---|---|---|
| Grant-matrix pgTAP checks catalog privileges, not a live UPDATE attempt | supabase/tests/org_email_domains_suite.sql:1069-1083 |
Add a live-statement block mirroring the existing admin_self_verify_err pattern |
owner_b fixture created but never used |
supabase/tests/org_email_domains_suite.sql:903,911,926 |
Mirror the org-A assertion block for org B; low risk given the symmetric policy |
Forward reference to org_domains table that doesn't exist yet, uncited |
supabase/migrations/20260819000000_org_email_domains.sql:74 |
Add (docs/plans/phase-5-domains-email.md §6, not yet built) |
| Org-anchor comment overclaims which callers it covers | app/api/admin/email-domain/route.ts:48-50 |
Broaden to "every query below in this file" — DELETE uses the same anchor without a service-role client |
| §10.1/§10.2 status-vocabulary listings also stale | docs/plans/phase-5-domains-email.md |
Bundle with HIGH #5's fix |
✅ What's Good
- Tenancy/RLS layer independently verified by three agents: restrictive isolation policy, column-level GRANT matrix, and tenant-root FK shape all check out against CLAUDE.md's rules.
- Both service-role routes correctly anchor
org_idon the caller's own RLS-scoped profile; service-role inventory updated correctly in the same PR. - Claim route's insert-failure rollback is genuinely well-built and logs if the rollback itself fails.
- pgTAP suite (39 assertions) asserts real row counts and catalog privileges rather than "no error" — avoids this codebase's known "zero-row writes report success" failure mode.
- Comment quality is high throughout; every checkable claim was independently verified against the actual GRANT/CHECK/RLS statements and the installed Resend SDK types.
- The widened
statusCHECK (Deviation 1) is sound and independently re-verified against the installedresend@6.20.0SDK.
📋 Suggested Follow-up Issues
| Issue Title | Priority | Related Finding |
|---|---|---|
Consolidate the three inline requireOrgAdmin()-shaped gates onto lib/members/access.ts's shared helper |
P2 | MEDIUM — broader than this PR |
Wrap admin-page fetch() handlers in try/finally to prevent stuck-busy state |
P3 | MEDIUM — same gap exists in app/admin/families/page.tsx today |
Next Steps
- 🟠 Address the 5 HIGH issues — all are scoped, mechanical changes with recommended code above.
- 🟡 Review the 4 MEDIUM issues — all recommended "fix now" given low effort relative to risk.
- 🟢 Consider the 5 LOW issues for this PR or a fast follow-up.
- This PR is currently a draft with CI still in-progress at review time — confirm all checks resolve green before requesting merge review.
Reviewed by Archon comprehensive-pr-review workflow
Artifacts: /Users/cody/.archon/workspaces/cwaits6/two42/artifacts/runs/d29e424939880f07f32879a9d1e4375f/review/
Fixed: - Symmetric Resend + DB rollback on post-create update failure, with rd.id logged so an orphaned domain is traceable (route.ts) - Top-level try/catch around Resend SDK calls in both routes, matching the codebase's existing convention, with insert-rollback on the claim route's catch path - Swapped both routes' reimplemented `requireOrgAdmin()` for the shared lib/members/access.ts helper (picks up its error logging; removes the name collision and the DELETE-route comment overclaim it caused) - admin page load() returns early on a read error instead of clobbering an already-displayed domain with null - admin page's three fetch() handlers wrapped in try/finally so a network-level failure can't leave the button stuck busy - Migration comment's org_domains forward-reference now cites its plan-doc section - Plan doc §10.1/§10.2/§10.3 status-vocabulary count corrected (4 -> 7 non-verified statuses) and reworded to gate on equality to 'verified' so it can't go stale on the next Resend SDK bump - pgTAP: added a live-statement UPDATE-denial check (not just catalog privileges) and an org-B admin own-org visibility check (the previously-unused owner_b fixture) Tests added: - app/api/admin/email-domain/route.test.ts: rollback-on-Resend-failure (insert and update sides), unexpected-exception rollback, DOMAIN_SHAPE boundary cases - app/admin/settings/email/page.test.ts: toDnsRecords/statusVariant/ statusLabel boundary and adversarial cases Skipped: none Validation: tsc, lint, vitest (263 passed), guard:tenancy, and the org_email_domains/tenancy_leak/schema_tenancy_lint pgTAP suites all pass locally.
⚡ Self-Fix Report (Aggressive)Status: COMPLETE Fixes Applied (14 total)
View all fixes
Tests Added
Skipped (0)(none — all findings addressed) Suggested Follow-up Issues
Validation✅ Type check | ✅ Lint | ✅ Tests (263 passed, up from 228) | ✅ Self-fix by Archon · aggressive mode · fixes pushed to |
Summary
Phase 5 · PR 6 — adds
org_email_domains, the table and admin routes that let an org claim and verify a custom sending domain through Resend. No change to how mail is actually sent (lib/email/identity.ts,resend.ts,serving.tsare untouched) — that's PR 7.Changes
supabase/migrations/20260819000000_org_email_domains.sql):org_email_domainstable withorg_iddefaultapp_current_org_id(), restrictive isolation policy (org_id = (select app_request_org_id())), a permissive admin-only policy, adomain_shapeCHECK, a unique-per-org index, and column-level GRANTs so admins can write the domain but notstatus/verified_at— only the service role can.POST /api/admin/email-domain: service-role claim route. Calls Resenddomains.create, inserts the row, rolls back the Resend domain if the insert fails. Org anchored on the caller's RLS-scoped profile.DELETE /api/admin/email-domain: remove route, request-client only (no service role, no Resenddomains.remove— per decision D5).POST /api/admin/email-domain/verify: service-role verify route. Calls Resenddomains.verify+domains.get, persistsstatus,records,verified_at,last_checked_at.app/admin/settings/email/page.tsx): claim form, status card, DNS record list, Verify / Remove actions. Linked fromapp/admin/settings/page.tsx.docs/security/service-role-inventory.md: two new rows under "App routes and pages" for the claim/verify routes.supabase/tests/org_email_domains_suite.sql, 39 assertions): cross-org isolation, non-admin write rejection, own-org lifecycle, the column-level grant matrix, and constraint checks.tenancy_leak_suite.sqlgets a fixture row so the completeness gate covers the new table.lib/supabase/database.types.ts: regenerated, trimmed to only theorg_email_domainshunk (unrelated local-stack strays from a parallel branch were dropped).Deviations from plan (see
docs/plans/phase-5-domains-email.md§10.1/§10.2/§12 step 6, decision D5)statusCHECK vocabulary widened to the union of the plan's spec (not_started,pending,verified,failure,temporary_failure) and the installedresend@6.20.0SDK'sDomainStatustype (pending,verified,failed,not_started,partially_verified,partially_failed). A real Resend status can never trip the CHECK and strand the verify route with a 500; anything outside both sets still fails loudly. pgTAP asserts a made-up status is rejected. Nothing gates on any value butverified.psql, notsupabase migration up— the shared local stack already carries20260818000000_reserved_org_slugsfrom the parallel Phase 5 · PR 1 — reserved slug labels denylist in provision_organization() + lib/org.ts #358 branch, which this branch doesn't have, andmigration uprefused withLegacyMigrationMissingLocalError. Applied the file directly withpsql -v ON_ERROR_STOP=1and recorded the version row insupabase_migrations.schema_migrations, same bookkeepingmigration upwould do. CI applies the file normally. If Phase 5 · PR 1 — reserved slug labels denylist in provision_organization() + lib/org.ts #358 merges first,20260819000000should be re-timestamped to sort after it.npm run db:typesoutput hand-trimmed — the generated diff also carried unrelated local-stack strays (apayment_handlestable,custom_handlenullability); only theorg_email_domainsblock was kept.Validation
npx tsc --noEmitnpm run build/admin/settings/email,/api/admin/email-domain,/api/admin/email-domain/verify)npm run lint(--max-warnings=0)npm run guard:tenancynpx vitest runorg_email_domains_suite.sql(local, docker exec)tenancy_leak_suite.sqlorg_email_domainsschema_tenancy_lint.sqlidor_suite.sql,branding_rls_suite.sqlUnauthenticated smoke: all three routes → 401,
/admin/settings/email→ 307 to login. Authenticated smoke of the claim route was deliberately not run — it would create a real domain in the Resend account tied to local.env.local. Local pgTAP ran against the shared stack (which also carries #358's migration); CI's ephemeralpgtapjob is the authoritative gate.Out of scope / next steps
domains.removecall on remove, and no scheduled re-verification (per decision D5).Fixes #363