diff --git a/.env.example b/.env.example index db20f54..c9a8a33 100644 --- a/.env.example +++ b/.env.example @@ -31,6 +31,11 @@ ALLOWED_SIGN_IN="" GOOGLE_CLIENT_ID="" GOOGLE_CLIENT_SECRET="" +# Serve the landing page at "/". It markets *this* product, so it is off unless +# you say otherwise: on an install of your own, a stranger arriving at the root +# is sent to /sign-in instead. The only value that turns it on is "true". +# IS_MARKETING="true" + # ── Where things are ───────────────────────────────────────────────────────── # Only needed when you deploy. The defaults below are the localhost ones. diff --git a/apps/app/lib/env.ts b/apps/app/lib/env.ts index 30b75b3..bd02b96 100644 --- a/apps/app/lib/env.ts +++ b/apps/app/lib/env.ts @@ -1,2 +1,6 @@ export const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:3001"; + +export function isMarketing(): boolean { + return process.env.IS_MARKETING === "true"; +} diff --git a/apps/app/proxy.ts b/apps/app/proxy.ts index 79dff2d..7d982aa 100644 --- a/apps/app/proxy.ts +++ b/apps/app/proxy.ts @@ -1,6 +1,7 @@ import { AUTH_COOKIE_PREFIX } from "@crm/auth/cookies"; import { getSessionCookie } from "better-auth/cookies"; import { type NextRequest, NextResponse } from "next/server"; +import { isMarketing } from "@/lib/env"; import { ONBOARDING_PATH, RESEARCH_PATH, @@ -13,7 +14,7 @@ const LANDING_PATH = "/"; const SIGN_IN_PATH = "/sign-in"; -const PUBLIC = [LANDING_PATH, SIGN_IN_PATH]; +const PUBLIC = [SIGN_IN_PATH]; const UNGATED = [SIGN_IN_PATH, "/grant-access", "/eve"]; @@ -70,6 +71,8 @@ function isUnder(pathname: string, prefix: string): boolean { } function isPublic(pathname: string): boolean { + if (pathname === LANDING_PATH) return isMarketing(); + return PUBLIC.some((prefix) => isUnder(pathname, prefix)); } diff --git a/apps/app/test/onboarding-gate.spec.ts b/apps/app/test/onboarding-gate.spec.ts index 72f1b7d..42122b7 100644 --- a/apps/app/test/onboarding-gate.spec.ts +++ b/apps/app/test/onboarding-gate.spec.ts @@ -10,10 +10,18 @@ const SLUG = "comp-ai"; const realFetch = globalThis.fetch; +const realMarketing = process.env.IS_MARKETING; + afterEach(() => { globalThis.fetch = realFetch; + marketing(realMarketing); }); +function marketing(value: string | undefined) { + if (value === undefined) delete process.env.IS_MARKETING; + else process.env.IS_MARKETING = value; +} + function stub(handler: (url: string) => Promise) { globalThis.fetch = ((input: string | URL | Request) => handler(String(input))) as unknown as typeof fetch; @@ -145,6 +153,8 @@ describe("readResearchGate", () => { describe("proxy", () => { it("shows a stranger the landing page and nothing behind it", async () => { + marketing("true"); + expect(redirectedTo(await proxy(request("/")))).toBeNull(); expect(redirectedTo(await proxy(request("/sign-in")))).toBeNull(); expect(redirectedTo(await proxy(request(`/${SLUG}`)))).toBe("/sign-in"); @@ -153,6 +163,24 @@ describe("proxy", () => { ); }); + it("sends a stranger to sign in when the install has no landing page", async () => { + marketing(undefined); + + expect(redirectedTo(await proxy(request("/")))).toBe("/sign-in"); + expect(redirectedTo(await proxy(request("/sign-in")))).toBeNull(); + }); + + it("reads the flag on every request, and only the literal true turns it on", async () => { + marketing("false"); + expect(redirectedTo(await proxy(request("/")))).toBe("/sign-in"); + + marketing("1"); + expect(redirectedTo(await proxy(request("/")))).toBe("/sign-in"); + + marketing("true"); + expect(redirectedTo(await proxy(request("/")))).toBeNull(); + }); + it("ignores a neighbour's cookie from the parent domain", async () => { expect(AUTH_COOKIE_PREFIX).not.toBe("better-auth"); setup(); diff --git a/apps/app/turbo.json b/apps/app/turbo.json index 4e93170..5b82d36 100644 --- a/apps/app/turbo.json +++ b/apps/app/turbo.json @@ -14,7 +14,8 @@ "AUTH_TRUSTED_ORIGINS", "BETTER_AUTH_SECRET", "BETTER_AUTH_URL", - "DATABASE_URL" + "DATABASE_URL", + "IS_MARKETING" ] }, "dev": { @@ -28,6 +29,7 @@ "BETTER_AUTH_SECRET", "BETTER_AUTH_URL", "DATABASE_URL", + "IS_MARKETING", "NEXT_PUBLIC_API_URL", "NEXT_PUBLIC_AUTH_URL" ] diff --git a/docs/api.md b/docs/api.md index 1687083..14538d1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -167,10 +167,20 @@ called, who works here, and what do we sell — and for nothing else. `requireGoogleAccess()` redirects to `/grant-access`, so gating it would ping-pong against the onboarding redirect for anyone who signed in without both scopes. - - **`/` and `/sign-in` are the only two paths a stranger may read**, and they - are a different list from the ungated one. `/` is the landing page in - `app/(landing)` — the marketing front of a clone, served to somebody who has - no account yet. Everything else still goes to `/sign-in`. + - **`/sign-in` is the only path a stranger may read**, and that list is a + different one from the ungated paths above. `/` joins it only when + `IS_MARKETING` is set: the landing page in `app/(landing)` markets *this* + product, so an install that is somebody else's sends a stranger arriving at + the root to `/sign-in` rather than to a page selling them a CRM they are + already running. See + [the environment rules](./environment.md#the-landing-page-is-a-flag-and-it-is-off). + - **The flag is read per request, not captured at import.** `isMarketing()` + in `apps/app/lib/env.ts` is a function for that reason — `proxy.ts` runs + in the Node runtime, so the variable is a live read, and a deployment that + changes it does not need a rebuild to be believed. + - **It changes nothing for anyone signed in.** A rep at `/` is sent to their + workspace by `appPath` either way, so the flag is exactly one decision: + what a stranger at the root is shown. - **An unreachable API fails open.** Each read returns `unknown` on a non-200, a timeout or a parse failure, and an unknown gate lets the request through. The alternative is an install that cannot reach its own API diff --git a/docs/environment.md b/docs/environment.md index de37b77..73ff7fc 100644 --- a/docs/environment.md +++ b/docs/environment.md @@ -103,6 +103,34 @@ at import, so the Better Auth CLI — which loads `auth.ts` in a process with no Live in `packages/auth/src/workspace.ts`. +## The landing page is a flag, and it is off + +`IS_MARKETING="true"` serves the landing page in `app/(landing)` at `/`. +Anything else — unset, empty, `false`, `1` — and a signed-out visitor to the +root is redirected to `/sign-in`. + +It defaults to off because the page markets *this* product. A clone serving it +is telling a stranger about a CRM that stranger is already running, under +somebody else's name; and on an internal install there is no stranger to tell, +only a rep who signed out and wants the sign-in box. + +- **Only the literal `true` turns it on**, which is the same shape as + `PRISMA_LOG_QUERIES`. A flag with several truthy spellings is a flag that is + set and not working. +- **It decides one thing: what a stranger at `/` is shown.** Anyone signed in is + sent to their workspace by the proxy either way, and no other route changes — + the landing page's own components are still built and still reachable from a + deploy that sets the flag. +- **The read is live.** `isMarketing()` in `apps/app/lib/env.ts` reads + `process.env` per request rather than at import: `proxy.ts` runs in Next's + Node runtime, so the value comes from the environment the server is running + in, not from the one it was built in. Declared in `apps/app/turbo.json` under + `passThroughEnv` for both `dev` and `build`, because Turbo runs in strict env + mode. + +See [the proxy rules](./api.md#there-is-exactly-one-organization-and-it-is-not-a-tenancy-boundary) +for where it sits among the other gates. + ## Where things are `API_URL` and `APP_URL` default to `http://localhost:3001` and