01-infrastructurewave: W6filled0 citations

Dev HTTPS & DNS Provisioning — Tenant Subdomains Across the PSL Boundary

Audiences: developer, internal

Dev HTTPS & DNS Provisioning — Tenant Subdomains Across the PSL Boundary

Each tenant lives on its own subdomain (<slug>.dudoxx.com in prod, <slug>.localhost in dev). Making that subdomain both resolve and prove its TLS identity differs sharply between environments. In dev, Next.js serves experimental HTTPS from an mkcert leaf whose SAN list enumerates every tenant slug, regenerated per-tenant during provisioning; *.localhost resolves to 127.0.0.1 automatically so no DNS step is needed. In prod, a real <slug>.dudoxx.com CNAME is created via ddx-dns-api and TLS terminates on the home-edge Apache wildcard. This was built to kill the NET::ERR_CERT_COMMON_NAME_INVALID error that broke local tenant logins.

Business Purpose

The self-serve onboarding flow ends by redirecting a freshly-provisioned clinic to its own subdomain to log in. For that redirect to land on a working page, two things must hold: the browser must resolve the subdomain to an IP, and the TLS handshake must present a certificate the browser trusts for that exact name. In production both are solved by infrastructure (a wildcard TLS terminator and a DNS API). On a developer laptop neither is free — and a naive wildcard dev cert fails silently.

The subtle defect: *.localhost is on the Public Suffix List (PSL). Chrome treats each PSL entry as a registry boundary, so it refuses to honour a wildcard certificate across that boundary — a cert valid for *.localhost does not cover acme.localhost. The result was NET::ERR_CERT_COMMON_NAME_INVALID the moment a developer was redirected to a tenant subdomain, blocking every local end-to-end onboarding run. The fix: enumerate every tenant slug as its own explicit DNS SAN in the dev cert, and regenerate that cert (with the new slug) as a provisioning step.

Business outcome: a developer can run the entire paid-onboarding flow locally and land on a green-padlock acme.localhost:6200/auth/login, instead of a browser interstitial; and in production the same flow creates a real CNAME and serves real TLS — with the dev machinery completely no-op'd.

Audiences

  • Investor: per-tenant subdomains with valid TLS are table stakes for a multi-tenant SaaS; the platform automates both provisioning paths (dev and prod) inside the same state machine.
  • Clinical buyer: not customer-facing — but the clinic's own branded subdomain works on first login because cert and DNS are provisioned automatically.
  • Developer/partner: dev TLS comes from ddx-web/dev-certs/localhost-{cert,key}.pem, loaded by Next.js via --experimental-https-* flags at boot. Adding a tenant means re-running scripts/regen-dev-certs.sh (idempotent, hashes the SAN set) and restarting ddx-web. Env knobs: DDX_DEV_CERT_AUTO_RESTART, DDX_MONOREPO_ROOT, DDX_EXTRA_TENANT_SLUGS.
  • Internal (ops/support): prod DNS is a real CNAME via ddx-dns-api (DDX_DNS_API_URL + DDX_DNS_API_TOKEN); dev no-ops both DNS and (in prod) cert regen. The cert is loaded at Next.js boot, so a new SAN requires a ddx-web restart to take effect.

Architecture

The provisioning state machine runs two adjacent steps in the CREATING_DNS phase — create-dns-cname (name resolution) and regen-dev-cert (TLS identity) — both of which branch hard on NODE_ENV:

diagram
                        DEV (NODE_ENV !== production)        PROD (NODE_ENV === production)
                        ─────────────────────────────       ──────────────────────────────
name resolution         *.localhost → 127.0.0.1 (auto)       create-dns-cname.step
(create-dns-cname)      DnsProvisioningService.mode='dev'      DnsProvisioningService.mode='prod'
                          → structured no-op log, ok=true       → POST ddx-dns-api /records
                                                                  {name:<slug>.dudoxx.com, type:CNAME,
                                                                   value:edge.dudoxx.com., ttl:300}

TLS identity            regen-dev-cert.step                   regen-dev-cert.step
(cert)                    → scripts/regen-dev-certs.sh          → no-op (returns CREATING_DNS)
                            (mkcert SAN = localhost +           wildcard TLS lives on the
                             *.localhost + every slug.localhost) home-edge Apache terminator
                          → ./ddx-manage.sh restart web
                            (Next reloads cert from disk)

cert at boot            Next.js dev script:
                          next dev --experimental-https
                            --experimental-https-key  dev-certs/localhost-key.pem
                            --experimental-https-cert dev-certs/localhost-cert.pem
                            -p 6200

regen-dev-certs.sh reads the live tenant list from Postgres (SELECT slug FROM organizations), unions it with DDX_EXTRA_TENANT_SLUGS (the provisioning step passes the new tenant's slug here), builds a deterministic SAN set (localhost, *.localhost, every <slug>.localhost, 127.0.0.1, ::1), hashes it to dev-certs/.slug-hash, and only re-issues with mkcert when the hash changed or the cert is within 30 days of expiry. The provisioning step then runs ./ddx-manage.sh restart web (unless DDX_DEV_CERT_AUTO_RESTART=false) because Next.js loads the cert into its in-process https.Server only at boot.

Tech Stack & Choices

  • mkcert leaf with explicit per-slug SANs — not a wildcard. The PSL boundary on *.localhost makes a wildcard useless for tenant subdomains in Chrome; each slug must be its own DNS SAN. *.localhost is retained in the SAN list only for non-tenant subdomains.
  • Next.js --experimental-https — Next serves TLS directly in dev (no local reverse proxy), reading the cert/key paths from CLI flags. The dev script in ddx-web/package.json conditionally adds the HTTPS flags only when both PEM files exist, so a developer without certs falls back to HTTP cleanly.
  • Cert regen as a provisioning step, not a hookRegenDevCertStep is part of the durable state machine so a newly-reserved slug is covered before the user is redirected. It is non-fatal: a regen or restart failure logs a warning and continues (the user sees at worst Chrome's "Proceed" interstitial, an annoyance not a blocker).
  • Idempotent regen via SAN hash — re-running the script when nothing changed is a no-op (.slug-hash + a 30-day openssl x509 -checkend validity check), so the step is cheap to call on every provision.
  • DNS dev/prod split via modeDnsProvisioningService derives mode from NODE_ENV. Dev returns { ok: true, note: 'dev no-op' } immediately (relying on *.localhost auto-resolution); prod POSTs a real CNAME to ddx-dns-api with Bearer auth, retry/backoff, and a DELETE rollback path. DDX_DNS_API_TOKEN is empty in dev (and throws if missing in prod).
  • resolveMonorepoRoot() — the step locates ddx-manage.sh by honouring DDX_MONOREPO_ROOT or walking up to 6 parents from process.cwd(), so it works regardless of where ddx-saas was launched.

Data Flow

  1. A new tenant slug is reserved earlier in the provisioning pipeline.
  2. create-dns-cname.step calls DnsProvisioningService.createSubdomain(slug). Dev: structured no-op log, returns ok. Prod: POST ${DDX_DNS_API_URL}/records with { name: '<slug>.dudoxx.com', type: 'CNAME', value: 'edge.dudoxx.com.', ttl: 300 } + Authorization: Bearer <DDX_DNS_API_TOKEN>, with [200,600,1800]ms retry on 502/503/504; records dnsTarget artifact.
  3. regen-dev-cert.step runs next (same CREATING_DNS phase). Prod: immediate no-op. Dev: execFile(scripts/regen-dev-certs.sh, [], { env: { ...process.env, DDX_EXTRA_TENANT_SLUGS: slug }, timeout: 30s }).
  4. The script unions the new slug with the Postgres slug list, rebuilds the SAN set, and re-issues localhost-cert.pem + localhost-key.pem via mkcert only if the SAN hash changed.
  5. Unless DDX_DEV_CERT_AUTO_RESTART=false, the step runs ./ddx-manage.sh restart web (detached, 60s timeout) so Next.js reloads the new cert before the user can click the "Ready" redirect.
  6. The onboarding status page redirects the browser to https://<slug>.localhost:6200/auth/login (dev) or https://<slug>.dudoxx.com/auth/login (prod) — green padlock either way.

Implicated Code

  • ddx-web/package.json — the dev script conditionally appends --experimental-https --experimental-https-key dev-certs/localhost-key.pem --experimental-https-cert dev-certs/localhost-cert.pem when both PEMs exist; dev:https forces it.
  • scripts/regen-dev-certs.sh — pre-flight mkcert/psql checks; Postgres slug query (SELECT slug FROM organizations); DDX_EXTRA_TENANT_SLUGS union; deterministic SAN list (localhost, *.localhost, <slug>.localhost, 127.0.0.1, ::1); SAN-set hash at dev-certs/.slug-hash; 30-day validity skip; mkcert -cert-file localhost-cert.pem -key-file localhost-key.pem <sans...>.
  • ddx-saas/src/provisioning/steps/regen-dev-cert.step.tsRegenDevCertStep (name = 'regen-dev-cert'); prod no-op; resolveMonorepoRoot(); execFileAsync of the regen script with DDX_EXTRA_TENANT_SLUGS=<slug>; non-fatal warn-and-continue; DDX_DEV_CERT_AUTO_RESTART gate; detached ddx-manage.sh restart web; no-op rollback.
  • ddx-saas/src/provisioning/steps/create-dns-cname.step.tsCreateDnsCnameStep (CREATING_DNS phase); delegates to DnsProvisioningService.createSubdomain(); records dnsTarget artifact; rollback via deleteSubdomain().
  • ddx-saas/src/platform/dns/dns-provisioning.service.tsmode from NODE_ENV; dev no-op vs prod CNAME POST /records / DELETE /records/:fqdn; resolveToken() (throws if DDX_DNS_API_TOKEN absent in prod); fetchWithRetry() with [200,600,1800]ms backoff; healthCheck().

Operational Notes

  • *.localhost is on the PSL — Chrome ignores wildcard certs across the PSL boundary, so a tenant subdomain needs an explicit SAN. This is the root cause of the original NET::ERR_CERT_COMMON_NAME_INVALID; the wildcard SAN alone is not enough.
  • Cert is loaded at Next.js boot — a freshly-issued cert with a new SAN does nothing until ddx-web restarts. The provisioning step does this automatically unless DDX_DEV_CERT_AUTO_RESTART=false, in which case the operator must ./ddx-manage.sh restart web.
  • Regen is idempotent and cheapdev-certs/.slug-hash + a 30-day expiry check short-circuit a no-change run, so the step is safe to invoke on every provision.
  • mkcert must be installed and its CA trusted (brew install mkcert nss); without it the script errors and the (non-fatal) step logs a warning, leaving the developer to click through Chrome's interstitial.
  • Prod DNS requires DDX_DNS_API_TOKENresolveToken() throws DDX_DNS_API_TOKEN required in production if absent. In dev the token is empty and the DNS step is a no-op by design.
  • The dev↔prod matrix in one line: dev = mkcert per-slug SANs + *.localhost auto-resolve + DNS no-op; prod = wildcard TLS on the home-edge Apache + real <slug>.dudoxx.com CNAMEs via ddx-dns-api.