From 4da7f20f2704bd124fa06e9a143266dd41377908 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 20 Apr 2026 17:21:40 -0700 Subject: [PATCH 01/13] Local emulator fixes - Local emulator now saves to config at the end - New "show-onboarding" value in stack.config.ts --- .claude/CLAUDE-KNOWLEDGE.md | 3 + .../migration.sql | 2 + .../tests/default-and-updates.ts | 57 ++++ apps/backend/prisma/schema.prisma | 1 + .../internal/local-emulator/project/route.tsx | 88 ++++- apps/backend/src/lib/local-emulator.test.ts | 28 +- apps/backend/src/lib/local-emulator.ts | 64 +++- apps/backend/src/lib/projects.tsx | 33 +- .../new-project/page-client-parts/content.tsx | 54 ++- .../project-onboarding-wizard.test.tsx | 7 + .../project-onboarding-wizard.tsx | 319 +++++++++++------- .../new-project/page-client-parts/shared.ts | 81 +++++ .../projects/page-client.tsx | 17 +- .../internal/local-emulator-project.test.ts | 59 +++- .../stack-cli/src/commands/config-file.ts | 15 +- packages/stack-cli/src/commands/emulator.ts | 68 +++- .../stack-shared/src/config-authoring.test.ts | 3 + packages/stack-shared/src/config-authoring.ts | 9 +- .../src/interface/crud/projects.ts | 15 + 19 files changed, 774 insertions(+), 149 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260420000000_add_project_onboarding_state/migration.sql create mode 100644 apps/backend/prisma/migrations/20260420000000_add_project_onboarding_state/tests/default-and-updates.ts diff --git a/.claude/CLAUDE-KNOWLEDGE.md b/.claude/CLAUDE-KNOWLEDGE.md index ffb334ee3c..5ae2db3457 100644 --- a/.claude/CLAUDE-KNOWLEDGE.md +++ b/.claude/CLAUDE-KNOWLEDGE.md @@ -355,3 +355,6 @@ Then restart the dev server. This rebuilds all packages and generates the necess ## Q: How is backwards compatibility for the offer→product rename handled in the payments purchase APIs? A: API v1 requests are routed through the `v2beta1` migration. The migration wraps the latest handlers, accepts legacy `offer_id`/`offer_inline` request fields, translates product-related errors back to the old offer error codes/messages, and augments responses (like `validate-code`) with `offer`/`conflicting_group_offers` aliases alongside the new `product` fields. Newer API versions keep the product-only contract. + +### Q: What's the reliable way to run targeted tests across backend, dashboard, stack-shared, and e2e at once? +A: Run from the monorepo root with explicit file paths: `pnpm test run "" "" ...`. This works even when individual packages do not define a local `test` script. Also avoid passing an extra `run` argument to package-level `test` scripts that already execute `vitest run`. diff --git a/apps/backend/prisma/migrations/20260420000000_add_project_onboarding_state/migration.sql b/apps/backend/prisma/migrations/20260420000000_add_project_onboarding_state/migration.sql new file mode 100644 index 0000000000..40411aed73 --- /dev/null +++ b/apps/backend/prisma/migrations/20260420000000_add_project_onboarding_state/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "Project" + ADD COLUMN "onboardingState" JSONB; diff --git a/apps/backend/prisma/migrations/20260420000000_add_project_onboarding_state/tests/default-and-updates.ts b/apps/backend/prisma/migrations/20260420000000_add_project_onboarding_state/tests/default-and-updates.ts new file mode 100644 index 0000000000..a474db0171 --- /dev/null +++ b/apps/backend/prisma/migrations/20260420000000_add_project_onboarding_state/tests/default-and-updates.ts @@ -0,0 +1,57 @@ +import { randomUUID } from "crypto"; +import type { Sql } from "postgres"; +import { expect } from "vitest"; + +export const preMigration = async (sql: Sql) => { + const projectId = `test-${randomUUID()}`; + await sql` + INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") + VALUES (${projectId}, NOW(), NOW(), 'Onboarding State Project', '', false) + `; + return { projectId }; +}; + +export const postMigration = async (sql: Sql, ctx: Awaited>) => { + const rows = await sql` + SELECT "onboardingState" + FROM "Project" + WHERE "id" = ${ctx.projectId} + `; + expect(rows).toHaveLength(1); + expect(rows[0].onboardingState).toBeNull(); + + const onboardingState = { + selected_config_choice: "create-new", + selected_apps: ["authentication", "emails"], + selected_sign_in_methods: ["credential", "magicLink"], + selected_email_theme_id: null, + selected_payments_country: "US", + }; + await sql` + UPDATE "Project" + SET "onboardingState" = ${JSON.stringify(onboardingState)}::jsonb + WHERE "id" = ${ctx.projectId} + `; + + const updatedRows = await sql` + SELECT "onboardingState" + FROM "Project" + WHERE "id" = ${ctx.projectId} + `; + expect(updatedRows).toHaveLength(1); + expect(updatedRows[0].onboardingState).toMatchInlineSnapshot(` + { + "selected_apps": [ + "authentication", + "emails", + ], + "selected_config_choice": "create-new", + "selected_email_theme_id": null, + "selected_payments_country": "US", + "selected_sign_in_methods": [ + "credential", + "magicLink", + ], + } + `); +}; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 1b20c77b17..0b32726af2 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -27,6 +27,7 @@ model Project { isProductionMode Boolean ownerTeamId String? @db.Uuid onboardingStatus String @default("completed") + onboardingState Json? logoUrl String? logoFullUrl String? diff --git a/apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx b/apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx index 5e13731498..543db2002c 100644 --- a/apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx +++ b/apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx @@ -4,15 +4,25 @@ import { LOCAL_EMULATOR_ADMIN_USER_ID, LOCAL_EMULATOR_ONLY_ENDPOINT_MESSAGE, LOCAL_EMULATOR_OWNER_TEAM_ID, + isLocalEmulatorOnboardingEnabledInConfig, isLocalEmulatorEnabled, readConfigFromFile, resolveEmulatorPath, - writeConfigToFile, + writeShowOnboardingConfigToFile, } from "@/lib/local-emulator"; import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; +import { + clientOrHigherAuthTypeSchema, + projectOnboardingStatusSchema, + projectOnboardingStatusValues, + type ProjectOnboardingStatus, + yupBoolean, + yupNumber, + yupObject, + yupString, +} from "@stackframe/stack-shared/dist/schema-fields"; import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto"; import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; @@ -23,6 +33,10 @@ type LocalEmulatorProjectMappingRow = { projectId: string, }; +function isProjectOnboardingStatus(value: string): value is ProjectOnboardingStatus { + return projectOnboardingStatusValues.some((status) => status === value); +} + async function assertLocalEmulatorOwnerTeamReadiness() { const internalTenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID); const internalPrisma = await getPrismaClientForTenancy(internalTenancy); @@ -177,6 +191,66 @@ async function getOrCreateCredentials(projectId: string) { }; } +async function syncLocalEmulatorOnboardingStatus(projectId: string, showOnboarding: boolean): Promise { + const onboardingStateColumnExistsRows = await globalPrismaClient.$queryRaw>(Prisma.sql` + SELECT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'Project' + AND column_name = 'onboardingState' + ) AS "exists" + `); + const onboardingStateColumnExists = onboardingStateColumnExistsRows[0]?.exists === true; + + const rows = await globalPrismaClient.$queryRaw>(Prisma.sql` + SELECT "onboardingStatus" + FROM "Project" + WHERE "id" = ${projectId} + LIMIT 1 + `); + const row = rows.length > 0 ? rows[0] : undefined; + if (!row) { + throw new StackAssertionError("Local emulator project not found while syncing onboarding state.", { projectId }); + } + if (!isProjectOnboardingStatus(row.onboardingStatus)) { + throw new StackAssertionError("Project onboarding status in DB is invalid.", { + projectId, + onboardingStatus: row.onboardingStatus, + }); + } + const currentOnboardingStatus = row.onboardingStatus; + + if (!showOnboarding) { + if (onboardingStateColumnExists) { + await globalPrismaClient.$executeRaw(Prisma.sql` + UPDATE "Project" + SET "onboardingStatus" = 'completed', + "onboardingState" = NULL + WHERE "id" = ${projectId} + `); + } else { + await globalPrismaClient.$executeRaw(Prisma.sql` + UPDATE "Project" + SET "onboardingStatus" = 'completed' + WHERE "id" = ${projectId} + `); + } + return "completed"; + } + + if (currentOnboardingStatus === "completed") { + await globalPrismaClient.$executeRaw(Prisma.sql` + UPDATE "Project" + SET "onboardingStatus" = 'config_choice' + WHERE "id" = ${projectId} + `); + return "config_choice"; + } + + return currentOnboardingStatus; +} + export const POST = createSmartRouteHandler({ metadata: { hidden: true, @@ -205,6 +279,8 @@ export const POST = createSmartRouteHandler({ secret_server_key: yupString().defined(), super_secret_admin_key: yupString().defined(), branch_config_override_string: yupString().defined(), + onboarding_status: projectOnboardingStatusSchema.defined(), + onboarding_outstanding: yupBoolean().defined(), }).defined(), }), handler: async (req) => { @@ -230,15 +306,17 @@ export const POST = createSmartRouteHandler({ throw new StatusError(StatusError.BadRequest, `Config file not found: ${absoluteFilePath}`); } - // If the file is empty, write a default config + // If the file is empty, write the onboarding sentinel config. const fileContent = await fs.readFile(resolvedFilePath, "utf-8"); if (fileContent.trim() === "") { - await writeConfigToFile(absoluteFilePath, {}); + await writeShowOnboardingConfigToFile(absoluteFilePath); } await assertLocalEmulatorOwnerTeamReadiness(); const { projectId } = await getOrCreateLocalEmulatorProjectId(absoluteFilePath); + const showOnboarding = await isLocalEmulatorOnboardingEnabledInConfig(absoluteFilePath); + const onboardingStatus = await syncLocalEmulatorOnboardingStatus(projectId, showOnboarding); const credentials = await getOrCreateCredentials(projectId); const fileConfig = await readConfigFromFile(absoluteFilePath); @@ -251,6 +329,8 @@ export const POST = createSmartRouteHandler({ secret_server_key: credentials.secretServerKey, super_secret_admin_key: credentials.superSecretAdminKey, branch_config_override_string: JSON.stringify(fileConfig), + onboarding_status: onboardingStatus, + onboarding_outstanding: onboardingStatus !== "completed", }, }; }, diff --git a/apps/backend/src/lib/local-emulator.test.ts b/apps/backend/src/lib/local-emulator.test.ts index bded64af07..efd23a1423 100644 --- a/apps/backend/src/lib/local-emulator.test.ts +++ b/apps/backend/src/lib/local-emulator.test.ts @@ -4,8 +4,10 @@ import path from "path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV, + isLocalEmulatorOnboardingEnabledInConfig, readConfigFromFile, writeConfigToFile, + writeShowOnboardingConfigToFile, } from "./local-emulator"; describe("local emulator config", () => { @@ -38,12 +40,20 @@ describe("local emulator config", () => { await expect(readConfigFromFile("/irrelevant/path/stack.config.ts")).resolves.toEqual({}); }); + it("treats show-onboarding config as an empty config override", async () => { + const content = `export const config = "show-onboarding";\n`; + vi.stubEnv("STACK_LOCAL_EMULATOR_CONFIG_CONTENT", Buffer.from(content).toString("base64")); + + await expect(readConfigFromFile("/irrelevant/path/stack.config.ts")).resolves.toEqual({}); + await expect(isLocalEmulatorOnboardingEnabledInConfig("/irrelevant/path/stack.config.ts")).resolves.toBe(true); + }); + it("throws when the config module does not export config", async () => { const content = `export default { auth: { allowLocalhost: true } };\n`; vi.stubEnv("STACK_LOCAL_EMULATOR_CONFIG_CONTENT", Buffer.from(content).toString("base64")); await expect(readConfigFromFile("/irrelevant/path/stack.config.ts")).rejects.toThrow( - "Invalid config in /irrelevant/path/stack.config.ts. The file must export a 'config' object." + "Invalid config in /irrelevant/path/stack.config.ts. The file must export a 'config' object or \"show-onboarding\"." ); }); @@ -81,6 +91,22 @@ describe("local emulator config", () => { ); }); + it("writes show-onboarding config files to the host mount", async () => { + const hostMountRoot = await fs.mkdtemp(path.join(os.tmpdir(), "stack-host-mount-")); + const absoluteFilePath = "/Users/foo/project/stack.config.ts"; + const mountedParentPath = path.join(hostMountRoot, "/Users/foo/project"); + const mountedFilePath = path.join(hostMountRoot, absoluteFilePath); + await fs.mkdir(mountedParentPath, { recursive: true }); + + vi.stubEnv(LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV, hostMountRoot); + + await writeShowOnboardingConfigToFile(absoluteFilePath); + + await expect(fs.readFile(mountedFilePath, "utf-8")).resolves.toBe( + `import type { StackConfig } from "@stackframe/js";\n\nexport const config: StackConfig = "show-onboarding";\n` + ); + }); + it("fails loudly when the QEMU host mount root is configured but unavailable", async () => { const hostMountRoot = await fs.mkdtemp(path.join(os.tmpdir(), "stack-host-mount-")); vi.stubEnv(LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV, hostMountRoot); diff --git a/apps/backend/src/lib/local-emulator.ts b/apps/backend/src/lib/local-emulator.ts index c56379d47d..2f2e5f7011 100644 --- a/apps/backend/src/lib/local-emulator.ts +++ b/apps/backend/src/lib/local-emulator.ts @@ -17,6 +17,9 @@ export const LOCAL_EMULATOR_ENV_CONFIG_BLOCKED_MESSAGE = export const LOCAL_EMULATOR_ONLY_ENDPOINT_MESSAGE = "This endpoint is only available in local emulator mode (set NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=true)."; export const LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV = "STACK_LOCAL_EMULATOR_HOST_MOUNT_ROOT"; +export const LOCAL_EMULATOR_SHOW_ONBOARDING_VALUE = "show-onboarding" as const; + +type LocalEmulatorConfigValue = Record | typeof LOCAL_EMULATOR_SHOW_ONBOARDING_VALUE; export function isLocalEmulatorEnabled() { return getEnvVariable("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR", "") === "true"; @@ -54,24 +57,25 @@ export function resolveEmulatorPath(filePath: string): string { return filePath; } -export async function readConfigFromFile(filePath: string): Promise> { +async function readConfigContent(filePath: string): Promise { // Check for base64-encoded config content override from env var const envContent = getEnvVariable("STACK_LOCAL_EMULATOR_CONFIG_CONTENT", ""); - let content: string; if (envContent) { - content = Buffer.from(envContent, "base64").toString("utf-8"); - } else { - const resolvedPath = resolveEmulatorPath(filePath); - try { - content = await fs.readFile(resolvedPath, "utf-8"); - } catch (e: any) { - if (e?.code === "ENOENT") { - return {}; - } - throw e; + return Buffer.from(envContent, "base64").toString("utf-8"); + } + const resolvedPath = resolveEmulatorPath(filePath); + try { + return await fs.readFile(resolvedPath, "utf-8"); + } catch (e: any) { + if (e?.code === "ENOENT") { + return ""; } + throw e; } +} +async function readConfigValueFromFile(filePath: string): Promise { + const content = await readConfigContent(filePath); if (content.trim() === "") { return {}; } @@ -79,8 +83,24 @@ export async function readConfigFromFile(filePath: string): Promise; const config = mod.config; + if (config === LOCAL_EMULATOR_SHOW_ONBOARDING_VALUE) { + return config; + } if (!isValidConfig(config)) { - throw new StatusError(StatusError.BadRequest, `Invalid config in ${filePath}. The file must export a 'config' object.`); + throw new StatusError(StatusError.BadRequest, `Invalid config in ${filePath}. The file must export a 'config' object or "show-onboarding".`); + } + return config; +} + +export async function isLocalEmulatorOnboardingEnabledInConfig(filePath: string): Promise { + const config = await readConfigValueFromFile(filePath); + return config === LOCAL_EMULATOR_SHOW_ONBOARDING_VALUE; +} + +export async function readConfigFromFile(filePath: string): Promise> { + const config = await readConfigValueFromFile(filePath); + if (config === LOCAL_EMULATOR_SHOW_ONBOARDING_VALUE) { + return {}; } return config; } @@ -102,3 +122,21 @@ export async function writeConfigToFile(filePath: string, config: Record { + const resolvedPath = resolveEmulatorPath(filePath); + const dir = path.dirname(resolvedPath); + const hostMountRoot = getEnvVariable(LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV, ""); + if (hostMountRoot) { + try { + await fs.access(dir); + } catch { + throw new Error(`Local emulator host mount root ${hostMountRoot} is configured but the parent directory for ${filePath} is not available at ${dir}. Ensure the host filesystem is mounted correctly.`); + } + } else { + await fs.mkdir(dir, { recursive: true }); + } + const importPackage = detectImportPackageFromDir(dir) ?? "@stackframe/js"; + const content = `import type { StackConfig } from "${importPackage}";\n\nexport const config: StackConfig = "show-onboarding";\n`; + await fs.writeFile(resolvedPath, content, "utf-8"); +} diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index c654691781..d839314b9b 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -55,6 +55,13 @@ export function getProjectQuery(projectId: string): RawQuery { - const onboardingStatusColumnExistsRows = await tx.$queryRaw>` + const onboardingColumnExistsRows = await tx.$queryRaw>` SELECT EXISTS ( SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = 'Project' AND column_name = 'onboardingStatus' - ) AS "exists" + ) AS "onboardingStatusExists", + EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'Project' + AND column_name = 'onboardingState' + ) AS "onboardingStateExists" `; - const onboardingStatusColumnExists = onboardingStatusColumnExistsRows[0]?.exists === true; + const onboardingStatusColumnExists = onboardingColumnExistsRows[0]?.onboardingStatusExists === true; + const onboardingStateColumnExists = onboardingColumnExistsRows[0]?.onboardingStateExists === true; let project: Prisma.ProjectGetPayload<{}>; let branchId: string; @@ -176,6 +192,17 @@ export async function createOrUpdateProjectWithLegacyConfig( branchId = options.branchId; } + if (onboardingStateColumnExists && options.data.onboarding_state !== undefined) { + const onboardingStateString = options.data.onboarding_state == null + ? null + : JSON.stringify(options.data.onboarding_state); + await tx.$executeRaw` + UPDATE "Project" + SET "onboardingState" = ${onboardingStateString}::jsonb + WHERE "id" = ${project.id} + `; + } + return [project.id, branchId]; }); diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/content.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/content.tsx index 9f8fb9b2b9..1c342ef581 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/content.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/content.tsx @@ -34,7 +34,9 @@ import { beginPendingAction, endPendingAction, getStackAppInternals, + isProjectOnboardingState, isProjectOnboardingStatus, + type ProjectOnboardingState, } from "./shared"; export default function PageClient() { @@ -54,6 +56,7 @@ export default function PageClient() { const mode = searchParams.get("mode"); const [projectStatuses, setProjectStatuses] = useState>(new Map()); + const [projectOnboardingStates, setProjectOnboardingStates] = useState>(new Map()); const [loadingStatuses, setLoadingStatuses] = useState(true); const [projectName, setProjectName] = useState(displayNameFromSearch ?? ""); const [selectedTeamId, setSelectedTeamId] = useState(null); @@ -113,6 +116,7 @@ export default function PageClient() { } const statusMap = new Map(); + const onboardingStateMap = new Map(); for (const item of body.items) { if (item == null || typeof item !== "object" || !("id" in item) || typeof item.id !== "string") { continue; @@ -123,10 +127,17 @@ export default function PageClient() { throw new Error(`Project ${item.id} returned an invalid onboarding status.`); } statusMap.set(item.id, onboardingStatus); + + const onboardingState = "onboarding_state" in item ? item.onboarding_state : null; + if (onboardingState != null && !isProjectOnboardingState(onboardingState)) { + throw new Error(`Project ${item.id} returned an invalid onboarding state.`); + } + onboardingStateMap.set(item.id, onboardingState); } if (!cancelled) { setProjectStatuses(statusMap); + setProjectOnboardingStates(onboardingStateMap); } } finally { if (!cancelled) { @@ -154,6 +165,13 @@ export default function PageClient() { return projectStatuses.get(selectedProjectId) ?? null; }, [projectStatuses, selectedProjectId]); + const selectedProjectOnboardingState = useMemo(() => { + if (selectedProjectId == null) { + return null; + } + return projectOnboardingStates.get(selectedProjectId) ?? null; + }, [projectOnboardingStates, selectedProjectId]); + useEffect(() => { if (selectedProject == null || loadingStatuses || selectedProjectStatus !== "completed") { return; @@ -190,7 +208,33 @@ export default function PageClient() { await appInternals.refreshOwnedProjects(); }; - if (isLocalEmulator) { + const setSelectedProjectOnboardingState = async (project: AdminOwnedProject, onboardingState: ProjectOnboardingState | null) => { + const projectInternals = getStackAppInternals(project.app); + + const response = await projectInternals.sendRequest( + "/internal/projects/current", + { + method: "PATCH", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify({ onboarding_state: onboardingState }), + }, + "admin", + ); + + if (!response.ok) { + throw new Error(`Failed to update onboarding state: ${response.status} ${await response.text()}`); + } + + setProjectOnboardingStates((previous) => { + const next = new Map(previous); + next.set(project.id, onboardingState); + return next; + }); + }; + + if (isLocalEmulator && selectedProjectId == null) { return (
@@ -344,6 +388,11 @@ export default function PageClient() { next.set(newProject.id, "config_choice"); return next; }); + setProjectOnboardingStates((previous) => { + const next = new Map(previous); + next.set(newProject.id, null); + return next; + }); if (redirectToNeonConfirmWith != null) { const confirmSearchParams = new URLSearchParams(redirectToNeonConfirmWith); @@ -455,9 +504,12 @@ export default function PageClient() { updateSearchParams({ mode: nextMode })} setStatus={(nextStatus) => setSelectedProjectStatus(selectedProject, nextStatus)} + setOnboardingState={(nextState) => setSelectedProjectOnboardingState(selectedProject, nextState)} + clearOnboardingState={() => setSelectedProjectOnboardingState(selectedProject, null)} onComplete={() => { router.push(`/projects/${encodeURIComponent(selectedProject.id)}`); }} diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.test.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.test.tsx index aa55dd95cc..da1ea3d167 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.test.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.test.tsx @@ -77,6 +77,10 @@ vi.mock("@/components/ui", () => ({ cn: (...classNames: Array) => classNames.filter(Boolean).join(" "), })); +vi.mock("@/lib/env", () => ({ + getPublicEnvVar: () => "false", +})); + vi.mock("@/lib/config-update", () => ({ useUpdateConfig: () => vi.fn(async () => true), })); @@ -183,9 +187,12 @@ describe("ProjectOnboardingWizard", () => { {})} + clearOnboardingState={vi.fn(async () => {})} onComplete={onComplete} />, ); diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx index 3baca08a41..c3b5a882ed 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx @@ -17,6 +17,7 @@ import { TooltipProvider, Typography, } from "@/components/ui"; +import { getPublicEnvVar } from "@/lib/env"; import { useUpdateConfig } from "@/lib/config-update"; import { ArrowsClockwiseIcon, @@ -30,6 +31,7 @@ import { } from "@phosphor-icons/react"; import { AdminOwnedProject, AuthPage } from "@stackframe/stack"; import { type AppId } from "@stackframe/stack-shared/dist/apps/apps-config"; +import { type EnvironmentConfigOverrideOverride } from "@stackframe/stack-shared/dist/config/schema"; import { projectOnboardingStatusValues, type ProjectOnboardingStatus } from "@stackframe/stack-shared/dist/schema-fields"; import { allProviders } from "@stackframe/stack-shared/dist/utils/oauth"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; @@ -49,9 +51,15 @@ import { deriveInitialApps, deriveInitialSignInMethods, getStepIndex, + normalizeProjectOnboardingState, + createProjectOnboardingState, + OAUTH_SIGN_IN_METHODS, + type OnboardingConfigChoice, + type OnboardingPaymentsCountry, orderedAppIds, PAYMENT_COUNTRY_OPTIONS, PRIMARY_APP_IDS, + type ProjectOnboardingState, REQUIRED_APP_IDS, SIGN_IN_METHODS, type SignInMethod, @@ -63,28 +71,47 @@ const PROJECT_ONBOARDING_STATUSES = projectOnboardingStatusValues; export function ProjectOnboardingWizard(props: { project: AdminOwnedProject, status: ProjectOnboardingStatus, + onboardingState: ProjectOnboardingState | null, mode: string | null, setMode: (mode: string | null) => void, setStatus: (status: ProjectOnboardingStatus) => Promise, + setOnboardingState: (state: ProjectOnboardingState) => Promise, + clearOnboardingState: () => Promise, onComplete: () => void, }) { const router = useRouter(); - const { project, status, setMode, setStatus, onComplete } = props; + const { project, status, onboardingState, setMode, setStatus, setOnboardingState, clearOnboardingState, onComplete } = props; + const isLocalEmulator = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true"; const completeConfig = project.useConfig(); const updateConfig = useUpdateConfig(); const setProjectOnboardingStatus = setStatus; const finishProjectOnboarding = onComplete; + const deriveCurrentOnboardingState = useCallback((onboardingStatus: ProjectOnboardingStatus): ProjectOnboardingState => { + const defaultState = createProjectOnboardingState({ + selectedConfigChoice: "create-new", + selectedApps: deriveInitialApps(completeConfig, onboardingStatus), + selectedSignInMethods: deriveInitialSignInMethods(project, onboardingStatus), + selectedEmailThemeId: completeConfig.emails.selectedThemeId, + selectedPaymentsCountry: "US", + localEmulator: isLocalEmulator, + }); + if (onboardingState == null) { + return defaultState; + } + return normalizeProjectOnboardingState(onboardingState, { localEmulator: isLocalEmulator }); + }, [completeConfig, isLocalEmulator, onboardingState, project]); + const initialOnboardingState = deriveCurrentOnboardingState(status); const [saving, setSaving] = useState(false); - const [selectedApps, setSelectedApps] = useState>(() => deriveInitialApps(completeConfig, status)); - const [signInMethods, setSignInMethods] = useState>(() => deriveInitialSignInMethods(project, status)); + const [selectedApps, setSelectedApps] = useState>(() => new Set(initialOnboardingState.selected_apps)); + const [signInMethods, setSignInMethods] = useState>(() => new Set(initialOnboardingState.selected_sign_in_methods)); const [trustedDomain, setTrustedDomain] = useState(""); const [domainHandlerPath, setDomainHandlerPath] = useState("/handler"); const [managedSubdomain, setManagedSubdomain] = useState(""); const [managedSenderLocalPart, setManagedSenderLocalPart] = useState(""); const [managedDomainSetupStatus, setManagedDomainSetupStatus] = useState(null); - const [selectedEmailThemeId, setSelectedEmailThemeId] = useState(completeConfig.emails.selectedThemeId); - const [selectedPaymentsCountry, setSelectedPaymentsCountry] = useState("US"); - const [selectedConfigChoice, setSelectedConfigChoice] = useState<"create-new" | "link-existing">("create-new"); + const [selectedEmailThemeId, setSelectedEmailThemeId] = useState(initialOnboardingState.selected_email_theme_id); + const [selectedPaymentsCountry, setSelectedPaymentsCountry] = useState(initialOnboardingState.selected_payments_country); + const [selectedConfigChoice, setSelectedConfigChoice] = useState(initialOnboardingState.selected_config_choice); const [authSetupMobileTab, setAuthSetupMobileTab] = useState<"methods" | "preview">("methods"); const [domainSetupAutoAdvanceError, setDomainSetupAutoAdvanceError] = useState(null); const [domainSetupAutoAdvancing, setDomainSetupAutoAdvancing] = useState(false); @@ -107,8 +134,9 @@ export function ProjectOnboardingWizard(props: { } previousProjectId.current = project.id; - setSelectedApps(deriveInitialApps(completeConfig, status)); - setSignInMethods(deriveInitialSignInMethods(project, status)); + const onboardingState = deriveCurrentOnboardingState(status); + setSelectedApps(new Set(onboardingState.selected_apps)); + setSignInMethods(new Set(onboardingState.selected_sign_in_methods)); const trustedDomains = Object.values(completeConfig.domains.trustedDomains) .filter((entry) => entry.baseUrl != null) @@ -129,17 +157,18 @@ export function ProjectOnboardingWizard(props: { const serverConfig = completeConfig.emails.server; setManagedSubdomain(serverConfig.managedSubdomain ?? ""); setManagedSenderLocalPart(serverConfig.managedSenderLocalPart ?? ""); - setSelectedEmailThemeId(completeConfig.emails.selectedThemeId); + setSelectedEmailThemeId(onboardingState.selected_email_theme_id); setManagedDomainSetupStatus(null); - setSelectedConfigChoice("create-new"); + setSelectedConfigChoice(onboardingState.selected_config_choice); + setSelectedPaymentsCountry(onboardingState.selected_payments_country); setAuthSetupMobileTab("methods"); setDomainSetupAutoAdvanceError(null); setDomainSetupAutoAdvancing(false); paymentsAutoCompletingRef.current = false; - }, [completeConfig, project, project.id, status]); + }, [completeConfig, deriveCurrentOnboardingState, project, project.id, status]); const emailThemes = project.app.useEmailThemes(); - const isLinkExistingMode = props.mode === "link-existing"; + const isLinkExistingMode = !isLocalEmulator && props.mode === "link-existing"; const paymentsAppEnabledInConfig = completeConfig.apps.installed.payments?.enabled === true; const includePayments = ( status === "payments_setup" @@ -233,12 +262,118 @@ export function ProjectOnboardingWizard(props: { }); }; + const buildOnboardingState = useCallback((): ProjectOnboardingState => { + return createProjectOnboardingState({ + selectedConfigChoice, + selectedApps, + selectedSignInMethods: signInMethods, + selectedEmailThemeId: selectedEmailThemeId ?? completeConfig.emails.selectedThemeId, + selectedPaymentsCountry, + localEmulator: isLocalEmulator, + }); + }, [completeConfig.emails.selectedThemeId, isLocalEmulator, selectedApps, selectedConfigChoice, selectedEmailThemeId, selectedPaymentsCountry, signInMethods]); + + const persistOnboardingState = useCallback(async () => { + await setOnboardingState(buildOnboardingState()); + }, [buildOnboardingState, setOnboardingState]); + + const buildBranchConfigUpdate = useCallback(() => { + const emailThemeId = selectedEmailThemeId ?? completeConfig.emails.selectedThemeId; + const configUpdate: EnvironmentConfigOverrideOverride = { + "auth.password.allowSignIn": signInMethods.has("credential"), + "auth.otp.allowSignIn": signInMethods.has("magicLink"), + "auth.passkey.allowSignIn": signInMethods.has("passkey"), + "emails.selectedThemeId": emailThemeId, + }; + for (const appId of ALL_APP_IDS) { + configUpdate[`apps.installed.${appId}.enabled`] = selectedApps.has(appId); + } + if (isLocalEmulator) { + configUpdate["auth.oauth.providers.google"] = signInMethods.has("google") ? { + type: "google", + allowSignIn: true, + allowConnectedAccounts: true, + } : null; + configUpdate["auth.oauth.providers.github"] = signInMethods.has("github") ? { + type: "github", + allowSignIn: true, + allowConnectedAccounts: true, + } : null; + configUpdate["auth.oauth.providers.microsoft"] = signInMethods.has("microsoft") ? { + type: "microsoft", + allowSignIn: true, + allowConnectedAccounts: true, + } : null; + } + return configUpdate; + }, [completeConfig.emails.selectedThemeId, isLocalEmulator, selectedApps, selectedEmailThemeId, signInMethods]); + + const buildEnvironmentOAuthConfigUpdate = useCallback(() => { + return { + "auth.oauth.providers.google": signInMethods.has("google") ? { + type: "google", + isShared: true, + allowSignIn: true, + allowConnectedAccounts: true, + } : null, + "auth.oauth.providers.github": signInMethods.has("github") ? { + type: "github", + isShared: true, + allowSignIn: true, + allowConnectedAccounts: true, + } : null, + "auth.oauth.providers.microsoft": signInMethods.has("microsoft") ? { + type: "microsoft", + isShared: true, + allowSignIn: true, + allowConnectedAccounts: true, + } : null, + }; + }, [signInMethods]); + const finalizeOnboarding = useCallback(async () => { await runWithSaving(async () => { + if (!isLinkExistingMode) { + await persistOnboardingState(); + + const branchConfigUpdated = await updateConfig({ + adminApp: props.project.app, + configUpdate: buildBranchConfigUpdate(), + pushable: true, + }); + if (!branchConfigUpdated) { + return; + } + + if (!isLocalEmulator) { + const providersUpdated = await updateConfig({ + adminApp: props.project.app, + configUpdate: buildEnvironmentOAuthConfigUpdate(), + pushable: false, + }); + if (!providersUpdated) { + return; + } + } + } + await setProjectOnboardingStatus("completed"); + await clearOnboardingState(); finishProjectOnboarding(); }); - }, [finishProjectOnboarding, runWithSaving, setProjectOnboardingStatus]); + }, [ + buildBranchConfigUpdate, + buildEnvironmentOAuthConfigUpdate, + finishProjectOnboarding, + isLinkExistingMode, + isLocalEmulator, + persistOnboardingState, + props.project.app, + clearOnboardingState, + runWithSaving, + setProjectOnboardingStatus, + updateConfig, + ]); useEffect(() => { if (status !== "payments_setup" || stripeAccountInfo?.details_submitted !== true || paymentsAutoCompletingRef.current) { @@ -248,13 +383,14 @@ export function ProjectOnboardingWizard(props: { paymentsAutoCompletingRef.current = true; runAsynchronouslyWithAlert(async () => { try { + await persistOnboardingState(); await setStatus("welcome"); } catch (error) { paymentsAutoCompletingRef.current = false; throw error; } }); - }, [setStatus, status, stripeAccountInfo?.details_submitted]); + }, [persistOnboardingState, setStatus, status, stripeAccountInfo?.details_submitted]); if (props.status === "welcome") { return ( @@ -267,7 +403,7 @@ export function ProjectOnboardingWizard(props: { ); } - if (props.status === "config_choice" && props.mode === "link-existing") { + if (props.status === "config_choice" && props.mode === "link-existing" && !isLocalEmulator) { return ( runAsynchronouslyWithAlert(() => runWithSaving(async () => { - if (selectedConfigChoice === "create-new") { + await persistOnboardingState(); + if (isLocalEmulator || selectedConfigChoice === "create-new") { await props.setStatus("apps_selection"); } else { props.setMode("link-existing"); @@ -321,7 +458,7 @@ export function ProjectOnboardingWizard(props: { } > -
+
- +
+ Link Existing Config + If you already have a Stack Auth project locally or on GitHub, link it here. +
+ + )}
); @@ -405,21 +544,7 @@ export function ProjectOnboardingWizard(props: { className="w-full rounded-full" loading={saving} onClick={() => runAsynchronouslyWithAlert(() => runWithSaving(async () => { - const appConfigUpdateEntries = new Map( - ALL_APP_IDS.map((appId) => [ - `apps.installed.${appId}.enabled`, - selectedApps.has(appId), - ]) - ); - - const configUpdated = await updateConfig({ - adminApp: props.project.app, - configUpdate: Object.fromEntries(appConfigUpdateEntries), - pushable: true, - }); - if (!configUpdated) { - return; - } + await persistOnboardingState(); await props.setStatus("auth_setup"); }))} > @@ -504,6 +629,10 @@ export function ProjectOnboardingWizard(props: { } if (props.status === "auth_setup") { + const availableSignInMethods = isLocalEmulator + ? SIGN_IN_METHODS.filter((method) => !OAUTH_SIGN_IN_METHODS.some((oauthMethod) => oauthMethod === method.id)) + : SIGN_IN_METHODS; + return ( @@ -603,14 +689,14 @@ export function ProjectOnboardingWizard(props: { Sign-in methods
- {SIGN_IN_METHODS.map((method, index) => { + {availableSignInMethods.map((method, index) => { const checked = signInMethods.has(method.id); return (
); @@ -166,11 +170,13 @@ function AsyncBasedTokens() { const [asyncResults, setAsyncResults] = useState<{ userAccessToken?: string | null, userRefreshToken?: string | null, + userAuthorizationHeader?: string | null, userAuthHeaders?: { "x-stack-auth": string } | null, userAuthJson?: { accessToken: string | null, refreshToken: string | null } | null, sessionTokens?: { accessToken: string | null, refreshToken: string | null } | null, appAccessToken?: string | null, appRefreshToken?: string | null, + appAuthorizationHeader?: string | null, appAuthHeaders?: { "x-stack-auth": string } | null, appAuthJson?: { accessToken: string | null, refreshToken: string | null } | null, } | null>(null); @@ -183,6 +189,7 @@ function AsyncBasedTokens() { if (user) { results.userAccessToken = await (user as any).getAccessToken?.(); results.userRefreshToken = await (user as any).getRefreshToken?.(); + results.userAuthorizationHeader = await (user as any).getAuthorizationHeader?.(); results.userAuthHeaders = await user.getAuthHeaders(); results.userAuthJson = await user.getAuthJson(); results.sessionTokens = await user.currentSession.getTokens(); @@ -190,6 +197,7 @@ function AsyncBasedTokens() { results.appAccessToken = await (app as any).getAccessToken?.(); results.appRefreshToken = await (app as any).getRefreshToken?.(); + results.appAuthorizationHeader = await (app as any).getAuthorizationHeader?.(); results.appAuthHeaders = await app.getAuthHeaders(); results.appAuthJson = await app.getAuthJson(); @@ -227,8 +235,9 @@ function AsyncBasedTokens() { <> - - + + + )} @@ -244,8 +253,9 @@ function AsyncBasedTokens() { - - + + +
); @@ -323,10 +333,13 @@ export default function TokensDemoPage() { useRefreshToken() / getRefreshToken(): Returns the long-lived refresh token used to obtain new access tokens.

- useAuthHeaders() / getAuthHeaders(): Returns headers ready to use with fetch() for cross-origin authenticated requests. + useAuthorizationHeader() / getAuthorizationHeader(): Returns a `Bearer ...` value for the HTTP `Authorization` header.

- useAuthJson() / getAuthJson() [deprecated]: Returns both tokens as JSON. Use individual token getters instead. + useAuthHeaders() / getAuthHeaders() [deprecated]: Returns legacy `x-stack-auth` headers. Prefer authorization-header methods. +

+

+ useAuthJson() / getAuthJson(): Returns both tokens as JSON. This is the recommended format for non-HTTP protocols.

currentSession.useTokens() / getTokens(): Returns both tokens from the current session object. diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 4422a824c2..1fc78b0aae 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -19,7 +19,7 @@ import { TeamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; import type { RestrictedReason } from "@stackframe/stack-shared/dist/schema-fields"; import { InternalSession } from "@stackframe/stack-shared/dist/sessions"; -import { decodeBase32, encodeBase32 } from "@stackframe/stack-shared/dist/utils/bytes"; +import { decodeBase32, decodeBase64, encodeBase32, encodeBase64 } from "@stackframe/stack-shared/dist/utils/bytes"; import { scrambleDuringCompileTime } from "@stackframe/stack-shared/dist/utils/compile-time"; import { isBrowserLike } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; @@ -83,6 +83,59 @@ const NextNavigation = scrambleDuringCompileTime(NextNavigationUnscrambled); const prefetchedCrossDomainHandoffTtlMs = 55 * 60 * 1000; const allClientApps = new Map]>(); +const STACK_AUTHORIZATION_VALUE_PREFIX = "stackauth_"; + +function getAuthorizationHeaderValueFromAuthJson(authJson: { accessToken: string | null, refreshToken: string | null }): string | null { + if (authJson.accessToken == null && authJson.refreshToken == null) { + return null; + } + + const encodedAuthJson = encodeBase64(new TextEncoder().encode(JSON.stringify(authJson))); + return `Bearer ${STACK_AUTHORIZATION_VALUE_PREFIX}${encodedAuthJson}`; +} + +function getAuthJsonFromAuthorizationHeaderValue(authorizationHeaderValue: string): { accessToken: string | null, refreshToken: string | null } | null { + const match = authorizationHeaderValue.match(/^Bearer\s+(.+)$/i); + if (match == null) { + return null; + } + + const credential = match[1].trim(); + if (!credential.startsWith(STACK_AUTHORIZATION_VALUE_PREFIX)) { + return null; + } + + const encodedAuthJson = credential.slice(STACK_AUTHORIZATION_VALUE_PREFIX.length); + if (encodedAuthJson.length === 0) { + throw new Error("Invalid Authorization header format. Expected `Bearer stackauth_`."); + } + + let parsed: unknown; + try { + const decodedAuthJson = new TextDecoder().decode(decodeBase64(encodedAuthJson)); + parsed = JSON.parse(decodedAuthJson); + } catch (e) { + throw new Error(`Invalid stackauth authorization header: ${authorizationHeaderValue}`, { cause: e }); + } + + if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error("Invalid stackauth authorization payload. Expected an object."); + } + + const accessToken = Reflect.get(parsed, "accessToken"); + const refreshToken = Reflect.get(parsed, "refreshToken"); + if (accessToken != null && typeof accessToken !== "string") { + throw new Error("Invalid stackauth authorization payload. `accessToken` must be a string or null."); + } + if (refreshToken != null && typeof refreshToken !== "string") { + throw new Error("Invalid stackauth authorization payload. `refreshToken` must be a string or null."); + } + + return { + accessToken: accessToken ?? null, + refreshToken: refreshToken ?? null, + }; +} type StackClientAppImplConstructorOptionsResolved = StackClientAppConstructorOptions & { inheritsFrom?: undefined }; @@ -985,8 +1038,22 @@ export class _StackClientAppImplIncomplete({ + accessToken: authJson.accessToken, + refreshToken: authJson.refreshToken, + }); + this._requestTokenStores.set(tokenStoreInit, tokenStore); + return tokenStore; + } + } + + // x-stack-auth header (legacy) + const stackAuthHeader = tokenStoreInit.headers.get("x-stack-auth") ?? tokenStoreInit.headers.get("X-Stack-Auth"); if (stackAuthHeader) { let parsed; try { @@ -1511,6 +1578,14 @@ export class _StackClientAppImplIncomplete { + return getAuthorizationHeaderValueFromAuthJson(await this.getAuthJson()); + }, + // IF_PLATFORM react-like + useAuthorizationHeader(): string | null { + return getAuthorizationHeaderValueFromAuthJson(this.useAuthJson()); + }, + // END_PLATFORM async getAuthHeaders(): Promise<{ "x-stack-auth": string }> { return { "x-stack-auth": JSON.stringify(await this.getAuthJson()), @@ -3297,6 +3372,16 @@ export class _StackClientAppImplIncomplete { + return getAuthorizationHeaderValueFromAuthJson(await this.getAuthJson(options)); + } + + // IF_PLATFORM react-like + useAuthorizationHeader(options?: { tokenStore?: TokenStoreInit }): string | null { + return getAuthorizationHeaderValueFromAuthJson(this.useAuthJson(options)); + } + // END_PLATFORM + async getAuthHeaders(options?: { tokenStore?: TokenStoreInit }): Promise<{ "x-stack-auth": string }> { return { "x-stack-auth": JSON.stringify(await this.getAuthJson(options)), diff --git a/packages/template/src/lib/stack-app/common.ts b/packages/template/src/lib/stack-app/common.ts index bce4c509a9..5a3964fb50 100644 --- a/packages/template/src/lib/stack-app/common.ts +++ b/packages/template/src/lib/stack-app/common.ts @@ -125,31 +125,32 @@ export type AuthLike = { useRefreshToken(options?: {} & ExtraOptions): string | null, // THIS_LINE_PLATFORM react-like /** - * Returns headers for sending authenticated HTTP requests to external servers. Most commonly used in cross-origin - * requests. Similar to `getAuthJson`, but specifically for HTTP requests. + * Returns the value for the HTTP `Authorization` header for authenticated requests to external servers. + * Most commonly used in cross-origin requests. Similar to `getAuthJson`, but specifically for HTTP requests. * * If you are using `tokenStore: "cookie"`, you don't need this for same-origin requests. However, most * browsers now disable third-party cookies by default, so we must pass authentication tokens by header instead * if the client and server are on different origins. * - * This function returns a header object that can be used with `fetch` or other HTTP request libraries to send - * authenticated requests. + * This function returns the header value in this format: + * `Bearer stackauth_`, or `null` if the user is not signed in. + * You can use this with `fetch` or other HTTP request libraries to send authenticated requests. * * On the server, you can then pass in the `Request` object to the `tokenStore` option * of your Stack app. Please note that CORS does not allow most headers by default, so you - * must include `x-stack-auth` in the [`Access-Control-Allow-Headers` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers) + * must include `authorization` in the [`Access-Control-Allow-Headers` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Headers) * of the CORS preflight response. * - * If you are not using HTTP (and hence cannot set headers), you will need to use the `getAccessToken()` and - * `getRefreshToken()` functions instead. + * If you are not using HTTP (and hence cannot set headers), use `getAuthJson()` instead. * * Example: * * ```ts * // client + * const authorizationHeader = await stackApp.getAuthorizationHeader(); * const res = await fetch("https://api.example.com", { * headers: { - * ...await stackApp.getAuthHeaders() + * ...(authorizationHeader ? { Authorization: authorizationHeader } : {}) * // you can also add your own headers here * }, * }); @@ -161,17 +162,25 @@ export type AuthLike = { * } * ``` */ + getAuthorizationHeader(options?: {} & ExtraOptions): Promise, + useAuthorizationHeader(options?: {} & ExtraOptions): string | null, // THIS_LINE_PLATFORM react-like + + /** + * @deprecated Use `getAuthorizationHeader()` instead. + * + * Returns the legacy `x-stack-auth` headers for authenticated HTTP requests. This remains for backwards + * compatibility with existing integrations. + */ getAuthHeaders(options?: {} & ExtraOptions): Promise<{ "x-stack-auth": string }>, + /** @deprecated Use `useAuthorizationHeader()` instead. */ useAuthHeaders(options?: {} & ExtraOptions): { "x-stack-auth": string }, // THIS_LINE_PLATFORM react-like /** - * @deprecated Use `getAccessToken()` and `getRefreshToken()` instead. - * * Creates a JSON-serializable object containing the information to authenticate a user on an external server. - * Similar to `getAuthHeaders`, but returns an object that can be sent over any protocol instead of just + * Similar to `getAuthorizationHeader`, but returns an object that can be sent over any protocol instead of just * HTTP headers. * - * While `getAuthHeaders` is the recommended way to send authentication tokens over HTTP, your app may use + * While `getAuthorizationHeader` is the recommended way to send authentication tokens over HTTP, your app may use * a different protocol, for example WebSockets or gRPC. This function returns a token object that can be JSON-serialized and sent to the server in any way you like. * * On the server, you can pass in this token object into the `tokenStore` option to fetch user details. @@ -194,7 +203,6 @@ export type AuthLike = { * ``` */ getAuthJson(options?: {} & ExtraOptions): Promise<{ accessToken: string | null, refreshToken: string | null }>, - /** @deprecated Use `useAccessToken()` and `useRefreshToken()` instead. */ useAuthJson(options?: {} & ExtraOptions): { accessToken: string | null, refreshToken: string | null }, // THIS_LINE_PLATFORM react-like }; diff --git a/sdks/spec/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md index ae18aeae18..e2fc436bb8 100644 --- a/sdks/spec/src/_utilities.spec.md +++ b/sdks/spec/src/_utilities.spec.md @@ -255,10 +255,12 @@ RequestLike object: This exists as a simplified way to support common backend frameworks in a more accessible way than the `{ accessToken: string, refreshToken: string }` one. - Extract tokens from the x-stack-auth header: - 1. Get header value: headers.get("x-stack-auth") - 2. Parse as JSON: { accessToken: string, refreshToken: string } - 3. Use those tokens for authentication + Extract tokens from request headers: + 1. Preferred: `Authorization` header in this format: + `Bearer stackauth_` + 2. Legacy fallback: `x-stack-auth` JSON header + (`{ "accessToken": string, "refreshToken": string }`) + 3. If neither auth header exists, read from cookies null: No token storage. When the constructor's tokenStore is null, the tokenStore @@ -294,12 +296,18 @@ This does NOT apply to explicit token stores (`{ accessToken, refreshToken }`), custom stores, or null stores - those are always created fresh per use. -### x-stack-auth Header Format +### Authorization Header Format For cross-origin requests or server-side handling, use this header: + Authorization: Bearer stackauth_", "refreshToken": "" })> + +Use getAuthorizationHeader() to generate this header value. + +### Legacy x-stack-auth Header Format + +For backwards compatibility, request-like token stores also accept: x-stack-auth: { "accessToken": "", "refreshToken": "" } -JSON-encoded object with both tokens. Use getAuthHeaders() to generate this header value. ## MFA Handling Pattern diff --git a/sdks/spec/src/apps/client-app.spec.md b/sdks/spec/src/apps/client-app.spec.md index 0c5c5d0ab0..42668afbaa 100644 --- a/sdks/spec/src/apps/client-app.spec.md +++ b/sdks/spec/src/apps/client-app.spec.md @@ -448,8 +448,29 @@ Panics: This is a programmer error - the code should be fixed to provide a tokenStore. +## getAuthorizationHeader(options?) + +Arguments: + options.tokenStore: TokenStoreInit? - override token storage for this call + +Returns: string | null + +Get current tokens and return the Authorization header value: + Bearer stackauth_ + +Returns null if not authenticated. + +For cross-origin authenticated requests where cookies can't be sent. + +Panics: + If constructor tokenStore was null and no tokenStore override is provided. + This is a programmer error - the code should be fixed to provide a tokenStore. + + ## getAuthHeaders(options?) +@deprecated Use getAuthorizationHeader(options?). + Arguments: options.tokenStore: TokenStoreInit? - override token storage for this call diff --git a/sdks/spec/src/types/users/current-user.spec.md b/sdks/spec/src/types/users/current-user.spec.md index c6ec04bfb3..2092b44d8e 100644 --- a/sdks/spec/src/types/users/current-user.spec.md +++ b/sdks/spec/src/types/users/current-user.spec.md @@ -467,8 +467,22 @@ Returns null if not authenticated. Does not error. +### getAuthorizationHeader() + +Returns: string | null + +Returns the HTTP Authorization header value in this format: + Bearer stackauth_ + +Returns null if not authenticated. + +Does not error. + + ### getAuthHeaders() +@deprecated Use getAuthorizationHeader(). + Returns: { "x-stack-auth": string } Returns headers for cross-origin authenticated requests. From f7f6032880b1a79e2c761f8ea1a6c20f190ad6c0 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 20 Apr 2026 18:19:25 -0700 Subject: [PATCH 03/13] TokenStoreInit now supports { headers: Record } --- .claude/CLAUDE-KNOWLEDGE.md | 3 +++ apps/e2e/tests/js/auth-like.test.ts | 24 +++++++++++++++++++ .../apps/implementations/client-app-impl.ts | 20 +++++++++++++--- packages/template/src/lib/stack-app/common.ts | 8 ++++--- sdks/spec/src/_utilities.spec.md | 6 ++++- 5 files changed, 54 insertions(+), 7 deletions(-) diff --git a/.claude/CLAUDE-KNOWLEDGE.md b/.claude/CLAUDE-KNOWLEDGE.md index f0c53de282..11f6d3397d 100644 --- a/.claude/CLAUDE-KNOWLEDGE.md +++ b/.claude/CLAUDE-KNOWLEDGE.md @@ -361,3 +361,6 @@ A: Run from the monorepo root with explicit file paths: `pnpm test run "" ### Q: What's the new Authorization header format for Stack token forwarding? A: Use `getAuthorizationHeader()`, which returns `Bearer stackauth_`. The payload encodes both `accessToken` and `refreshToken`, and request-like token stores should parse this format first, with legacy `x-stack-auth` remaining as a backward-compatible fallback. + +### Q: What RequestLike header shapes are supported by tokenStore overrides? +A: `RequestLike` accepts both `{ headers: { get(name): string | null } }` and `{ headers: Record }`. Header lookup is case-insensitive for record-style headers, and supports `authorization`, `x-stack-auth`, and `cookie`. diff --git a/apps/e2e/tests/js/auth-like.test.ts b/apps/e2e/tests/js/auth-like.test.ts index f32505c99c..ce6cd5f45d 100644 --- a/apps/e2e/tests/js/auth-like.test.ts +++ b/apps/e2e/tests/js/auth-like.test.ts @@ -601,6 +601,30 @@ it("getUser should work with Authorization header in request-like tokenStore", a expect(serverUser!.id).toBe(clientUser.id); }); +it("getUser should work with record-style headers in request-like tokenStore", async ({ expect }) => { + const { serverApp, clientApp } = await createApp({}); + await signIn(clientApp); + + const authorizationHeader = await clientApp.getAuthorizationHeader(); + if (authorizationHeader == null) { + throw new Error("Expected authorization header for signed-in user."); + } + + const requestLike = { + headers: { + Authorization: authorizationHeader, + Cookie: null, + }, + }; + + const serverUser = await serverApp.getUser({ tokenStore: requestLike }); + const clientUser = await clientApp.getUser({ or: "throw" }); + + expect(serverUser).not.toBeNull(); + expect(serverUser!.primaryEmail).toBe("test@test.com"); + expect(serverUser!.id).toBe(clientUser.id); +}); + it("getUser should work with x-stack-auth header in request-like tokenStore", async ({ expect }) => { const { serverApp, clientApp } = await createApp({}); await signIn(clientApp); diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 1fc78b0aae..1807b83380 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -137,6 +137,20 @@ function getAuthJsonFromAuthorizationHeaderValue(authorizationHeaderValue: strin }; } +function getHeaderValueFromRequestLikeHeaders(headers: RequestLike["headers"], name: string): string | null { + if ("get" in headers && typeof headers.get === "function") { + return headers.get(name); + } + + const lowerCaseName = name.toLowerCase(); + for (const [headerName, headerValue] of Object.entries(headers)) { + if (headerName.toLowerCase() === lowerCaseName) { + return headerValue; + } + } + return null; +} + type StackClientAppImplConstructorOptionsResolved = StackClientAppConstructorOptions & { inheritsFrom?: undefined }; export class _StackClientAppImplIncomplete implements StackClientApp { @@ -1039,7 +1053,7 @@ export class _StackClientAppImplIncomplete(this._getTokensFromCookies(parsed)); this._requestTokenStores.set(tokenStoreInit, res); diff --git a/packages/template/src/lib/stack-app/common.ts b/packages/template/src/lib/stack-app/common.ts index 5a3964fb50..293f654ec3 100644 --- a/packages/template/src/lib/stack-app/common.ts +++ b/packages/template/src/lib/stack-app/common.ts @@ -78,9 +78,11 @@ export type GetCurrentPartialUserOptions = } : {}); export type RequestLike = { - headers: { - get: (name: string) => string | null, - }, + headers: + | { + get: (name: string) => string | null, + } + | Record, }; export type TokenStoreInit = diff --git a/sdks/spec/src/_utilities.spec.md b/sdks/spec/src/_utilities.spec.md index e2fc436bb8..077708ee5e 100644 --- a/sdks/spec/src/_utilities.spec.md +++ b/sdks/spec/src/_utilities.spec.md @@ -251,7 +251,11 @@ To refresh an access token from a refresh token, use an OAuth2 token grant: For custom token management scenarios. RequestLike object: - An object that conforms to whatever the requests look like in common backend frameworks. For example, in JavaScript, these often have the shape `{ headers: { get(name: string): string | null } }`, but in other languages this may drastically differ (and may not even be an interface and instead rather just be an abstract class, or not exist at all). + An object that conforms to whatever the requests look like in common backend frameworks. + In JavaScript, common shapes include: + - `{ headers: { get(name: string): string | null } }` + - `{ headers: Record }` + In other languages this may drastically differ (and may not even be an interface and instead rather just be an abstract class, or not exist at all). This exists as a simplified way to support common backend frameworks in a more accessible way than the `{ accessToken: string, refreshToken: string }` one. From ea8e1a4d63efaa9746618c6d2f34ce5fcc503a9f Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 20 Apr 2026 18:44:30 -0700 Subject: [PATCH 04/13] Add `pnpm run cli` --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 7828d0154a..258a3b8110 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "typecheck": "pnpm pre && turbo typecheck --", "build:dev": "pnpm pre && NODE_ENV=development pnpm run build", "build": "pnpm pre && turbo build", + "cli": "pnpm pre && pnpm run --filter=@stackframe/stack-cli build && node packages/stack-cli/dist/index.js", "build:backend": "pnpm pre && turbo run build --filter=@stackframe/backend...", "build:dashboard": "pnpm pre && turbo run build --filter=@stackframe/dashboard...", "build:demo": "pnpm pre && turbo run build --filter=demo-app...", From 61f3db1772cf0d8ed0efe7bfb67d234a1e65c035 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Tue, 21 Apr 2026 10:05:41 -0400 Subject: [PATCH 05/13] Fix local emulator bugs & include prompt in docs --- .claude/CLAUDE-KNOWLEDGE.md | 18 + .../src/app/api/latest/setup-prompt/route.ts | 33 + apps/backend/src/lib/local-emulator.test.ts | 19 + apps/backend/src/lib/local-emulator.ts | 3 +- apps/backend/src/lib/projects.tsx | 2 - .../project-onboarding-wizard.tsx | 97 ++- .../internal/local-emulator-project.test.ts | 41 + .../endpoints/api/v1/setup-prompt.test.ts | 11 + .../guides/getting-started/setup.mdx | 777 ++---------------- docs-mintlify/snippets/home-prompt-island.jsx | 64 +- packages/stack-cli/src/commands/emulator.ts | 22 +- packages/stack-shared/src/ai/prompts.ts | 276 +++++++ .../client-app-impl.oauth-prefetch.test.ts | 32 + .../apps/implementations/client-app-impl.ts | 1 - 14 files changed, 636 insertions(+), 760 deletions(-) create mode 100644 apps/backend/src/app/api/latest/setup-prompt/route.ts create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/setup-prompt.test.ts create mode 100644 packages/stack-shared/src/ai/prompts.ts create mode 100644 packages/template/src/lib/stack-app/apps/implementations/client-app-impl.oauth-prefetch.test.ts diff --git a/.claude/CLAUDE-KNOWLEDGE.md b/.claude/CLAUDE-KNOWLEDGE.md index 11f6d3397d..491f42027e 100644 --- a/.claude/CLAUDE-KNOWLEDGE.md +++ b/.claude/CLAUDE-KNOWLEDGE.md @@ -364,3 +364,21 @@ A: Use `getAuthorizationHeader()`, which returns `Bearer stackauth_ }`. Header lookup is case-insensitive for record-style headers, and supports `authorization`, `x-stack-auth`, and `cookie`. + +### Q: Which env var should emulator onboarding URLs use for dashboard port? +A: Use `EMULATOR_DASHBOARD_PORT` (default `26700`) or explicit `STACK_LOCAL_EMULATOR_DASHBOARD_URL`. Do not derive emulator URLs from `NEXT_PUBLIC_STACK_PORT_PREFIX`, because that points to the host dev environment ports (e.g. `92xx`) rather than the emulator host-forwarded ports. + +### Q: Why does `PATCH /api/v1/internal/projects/current` fail in local emulator when updating only `onboarding_state`? +A: `createOrUpdateProjectWithLegacyConfig` always called `overrideEnvironmentConfigOverride`, even when there were zero config override keys to apply. In local emulator mode, environment config overrides are intentionally blocked, so this threw `Environment configuration overrides cannot be changed in the local emulator` and returned 500. The fix is to skip `overrideEnvironmentConfigOverride` unless `configOverrideOverride` has at least one key. + +### Q: Why might local emulator UI changes in `apps/dashboard` not appear immediately at `localhost:26700`? +A: The QEMU local emulator serves the dashboard from the Docker image bundled inside the VM, not from the host repo's live source tree. Source edits in `apps/dashboard` are reflected in lint/typecheck/tests immediately, but you need an updated emulator image/runtime to see the visual change on `26700`. + +### Q: Why can local emulator onboarding break with `ParseError` on non-`.ts` config files (e.g. `test-config.untracked`)? +A: The emulator writes TypeScript-style config source (`import type ...` and `config: StackConfig`) and later evaluates it with Jiti. If the filename has a non-TS extension, Jiti may parse it as plain JS and fail. Fix by evaluating unknown extensions as TypeScript (use a `.ts` eval filename fallback) and add regression coverage for non-`.ts` config paths. + +### Q: How should docs fetch the canonical AI setup prompt text? +A: Expose an unauthenticated backend endpoint at `/api/v1/setup-prompt` that returns `getSdkSetupPrompt("ai-prompt", { tanstackQuery: false })` as plain text and sets `Cache-Control: public, max-age=60`. Mintlify docs should fetch `https://api.stack-auth.com/api/v1/setup-prompt` directly when docs and API are on different origins. + +### Q: Can Mintlify snippets import other snippets? +A: No. Keep snippet logic inline within each snippet file; avoid snippet-to-snippet imports. For setup prompt fetching, point directly to `https://api.stack-auth.com/api/v1/setup-prompt` when docs run on a different origin/port than the API. diff --git a/apps/backend/src/app/api/latest/setup-prompt/route.ts b/apps/backend/src/app/api/latest/setup-prompt/route.ts new file mode 100644 index 0000000000..15c0abf626 --- /dev/null +++ b/apps/backend/src/app/api/latest/setup-prompt/route.ts @@ -0,0 +1,33 @@ +import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; +import { getSdkSetupPrompt } from "@stackframe/stack-shared/dist/ai/prompts"; +import { yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields"; + +export const GET = createSmartRouteHandler({ + metadata: { + hidden: true, + summary: "SDK setup prompt", + description: "Returns the AI setup prompt used by Stack docs.", + tags: [], + }, + request: yupObject({ + method: yupString().oneOf(["GET"]).defined(), + }), + response: yupObject({ + statusCode: yupNumber().oneOf([200]).defined(), + bodyType: yupString().oneOf(["text"]).defined(), + body: yupString().defined(), + headers: yupObject({ + "Cache-Control": yupTuple([yupString().defined()]).defined(), + }).defined(), + }), + handler: async () => { + return { + statusCode: 200, + bodyType: "text", + body: getSdkSetupPrompt("ai-prompt", { tanstackQuery: false }), + headers: { + "Cache-Control": ["public, max-age=60"] as const, + }, + }; + }, +}); diff --git a/apps/backend/src/lib/local-emulator.test.ts b/apps/backend/src/lib/local-emulator.test.ts index efd23a1423..cbb3fffdfd 100644 --- a/apps/backend/src/lib/local-emulator.test.ts +++ b/apps/backend/src/lib/local-emulator.test.ts @@ -107,6 +107,25 @@ describe("local emulator config", () => { ); }); + it("supports non-ts config filenames by evaluating them as TypeScript", async () => { + const hostMountRoot = await fs.mkdtemp(path.join(os.tmpdir(), "stack-host-mount-")); + const absoluteFilePath = "/Users/foo/project/test-config.untracked"; + const mountedParentPath = path.join(hostMountRoot, "/Users/foo/project"); + const mountedFilePath = path.join(hostMountRoot, absoluteFilePath); + await fs.mkdir(mountedParentPath, { recursive: true }); + + vi.stubEnv(LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV, hostMountRoot); + + await writeConfigToFile(absoluteFilePath, { auth: { allowLocalhost: true } }); + + await expect(readConfigFromFile(absoluteFilePath)).resolves.toEqual({ + auth: { + allowLocalhost: true, + }, + }); + await expect(fs.readFile(mountedFilePath, "utf-8")).resolves.toContain(`import type { StackConfig }`); + }); + it("fails loudly when the QEMU host mount root is configured but unavailable", async () => { const hostMountRoot = await fs.mkdtemp(path.join(os.tmpdir(), "stack-host-mount-")); vi.stubEnv(LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV, hostMountRoot); diff --git a/apps/backend/src/lib/local-emulator.ts b/apps/backend/src/lib/local-emulator.ts index 2f2e5f7011..098ecc07e5 100644 --- a/apps/backend/src/lib/local-emulator.ts +++ b/apps/backend/src/lib/local-emulator.ts @@ -80,8 +80,9 @@ async function readConfigValueFromFile(filePath: string): Promise; + const mod = jiti.evalModule(content, { filename: evalFilename }) as Record; const config = mod.config; if (config === LOCAL_EMULATOR_SHOW_ONBOARDING_VALUE) { return config; diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index d839314b9b..ef5a4a93de 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -319,8 +319,6 @@ export async function createOrUpdateProjectWithLegacyConfig( branchId: branchId, environmentConfigOverrideOverride: configOverrideOverride, }); - - const result = await getProject(projectId); if (!result) { throw new StackAssertionError("Project not found after creation/update", { projectId }); diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx index c3b5a882ed..ef9e09c1f2 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/project-onboarding-wizard.tsx @@ -429,14 +429,49 @@ export function ProjectOnboardingWizard(props: { } if (props.status === "config_choice") { + if (isLocalEmulator) { + return ( + runAsynchronouslyWithAlert(() => runWithSaving(async () => { + await persistOnboardingState(); + await props.setStatus("apps_selection"); + }))} + > + Continue + + } + > +

+ + This local project is ready for onboarding. + + + Next, we will guide you through the onboarding flow to set up your Stack Auth configuration. + +
+ + ); + } + const createNewSelected = selectedConfigChoice === "create-new"; - const linkExistingSelected = !isLocalEmulator && selectedConfigChoice === "link-existing"; + const linkExistingSelected = selectedConfigChoice === "link-existing"; return ( runAsynchronouslyWithAlert(() => runWithSaving(async () => { await persistOnboardingState(); - if (isLocalEmulator || selectedConfigChoice === "create-new") { + if (selectedConfigChoice === "create-new") { await props.setStatus("apps_selection"); } else { props.setMode("link-existing"); @@ -458,7 +493,7 @@ export function ProjectOnboardingWizard(props: { } > -
+
- {!isLocalEmulator && ( - - )} + )} +
+ +
+
+ Link Existing Config + If you already have a Stack Auth project locally or on GitHub, link it here. +
+
); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/internal/local-emulator-project.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/internal/local-emulator-project.test.ts index c4cea8669b..846f3ef51f 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/internal/local-emulator-project.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/internal/local-emulator-project.test.ts @@ -217,4 +217,45 @@ describe("local emulator project endpoint", () => { expect(projectBResponse.status).toBe(200); expect(projectBResponse.body.owner_team_id).toBe(LOCAL_EMULATOR_OWNER_TEAM_ID); }); + + it.runIf(isLocalEmulator)("updates onboarding_state on local emulator projects without mutating env config", async ({ expect }) => { + const filePath = `/tmp/${randomUUID()}/stack.config.ts`; + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, "", "utf-8"); + + const localEmulatorProjectResponse = await niceBackendFetch(LOCAL_EMULATOR_PROJECT_ENDPOINT, { + accessType: "admin", + method: "POST", + body: { + absolute_file_path: filePath, + }, + }); + expect(localEmulatorProjectResponse.status).toBe(200); + + backendContext.set({ + projectKeys: { + projectId: localEmulatorProjectResponse.body.project_id, + superSecretAdminKey: localEmulatorProjectResponse.body.super_secret_admin_key, + }, + }); + + const onboardingState = { + selected_config_choice: "create-new" as const, + selected_apps: ["authentication", "payments", "emails", "analytics"] as const, + selected_sign_in_methods: [] as const, + selected_email_theme_id: randomUUID(), + selected_payments_country: "US" as const, + }; + + const updateCurrentProjectResponse = await niceBackendFetch("/api/v1/internal/projects/current", { + accessType: "admin", + method: "PATCH", + body: { + onboarding_state: onboardingState, + }, + }); + + expect(updateCurrentProjectResponse.status).toBe(200); + expect(updateCurrentProjectResponse.body.onboarding_state).toEqual(onboardingState); + }); }); diff --git a/apps/e2e/tests/backend/endpoints/api/v1/setup-prompt.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/setup-prompt.test.ts new file mode 100644 index 0000000000..c352cc0adc --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/setup-prompt.test.ts @@ -0,0 +1,11 @@ +import { it } from "../../../../helpers"; +import { niceBackendFetch } from "../../../backend-helpers"; + +it("returns the SDK setup prompt as text with short cache headers", async ({ expect }) => { + const response = await niceBackendFetch("/api/v1/setup-prompt"); + + expect(response.status).toBe(200); + expect(typeof response.body).toBe("string"); + expect(response.body).toContain("# Setting up Stack Auth"); + expect(response.headers.get("cache-control")).toBe("public, max-age=60"); +}); diff --git a/docs-mintlify/guides/getting-started/setup.mdx b/docs-mintlify/guides/getting-started/setup.mdx index 642f9d2b6b..782c80ea90 100644 --- a/docs-mintlify/guides/getting-started/setup.mdx +++ b/docs-mintlify/guides/getting-started/setup.mdx @@ -4,711 +4,84 @@ description: Install and configure Stack Auth for your project sidebarTitle: Setup --- -# Setup - -## Prerequisites - -Before getting started, make sure you have a project set up for your chosen platform: - -- **Next.js**: A [Next.js project](https://nextjs.org/docs/getting-started/installation) using the app router (Stack Auth does not support the pages router on Next.js) -- **React**: A [React project](https://react.dev/learn/creating-a-react-app) (we show examples with Vite) -- **JavaScript**: A Node.js project with Express -- **Python**: A Python environment with your chosen framework (Django, FastAPI, or Flask) - -We recommend using our **setup wizard** for JavaScript frameworks for a seamless installation experience. For Python, we recommend using the REST API approach. - -## Setup Wizard / Manual Installation - -### Setup wizard (recommended for JS) - -#### Run installation wizard - - - The setup wizard is available for JavaScript/TypeScript frameworks. For Python projects, please use the manual installation method. - - -Run Stack's installation wizard with the following command: - -```sh title="Terminal" -npx @stackframe/stack-cli@latest init -``` - -#### Update API keys - -Create an account on [the Stack Auth dashboard](https://app.stack-auth.com/projects), create a new project, and copy its environment variables into the appropriate configuration file. If your project requires publishable client keys, create a project key that includes one and copy that as well. - - - - ```bash title=".env.local" - NEXT_PUBLIC_STACK_PROJECT_ID= - NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY= - STACK_SECRET_SERVER_KEY= - ``` - - - ```typescript title="stack/client.ts" - // Update the values in stack/client.ts created by the wizard - export const stackClientApp = new StackClientApp({ - projectId: "your-project-id", - publishableClientKey: "your-publishable-client-key", - tokenStore: "cookie", - }); - ``` - - - ```bash title=".env" - STACK_PROJECT_ID= - STACK_PUBLISHABLE_CLIENT_KEY= - STACK_SECRET_SERVER_KEY= - ``` - - - -#### Done! - -That's it! The wizard should have created or updated the following files in your project: - - - - - `app/handler/[...stack]/page.tsx`: Default pages for sign-in, sign-out, account settings, and more - - `app/layout.tsx`: Updated to wrap the entire body with `StackProvider` and `StackTheme` - - `app/loading.tsx`: Suspense boundary for Stack's async hooks - - `stack/server.ts`: Contains the `stackServerApp` for server-side usage - - `stack/client.ts`: Contains the `stackClientApp` for client-side usage - - - - `stack/client.ts`: Contains the `stackClientApp` configuration - - Your app should be wrapped with `StackProvider` and `StackTheme` - - - - `stack/server.ts`: Contains the `stackServerApp` configuration - - - -### Manual installation - - - The setup wizard also supports existing, complicated projects. Cases where manual installation is necessary are rare for JavaScript frameworks. - - - - - First, install the appropriate Stack package: - - - - ```bash title="Terminal" - npm install @stackframe/stack - ``` - - - ```bash title="Terminal" - npm install @stackframe/react - ``` - - - ```bash title="Terminal" - npm install @stackframe/js - ``` - - - ```bash title="Terminal" - npm install @stackframe/js - ``` - - - ```bash title="Terminal" - pip install requests - ``` - - - ```bash title="Terminal" - pip install requests - ``` - - - ```bash title="Terminal" - pip install requests - ``` - - - - - - [Register a new account on Stack](https://app.stack-auth.com/handler/sign-up), create a project in the dashboard, and copy the project ID. If your project requires publishable client keys, also create a project key from the left sidebar and copy the publishable client key. For server-side setups, also copy the secret server key. - - - - Set up your environment variables or configuration: - - - - ```bash title=".env.local" - NEXT_PUBLIC_STACK_PROJECT_ID= - NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY= - STACK_SECRET_SERVER_KEY= - ``` - - - ```bash title=".env" - # Store these in environment variables or directly in the client file during development - VITE_STACK_PROJECT_ID= - VITE_STACK_PUBLISHABLE_CLIENT_KEY= - ``` - - - ```bash title=".env" - STACK_PROJECT_ID= - STACK_PUBLISHABLE_CLIENT_KEY= - STACK_SECRET_SERVER_KEY= - ``` - - - ```bash title=".env" - STACK_PROJECT_ID= - STACK_PUBLISHABLE_CLIENT_KEY= - STACK_SECRET_SERVER_KEY= - ``` - - - ```python title="settings.py" - import os - - stack_project_id = os.getenv("STACK_PROJECT_ID") - stack_publishable_client_key = os.getenv("STACK_PUBLISHABLE_CLIENT_KEY") - stack_secret_server_key = os.getenv("STACK_SECRET_SERVER_KEY") - ``` - - - ```python title="main.py" - import os - - stack_project_id = os.getenv("STACK_PROJECT_ID") - stack_publishable_client_key = os.getenv("STACK_PUBLISHABLE_CLIENT_KEY") - stack_secret_server_key = os.getenv("STACK_SECRET_SERVER_KEY") - ``` - - - ```python title="app.py" - import os - - stack_project_id = os.getenv("STACK_PROJECT_ID") - stack_publishable_client_key = os.getenv("STACK_PUBLISHABLE_CLIENT_KEY") - stack_secret_server_key = os.getenv("STACK_SECRET_SERVER_KEY") - ``` - - - - - - Create the Stack app configuration: - - - - ```typescript title="stack/server.ts" - import "server-only"; - import { StackServerApp } from "@stackframe/stack"; - - export const stackServerApp = new StackServerApp({ - tokenStore: "nextjs-cookie", // storing auth tokens in cookies - }); - ``` - - - ```typescript title="stack/client.ts" - import { StackClientApp } from "@stackframe/stack"; - - export const stackClientApp = new StackClientApp({ - // Environment variables are automatically read - }); - ``` - - - ```typescript title="stack/client.ts" - import { StackClientApp } from "@stackframe/react"; - // If you use a router, uncomment the appropriate import and the redirectMethod below - // import { useNavigate } from "react-router-dom"; // React Router - // import { useNavigate } from "@tanstack/react-router"; // TanStack Router - - export const stackClientApp = new StackClientApp({ - projectId: process.env.VITE_STACK_PROJECT_ID || "your-project-id", - publishableClientKey: process.env.VITE_STACK_PUBLISHABLE_CLIENT_KEY || "your-publishable-client-key", - tokenStore: "cookie", - // redirectMethod: { useNavigate }, // Set this for non-Next.js frameworks - }); - ``` - - - ```typescript title="stack/server.ts" - import { StackServerApp } from "@stackframe/js"; - - export const stackServerApp = new StackServerApp({ - projectId: process.env.STACK_PROJECT_ID, - publishableClientKey: process.env.STACK_PUBLISHABLE_CLIENT_KEY, - secretServerKey: process.env.STACK_SECRET_SERVER_KEY, - tokenStore: "memory", - }); - ``` - - - ```javascript title="stack/server.js" - import { StackServerApp } from "@stackframe/js"; - - export const stackServerApp = new StackServerApp({ - projectId: process.env.STACK_PROJECT_ID, - publishableClientKey: process.env.STACK_PUBLISHABLE_CLIENT_KEY, - secretServerKey: process.env.STACK_SECRET_SERVER_KEY, - tokenStore: "memory", - }); - ``` - - - ```javascript title="stack/client.js" - import { StackClientApp } from "@stackframe/js"; - - export const stackClientApp = new StackClientApp({ - projectId: process.env.STACK_PROJECT_ID, - publishableClientKey: process.env.STACK_PUBLISHABLE_CLIENT_KEY, - tokenStore: "cookie", - }); - ``` - - - ```python title="views.py" - import requests - - def stack_auth_request(method, endpoint, **kwargs): - res = requests.request( - method, - f'https://api.stack-auth.com/{endpoint}', - headers={ - 'x-stack-access-type': 'server', # or 'client' if you're only accessing the client API - 'x-stack-project-id': stack_project_id, - 'x-stack-publishable-client-key': stack_publishable_client_key, - 'x-stack-secret-server-key': stack_secret_server_key, # not necessary if access type is 'client' - **kwargs.pop('headers', {}), - }, - **kwargs, - ) - if res.status_code >= 400: - raise Exception(f"Stack Auth API request failed with {res.status_code}: {res.text}") - return res.json() - ``` - - - ```python title="main.py" - import requests - - def stack_auth_request(method, endpoint, **kwargs): - res = requests.request( - method, - f'https://api.stack-auth.com/{endpoint}', - headers={ - 'x-stack-access-type': 'server', # or 'client' if you're only accessing the client API - 'x-stack-project-id': stack_project_id, - 'x-stack-publishable-client-key': stack_publishable_client_key, - 'x-stack-secret-server-key': stack_secret_server_key, # not necessary if access type is 'client' - **kwargs.pop('headers', {}), - }, - **kwargs, - ) - if res.status_code >= 400: - raise Exception(f"Stack Auth API request failed with {res.status_code}: {res.text}") - return res.json() - ``` - - - ```python title="app.py" - import requests - - def stack_auth_request(method, endpoint, **kwargs): - res = requests.request( - method, - f'https://api.stack-auth.com/{endpoint}', - headers={ - 'x-stack-access-type': 'server', # or 'client' if you're only accessing the client API - 'x-stack-project-id': stack_project_id, - 'x-stack-publishable-client-key': stack_publishable_client_key, - 'x-stack-secret-server-key': stack_secret_server_key, # not necessary if access type is 'client' - **kwargs.pop('headers', {}), - }, - **kwargs, - ) - if res.status_code >= 400: - raise Exception(f"Stack Auth API request failed with {res.status_code}: {res.text}") - return res.json() - ``` - - - - - - For JavaScript frameworks, create the authentication handler: - - - - ```typescript title="app/handler/[...stack]/page.tsx" - import { StackHandler } from "@stackframe/stack"; - import { stackServerApp } from "@/stack/server"; - - export default function Handler(props: unknown) { - return ; - } - ``` - - - ```typescript title="App.tsx" - import { StackHandler, StackProvider, StackTheme } from "@stackframe/react"; - import { Suspense } from "react"; - import { BrowserRouter, Route, Routes, useLocation } from "react-router-dom"; - import { stackClientApp } from "./stack/client"; - - function HandlerRoutes() { - const location = useLocation(); - return ( - - ); - } - - export default function App() { - return ( - - - - - - } /> - hello world
} /> - - - - - - ); - } - ``` - - - ```typescript title="Note" - // Express doesn't use built-in handlers - // Use the REST API or integrate with your frontend - ``` - - - ```javascript title="Note" - // Node.js doesn't use built-in handlers - // Use the REST API or integrate with your frontend - ``` - - - - - - For Next.js and React, wrap your app with Stack providers: - - - - ```typescript title="app/layout.tsx" - import React from "react"; - import { StackProvider, StackTheme } from "@stackframe/stack"; - import { stackServerApp } from "@/stack/server"; - - export default function RootLayout({ children }: { children: React.ReactNode }) { - return ( - - - - - {children} - - - - - ); - } - ``` - - - ```typescript title="Note" - // Already shown in the App.tsx example above - // Make sure to wrap your app with StackProvider and StackTheme - ``` - - - - - - For Next.js, add a Suspense boundary: - - ```typescript title="app/loading.tsx" - export default function Loading() { - // You can use any loading indicator here - return <>Loading...; +export const SetupPromptBlock = () => { + const setupPromptEndpoint = "https://api.stack-auth.com/api/v1/setup-prompt"; + const setupPromptLoadErrorMessage = "Failed to load the setup prompt. Please try again."; + + const [prompt, setPrompt] = useState(""); + const [isLoading, setIsLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + + const loadPrompt = async () => { + setLoadError(null); + const response = await fetch(setupPromptEndpoint, { + method: "GET", + headers: { + Accept: "text/plain", + }, + }).then( + (value) => value, + () => { + throw new Error(setupPromptLoadErrorMessage); + }, + ); + if (!response.ok) { + throw new Error(setupPromptLoadErrorMessage); } - ``` - - - - For React, add a suspense boundary: - ```typescript title="App.tsx" - import { Suspense } from "react"; - import { StackProvider } from "@stackframe/react"; - import { stackClientApp } from "./stack/client"; - - export default function App() { - return ( - // Wrap your StackProvider with Suspense for async hooks to work - Loading...
}> - - {/* Your app content */} - - - ); + const text = await response.text(); + if (text.trim().length === 0) { + throw new Error(setupPromptLoadErrorMessage); } - ``` - - - -## Post-setup - -That's it! Stack is now configured in your project. - -### Testing your setup - - - - ```bash title="Terminal" - # Start your Next.js app - npm run dev - - # Navigate to the sign-up page - # http://localhost:3000/handler/sign-up - ``` - - - ```bash title="Terminal" - # Start your React app - npm run dev - - # Navigate to the sign-up page - # http://localhost:5173/handler/sign-up - ``` - - - ```bash title="Terminal" - # Start your Express server - npm start - - # Use the REST API or integrate with your frontend - # Check the REST API documentation for endpoints - ``` - - - ```bash title="Terminal" - # Start your Node.js app - node index.js - - # Use the REST API or integrate with your frontend - # Check the REST API documentation for endpoints - ``` - - - ```python title="Terminal" - # Test the Stack Auth API connection - print(stack_auth_request('GET', '/api/v1/projects/current')) - - # Start your Django server - python manage.py runserver - ``` - - - ```python title="Terminal" - # Test the Stack Auth API connection - print(stack_auth_request('GET', '/api/v1/projects/current')) - - # Start your FastAPI server - uvicorn main:app --reload - ``` - - - ```python title="Terminal" - # Test the Stack Auth API connection - print(stack_auth_request('GET', '/api/v1/projects/current')) - - # Start your Flask server - flask run - ``` - - - -### What you'll see - -For JavaScript frameworks with built-in UI components, you'll see the Stack Auth sign-up page: - - - Stack sign-in page - - -After signing up/in, you will be redirected back to the home page. You can also check out the account settings page. - - - Stack account settings page - - -For Python and backend-only JavaScript setups, you'll interact with Stack Auth through the REST API. - -## Example usage - -Here are some basic usage examples for each platform: - - - ```typescript title="Server Component" - import { stackServerApp } from "@/stack/server"; + setPrompt(text); + setIsLoading(false); + return text; + }; - // In a Server Component or API route - const user = await stackServerApp.getUser(); - if (user) { - console.log("User is signed in:", user.displayName); - } else { - console.log("User is not signed in"); + const onMount = (node) => { + if (node == null || node.dataset.setupPromptInitialized === "true") { + return; } - ``` - - - ```typescript title="Client Component" - 'use client'; - import { useUser } from "@stackframe/stack"; - - export default function MyComponent() { - const user = useUser(); - - if (user) { - return
Hello, {user.displayName}!
; - } else { - return
Please sign in
; - } - } - ``` -
- - ```typescript title="Component" - import { useUser } from "@stackframe/react"; - - export default function MyComponent() { - const user = useUser(); - - if (user) { - return
Hello, {user.displayName}!
; - } else { - return
Please sign in
; - } - } - ``` -
- - ```typescript title="server.ts" - import { stackServerApp } from "./stack/server.js"; - - app.get('/profile', async (req, res) => { - try { - // Get access token from request headers - const accessToken = req.headers['x-stack-access-token']; - const user = await stackServerApp.getUser({ accessToken }); - - if (user) { - res.json({ message: `Hello, ${user.displayName}!` }); - } else { - res.status(401).json({ error: 'Not authenticated' }); - } - } catch (error) { - res.status(500).json({ error: 'Server error' }); - } - }); - ``` - - - ```javascript title="index.js" - import { stackServerApp } from "./stack/server.js"; - - async function checkUser(accessToken) { - try { - const user = await stackServerApp.getUser({ accessToken }); - - if (user) { - console.log(`Hello, ${user.displayName}!`); - } else { - console.log('User not authenticated'); - } - } catch (error) { - console.error('Error:', error); - } - } - ``` - - - ```python title="views.py" - # In your views.py - def profile_view(request): - # Get access token from request headers - access_token = request.headers.get('X-Stack-Access-Token') - - try: - user_data = stack_auth_request('GET', '/api/v1/users/me', headers={ - 'x-stack-access-token': access_token, - }) - return JsonResponse({'message': f"Hello, {user_data['displayName']}!"}) - except Exception as e: - return JsonResponse({'error': 'Not authenticated'}, status=401) - ``` - - - ```python title="main.py" - from fastapi import FastAPI, Header, HTTPException - - app = FastAPI() - - @app.get("/profile") - async def get_profile(x_stack_access_token: str = Header(None)): - if not x_stack_access_token: - raise HTTPException(status_code=401, detail="Access token required") - - try: - user_data = stack_auth_request('GET', '/api/v1/users/me', headers={ - 'x-stack-access-token': x_stack_access_token, - }) - return {"message": f"Hello, {user_data['displayName']}!"} - except Exception as e: - raise HTTPException(status_code=401, detail="Not authenticated") - ``` - - - ```python title="app.py" - from flask import Flask, request, jsonify - - app = Flask(__name__) - - @app.route('/profile') - def profile(): - access_token = request.headers.get('X-Stack-Access-Token') - - if not access_token: - return jsonify({'error': 'Access token required'}), 401 - - try: - user_data = stack_auth_request('GET', '/api/v1/users/me', headers={ - 'x-stack-access-token': access_token, - }) - return jsonify({'message': f"Hello, {user_data['displayName']}!"}) - except Exception as e: - return jsonify({'error': 'Not authenticated'}), 401 - ``` - -
- -## Next steps - -Next up, we will show you how to [retrieve and update user information](/guides/getting-started/user-fundamentals), and how to [protect a page](/guides/getting-started/user-fundamentals#protecting-a-page) from unauthorized access. - -For Python developers, check out the [REST API documentation](/api/overview) to learn more about the available endpoints and how to use them in your Python application. + node.dataset.setupPromptInitialized = "true"; + loadPrompt().then( + () => undefined, + (error) => { + setLoadError(error instanceof Error ? error.message : setupPromptLoadErrorMessage); + setIsLoading(false); + }, + ); + }; + + const onCopy = async (event) => { + const promptToCopy = isLoading ? await loadPrompt() : prompt; + await navigator.clipboard.writeText(promptToCopy); + const button = event.currentTarget; + button.textContent = "Copied"; + window.setTimeout(() => { + button.textContent = "Copy prompt"; + }, 1300); + }; + + return ( +
+