Skip to content

Commit 765b0f4

Browse files
authored
New setup (#1413)
1 parent 440c18c commit 765b0f4

58 files changed

Lines changed: 33716 additions & 1482 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/CLAUDE-KNOWLEDGE.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,33 @@ Then restart the dev server. This rebuilds all packages and generates the necess
356356
## Q: How is backwards compatibility for the offer→product rename handled in the payments purchase APIs?
357357
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.
358358

359+
### Q: What's the reliable way to run targeted tests across backend, dashboard, stack-shared, and e2e at once?
360+
A: Run from the monorepo root with explicit file paths: `pnpm test run "<path1>" "<path2>" ...`. 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`.
361+
362+
### Q: What's the new Authorization header format for Stack token forwarding?
363+
A: Use `getAuthorizationHeader()`, which returns `Bearer stackauth_<base64(getAuthJson())>`. 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.
364+
365+
### Q: What RequestLike header shapes are supported by tokenStore overrides?
366+
A: `RequestLike` accepts both `{ headers: { get(name): string | null } }` and `{ headers: Record<string, string | null> }`. Header lookup is case-insensitive for record-style headers, and supports `authorization`, `x-stack-auth`, and `cookie`.
367+
368+
### Q: Which env var should emulator onboarding URLs use for dashboard port?
369+
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.
370+
371+
### Q: Why does `PATCH /api/v1/internal/projects/current` fail in local emulator when updating only `onboarding_state`?
372+
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.
373+
374+
### Q: Why might local emulator UI changes in `apps/dashboard` not appear immediately at `localhost:26700`?
375+
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`.
376+
377+
### Q: Why can local emulator onboarding break with `ParseError` on non-`.ts` config files (e.g. `test-config.untracked`)?
378+
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.
379+
380+
### Q: How should docs fetch the canonical AI setup prompt text?
381+
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.
382+
383+
### Q: Can Mintlify snippets import other snippets?
384+
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.
385+
359386
## Q: How does `/api/v1/ai/query/generate` reject invalid AI tool names?
360387
A: Invalid `tools` entries are rejected by `requestBodySchema` in `apps/backend/src/lib/ai/schema.ts` via `yupString().oneOf(TOOL_NAMES)`, so the endpoint returns a structured `SCHEMA_ERROR` object mentioning `body.tools[n]` rather than a custom `"Invalid tool names"` string from handler logic.
361388

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub
7474
- Always run typecheck, lint, and test to make sure your changes are working as expected. You can save time by only linting and testing the files you've changed (and/or related E2E tests).
7575
- The project uses a custom route handler system in the backend for consistent API responses
7676
- When writing tests, prefer .toMatchInlineSnapshot over other selectors, if possible. You can check (and modify) the snapshot-serializer.ts file to see how the snapshots are formatted and how non-deterministic values are handled.
77-
- Whenever you learn something new, or at the latest right before you call the `Stop` tool, write whatever you learned into the ./claude/CLAUDE-KNOWLEDGE.md file, in the Q&A format in there. You will later be able to look up knowledge from there (based on the question you asked).
77+
- Whenever you learn something new, or at the latest right before you call the `Stop` tool, write whatever you learned into the .claude/CLAUDE-KNOWLEDGE.md file, in the Q&A format in there. You will later be able to look up knowledge from there (based on the question you asked).
7878
- Animations: Keep hover/click transitions snappy and fast. Don't delay the action with a pre-transition (e.g. no fade-in when hovering a button) — it makes the UI feel sluggish. Instead, apply transitions after the action, like a smooth fade-out when the hover ends.
7979
- Whenever you make changes in the dashboard, provide the user with a deep link to the dashboard page that you've just changed. Usually, this takes the form of `http://localhost:<whatever-is-in-$NEXT_PUBLIC_STACK_PORT_PREFIX>01/projects/-selector-/...`, although sometimes it's different. If $NEXT_PUBLIC_STACK_PORT_PREFIX is set to 91, 92, or 93, use `a.localhost`, `b.localhost`, and `c.localhost` for the domains, respectively.
8080
- To update the list of apps available, edit `apps-frontend.tsx` and `apps-config.ts`. When you're tasked to implement a new app or a new page, always check existing apps for inspiration on how you could implement the new app or page.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE "Project"
2+
ADD COLUMN "onboardingState" JSONB;
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { randomUUID } from "crypto";
2+
import type { Sql } from "postgres";
3+
import { expect } from "vitest";
4+
5+
export const preMigration = async (sql: Sql) => {
6+
const projectId = `test-${randomUUID()}`;
7+
await sql`
8+
INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode")
9+
VALUES (${projectId}, NOW(), NOW(), 'Onboarding State Project', '', false)
10+
`;
11+
return { projectId };
12+
};
13+
14+
export const postMigration = async (sql: Sql, ctx: Awaited<ReturnType<typeof preMigration>>) => {
15+
const rows = await sql`
16+
SELECT "onboardingState"
17+
FROM "Project"
18+
WHERE "id" = ${ctx.projectId}
19+
`;
20+
expect(rows).toHaveLength(1);
21+
expect(rows[0].onboardingState).toBeNull();
22+
23+
const onboardingState = {
24+
selected_config_choice: "create-new",
25+
selected_apps: ["authentication", "emails"],
26+
selected_sign_in_methods: ["credential", "magicLink"],
27+
selected_email_theme_id: null,
28+
selected_payments_country: "US",
29+
};
30+
await sql`
31+
UPDATE "Project"
32+
SET "onboardingState" = ${JSON.stringify(onboardingState)}::jsonb
33+
WHERE "id" = ${ctx.projectId}
34+
`;
35+
36+
const updatedRows = await sql`
37+
SELECT "onboardingState"
38+
FROM "Project"
39+
WHERE "id" = ${ctx.projectId}
40+
`;
41+
expect(updatedRows).toHaveLength(1);
42+
expect(updatedRows[0].onboardingState).toMatchInlineSnapshot(`
43+
{
44+
"selected_apps": [
45+
"authentication",
46+
"emails",
47+
],
48+
"selected_config_choice": "create-new",
49+
"selected_email_theme_id": null,
50+
"selected_payments_country": "US",
51+
"selected_sign_in_methods": [
52+
"credential",
53+
"magicLink",
54+
],
55+
}
56+
`);
57+
};

apps/backend/prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ model Project {
2727
isProductionMode Boolean
2828
ownerTeamId String? @db.Uuid
2929
onboardingStatus String @default("completed")
30+
onboardingState Json?
3031
3132
logoUrl String?
3233
logoFullUrl String?

apps/backend/scripts/generate-openapi-fumadocs.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { webhookEvents } from '@stackframe/stack-shared/dist/interface/webhooks'
44
import { writeFileSyncIfChanged } from '@stackframe/stack-shared/dist/utils/fs';
55
import { HTTP_METHODS } from '@stackframe/stack-shared/dist/utils/http';
66
import { typedKeys } from '@stackframe/stack-shared/dist/utils/objects';
7+
import { stringCompare } from '@stackframe/stack-shared/dist/utils/strings';
78
import fs from 'fs';
89
import { glob } from 'glob';
910
import path from 'path';
@@ -29,7 +30,7 @@ async function main() {
2930
// Generate OpenAPI specs for each audience (let parseOpenAPI handle the filtering)
3031
const filePathPrefix = path.resolve(process.platform === "win32" ? "apps/src/app/api/latest" : "src/app/api/latest");
3132
const importPathPrefix = "@/app/api/latest";
32-
const filePaths = [...await glob(filePathPrefix + "/**/route.{js,jsx,ts,tsx}")];
33+
const filePaths = [...await glob(filePathPrefix + "/**/route.{js,jsx,ts,tsx}")].sort((a, b) => stringCompare(a, b));
3334

3435
const endpoints = new Map(await Promise.all(filePaths.map(async (filePath) => {
3536
if (!filePath.startsWith(filePathPrefix)) {

apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx

Lines changed: 86 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,25 @@ import {
44
LOCAL_EMULATOR_ADMIN_USER_ID,
55
LOCAL_EMULATOR_ONLY_ENDPOINT_MESSAGE,
66
LOCAL_EMULATOR_OWNER_TEAM_ID,
7+
isLocalEmulatorOnboardingEnabledInConfig,
78
isLocalEmulatorEnabled,
89
readConfigFromFile,
910
resolveEmulatorPath,
10-
writeConfigToFile,
11+
writeShowOnboardingConfigToFile,
1112
} from "@/lib/local-emulator";
1213
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
1314
import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client";
1415
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
15-
import { clientOrHigherAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
16+
import {
17+
clientOrHigherAuthTypeSchema,
18+
projectOnboardingStatusSchema,
19+
projectOnboardingStatusValues,
20+
type ProjectOnboardingStatus,
21+
yupBoolean,
22+
yupNumber,
23+
yupObject,
24+
yupString,
25+
} from "@stackframe/stack-shared/dist/schema-fields";
1626
import { generateSecureRandomString } from "@stackframe/stack-shared/dist/utils/crypto";
1727
import { StackAssertionError, StatusError } from "@stackframe/stack-shared/dist/utils/errors";
1828
import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids";
@@ -23,6 +33,10 @@ type LocalEmulatorProjectMappingRow = {
2333
projectId: string,
2434
};
2535

36+
function isProjectOnboardingStatus(value: string): value is ProjectOnboardingStatus {
37+
return projectOnboardingStatusValues.some((status) => status === value);
38+
}
39+
2640
async function assertLocalEmulatorOwnerTeamReadiness() {
2741
const internalTenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID);
2842
const internalPrisma = await getPrismaClientForTenancy(internalTenancy);
@@ -177,6 +191,66 @@ async function getOrCreateCredentials(projectId: string) {
177191
};
178192
}
179193

194+
async function syncLocalEmulatorOnboardingStatus(projectId: string, showOnboarding: boolean): Promise<ProjectOnboardingStatus> {
195+
const onboardingStateColumnExistsRows = await globalPrismaClient.$queryRaw<Array<{ exists: boolean }>>(Prisma.sql`
196+
SELECT EXISTS (
197+
SELECT 1
198+
FROM information_schema.columns
199+
WHERE table_schema = 'public'
200+
AND table_name = 'Project'
201+
AND column_name = 'onboardingState'
202+
) AS "exists"
203+
`);
204+
const onboardingStateColumnExists = onboardingStateColumnExistsRows[0]?.exists === true;
205+
206+
const rows = await globalPrismaClient.$queryRaw<Array<{ onboardingStatus: string }>>(Prisma.sql`
207+
SELECT "onboardingStatus"
208+
FROM "Project"
209+
WHERE "id" = ${projectId}
210+
LIMIT 1
211+
`);
212+
const row = rows.length > 0 ? rows[0] : undefined;
213+
if (!row) {
214+
throw new StackAssertionError("Local emulator project not found while syncing onboarding state.", { projectId });
215+
}
216+
if (!isProjectOnboardingStatus(row.onboardingStatus)) {
217+
throw new StackAssertionError("Project onboarding status in DB is invalid.", {
218+
projectId,
219+
onboardingStatus: row.onboardingStatus,
220+
});
221+
}
222+
const currentOnboardingStatus = row.onboardingStatus;
223+
224+
if (!showOnboarding) {
225+
if (onboardingStateColumnExists) {
226+
await globalPrismaClient.$executeRaw(Prisma.sql`
227+
UPDATE "Project"
228+
SET "onboardingStatus" = 'completed',
229+
"onboardingState" = NULL
230+
WHERE "id" = ${projectId}
231+
`);
232+
} else {
233+
await globalPrismaClient.$executeRaw(Prisma.sql`
234+
UPDATE "Project"
235+
SET "onboardingStatus" = 'completed'
236+
WHERE "id" = ${projectId}
237+
`);
238+
}
239+
return "completed";
240+
}
241+
242+
if (currentOnboardingStatus === "completed") {
243+
await globalPrismaClient.$executeRaw(Prisma.sql`
244+
UPDATE "Project"
245+
SET "onboardingStatus" = 'config_choice'
246+
WHERE "id" = ${projectId}
247+
`);
248+
return "config_choice";
249+
}
250+
251+
return currentOnboardingStatus;
252+
}
253+
180254
export const POST = createSmartRouteHandler({
181255
metadata: {
182256
hidden: true,
@@ -205,6 +279,8 @@ export const POST = createSmartRouteHandler({
205279
secret_server_key: yupString().defined(),
206280
super_secret_admin_key: yupString().defined(),
207281
branch_config_override_string: yupString().defined(),
282+
onboarding_status: projectOnboardingStatusSchema.defined(),
283+
onboarding_outstanding: yupBoolean().defined(),
208284
}).defined(),
209285
}),
210286
handler: async (req) => {
@@ -230,17 +306,19 @@ export const POST = createSmartRouteHandler({
230306
throw new StatusError(StatusError.BadRequest, `Config file not found: ${absoluteFilePath}`);
231307
}
232308

233-
// If the file is empty, write a default config
234309
const fileContent = await fs.readFile(resolvedFilePath, "utf-8");
235-
if (fileContent.trim() === "") {
236-
await writeConfigToFile(absoluteFilePath, {});
237-
}
310+
const shouldWriteShowOnboardingConfig = fileContent.trim() === "";
238311

239312
await assertLocalEmulatorOwnerTeamReadiness();
240313

241314
const { projectId } = await getOrCreateLocalEmulatorProjectId(absoluteFilePath);
315+
const showOnboarding = shouldWriteShowOnboardingConfig || await isLocalEmulatorOnboardingEnabledInConfig(absoluteFilePath);
316+
const onboardingStatus = await syncLocalEmulatorOnboardingStatus(projectId, showOnboarding);
242317
const credentials = await getOrCreateCredentials(projectId);
243318
const fileConfig = await readConfigFromFile(absoluteFilePath);
319+
if (shouldWriteShowOnboardingConfig) {
320+
await writeShowOnboardingConfigToFile(absoluteFilePath);
321+
}
244322

245323
return {
246324
statusCode: 200 as const,
@@ -251,6 +329,8 @@ export const POST = createSmartRouteHandler({
251329
secret_server_key: credentials.secretServerKey,
252330
super_secret_admin_key: credentials.superSecretAdminKey,
253331
branch_config_override_string: JSON.stringify(fileConfig),
332+
onboarding_status: onboardingStatus,
333+
onboarding_outstanding: onboardingStatus !== "completed",
254334
},
255335
};
256336
},

apps/backend/src/lib/local-emulator.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import path from "path";
44
import { afterEach, describe, expect, it, vi } from "vitest";
55
import {
66
LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV,
7+
isLocalEmulatorOnboardingEnabledInConfig,
78
readConfigFromFile,
89
writeConfigToFile,
10+
writeShowOnboardingConfigToFile,
911
} from "./local-emulator";
1012

1113
describe("local emulator config", () => {
@@ -38,12 +40,20 @@ describe("local emulator config", () => {
3840
await expect(readConfigFromFile("/irrelevant/path/stack.config.ts")).resolves.toEqual({});
3941
});
4042

43+
it("treats show-onboarding config as an empty config override", async () => {
44+
const content = `export const config = "show-onboarding";\n`;
45+
vi.stubEnv("STACK_LOCAL_EMULATOR_CONFIG_CONTENT", Buffer.from(content).toString("base64"));
46+
47+
await expect(readConfigFromFile("/irrelevant/path/stack.config.ts")).resolves.toEqual({});
48+
await expect(isLocalEmulatorOnboardingEnabledInConfig("/irrelevant/path/stack.config.ts")).resolves.toBe(true);
49+
});
50+
4151
it("throws when the config module does not export config", async () => {
4252
const content = `export default { auth: { allowLocalhost: true } };\n`;
4353
vi.stubEnv("STACK_LOCAL_EMULATOR_CONFIG_CONTENT", Buffer.from(content).toString("base64"));
4454

4555
await expect(readConfigFromFile("/irrelevant/path/stack.config.ts")).rejects.toThrow(
46-
"Invalid config in /irrelevant/path/stack.config.ts. The file must export a 'config' object."
56+
"Invalid config in /irrelevant/path/stack.config.ts. The file must export a 'config' object or \"show-onboarding\"."
4757
);
4858
});
4959

@@ -81,6 +91,41 @@ describe("local emulator config", () => {
8191
);
8292
});
8393

94+
it("writes show-onboarding config files to the host mount", async () => {
95+
const hostMountRoot = await fs.mkdtemp(path.join(os.tmpdir(), "stack-host-mount-"));
96+
const absoluteFilePath = "/Users/foo/project/stack.config.ts";
97+
const mountedParentPath = path.join(hostMountRoot, "/Users/foo/project");
98+
const mountedFilePath = path.join(hostMountRoot, absoluteFilePath);
99+
await fs.mkdir(mountedParentPath, { recursive: true });
100+
101+
vi.stubEnv(LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV, hostMountRoot);
102+
103+
await writeShowOnboardingConfigToFile(absoluteFilePath);
104+
105+
await expect(fs.readFile(mountedFilePath, "utf-8")).resolves.toBe(
106+
`import type { StackConfig } from "@stackframe/js";\n\nexport const config: StackConfig = "show-onboarding";\n`
107+
);
108+
});
109+
110+
it("supports non-ts config filenames by evaluating them as TypeScript", async () => {
111+
const hostMountRoot = await fs.mkdtemp(path.join(os.tmpdir(), "stack-host-mount-"));
112+
const absoluteFilePath = "/Users/foo/project/test-config.untracked";
113+
const mountedParentPath = path.join(hostMountRoot, "/Users/foo/project");
114+
const mountedFilePath = path.join(hostMountRoot, absoluteFilePath);
115+
await fs.mkdir(mountedParentPath, { recursive: true });
116+
117+
vi.stubEnv(LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV, hostMountRoot);
118+
119+
await writeConfigToFile(absoluteFilePath, { auth: { allowLocalhost: true } });
120+
121+
await expect(readConfigFromFile(absoluteFilePath)).resolves.toEqual({
122+
auth: {
123+
allowLocalhost: true,
124+
},
125+
});
126+
await expect(fs.readFile(mountedFilePath, "utf-8")).resolves.toContain(`import type { StackConfig }`);
127+
});
128+
84129
it("fails loudly when the QEMU host mount root is configured but unavailable", async () => {
85130
const hostMountRoot = await fs.mkdtemp(path.join(os.tmpdir(), "stack-host-mount-"));
86131
vi.stubEnv(LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV, hostMountRoot);

0 commit comments

Comments
 (0)