diff --git a/CLAUDE.md b/CLAUDE.md index da5cd02..07415fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1237,7 +1237,7 @@ set -a; . /etc/jqc/control.env; set +a | 75 | **`delete_tenant()` + `_add_domains()` are retry-safe** | `_add_domains` uses `_upsert_domain()` (delete-then-insert) to handle orphan rows from failed partial runs. `delete_tenant()` also purges by derived domain string, not only by `tenant_id`, catching orphans whose parent tenant row was rolled back. | | 76 | **`register-tenant-zero` never bootstraps and never drops the DB** | `register_tenant_zero()` only reads the existing head, inserts control rows, and maps domains. `delete_tenant()` on tenant-zero must never use `--drop-db` — the guard checks `db_name == db_name_for(slug)` and refuses non-provisioner-named DBs (LT's DB name is `jqc_lt`, not `jqc_lts`). | | 84 | **Device registration is consolidated on `DeviceToken` / `api_device_tokens` — one handler only** | RESOLVED. There is exactly one `POST /api/v1/devices/register`, in `app/api/auth.py` (blueprint `api_auth`); it upserts `DeviceToken` (device_id, device_name, app_version, ios_version, apns_token, last_seen_at) which the admin Devices page reads. The former duplicate `api_devices` blueprint (`app/api/devices.py`) and the orphaned `DeviceRegistration` model / `device_registrations` table were **deleted** — that path wrote to a table phase31/32 drop. Do not reintroduce a second `/devices/register` route or a `device_registrations`-backed model. | -| 85 | **Apex host serves the public landing page; the landing route lives at `/welcome`, NOT `/`** | The dashboard owns `/` (login-gated) on tenant hosts, so the landing page cannot register a second `/` route (same collision class as rule 84). Instead the tenant middleware detects the apex host (`TENANT_BASE_DOMAIN` + `www.`) and calls `landing.index` directly for `/`, redirecting other non-exempt apex paths to `/`. `/welcome`, `/signup`, `/static/` are tenant-exempt. The apex handling is inert when `MULTI_TENANT_ENABLED=false`, so `/welcome` is the always-reachable preview URL. Requires the Nginx apex block to **proxy** (not 301-redirect) to port 8000 with `Host` passed through. | +| 85 | **Apex host serves the public landing page; the landing route lives at `/welcome`, NOT `/`** | The dashboard owns `/` (login-gated) on tenant hosts, so the landing page cannot register a second `/` route (same collision class as rule 84). Instead the tenant middleware detects the apex host (`TENANT_BASE_DOMAIN` + `www.`) and calls `landing.index` directly for `/`, redirecting other non-exempt apex paths to `/`. `/welcome`, `/signup`, `/static/` are tenant-exempt. **The apex check runs BEFORE the `MULTI_TENANT_ENABLED` gate** — it must work in single-tenant mode too, otherwise the app serves its default database (tenant-zero) for the apex host and the landing page never shows. Requires the Nginx apex block to **proxy** (not 301-redirect) to port 8000 with `Host` passed through, and `TENANT_BASE_DOMAIN` set correctly in the app environment. | | 86 | **Free plan is free-forever, not a trial** | `signup.index()` passes `trial_days=0` for `plan_code == 'free'`; `create_tenant()` then sets `subscription_status='active'` (no `trial_ends_at`) so `_billing_gate()` never blocks it. Paid plans keep the 14-day trial (`trial_days=14`). The welcome email adapts via `trial_note` and hides the trial row when `trial_ends_at` is blank. Do not reintroduce a hardcoded `trial_days=14` in the signup path. | --- diff --git a/app/tenancy/middleware.py b/app/tenancy/middleware.py index 054c75e..1d44ec1 100644 --- a/app/tenancy/middleware.py +++ b/app/tenancy/middleware.py @@ -78,6 +78,26 @@ def init_tenancy(app): g.billing_warning = None # MT-8: set to 'past_due' by _billing_gate when needed g.is_apex = False # True when serving the public apex/landing host + # ── Public apex (marketing) host → landing page ────────────────────── + # The apex domain (TENANT_BASE_DOMAIN, e.g. jqc.app) and its www. variant + # are NEVER a tenant. Serve the public landing page for '/' and bounce any + # other non-exempt apex path back to '/'. This runs BEFORE the + # MULTI_TENANT_ENABLED gate on purpose: the marketing site must work even + # in single-tenant mode (MT flag off), where the app would otherwise serve + # its default database (tenant-zero) for every host. /welcome, /signup, + # and /static/ are exempt and pass straight through. + host = (request.host or '').split(':')[0].strip().lower() + base = (current_app.config.get('TENANT_BASE_DOMAIN') or '').strip().lower() + if base and host in (base, f'www.{base}'): + g.is_apex = True + if _is_exempt(request.path): + return + if request.path == '/': + # Serve the landing view without a redirect (the dashboard owns + # '/' on tenant hosts, so we can't register a second '/' route). + return current_app.view_functions['landing.index']() + return redirect('/') + if not current_app.config.get('MULTI_TENANT_ENABLED', False): return # inert: default database serves everything (single-tenant) @@ -127,21 +147,8 @@ def init_tenancy(app): session.pop('impersonating_tenant_id', None) session.pop('impersonating_superadmin_id', None) - # ── Apex (marketing) host → public landing page ────────────────── - # The apex domain (jqc.app) and its www. variant are NOT tenants. Serve - # the public landing page for '/' and send any other non-exempt apex - # path back to it. /signup, /static, /welcome are already exempt above. - host = (request.host or '').split(':')[0].strip().lower() - base = (current_app.config.get('TENANT_BASE_DOMAIN') or '').strip().lower() - if base and host in (base, f'www.{base}'): - g.is_apex = True - if request.path == '/': - # Serve the landing view without a redirect (dashboard owns '/' - # on tenant hosts, so we can't register a second '/' route). - return current_app.view_functions['landing.index']() - return redirect('/') - # ── Normal Host → tenant resolution ────────────────────────────── + host = (request.host or '').split(':')[0].strip().lower() tenant = resolve_tenant(host) if tenant is None: return Response(_UNKNOWN_TENANT_PAGE, status=404, mimetype='text/html') diff --git a/tests/test_landing.py b/tests/test_landing.py index 5f6b2e1..6e8db2f 100644 --- a/tests/test_landing.py +++ b/tests/test_landing.py @@ -23,6 +23,24 @@ def test_welcome_renders_landing(app): assert b'Simple plans that grow with you' in r.data # pricing section +def test_apex_serves_landing_even_with_mt_disabled(app): + """Regression guard: the apex landing must work in single-tenant mode too. + + The base `app` fixture has MULTI_TENANT_ENABLED=False and the default + TENANT_BASE_DOMAIN='jqc.app'. Before the fix, the apex fell through to the + default database (tenant-zero) instead of the landing page. + """ + client = app.test_client() + r = client.get('/', headers={'Host': 'jqc.app'}) + assert r.status_code == 200 + assert b'Quality control for janitorial contracts' in r.data + + # A non-root apex path still bounces back to '/' even with MT off. + r2 = client.get('/dashboard', headers={'Host': 'jqc.app'}) + assert r2.status_code == 302 + assert r2.headers['Location'] == '/' + + @pytest.fixture def mt_app(app): """The shared app with multi-tenancy temporarily enabled (apex = jqc.app).