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: 4 additions & 1 deletion .github/workflows/deploy-pr-preview.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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' }}
Expand Down Expand Up @@ -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]"
Expand Down
19 changes: 19 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<n>.maple.dev` / `api-pr-<n>.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 <n> # 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
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/resources/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/routes/auth.http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }) =>
Expand All @@ -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))
}),
)

Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/runtime/service-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -87,6 +88,7 @@ export const HttpServicesLive = Layer.mergeAll(
ApiKeysService.layer,
CliDeviceAuthService.layer,
McpOAuthService.layer,
PreviewSignInService.layer,
CloudflareOAuthService.layer,
DashboardPersistenceService.layer,
SharedDashboardService.layer,
Expand Down
34 changes: 34 additions & 0 deletions apps/web/src/lib/preview-sign-in.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
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`

/** 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, { signal: AbortSignal.timeout(STATUS_TIMEOUT_MS) })
.then((response) => (response.ok ? (response.json() as Promise<{ enabled?: unknown }>) : null))
.then((body) => body?.enabled === true)
.catch(() => false)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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 `<SignIn>` 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()}`)
}
35 changes: 34 additions & 1 deletion apps/web/src/routes/-sign-in.clerk.browser.test.tsx
Original file line number Diff line number Diff line change
@@ -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)", () => {
Expand All @@ -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(<SignInPage />)

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(<PreviewAccessForm onTicket={onTicket} />)
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(<PreviewAccessForm onTicket={onTicket} />)
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)
})
58 changes: 58 additions & 0 deletions apps/web/src/routes/sign-in.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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 = () => {
Expand Down Expand Up @@ -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 `<SignIn>`
* 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<string | null>(null)

const [submit, isSubmitting] = useAsyncAction(async () => {
setErrorMessage(null)
await requestPreviewTicket(key.trim())
.then(onTicket)
.catch((error: unknown) => setErrorMessage(getErrorMessage(error)))
})

const onSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (!isSubmitting) void submit()
}

return (
<form className="mt-8 space-y-4 border-t border-border pt-6" onSubmit={onSubmit}>
<div className="space-y-1">
<p className="font-mono text-xs tracking-[0.02em]">PR preview</p>
<p className="font-display text-muted-foreground text-sm leading-relaxed">
Sign in as the shared preview reviewer with the preview access key.
</p>
</div>
<Field invalid={Boolean(errorMessage)} className="items-stretch">
<FieldLabel className="font-mono text-xs sm:text-xs tracking-[0.02em]">Access key</FieldLabel>
<Input
type="password"
value={key}
onChange={(event) => setKey(event.target.value)}
autoComplete="off"
disabled={isSubmitting}
required
/>
{errorMessage ? (
<FieldError match role="alert" className="font-mono">
{errorMessage}
</FieldError>
) : null}
</Field>
<Button type="submit" variant="outline" className="w-full" loading={isSubmitting}>
Sign in to preview
</Button>
</form>
)
}

export function SelfHostedSignInPage() {
const [password, setPassword] = useState("")
const [errorMessage, setErrorMessage] = useState<string | null>(null)
Expand Down Expand Up @@ -120,6 +174,7 @@ export function SelfHostedSignInPage() {

function SignInPage() {
const { redirect_url } = Route.useSearch()
const { previewSignIn } = Route.useLoaderData()
// Clerk's <SignIn> 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).
Expand All @@ -129,6 +184,9 @@ function SignInPage() {
return (
<AccountLayout>
<SignIn appearance={clerkAuthCardAppearance} forceRedirectUrl={target ?? undefined} />
{previewSignIn ? (
<PreviewAccessForm onTicket={(ticket) => openTicketSignIn(ticket, target)} />
) : null}
</AccountLayout>
)
}
Expand Down
27 changes: 23 additions & 4 deletions docs/pr-previews.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pr>
```

prints a signed-in link. The endpoint (`/api/auth/preview/sign-in`, `PreviewSignInService`)
answers 404 unless the API runs on a `pr-<n>` 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

Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/backend/src/platform/Env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export interface EnvConfig {
readonly CLERK_JWT_KEY: Option.Option<Redacted.Redacted<string>>
/** Svix signing secret (`whsec_…`) for `POST /webhooks/clerk`; the route answers 503 while unset. */
readonly CLERK_WEBHOOK_SECRET: Option.Option<Redacted.Redacted<string>>
/** Shared access key for PR-preview sign-in; bound on `pr-<n>` stages only (`PreviewSignInService`). */
readonly PREVIEW_SIGNIN_KEY: Option.Option<Redacted.Redacted<string>>
readonly MAPLE_ORG_ID_OVERRIDE: Option.Option<string>
readonly AUTUMN_SECRET_KEY: Option.Option<Redacted.Redacted<string>>
readonly AUTUMN_API_URL: string
Expand Down Expand Up @@ -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"),
Expand Down
Loading
Loading