feat(host-to-org): route verified custom domains to their org (CL-3)#500
Merged
Conversation
Resolve the org for a non-subdomain Host via a verified OrganizationDomainClaim (verifiedAt not null) whose domain equals the full hostname, falling back to DEFAULT_ORG_ID only when no verified claim matches. The existing <orgSlug>.vectorflow.sh subdomain path and slug grammar are preserved and still take precedence. Add a short (30s) in-process TTL cache keyed by hostname (positive + negative) so custom-domain hosts don't pay a DB round-trip per request, with oldest-first eviction to bound memory against abusive Host headers. The custom-domain lookup is a DB read, consumed only by the Node auth layer (src/auth.ts per-org NextAuth/OIDC, SCIM auth). The edge middleware (src/proxy.ts) deliberately does not call this resolver and stays on the subdomain/auth-gate path, so no DB access is added at the edge. Boundary documented in code. No migration: @@index([domain]) and the partial unique index on verified rows already cover the equality probe.
…L-3) The slug path ran on any multi-label host's first label, so a custom domain (e.g. logs.acme.com) whose first label collides with an existing org slug (logs) was misrouted to the slug-org, shadowing the verified claim (confused-deputy on auth/OIDC/SCIM config). Reorder to claim-first: a DNS-verified full-host OrganizationDomainClaim wins over the slug-prefix match; a claim can't exist for *.vectorflow.sh (no tenant DNS-TXT), so genuine subdomains are unaffected. Cache covers the extra indexed probe on the subdomain hot path. +shadowing test.
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.
CL-3 — custom-domain routing slice
Today
resolveOrgIdFromHost(src/lib/host-to-org.ts) resolves an org from a<slug>.vectorflow.shsubdomain and falls back toDEFAULT_ORG_IDfor anyother host. So a custom hostname pointed at the platform (e.g.
logs.acme.comCNAME'd in) never routes to its org, even when the org has proven ownership via
a verified
OrganizationDomainClaim(DNS-TXT).Change
resolveOrgIdFromHostnow resolves in two ordered paths:look up
Organization.slug. The<orgSlug>.vectorflow.shscheme + sluggrammar (
isValidOrgSlug) are preserved and take precedence.OrganizationDomainClaim(verifiedAtnot null) whosedomainequals thefull hostname → that org.
Only when neither matches do we fall back to
DEFAULT_ORG_ID(still fails open;cross-org leakage is prevented by RLS + per-org JWT secrets, not this lookup).
Caching
Added a short (30s) in-process TTL cache keyed by normalised hostname, caching
both positive and negative results so custom-domain hosts don't pay a DB
round-trip per request. Oldest-first eviction caps memory (1024 entries) against
abusive
Host:headers. Transient DB errors are not cached (fail open +retry next request). The short TTL is the staleness bound that holds across
multiple server instances (newly-verified / removed claims start / stop routing
within the TTL).
Runtime boundary (documented in code)
The custom-domain lookup is a DB read.
resolveOrgIdFromHostis consumed onlyby the Node auth layer —
src/auth.ts(per-org NextAuth instance + OIDC)and
src/app/api/scim/v2/auth.ts. The edge middlewaresrc/proxy.tsdeliberately does not call this resolver (it only does auth-gating + CSP nonce
and resolves org from the session, not the host), so no DB access is added at
the edge.
createContext(src/trpc/init.ts) resolves org from the session'sOrgMember, so it is unaffected.Migration
None.
OrganizationDomainClaimalready exists, and@@index([domain])(plus the partial unique index
OrganizationDomainClaim_domain_verified_uniqueon
verifiedAt IS NOT NULLrows) already cover the equality probeWHERE domain = ? AND verifiedAt IS NOT NULL. No schema change.Tests
Extended
src/lib/__tests__/host-to-org.test.ts(18 pass):logs.acme.com→ its org;verifiedAt: { not: null }) →DEFAULT_ORG_ID;DEFAULT_ORG_ID;npx vitest run src/lib/__tests__/host-to-org.test.ts→ 18/18. Filteredtsc --noEmitclean for the changed files. Direct consumer(
scim/v2/Groups/route.test.ts) still passes.Acceptance: a verified custom domain routes to its org.