From 31d33ecea1de1b656b23ea318e1f767ec9fad3e5 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 16 Oct 2026 00:57:04 +0200 Subject: [PATCH 1/4] feat(previews): sign in to PR previews with a shared access key Previews run the development auth instance, where reviewers have no account. On a pr- stage with a test auth key, the sign-in page now shows a "PR preview" form: the shared PREVIEW_SIGNIN_KEY buys a single-use sign-in ticket for a preview-only reviewer user and org, created on first use. `bun run preview:signin ` prints the same as a signed-in link. The smoke test provisions the reviewer and seeds its org. --- .github/workflows/deploy-pr-preview.yml | 5 +- apps/api/src/resources/env.ts | 2 + apps/api/src/routes/auth.http.ts | 4 + apps/api/src/runtime/service-graph.ts | 2 + apps/web/src/lib/preview-sign-in.ts | 31 ++++ .../routes/-sign-in.clerk.browser.test.tsx | 35 +++- apps/web/src/routes/sign-in.tsx | 58 +++++++ docs/pr-previews.md | 27 +++- package.json | 1 + packages/backend/src/platform/Env.ts | 3 + .../auth/PreviewSignInService.test.ts | 98 +++++++++++ .../src/services/auth/PreviewSignInService.ts | 153 ++++++++++++++++++ packages/domain/src/http/auth.ts | 46 ++++++ scripts/preview-seed-smoke.ts | 37 +++-- scripts/preview-signin.ts | 42 +++++ 15 files changed, 529 insertions(+), 15 deletions(-) create mode 100644 apps/web/src/lib/preview-sign-in.ts create mode 100644 packages/backend/src/services/auth/PreviewSignInService.test.ts create mode 100644 packages/backend/src/services/auth/PreviewSignInService.ts create mode 100644 scripts/preview-signin.ts diff --git a/.github/workflows/deploy-pr-preview.yml b/.github/workflows/deploy-pr-preview.yml index ae3e8ab74c..73810fcce9 100644 --- a/.github/workflows/deploy-pr-preview.yml +++ b/.github/workflows/deploy-pr-preview.yml @@ -114,7 +114,8 @@ jobs: AWS_ACCOUNT_ID: ${{ steps.setup.outputs.aws-account-id }} run: bun run alchemy:deploy:pr - # Seeds demo telemetry as the dev test user, then fails on any 5xx from a read path. + # Provisions the preview reviewer, seeds demo telemetry into its org, then fails on any + # 5xx from a read path. - name: Seed and smoke-test the preview id: smoke if: ${{ env.TEARDOWN != 'true' && steps.deploy.outcome == 'success' }} @@ -208,6 +209,8 @@ jobs: *) printf 'Smoke test: %s\n' "${SMOKE_OUTCOME:-not run}" ;; esac echo + echo 'Sign in with the **PR preview** form on the sign-in page and the preview access key (`PREVIEW_SIGNIN_KEY` in Infisical), or run `bun run preview:signin '"$PR_NUMBER"'` for a signed-in link.' + echo printf 'Deployed from [`%s`](%s) · [View workflow run](%s)\n' "$short_sha" "$COMMIT_URL" "$RUN_URL" else echo "> [!WARNING]" diff --git a/apps/api/src/resources/env.ts b/apps/api/src/resources/env.ts index e38ccc8cdd..69be23502a 100644 --- a/apps/api/src/resources/env.ts +++ b/apps/api/src/resources/env.ts @@ -35,6 +35,8 @@ export const apiConfiguredEnv = (stage: MapleStage, region: MapleRegion, domains optionalPlain("MAPLE_IGNORE_ORG_CLICKHOUSE"), // Dev only: on a deploy a pin would point the whole API at one tenant. ...(stage.kind === "dev" ? [optionalPlain("MAPLE_ORG_ID_OVERRIDE")] : []), + // PR previews only: the shared key behind the sign-in page's preview access form. + ...(stage.kind === "pr" ? [optionalSecret("PREVIEW_SIGNIN_KEY")] : []), authEnv, ingestKeyCryptoEnv, requireSecretEntry("MAPLE_SHARE_TOKEN_HMAC_KEY"), diff --git a/apps/api/src/routes/auth.http.ts b/apps/api/src/routes/auth.http.ts index 1f0b18fdbe..60ec487882 100644 --- a/apps/api/src/routes/auth.http.ts +++ b/apps/api/src/routes/auth.http.ts @@ -13,6 +13,7 @@ import { ApiKeysService } from "@maple/backend/services/org/ApiKeysService" import { AuthService } from "@maple/backend/services/auth/AuthService" import { CliDeviceAuthService } from "@maple/backend/services/auth/CliDeviceAuthService" import { McpOAuthService } from "@maple/backend/services/auth/McpOAuthService" +import { PreviewSignInService } from "@maple/backend/services/auth/PreviewSignInService" const bearerToken = (header: string | undefined) => { if (!header) return undefined @@ -24,6 +25,7 @@ export const HttpAuthPublicLive = HttpApiBuilder.group(MapleApi, "authPublic", ( Effect.gen(function* () { const authService = yield* AuthService const cliAuth = yield* CliDeviceAuthService + const previewSignIn = yield* PreviewSignInService return handlers .handle("login", ({ payload }) => authService.loginSelfHosted(payload.password)) .handle("cliDeviceStart", ({ payload }) => @@ -35,6 +37,8 @@ export const HttpAuthPublicLive = HttpApiBuilder.group(MapleApi, "authPublic", ( }), ) .handle("cliDevicePoll", ({ payload }) => cliAuth.poll(payload.deviceCode)) + .handle("previewSignInStatus", () => previewSignIn.status()) + .handle("previewSignIn", ({ payload }) => previewSignIn.signIn(payload.key)) }), ) diff --git a/apps/api/src/runtime/service-graph.ts b/apps/api/src/runtime/service-graph.ts index f5706d6579..45da67818a 100644 --- a/apps/api/src/runtime/service-graph.ts +++ b/apps/api/src/runtime/service-graph.ts @@ -15,6 +15,7 @@ import { CliDeviceAuthService } from "@maple/backend/services/auth/CliDeviceAuth import { CloudflareOAuthService } from "@maple/backend/services/auth/CloudflareOAuthService" import { HazelOAuthService } from "@maple/backend/services/auth/HazelOAuthService" import { McpOAuthService } from "@maple/backend/services/auth/McpOAuthService" +import { PreviewSignInService } from "@maple/backend/services/auth/PreviewSignInService" import { DailySpendService } from "@maple/backend/services/billing/DailySpendService" import { AutumnClient } from "@maple/backend/services/billing/autumn-http" @@ -87,6 +88,7 @@ export const HttpServicesLive = Layer.mergeAll( ApiKeysService.layer, CliDeviceAuthService.layer, McpOAuthService.layer, + PreviewSignInService.layer, CloudflareOAuthService.layer, DashboardPersistenceService.layer, SharedDashboardService.layer, diff --git a/apps/web/src/lib/preview-sign-in.ts b/apps/web/src/lib/preview-sign-in.ts new file mode 100644 index 0000000000..360a232086 --- /dev/null +++ b/apps/web/src/lib/preview-sign-in.ts @@ -0,0 +1,31 @@ +import { apiBaseUrl } from "@/lib/services/common/api-base-url" +import { tracedFetch } from "@/lib/services/common/telemetry" + +const PREVIEW_SIGN_IN_URL = `${apiBaseUrl}/api/auth/preview/sign-in` + +/** PR previews answer `enabled: true`; everywhere else, or on any failure, it is false. */ +export const loadPreviewSignInEnabled = () => + tracedFetch("maple-api", PREVIEW_SIGN_IN_URL) + .then((response) => (response.ok ? (response.json() as Promise<{ enabled?: unknown }>) : null)) + .then((body) => body?.enabled === true) + .catch(() => false) + +export async function requestPreviewTicket(key: string) { + const response = await tracedFetch("maple-api", PREVIEW_SIGN_IN_URL, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ key }), + }) + if (!response.ok) { + const errorBody = (await response.json().catch(() => null)) as { message?: string } | null + throw new Error(errorBody?.message ?? "Preview sign-in failed") + } + return ((await response.json()) as { ticket: string }).ticket +} + +/** Reload `/sign-in` with the ticket; Clerk's `` redeems `__clerk_ticket` on mount. */ +export const openTicketSignIn = (ticket: string, redirectUrl: string | null) => { + const params = new URLSearchParams({ __clerk_ticket: ticket }) + if (redirectUrl) params.set("redirect_url", redirectUrl) + window.location.assign(`/sign-in?${params.toString()}`) +} diff --git a/apps/web/src/routes/-sign-in.clerk.browser.test.tsx b/apps/web/src/routes/-sign-in.clerk.browser.test.tsx index ac8356c1f8..6de09aead7 100644 --- a/apps/web/src/routes/-sign-in.clerk.browser.test.tsx +++ b/apps/web/src/routes/-sign-in.clerk.browser.test.tsx @@ -1,6 +1,6 @@ // TEST-SEAM: This focused test replaces process-global modules that have no instance-level injection seam. -import { cleanup, render, screen } from "@testing-library/react" +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react" import { afterEach, describe, expect, it, vi } from "vitest" describe("SignInPage (clerk mode)", () => { @@ -24,12 +24,45 @@ describe("SignInPage (clerk mode)", () => { // SignInPage reads redirect_url via Route.useSearch(), which needs a live // router — stub it since this test renders the page standalone. vi.spyOn(module.Route, "useSearch").mockReturnValue({ redirect_url: undefined }) + vi.spyOn(module.Route, "useLoaderData").mockReturnValue({ previewSignIn: false }) render() expect(screen.getByText("Clerk Sign In")).toBeTruthy() + expect(screen.queryByRole("button", { name: "Sign in to preview" })).toBeNull() // The two dynamic imports pull the route's whole module graph in cold, which // overruns the 5s default when the suite shares the machine with turbo's // typecheck/build tasks. }, 30_000) + + it("trades the preview access key for a Clerk ticket", async () => { + const fetchSpy = vi + .spyOn(window, "fetch") + .mockResolvedValue(new Response(JSON.stringify({ ticket: "tkt_123" }), { status: 200 })) + const { PreviewAccessForm } = await import("./sign-in") + const onTicket = vi.fn() + + render() + fireEvent.change(screen.getByLabelText("Access key"), { target: { value: " shared-key " } }) + fireEvent.click(screen.getByRole("button", { name: "Sign in to preview" })) + + await waitFor(() => expect(onTicket).toHaveBeenCalledWith("tkt_123")) + const call = fetchSpy.mock.calls.find(([url]) => String(url).endsWith("/api/auth/preview/sign-in")) + expect(call?.[1]?.body).toBe(JSON.stringify({ key: "shared-key" })) + }, 30_000) + + it("shows the API's message for a wrong key", async () => { + vi.spyOn(window, "fetch").mockResolvedValue( + new Response(JSON.stringify({ message: "Invalid preview access key" }), { status: 401 }), + ) + const { PreviewAccessForm } = await import("./sign-in") + const onTicket = vi.fn() + + render() + fireEvent.change(screen.getByLabelText("Access key"), { target: { value: "nope" } }) + fireEvent.click(screen.getByRole("button", { name: "Sign in to preview" })) + + expect((await screen.findByRole("alert")).textContent).toBe("Invalid preview access key") + expect(onTicket).not.toHaveBeenCalled() + }, 30_000) }) diff --git a/apps/web/src/routes/sign-in.tsx b/apps/web/src/routes/sign-in.tsx index 28fc045a89..7337ef9c00 100644 --- a/apps/web/src/routes/sign-in.tsx +++ b/apps/web/src/routes/sign-in.tsx @@ -14,6 +14,7 @@ import { AccountLayout } from "@/components/layout/account-layout" import { useAsyncAction } from "@/hooks/use-mutation-action" import { clerkAuthCardAppearance } from "@/lib/clerk-appearance" import { tracedFetch } from "@/lib/services/common/telemetry" +import { loadPreviewSignInEnabled, openTicketSignIn, requestPreviewTicket } from "@/lib/preview-sign-in" const SignInSearch = Schema.Struct({ redirect_url: Schema.optional(Schema.String), @@ -22,6 +23,7 @@ const SignInSearch = Schema.Struct({ export const Route = createFileRoute("/sign-in")({ component: SignInPage, validateSearch: Schema.toStandardSchemaV1(SignInSearch), + loader: async () => ({ previewSignIn: isClerkAuthEnabled && (await loadPreviewSignInEnabled()) }), }) export const redirectToDashboard = () => { @@ -58,6 +60,58 @@ async function loginSelfHosted(password: string) { return (await response.json()) as { token: string } } +/** + * PR previews run on Clerk's development instance, where reviewers have no account. The + * shared access key buys a sign-in ticket for the preview reviewer, which `` + * redeems from `__clerk_ticket` once the page reloads with it. + */ +export function PreviewAccessForm({ onTicket }: { onTicket: (ticket: string) => void }) { + const [key, setKey] = useState("") + const [errorMessage, setErrorMessage] = useState(null) + + const [submit, isSubmitting] = useAsyncAction(async () => { + setErrorMessage(null) + await requestPreviewTicket(key.trim()) + .then(onTicket) + .catch((error: unknown) => setErrorMessage(getErrorMessage(error))) + }) + + const onSubmit = (event: FormEvent) => { + event.preventDefault() + if (!isSubmitting) void submit() + } + + return ( +
+
+

PR preview

+

+ Sign in as the shared preview reviewer with the preview access key. +

+
+ + Access key + setKey(event.target.value)} + autoComplete="off" + disabled={isSubmitting} + required + /> + {errorMessage ? ( + + {errorMessage} + + ) : null} + + +
+ ) +} + export function SelfHostedSignInPage() { const [password, setPassword] = useState("") const [errorMessage, setErrorMessage] = useState(null) @@ -120,6 +174,7 @@ export function SelfHostedSignInPage() { function SignInPage() { const { redirect_url } = Route.useSearch() + const { previewSignIn } = Route.useLoaderData() // Clerk's defaults its post-sign-in redirect to "/", which would // discard the guard-preserved redirect_url (e.g. after a hard reload on a // deep link bounced through /sign-in while the session was still settling). @@ -129,6 +184,9 @@ function SignInPage() { return ( + {previewSignIn ? ( + openTicketSignIn(ticket, target)} /> + ) : null} ) } diff --git a/docs/pr-previews.md b/docs/pr-previews.md index e4ec27a935..b088fcb94c 100644 --- a/docs/pr-previews.md +++ b/docs/pr-previews.md @@ -22,11 +22,29 @@ callbacks, which are registered for fixed hosts. The app's own browser telemetry still goes to the shared ingest (`VITE_MAPLE_SELF_INGEST_URL`); setup snippets and connect flows hand out the preview's gateway. +## Signing in + +Previews run Clerk's development instance, where reviewers have no account and the OAuth +buttons lead nowhere useful. The sign-in page on a preview shows a **PR preview** form: the +shared access key `PREVIEW_SIGNIN_KEY` (Infisical) buys a single-use Clerk sign-in ticket for +`preview-reviewer+clerk_test@maple.dev`, a user created on first use with its own `Preview` org, +apart from anyone's dev account. From a terminal: + +```bash +infisical run --env dev -- bun run preview:signin +``` + +prints a signed-in link. The endpoint (`/api/auth/preview/sign-in`, `PreviewSignInService`) +answers 404 unless the API runs on a `pr-` stage with an `sk_test_` Clerk key and the access +key bound, and alchemy binds the key on PR stages only. The repo is public, so the key never +goes in the PR comment; rotate it in Infisical if it leaks. + ## After the deploy -`scripts/preview-seed-smoke.ts` signs in as the dev test user, seeds demo telemetry into its -org through the preview's API, and fails the job if a Postgres- or warehouse-backed route -returns a 5xx. The PR comment reports the outcome. +`scripts/preview-seed-smoke.ts` calls the preview sign-in endpoint (which provisions the +reviewer and its org), seeds demo telemetry into the reviewer's org through the preview's API, +and fails the job if a Postgres- or warehouse-backed route returns a 5xx. Without +`PREVIEW_SIGNIN_KEY` it seeds the dev test user instead. The PR comment reports the outcome. ## Limits and cost @@ -42,7 +60,8 @@ returns a 5xx. The PR comment reports the outcome. and enable logical replication on it (Settings, Logical replication). Electric needs it, and the owner role only gets `REPLICATION` once it is on. 2. **Infisical** (the preview environment, `dev` unless `PREVIEW_INFISICAL_ENV_SLUG` says - otherwise): `NEON_API_KEY` (an org or project API key) and `NEON_PROJECT_ID`. + otherwise): `NEON_API_KEY` (an org or project API key), `NEON_PROJECT_ID`, and + `PREVIEW_SIGNIN_KEY` (any long random string, e.g. `openssl rand -base64 24`). 3. **AWS.** The deploy role must trust `repo:MapleTechLabs/maple:environment:pr-preview`. 4. Optional: a dedicated Infisical environment for previews, then set the repo variable `PREVIEW_INFISICAL_ENV_SLUG`, so previews stop sharing every `dev` credential. diff --git a/package.json b/package.json index 0080430997..7e65d58ebc 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "build": "turbo build", "dev": "bun run ./scripts/dev.ts", "dev:signin": "bun run ./scripts/dev-signin.ts", + "preview:signin": "bun run ./scripts/preview-signin.ts", "errors:tick": "bun run ./scripts/errors-tick.ts", "errors:replay": "bun run ./scripts/errors-replay.ts", "seed:demo": "bun run ./scripts/seed-demo.ts", diff --git a/packages/backend/src/platform/Env.ts b/packages/backend/src/platform/Env.ts index 22f77e12cd..5bca371c22 100644 --- a/packages/backend/src/platform/Env.ts +++ b/packages/backend/src/platform/Env.ts @@ -63,6 +63,8 @@ export interface EnvConfig { readonly CLERK_JWT_KEY: Option.Option> /** Svix signing secret (`whsec_…`) for `POST /webhooks/clerk`; the route answers 503 while unset. */ readonly CLERK_WEBHOOK_SECRET: Option.Option> + /** Shared access key for PR-preview sign-in; bound on `pr-` stages only (`PreviewSignInService`). */ + readonly PREVIEW_SIGNIN_KEY: Option.Option> readonly MAPLE_ORG_ID_OVERRIDE: Option.Option readonly AUTUMN_SECRET_KEY: Option.Option> readonly AUTUMN_API_URL: string @@ -212,6 +214,7 @@ const envConfig = Config.all({ CLERK_PUBLISHABLE_KEY: optionalString("CLERK_PUBLISHABLE_KEY"), CLERK_JWT_KEY: optionalRedacted("CLERK_JWT_KEY"), CLERK_WEBHOOK_SECRET: optionalRedacted("CLERK_WEBHOOK_SECRET"), + PREVIEW_SIGNIN_KEY: optionalRedacted("PREVIEW_SIGNIN_KEY"), MAPLE_ORG_ID_OVERRIDE: optionalString("MAPLE_ORG_ID_OVERRIDE"), AUTUMN_SECRET_KEY: optionalRedacted("AUTUMN_SECRET_KEY"), AUTUMN_API_URL: stringWithDefault("AUTUMN_API_URL", "https://api.useautumn.com"), diff --git a/packages/backend/src/services/auth/PreviewSignInService.test.ts b/packages/backend/src/services/auth/PreviewSignInService.test.ts new file mode 100644 index 0000000000..c430b7380b --- /dev/null +++ b/packages/backend/src/services/auth/PreviewSignInService.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from "@effect/vitest" +import { Effect, Option, Redacted } from "effect" +import { + makePreviewSignIn, + PREVIEW_REVIEWER_EMAIL, + PREVIEW_REVIEWER_ORG_NAME, + previewKey, + type PreviewClerk, +} from "./PreviewSignInService" + +const previewEnv = { + MAPLE_AUTH_MODE: "clerk", + MAPLE_ENVIRONMENT: "pr-1336", + CLERK_SECRET_KEY: Option.some(Redacted.make("sk_test_abc")), + PREVIEW_SIGNIN_KEY: Option.some(Redacted.make("shared-key")), +} + +/** An in-memory Clerk that records every write, starting with `users` already present. */ +const fakeClerk = (users: Record = {}) => { + const writes: string[] = [] + const clerk: PreviewClerk = { + findUserIdByEmail: (email) => Effect.succeed(Option.fromNullishOr(users[email]?.id)), + createUser: (email) => + Effect.sync(() => { + writes.push(`createUser ${email}`) + users[email] = { id: "user_new", hasOrg: false } + return "user_new" + }), + hasOrganization: (userId) => + Effect.succeed(Object.values(users).some((user) => user.id === userId && user.hasOrg)), + createOrganization: (userId, name) => + Effect.sync(() => void writes.push(`createOrg ${userId} ${name}`)), + createSignInToken: (userId, ttl) => Effect.succeed(`ticket:${userId}:${ttl}`), + } + return { clerk, writes } +} + +describe("previewKey", () => { + it("is the key on a PR stage with a Clerk development instance", () => { + expect(previewKey(previewEnv)).toEqual(Option.some("shared-key")) + }) + + it.each([ + ["production", { MAPLE_ENVIRONMENT: "production" }], + ["a dev stage", { MAPLE_ENVIRONMENT: "development" }], + ["a live Clerk instance", { CLERK_SECRET_KEY: Option.some(Redacted.make("sk_live_abc")) }], + ["self-hosted auth", { MAPLE_AUTH_MODE: "self_hosted" }], + ["an unset key", { PREVIEW_SIGNIN_KEY: Option.none() }], + ["a blank key", { PREVIEW_SIGNIN_KEY: Option.some(Redacted.make(" ")) }], + ])("is none on %s", (_, override) => { + expect(previewKey({ ...previewEnv, ...override })).toEqual(Option.none()) + }) +}) + +describe("makePreviewSignIn", () => { + it.effect("reports disabled and refuses sign-in outside previews", () => + Effect.gen(function* () { + const service = makePreviewSignIn(Option.none()) + expect((yield* service.status()).enabled).toBe(false) + const error = yield* Effect.flip(service.signIn("shared-key")) + expect(error._tag).toBe("@maple/http/errors/PreviewSignInDisabledError") + }), + ) + + it.effect("rejects a wrong key without touching Clerk", () => + Effect.gen(function* () { + const { clerk, writes } = fakeClerk() + const service = makePreviewSignIn(Option.some({ key: "shared-key", clerk })) + const error = yield* Effect.flip(service.signIn("shared-kez")) + expect(error._tag).toBe("@maple/http/errors/PreviewSignInInvalidKeyError") + expect(writes).toEqual([]) + }), + ) + + it.effect("creates the reviewer and its org on first use, then mints a ticket", () => + Effect.gen(function* () { + const { clerk, writes } = fakeClerk() + const service = makePreviewSignIn(Option.some({ key: "shared-key", clerk })) + expect((yield* service.status()).enabled).toBe(true) + const { ticket } = yield* service.signIn("shared-key") + expect(ticket).toBe("ticket:user_new:120") + expect(writes).toEqual([ + `createUser ${PREVIEW_REVIEWER_EMAIL}`, + `createOrg user_new ${PREVIEW_REVIEWER_ORG_NAME}`, + ]) + }), + ) + + it.effect("reuses an existing reviewer that already has an org", () => + Effect.gen(function* () { + const { clerk, writes } = fakeClerk({ [PREVIEW_REVIEWER_EMAIL]: { id: "user_1", hasOrg: true } }) + const service = makePreviewSignIn(Option.some({ key: "shared-key", clerk })) + const { ticket } = yield* service.signIn("shared-key") + expect(ticket).toBe("ticket:user_1:120") + expect(writes).toEqual([]) + }), + ) +}) diff --git a/packages/backend/src/services/auth/PreviewSignInService.ts b/packages/backend/src/services/auth/PreviewSignInService.ts new file mode 100644 index 0000000000..a8a151a293 --- /dev/null +++ b/packages/backend/src/services/auth/PreviewSignInService.ts @@ -0,0 +1,153 @@ +import { createHash, timingSafeEqual } from "node:crypto" +import { createClerkClient } from "@clerk/backend" +import { + PreviewSignInDisabledError, + PreviewSignInInvalidKeyError, + PreviewSignInResponse, + PreviewSignInStatusResponse, + PreviewSignInUnavailableError, +} from "@maple/domain/http" +import { Context, Effect, Layer, Option, Redacted } from "effect" +import { Env, type EnvConfig } from "@maple/backend/platform/Env" +import { clerkRequest, type ClerkRequestError } from "@maple/backend/services/auth/clerk-request" + +/** + * One reviewer identity shared by every preview, kept apart from the developers' own dev users + * so a leaked preview session reaches nothing but preview data. + */ +export const PREVIEW_REVIEWER_EMAIL = "preview-reviewer+clerk_test@maple.dev" +export const PREVIEW_REVIEWER_ORG_NAME = "Preview" + +/** Long enough to survive the redirect to `/sign-in`, short enough to be useless if copied. */ +const TICKET_TTL_SECONDS = 120 + +/** The slice of the Clerk Backend API this service uses; tests supply a fake. */ +export interface PreviewClerk { + readonly findUserIdByEmail: (email: string) => Effect.Effect, ClerkRequestError> + readonly createUser: (email: string) => Effect.Effect + readonly hasOrganization: (userId: string) => Effect.Effect + readonly createOrganization: (userId: string, name: string) => Effect.Effect + readonly createSignInToken: ( + userId: string, + ttlSeconds: number, + ) => Effect.Effect +} + +export interface PreviewSignInServiceApi { + readonly status: () => Effect.Effect + readonly signIn: ( + key: string, + ) => Effect.Effect< + PreviewSignInResponse, + PreviewSignInDisabledError | PreviewSignInInvalidKeyError | PreviewSignInUnavailableError + > +} + +const digest = (value: string) => createHash("sha256").update(value).digest() + +/** Hashing first makes the comparison constant-time regardless of the guess's length. */ +const keyMatches = (provided: string, expected: string) => timingSafeEqual(digest(provided), digest(expected)) + +type PreviewEnv = Pick< + EnvConfig, + "MAPLE_AUTH_MODE" | "MAPLE_ENVIRONMENT" | "CLERK_SECRET_KEY" | "PREVIEW_SIGNIN_KEY" +> + +/** + * The key only means anything on a PR stage running a Clerk development instance: tickets + * minted by a production instance would be real sessions for real people. + */ +export const previewKey = (env: PreviewEnv): Option.Option => + env.MAPLE_AUTH_MODE.toLowerCase() === "clerk" && + /^pr-\d+$/.test(env.MAPLE_ENVIRONMENT) && + Option.exists(env.CLERK_SECRET_KEY, (secret) => Redacted.value(secret).startsWith("sk_test_")) + ? Option.map( + Option.filter(env.PREVIEW_SIGNIN_KEY, (key) => Redacted.value(key).trim().length > 0), + Redacted.value, + ) + : Option.none() + +/** `None` is the state of every non-preview deployment: status says disabled, sign-in 404s. */ +export const makePreviewSignIn = ( + enabled: Option.Option<{ readonly key: string; readonly clerk: PreviewClerk }>, +): PreviewSignInServiceApi => { + const unavailable = (error: ClerkRequestError) => + new PreviewSignInUnavailableError({ message: `Clerk: ${error.message}`, cause: error }) + + const ensureReviewer = Effect.fnUntraced(function* (clerk: PreviewClerk) { + const existing = yield* clerk.findUserIdByEmail(PREVIEW_REVIEWER_EMAIL) + const userId = Option.isSome(existing) + ? existing.value + : yield* clerk.createUser(PREVIEW_REVIEWER_EMAIL) + // Without an org the app parks the reviewer on `/org-required`; the preview's empty + // database provisions the org's rows on first request. + if (!(yield* clerk.hasOrganization(userId))) { + yield* clerk.createOrganization(userId, PREVIEW_REVIEWER_ORG_NAME) + } + return userId + }) + + const signIn = Effect.fn("PreviewSignInService.signIn")(function* (key: string) { + if (Option.isNone(enabled)) { + return yield* new PreviewSignInDisabledError({ message: "Preview sign-in is not enabled here" }) + } + const { clerk } = enabled.value + if (!keyMatches(key, enabled.value.key)) { + return yield* new PreviewSignInInvalidKeyError({ message: "Invalid preview access key" }) + } + const userId = yield* ensureReviewer(clerk).pipe(Effect.mapError(unavailable)) + const ticket = yield* clerk + .createSignInToken(userId, TICKET_TTL_SECONDS) + .pipe(Effect.mapError(unavailable)) + return new PreviewSignInResponse({ ticket }) + }) + + return { + status: () => Effect.succeed(new PreviewSignInStatusResponse({ enabled: Option.isSome(enabled) })), + signIn, + } +} + +const clerkPort = (secretKey: string): PreviewClerk => { + const clerk = createClerkClient({ secretKey }) + return { + findUserIdByEmail: (email) => + clerkRequest("Clerk.users.getUserList", {}, () => + clerk.users.getUserList({ emailAddress: [email], limit: 1 }), + ).pipe(Effect.map((response) => Option.fromNullishOr(response.data[0]?.id))), + createUser: (email) => + clerkRequest("Clerk.users.createUser", {}, () => + clerk.users.createUser({ + emailAddress: [email], + firstName: "Preview", + lastName: "Reviewer", + skipPasswordRequirement: true, + }), + ).pipe(Effect.map((user) => user.id)), + hasOrganization: (userId) => + clerkRequest("Clerk.users.getOrganizationMembershipList", { "tenant.userId": userId }, () => + clerk.users.getOrganizationMembershipList({ userId, limit: 1 }), + ).pipe(Effect.map((response) => response.data.length > 0)), + createOrganization: (userId, name) => + clerkRequest("Clerk.organizations.createOrganization", { "tenant.userId": userId }, () => + clerk.organizations.createOrganization({ name, createdBy: userId }), + ).pipe(Effect.asVoid), + createSignInToken: (userId, ttlSeconds) => + clerkRequest("Clerk.signInTokens.createSignInToken", { "tenant.userId": userId }, () => + clerk.signInTokens.createSignInToken({ userId, expiresInSeconds: ttlSeconds }), + ).pipe(Effect.map((token) => token.token)), + } +} + +export class PreviewSignInService extends Context.Service()( + "@maple/api/services/auth/PreviewSignInService", + { + make: Effect.gen(function* () { + const env = yield* Env + const clerk = Option.map(env.CLERK_SECRET_KEY, (secret) => clerkPort(Redacted.value(secret))) + return makePreviewSignIn(Option.zipWith(previewKey(env), clerk, (key, clerk) => ({ key, clerk }))) + }), + }, +) { + static readonly layer = Layer.effect(this, this.make) +} diff --git a/packages/domain/src/http/auth.ts b/packages/domain/src/http/auth.ts index a880715069..73dd4f3612 100644 --- a/packages/domain/src/http/auth.ts +++ b/packages/domain/src/http/auth.ts @@ -178,6 +178,39 @@ export class SelfHostedInvalidPasswordError extends Schema.TaggedError( + "PreviewSignInStatusResponse", +)({ + enabled: Schema.Boolean, +}) {} + +export class PreviewSignInRequest extends Schema.Class("PreviewSignInRequest")({ + key: Schema.String, +}) {} + +/** A single-use Clerk sign-in ticket; `` redeems it from `?__clerk_ticket=`. */ +export class PreviewSignInResponse extends Schema.Class("PreviewSignInResponse")({ + ticket: Schema.String, +}) {} + +export class PreviewSignInDisabledError extends Schema.TaggedError()( + "@maple/http/errors/PreviewSignInDisabledError", + { message: Schema.String }, + { httpApiStatus: 404 }, +) {} + +export class PreviewSignInInvalidKeyError extends Schema.TaggedError()( + "@maple/http/errors/PreviewSignInInvalidKeyError", + { message: Schema.String }, + { httpApiStatus: 401 }, +) {} + +export class PreviewSignInUnavailableError extends Schema.TaggedError()( + "@maple/http/errors/PreviewSignInUnavailableError", + { message: Schema.String, cause: Schema.Defect() }, + { httpApiStatus: 503 }, +) {} + export class AuthPublicApiGroup extends HttpApiGroup.make("authPublic") .add( HttpApiEndpoint.post("login", "/login", { @@ -200,6 +233,19 @@ export class AuthPublicApiGroup extends HttpApiGroup.make("authPublic") error: [CliDevicePersistenceError, CliDeviceRateLimitError], }), ) + // PR previews only: a shared access key buys a sign-in ticket for the preview reviewer. + .add( + HttpApiEndpoint.get("previewSignInStatus", "/preview/sign-in", { + success: PreviewSignInStatusResponse, + }), + ) + .add( + HttpApiEndpoint.post("previewSignIn", "/preview/sign-in", { + payload: PreviewSignInRequest, + success: PreviewSignInResponse, + error: [PreviewSignInDisabledError, PreviewSignInInvalidKeyError, PreviewSignInUnavailableError], + }), + ) .prefix("/api/auth") {} export class AuthApiGroup extends HttpApiGroup.make("auth") diff --git a/scripts/preview-seed-smoke.ts b/scripts/preview-seed-smoke.ts index e5358ab7e8..4491b4fb30 100644 --- a/scripts/preview-seed-smoke.ts +++ b/scripts/preview-seed-smoke.ts @@ -1,15 +1,20 @@ /** * `bun scripts/preview-seed-smoke.ts [--skip-seed]`: seed and smoke-test a PR preview. * - * Mints a Clerk session for the dev test user (development instance only), seeds - * demo telemetry through the API, then hits read endpoints that touch Postgres and - * the warehouse. Any 5xx or network error exits 1. Tokens are never printed. + * With PREVIEW_SIGNIN_KEY set, first exercises the preview sign-in endpoint, which + * creates the shared preview reviewer and its org, and seeds that org so reviewers land + * on data. Otherwise seeds the dev test user's org. Mints a Clerk session (development + * instance only), seeds demo telemetry through the API, then hits read endpoints that + * touch Postgres and the warehouse. Any 5xx or network error exits 1. Tokens are never + * printed. * - * Env: API_URL, WEB_URL, CLERK_SECRET_KEY, optional PREVIEW_SEED_ORG_ID and - * PREVIEW_SEED_EMAIL. + * Env: API_URL, WEB_URL, CLERK_SECRET_KEY, optional PREVIEW_SIGNIN_KEY, + * PREVIEW_SEED_ORG_ID and PREVIEW_SEED_EMAIL. */ const CLERK_API = "https://api.clerk.com/v1" const DEFAULT_EMAIL = "david+clerk_test@gmail.com" +// Keep in step with `PREVIEW_REVIEWER_EMAIL` in `PreviewSignInService.ts`. +const PREVIEW_REVIEWER_EMAIL = "preview-reviewer+clerk_test@maple.dev" const TOKEN_TTL_SECONDS = 1800 const HEALTH_DEADLINE_MS = 180_000 const REQUEST_TIMEOUT_MS = 60_000 @@ -28,7 +33,8 @@ if (!secretKey.startsWith("sk_test_")) { console.error("CLERK_SECRET_KEY is not a development key (sk_test_...). Refusing to mint a session.") process.exit(2) } -const email = process.env.PREVIEW_SEED_EMAIL ?? DEFAULT_EMAIL +const previewSignInKey = process.env.PREVIEW_SIGNIN_KEY?.trim() || undefined +const email = process.env.PREVIEW_SEED_EMAIL ?? (previewSignInKey ? PREVIEW_REVIEWER_EMAIL : DEFAULT_EMAIL) const fail = (message: string): never => { console.error(`::error::${message}`) @@ -71,7 +77,20 @@ const waitForHealth = async () => { await waitForHealth() console.log(`API healthy: ${apiUrl}`) -// 2. A session with an active org: the internal API takes the tenant from the token's org claim. +// 2. Preview sign-in: the ticket is discarded, the call provisions the reviewer and its org. +if (previewSignInKey) { + const response = await timedFetch(`${apiUrl}/api/auth/preview/sign-in`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ key: previewSignInKey }), + }) + if (response.status !== 200) { + fail(`Preview sign-in returned ${response.status}: ${(await response.text()).slice(0, 500)}`) + } + console.log(`Preview sign-in ready for ${PREVIEW_REVIEWER_EMAIL}`) +} + +// 3. A session with an active org: the internal API takes the tenant from the token's org claim. const users = await clerkFetch>(`/users?email_address=${encodeURIComponent(email)}`) const user = users[0] ?? fail(`No user with email ${email} on this Clerk instance.`) @@ -117,14 +136,14 @@ const call = async (name: string, method: "GET" | "POST", path: string, body?: u return outcome } -// 3. Seed. Ingest keys are created on first read, so the GET below provisions them. +// 4. Seed. Ingest keys are created on first read, so the GET below provisions them. if (!skipSeed) { const seeded = await call("/internal/demo/seed", "POST", "/internal/demo/seed", { hours: 6 }) if (seeded.status !== "200") fail(`Demo seed returned ${seeded.status}: ${seeded.text.slice(0, 500)}`) console.log(`Seed: ${seeded.text}`) } -// 4. Smoke reads across Postgres (keys, dashboards, alerts) and the warehouse. +// 5. Smoke reads across Postgres (keys, dashboards, alerts) and the warehouse. const endTime = new Date() const startTime = new Date(endTime.getTime() - 24 * 60 * 60 * 1000) const window = { start_time: startTime.toISOString(), end_time: endTime.toISOString() } diff --git a/scripts/preview-signin.ts b/scripts/preview-signin.ts new file mode 100644 index 0000000000..a585d4f3e5 --- /dev/null +++ b/scripts/preview-signin.ts @@ -0,0 +1,42 @@ +/** + * `bun run preview:signin `: a signed-in link to PR preview ``, as the shared + * preview reviewer. + * + * Trades `PREVIEW_SIGNIN_KEY` for a sign-in ticket at the preview's own API, the same + * call the sign-in page's "PR preview" form makes. Run it under Infisical: + * `infisical run --env dev -- bun run preview:signin 1336`. The ticket is single-use + * and expires in two minutes. + */ +import { resolveMapleDomains } from "../packages/infra/src/cloudflare/stage.ts" + +const prNumber = Number(process.argv[2]) +if (!Number.isInteger(prNumber) || prNumber <= 0) { + console.error("Usage: bun run preview:signin ") + process.exit(2) +} + +const key = process.env.PREVIEW_SIGNIN_KEY?.trim() +if (!key) { + console.error("PREVIEW_SIGNIN_KEY is not set. Run under `infisical run --env dev -- …`.") + process.exit(2) +} + +const domains = resolveMapleDomains({ stage: { kind: "pr", prNumber }, region: "us" }) + +const response = await fetch(`https://${domains.api}/api/auth/preview/sign-in`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ key }), + signal: AbortSignal.timeout(30_000), +}).catch((error: unknown) => { + console.error(`Could not reach https://${domains.api}: ${String(error)}`) + return process.exit(1) +}) +if (!response.ok) { + console.error(`Preview sign-in failed: ${response.status} ${await response.text()}`) + process.exit(1) +} + +const { ticket } = (await response.json()) as { ticket: string } +console.log(`\nSigned-in link for PR #${prNumber} (single use, valid 2 minutes):\n`) +console.log(` https://${domains.web}/sign-in?__clerk_ticket=${ticket}\n`) From 9ec744c7d605f3daf36a3d0a1ecb6ef650d3e826 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 16 Oct 2026 01:07:01 +0200 Subject: [PATCH 2/4] fix(domain): regenerate anticipated errors for preview sign-in --- packages/domain/src/generated/anticipated-error-identifiers.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/domain/src/generated/anticipated-error-identifiers.ts b/packages/domain/src/generated/anticipated-error-identifiers.ts index 94c3a43151..13a6903ff3 100644 --- a/packages/domain/src/generated/anticipated-error-identifiers.ts +++ b/packages/domain/src/generated/anticipated-error-identifiers.ts @@ -67,6 +67,8 @@ export const ANTICIPATED_ERROR_IDENTIFIER_LIST: ReadonlyArray = [ "@maple/http/errors/OrganizationForbiddenError", "@maple/http/errors/OrganizationRegionLockedError", "@maple/http/errors/OrganizationWrongRegionError", + "@maple/http/errors/PreviewSignInDisabledError", + "@maple/http/errors/PreviewSignInInvalidKeyError", "@maple/http/errors/QueryEngineValidationError", "@maple/http/errors/RawSqlValidationError", "@maple/http/errors/RecommendationIssueNotFoundError", From b1982885bb96d3a82cc2be0902167965f2eb89c1 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 16 Oct 2026 01:08:26 +0200 Subject: [PATCH 3/4] fix(previews): pin the reviewer to its Preview org, time out the status probe - Provisioning checks membership in the Preview org by name, not any org. - The smoke script seeds the reviewer's Preview org rather than its first membership. - The sign-in route's status probe gives up after 3s, so a slow API cannot hold back the sign-in card. --- apps/web/src/lib/preview-sign-in.ts | 7 ++++-- .../auth/PreviewSignInService.test.ts | 25 +++++++++++++++---- .../src/services/auth/PreviewSignInService.ts | 14 +++++++---- scripts/preview-seed-smoke.ts | 14 ++++++++--- 4 files changed, 45 insertions(+), 15 deletions(-) diff --git a/apps/web/src/lib/preview-sign-in.ts b/apps/web/src/lib/preview-sign-in.ts index 360a232086..40e02c20a3 100644 --- a/apps/web/src/lib/preview-sign-in.ts +++ b/apps/web/src/lib/preview-sign-in.ts @@ -3,9 +3,12 @@ import { tracedFetch } from "@/lib/services/common/telemetry" const PREVIEW_SIGN_IN_URL = `${apiBaseUrl}/api/auth/preview/sign-in` -/** PR previews answer `enabled: true`; everywhere else, or on any failure, it is false. */ +/** The sign-in route waits on this probe, so a slow API must not hold back Clerk's card. */ +const STATUS_TIMEOUT_MS = 3_000 + +/** PR previews answer `enabled: true`; everywhere else, or on any failure or timeout, it is false. */ export const loadPreviewSignInEnabled = () => - tracedFetch("maple-api", PREVIEW_SIGN_IN_URL) + tracedFetch("maple-api", PREVIEW_SIGN_IN_URL, { signal: AbortSignal.timeout(STATUS_TIMEOUT_MS) }) .then((response) => (response.ok ? (response.json() as Promise<{ enabled?: unknown }>) : null)) .then((body) => body?.enabled === true) .catch(() => false) diff --git a/packages/backend/src/services/auth/PreviewSignInService.test.ts b/packages/backend/src/services/auth/PreviewSignInService.test.ts index c430b7380b..ab19c40796 100644 --- a/packages/backend/src/services/auth/PreviewSignInService.test.ts +++ b/packages/backend/src/services/auth/PreviewSignInService.test.ts @@ -16,18 +16,20 @@ const previewEnv = { } /** An in-memory Clerk that records every write, starting with `users` already present. */ -const fakeClerk = (users: Record = {}) => { +const fakeClerk = (users: Record }> = {}) => { const writes: string[] = [] const clerk: PreviewClerk = { findUserIdByEmail: (email) => Effect.succeed(Option.fromNullishOr(users[email]?.id)), createUser: (email) => Effect.sync(() => { writes.push(`createUser ${email}`) - users[email] = { id: "user_new", hasOrg: false } + users[email] = { id: "user_new", orgs: [] } return "user_new" }), - hasOrganization: (userId) => - Effect.succeed(Object.values(users).some((user) => user.id === userId && user.hasOrg)), + isMemberOf: (userId, name) => + Effect.succeed( + Object.values(users).some((user) => user.id === userId && user.orgs.includes(name)), + ), createOrganization: (userId, name) => Effect.sync(() => void writes.push(`createOrg ${userId} ${name}`)), createSignInToken: (userId, ttl) => Effect.succeed(`ticket:${userId}:${ttl}`), @@ -88,11 +90,24 @@ describe("makePreviewSignIn", () => { it.effect("reuses an existing reviewer that already has an org", () => Effect.gen(function* () { - const { clerk, writes } = fakeClerk({ [PREVIEW_REVIEWER_EMAIL]: { id: "user_1", hasOrg: true } }) + const { clerk, writes } = fakeClerk({ + [PREVIEW_REVIEWER_EMAIL]: { id: "user_1", orgs: [PREVIEW_REVIEWER_ORG_NAME] }, + }) const service = makePreviewSignIn(Option.some({ key: "shared-key", clerk })) const { ticket } = yield* service.signIn("shared-key") expect(ticket).toBe("ticket:user_1:120") expect(writes).toEqual([]) }), ) + + it.effect("creates the Preview org for a reviewer that only belongs to other orgs", () => + Effect.gen(function* () { + const { clerk, writes } = fakeClerk({ + [PREVIEW_REVIEWER_EMAIL]: { id: "user_1", orgs: ["Acme"] }, + }) + const service = makePreviewSignIn(Option.some({ key: "shared-key", clerk })) + yield* service.signIn("shared-key") + expect(writes).toEqual([`createOrg user_1 ${PREVIEW_REVIEWER_ORG_NAME}`]) + }), + ) }) diff --git a/packages/backend/src/services/auth/PreviewSignInService.ts b/packages/backend/src/services/auth/PreviewSignInService.ts index a8a151a293..3f4fa93bf1 100644 --- a/packages/backend/src/services/auth/PreviewSignInService.ts +++ b/packages/backend/src/services/auth/PreviewSignInService.ts @@ -18,6 +18,9 @@ import { clerkRequest, type ClerkRequestError } from "@maple/backend/services/au export const PREVIEW_REVIEWER_EMAIL = "preview-reviewer+clerk_test@maple.dev" export const PREVIEW_REVIEWER_ORG_NAME = "Preview" +/** Clerk's page maximum; the reviewer only ever joins orgs this service creates. */ +const MEMBERSHIP_PAGE_LIMIT = 500 + /** Long enough to survive the redirect to `/sign-in`, short enough to be useless if copied. */ const TICKET_TTL_SECONDS = 120 @@ -25,7 +28,8 @@ const TICKET_TTL_SECONDS = 120 export interface PreviewClerk { readonly findUserIdByEmail: (email: string) => Effect.Effect, ClerkRequestError> readonly createUser: (email: string) => Effect.Effect - readonly hasOrganization: (userId: string) => Effect.Effect + /** Whether `userId` belongs to an organization called `name`; other memberships do not count. */ + readonly isMemberOf: (userId: string, name: string) => Effect.Effect readonly createOrganization: (userId: string, name: string) => Effect.Effect readonly createSignInToken: ( userId: string, @@ -81,7 +85,7 @@ export const makePreviewSignIn = ( : yield* clerk.createUser(PREVIEW_REVIEWER_EMAIL) // Without an org the app parks the reviewer on `/org-required`; the preview's empty // database provisions the org's rows on first request. - if (!(yield* clerk.hasOrganization(userId))) { + if (!(yield* clerk.isMemberOf(userId, PREVIEW_REVIEWER_ORG_NAME))) { yield* clerk.createOrganization(userId, PREVIEW_REVIEWER_ORG_NAME) } return userId @@ -124,10 +128,10 @@ const clerkPort = (secretKey: string): PreviewClerk => { skipPasswordRequirement: true, }), ).pipe(Effect.map((user) => user.id)), - hasOrganization: (userId) => + isMemberOf: (userId, name) => clerkRequest("Clerk.users.getOrganizationMembershipList", { "tenant.userId": userId }, () => - clerk.users.getOrganizationMembershipList({ userId, limit: 1 }), - ).pipe(Effect.map((response) => response.data.length > 0)), + clerk.users.getOrganizationMembershipList({ userId, limit: MEMBERSHIP_PAGE_LIMIT }), + ).pipe(Effect.map((response) => response.data.some((m) => m.organization.name === name))), createOrganization: (userId, name) => clerkRequest("Clerk.organizations.createOrganization", { "tenant.userId": userId }, () => clerk.organizations.createOrganization({ name, createdBy: userId }), diff --git a/scripts/preview-seed-smoke.ts b/scripts/preview-seed-smoke.ts index 4491b4fb30..0e3018fba6 100644 --- a/scripts/preview-seed-smoke.ts +++ b/scripts/preview-seed-smoke.ts @@ -13,8 +13,9 @@ */ const CLERK_API = "https://api.clerk.com/v1" const DEFAULT_EMAIL = "david+clerk_test@gmail.com" -// Keep in step with `PREVIEW_REVIEWER_EMAIL` in `PreviewSignInService.ts`. +// Keep in step with `PREVIEW_REVIEWER_EMAIL` / `_ORG_NAME` in `PreviewSignInService.ts`. const PREVIEW_REVIEWER_EMAIL = "preview-reviewer+clerk_test@maple.dev" +const PREVIEW_REVIEWER_ORG_NAME = "Preview" const TOKEN_TTL_SECONDS = 1800 const HEALTH_DEADLINE_MS = 180_000 const REQUEST_TIMEOUT_MS = 60_000 @@ -96,9 +97,16 @@ const user = users[0] ?? fail(`No user with email ${email} on this Clerk instanc const resolveOrgId = async (): Promise => { if (process.env.PREVIEW_SEED_ORG_ID) return process.env.PREVIEW_SEED_ORG_ID - const memberships = await clerkFetch<{ data: Array<{ organization: { id: string } }> }>( - `/users/${user.id}/organization_memberships?limit=1`, + const memberships = await clerkFetch<{ data: Array<{ organization: { id: string; name: string } }> }>( + `/users/${user.id}/organization_memberships?limit=100`, ) + // The reviewer is what preview visitors sign in as, so seed the org they land in. + if (email === PREVIEW_REVIEWER_EMAIL) { + const preview = memberships.data.find((m) => m.organization.name === PREVIEW_REVIEWER_ORG_NAME) + return ( + preview?.organization.id ?? fail(`${email} has no "${PREVIEW_REVIEWER_ORG_NAME}" organization.`) + ) + } return memberships.data[0]?.organization.id ?? fail(`${email} is not a member of any organization.`) } const orgId = await resolveOrgId() From 20464fd5e9dfe6d60483d0375b6bd8b02d0311f9 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Fri, 16 Oct 2026 01:24:08 +0200 Subject: [PATCH 4/4] docs(agents): how to sign in to a PR preview --- CLAUDE.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index fb83bbc0bb..ba87182db2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -144,6 +144,25 @@ git clone in Cloudflare's Sandbox container (`apps/sandbox`, prd only). Invarian Test with `bun run --cwd apps/sandbox verify:image` (needs Docker); it catches what unit tests can't. +## PR previews + +A PR labelled `preview` deploys the full stack to `app-pr-.maple.dev` / `api-pr-.maple.dev` +on the Clerk development instance (`docs/pr-previews.md`). To sign in to one: + +```bash +infisical run --env dev -- bun run preview:signin # one-shot signed-in link, valid 2 minutes +``` + +It signs in as the shared reviewer `preview-reviewer+clerk_test@maple.dev` in the `Preview` org, +which the deploy's smoke test seeds with demo telemetry. Humans can instead use the "PR preview" +form on the sign-in page with `PREVIEW_SIGNIN_KEY` (Infisical `dev`). + +- Never type the key into a form or print it: mint the link with the command above and open it. +- `{"enabled":false}` from `GET /api/auth/preview/sign-in` means the deploy ran without the key; + re-run the preview deploy. +- A preview hostname that will not resolve locally right after deploy is a cached NXDOMAIN; it + clears on its own (or use `curl --resolve` with `dig @1.1.1.1`). + ## Docs Read the relevant doc before touching an area: `error-issue-lifecycle.md` (anything in