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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions apps/app/lib/env.ts
Original file line number Diff line number Diff line change
@@ -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";
}
5 changes: 4 additions & 1 deletion apps/app/proxy.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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"];

Expand Down Expand Up @@ -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));
}

Expand Down
28 changes: 28 additions & 0 deletions apps/app/test/onboarding-gate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>) {
globalThis.fetch = ((input: string | URL | Request) =>
handler(String(input))) as unknown as typeof fetch;
Expand Down Expand Up @@ -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");
Expand All @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion apps/app/turbo.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
"AUTH_TRUSTED_ORIGINS",
"BETTER_AUTH_SECRET",
"BETTER_AUTH_URL",
"DATABASE_URL"
"DATABASE_URL",
"IS_MARKETING"
]
},
"dev": {
Expand All @@ -28,6 +29,7 @@
"BETTER_AUTH_SECRET",
"BETTER_AUTH_URL",
"DATABASE_URL",
"IS_MARKETING",
"NEXT_PUBLIC_API_URL",
"NEXT_PUBLIC_AUTH_URL"
]
Expand Down
18 changes: 14 additions & 4 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down