diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07cfed4..7e575b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -122,13 +122,18 @@ jobs: - name: Build run: npm run build - # NODE_ENV=test keeps the dev-session bypass available on a production - # build (see allowDevBypass in src/lib/config.ts), so the authenticated - # sweep still runs instead of skipping itself. + # NODE_ENV stays "development" so the dev-session bypass is available on + # a production build (allowDevBypass in src/lib/config.ts) and the + # authenticated sweep runs instead of skipping itself. + # + # Not "test": Next deliberately skips .env.local under NODE_ENV=test, so + # the same command run on a developer's machine would start a server with + # no DATABASE_URL and report every migration missing — a confusing false + # failure. CI passes its database in through the job env either way. - name: Run E2E smoke suite run: npm run e2e env: - NODE_ENV: test + NODE_ENV: development E2E_SERVER_COMMAND: npm run start - name: Upload Playwright report diff --git a/README.md b/README.md index 5ec8d72..5862a59 100644 --- a/README.md +++ b/README.md @@ -702,9 +702,43 @@ Local demo mode is only available when `ESTATEOS_ENABLE_DEV_BYPASS=true`. It is Local development keeps the central-auth architecture easy to test: - `NEXT_PUBLIC_APP_URL`, `NEXT_PUBLIC_PLATFORM_BASE_URL`, and `NEXT_PUBLIC_PORTAL_BASE_URL` can all stay on localhost. -- tenant public pages can still use `DEFAULT_COMPANY_SLUG` in development without requiring multi-domain DNS. +- `DEFAULT_COMPANY_SLUG` gives signed-in areas (`/admin`, `/portal`) a tenant without multi-domain DNS. It does **not** apply to public pages — see below. - central auth redirect helpers collapse back to the same localhost origin when portal and platform share the same dev host. +### Opening A Tenant Site Locally + +`npm run dev` and then http://localhost:3000 renders the **platform** marketing +page, not a tenant site. That is correct behavior, not a broken database: +public pages resolve the tenant from the **host**, and `DEFAULT_COMPANY_SLUG` +is deliberately not consulted for them, so a fallback can never serve one +tenant's public site on another host in production. + +Use either of these instead (the dev server also prints this hint, naming the +companies present in your database, whenever it falls back to the platform page): + +```bash +# 1. Query parameter — needs DEV_ACCESS_MODE=true in .env.local +http://localhost:3000/?devTenant=acme-realty + +# 2. Tenant host — no env flag needed; *.localhost resolves to loopback in +# most browsers, otherwise add "127.0.0.1 acme-realty.localhost" to your +# hosts file +http://acme-realty.localhost:3000 +``` + +Option 1 is the intended day-to-day workflow. Option 2 is the closer match to +production, because it exercises the same host-based resolution that a real +tenant domain uses, and it is what the E2E suite drives. + +If the hint reports that no companies exist, the database simply has not been +seeded: + +```bash +createdb estateos_dev # or: psql -c "CREATE DATABASE estateos_dev" +npm run db:migrate:deploy # applies prisma/migrations to that database +npm run db:seed # creates the acme-realty demo tenant +``` + ## Database Setup (Supabase) ### 1. Create A Supabase Project diff --git a/src/lib/tenancy/context.ts b/src/lib/tenancy/context.ts index 85d5baf..b41a285 100644 --- a/src/lib/tenancy/context.ts +++ b/src/lib/tenancy/context.ts @@ -100,6 +100,70 @@ async function lookupCompany( return null; } +/** + * Development-only guidance for the single most common local confusion: + * `npm run dev` + http://localhost:3000 renders the PLATFORM marketing site, + * not a tenant site, and the log line above ("no-tenant-host-match") does not + * say what to do about it. + * + * The reason is deliberate and must not be softened: public pages resolve the + * tenant from the HOST. DEFAULT_COMPANY_SLUG is applied to authenticated areas + * only (see `resolvedCompany` above), so it can never leak one tenant's public + * site onto another host in production. Locally that means the host has to + * name the tenant. + * + * Logged once per host per server process so it guides without spamming, and + * it names the companies actually present so a missing seed is obvious. + */ +const loggedTenantFallbackHosts = new Set(); + +async function logLocalTenantFallbackHint(host: string | null, devTenantSlug: string | null) { + if (featureFlags.isProduction) { + return; + } + + const key = host ?? "(no host)"; + if (loggedTenantFallbackHosts.has(key)) { + return; + } + loggedTenantFallbackHosts.add(key); + + let knownSlugs: string[] = []; + if (featureFlags.hasDatabase) { + try { + const companies = await prisma.company.findMany({ + select: { slug: true }, + orderBy: { createdAt: "asc" }, + take: 5, + }); + knownSlugs = companies.map((company) => company.slug); + } catch { + // The hint is best-effort; a database problem is reported elsewhere. + } + } + + const example = devTenantSlug ?? knownSlugs[0] ?? ""; + const nextStep = + knownSlugs.length === 0 + ? "This database has no companies yet — run `npm run db:seed` first." + : `Companies in this database: ${knownSlugs.join(", ")}.`; + + logWarn( + `No tenant site matched host "${key}", so the platform marketing page is being rendered. ` + + "Public pages resolve the tenant from the host; DEFAULT_COMPANY_SLUG applies to signed-in areas only, " + + "which is why plain localhost never shows a tenant site. " + + `${nextStep} ` + + `To open a tenant site locally use http://localhost:3000/?devTenant=${example} ` + + `(needs DEV_ACCESS_MODE=true) or http://${example}.localhost:3000.`, + { + route: "/", + host: key, + step: "tenant-host-resolution", + knownCompanySlugs: knownSlugs, + }, + ); +} + export async function resolveCompanyForTenantHint(input: { companySlug?: string | null; host?: string | null; @@ -285,6 +349,10 @@ async function resolveTenantContextUncached( ? "no-tenant-host-match" : null, }); + + if (!resolvedCompany) { + await logLocalTenantFallbackHint(host, devTenantSlug); + } } if (!session) {