Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 35 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions src/lib/tenancy/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

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] ?? "<company-slug>";
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;
Expand Down Expand Up @@ -285,6 +349,10 @@ async function resolveTenantContextUncached(
? "no-tenant-host-match"
: null,
});

if (!resolvedCompany) {
await logLocalTenantFallbackHint(host, devTenantSlug);
}
}

if (!session) {
Expand Down
Loading