From 5dffb6509751c7cebba1e3257f1dcd9a3b1daded Mon Sep 17 00:00:00 2001 From: Asapteejo Date: Thu, 17 Sep 2026 10:05:57 +0100 Subject: [PATCH 1/3] fix(dev): explain in the log why localhost renders the platform page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public pages resolve the tenant from the host, and DEFAULT_COMPANY_SLUG is applied to signed-in areas only, so plain localhost always falls back to the platform marketing page. The existing log said "no-tenant-host-match" without saying what to do about it, and the natural reading — "my database is not seeded" — is usually wrong. Development now logs the cause, both supported local entry points, and the company slugs actually present in the database, so a genuinely unseeded database is distinguishable from correct host-based behavior. Once per host per process, never in production. The resolution rule itself is unchanged. Co-Authored-By: Claude Opus 5 --- src/lib/tenancy/context.ts | 68 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) 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) { From bba38a155347506b92aea2c3e5791a47162358ea Mon Sep 17 00:00:00 2001 From: Asapteejo Date: Thu, 17 Sep 2026 10:05:58 +0100 Subject: [PATCH 2/3] docs: correct the DEFAULT_COMPANY_SLUG claim and document local tenant access The README stated that tenant public pages can use DEFAULT_COMPANY_SLUG in development. They cannot: the fallback is deliberately restricted to authenticated areas so it can never serve one tenant's public site on another host. Following the old text leads to "localhost shows the platform page" and a hunt for a database fault that does not exist. Adds "Opening A Tenant Site Locally" covering ?devTenant= and .localhost, which of the two is the day-to-day workflow, and the create/migrate/seed commands for a genuinely empty database. Co-Authored-By: Claude Opus 5 --- README.md | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) 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 From f2077afff515bc8dd6e8e21dc390183988f35f43 Mon Sep 17 00:00:00 2001 From: Asapteejo Date: Thu, 17 Sep 2026 10:05:59 +0100 Subject: [PATCH 3/3] fix(ci): run the E2E server as development, not test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next skips .env.local when NODE_ENV=test, so running CI's own E2E command locally started a server with no DATABASE_URL and reported every migration missing — a false failure that is easy to misread as a broken local database. NODE_ENV=development keeps the dev-session bypass available on a production build and loads .env.local locally; CI supplies its database through the job env either way. Co-Authored-By: Claude Opus 5 --- .github/workflows/ci.yml | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) 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