Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .claude/CLAUDE-KNOWLEDGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,33 @@ 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 "<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`.

### Q: What's the new Authorization header format for Stack token forwarding?
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.

### Q: What RequestLike header shapes are supported by tokenStore overrides?
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`.

### 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.

## Q: How does `/api/v1/ai/query/generate` reject invalid AI tool names?
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.

Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub
- 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).
- The project uses a custom route handler system in the backend for consistent API responses
- 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.
- 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).
- 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).
- 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.
- 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.
- 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.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE "Project"
ADD COLUMN "onboardingState" JSONB;
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof preMigration>>) => {
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(`

Check failure on line 42 in apps/backend/prisma/migrations/20260420000000_add_project_onboarding_state/tests/default-and-updates.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (Local Emulator, Node 22.x)

src/auto-migrations/migration-tests.test.ts > database migration tests > 20260420000000_add_project_onboarding_state > default-and-updates

Error: Snapshot `database migration tests > 20260420000000_add_project_onboarding_state > default-and-updates 1` mismatched - Expected + Received - { - "selected_apps": [ - "authentication", - "emails", - ], - "selected_config_choice": "create-new", - "selected_email_theme_id": null, - "selected_payments_country": "US", - "selected_sign_in_methods": [ - "credential", - "magicLink", - ], - } + "{"selected_config_choice":"create-new","selected_apps":["authentication","emails"],"selected_sign_in_methods":["credential","magicLink"],"selected_email_theme_id":null,"selected_payments_country":"US"}" ❯ Module.postMigration prisma/migrations/20260420000000_add_project_onboarding_state/tests/default-and-updates.ts:42:42 ❯ src/auto-migrations/migration-tests.test.ts:148:13

Check failure on line 42 in apps/backend/prisma/migrations/20260420000000_add_project_onboarding_state/tests/default-and-updates.ts

View workflow job for this annotation

GitHub Actions / Back-compat — Current branch migrations with dev branch code

src/auto-migrations/migration-tests.test.ts > database migration tests > 20260420000000_add_project_onboarding_state > default-and-updates

Error: Snapshot `database migration tests > 20260420000000_add_project_onboarding_state > default-and-updates 1` mismatched - Expected + Received - { - "selected_apps": [ - "authentication", - "emails", - ], - "selected_config_choice": "create-new", - "selected_email_theme_id": null, - "selected_payments_country": "US", - "selected_sign_in_methods": [ - "credential", - "magicLink", - ], - } + "{"selected_config_choice":"create-new","selected_apps":["authentication","emails"],"selected_sign_in_methods":["credential","magicLink"],"selected_email_theme_id":null,"selected_payments_country":"US"}" ❯ Module.postMigration prisma/migrations/20260420000000_add_project_onboarding_state/tests/default-and-updates.ts:42:42 ❯ src/auto-migrations/migration-tests.test.ts:148:13

Check failure on line 42 in apps/backend/prisma/migrations/20260420000000_add_project_onboarding_state/tests/default-and-updates.ts

View workflow job for this annotation

GitHub Actions / build (22.x)

src/auto-migrations/migration-tests.test.ts > database migration tests > 20260420000000_add_project_onboarding_state > default-and-updates

Error: Snapshot `database migration tests > 20260420000000_add_project_onboarding_state > default-and-updates 1` mismatched - Expected + Received - { - "selected_apps": [ - "authentication", - "emails", - ], - "selected_config_choice": "create-new", - "selected_email_theme_id": null, - "selected_payments_country": "US", - "selected_sign_in_methods": [ - "credential", - "magicLink", - ], - } + "{"selected_config_choice":"create-new","selected_apps":["authentication","emails"],"selected_sign_in_methods":["credential","magicLink"],"selected_email_theme_id":null,"selected_payments_country":"US"}" ❯ Module.postMigration prisma/migrations/20260420000000_add_project_onboarding_state/tests/default-and-updates.ts:42:42 ❯ src/auto-migrations/migration-tests.test.ts:148:13

Check failure on line 42 in apps/backend/prisma/migrations/20260420000000_add_project_onboarding_state/tests/default-and-updates.ts

View workflow job for this annotation

GitHub Actions / E2E Tests (Node 22.x, Freestyle mock)

src/auto-migrations/migration-tests.test.ts > database migration tests > 20260420000000_add_project_onboarding_state > default-and-updates

Error: Snapshot `database migration tests > 20260420000000_add_project_onboarding_state > default-and-updates 1` mismatched - Expected + Received - { - "selected_apps": [ - "authentication", - "emails", - ], - "selected_config_choice": "create-new", - "selected_email_theme_id": null, - "selected_payments_country": "US", - "selected_sign_in_methods": [ - "credential", - "magicLink", - ], - } + "{"selected_config_choice":"create-new","selected_apps":["authentication","emails"],"selected_sign_in_methods":["credential","magicLink"],"selected_email_theme_id":null,"selected_payments_country":"US"}" ❯ Module.postMigration prisma/migrations/20260420000000_add_project_onboarding_state/tests/default-and-updates.ts:42:42 ❯ src/auto-migrations/migration-tests.test.ts:148:13

Check failure on line 42 in apps/backend/prisma/migrations/20260420000000_add_project_onboarding_state/tests/default-and-updates.ts

View workflow job for this annotation

GitHub Actions / Back-compat — Current branch migrations with dev branch code

src/auto-migrations/migration-tests.test.ts > database migration tests > 20260420000000_add_project_onboarding_state > default-and-updates

Error: Snapshot `database migration tests > 20260420000000_add_project_onboarding_state > default-and-updates 1` mismatched - Expected + Received - { - "selected_apps": [ - "authentication", - "emails", - ], - "selected_config_choice": "create-new", - "selected_email_theme_id": null, - "selected_payments_country": "US", - "selected_sign_in_methods": [ - "credential", - "magicLink", - ], - } + "{"selected_config_choice":"create-new","selected_apps":["authentication","emails"],"selected_sign_in_methods":["credential","magicLink"],"selected_email_theme_id":null,"selected_payments_country":"US"}" ❯ Module.postMigration prisma/migrations/20260420000000_add_project_onboarding_state/tests/default-and-updates.ts:42:42 ❯ src/auto-migrations/migration-tests.test.ts:148:13

Check failure on line 42 in apps/backend/prisma/migrations/20260420000000_add_project_onboarding_state/tests/default-and-updates.ts

View workflow job for this annotation

GitHub Actions / Forward-compat — Current branch code with dev branch migrations

src/auto-migrations/migration-tests.test.ts > database migration tests > 20260420000000_add_project_onboarding_state > default-and-updates

Error: Snapshot `database migration tests > 20260420000000_add_project_onboarding_state > default-and-updates 1` mismatched - Expected + Received - { - "selected_apps": [ - "authentication", - "emails", - ], - "selected_config_choice": "create-new", - "selected_email_theme_id": null, - "selected_payments_country": "US", - "selected_sign_in_methods": [ - "credential", - "magicLink", - ], - } + "{"selected_config_choice":"create-new","selected_apps":["authentication","emails"],"selected_sign_in_methods":["credential","magicLink"],"selected_email_theme_id":null,"selected_payments_country":"US"}" ❯ Module.postMigration prisma/migrations/20260420000000_add_project_onboarding_state/tests/default-and-updates.ts:42:42 ❯ src/auto-migrations/migration-tests.test.ts:148:13

Check failure on line 42 in apps/backend/prisma/migrations/20260420000000_add_project_onboarding_state/tests/default-and-updates.ts

View workflow job for this annotation

GitHub Actions / Forward-compat — Current branch code with dev branch migrations

src/auto-migrations/migration-tests.test.ts > database migration tests > 20260420000000_add_project_onboarding_state > default-and-updates

Error: Snapshot `database migration tests > 20260420000000_add_project_onboarding_state > default-and-updates 1` mismatched - Expected + Received - { - "selected_apps": [ - "authentication", - "emails", - ], - "selected_config_choice": "create-new", - "selected_email_theme_id": null, - "selected_payments_country": "US", - "selected_sign_in_methods": [ - "credential", - "magicLink", - ], - } + "{"selected_config_choice":"create-new","selected_apps":["authentication","emails"],"selected_sign_in_methods":["credential","magicLink"],"selected_email_theme_id":null,"selected_payments_country":"US"}" ❯ Module.postMigration prisma/migrations/20260420000000_add_project_onboarding_state/tests/default-and-updates.ts:42:42 ❯ src/auto-migrations/migration-tests.test.ts:148:13
{
"selected_apps": [
"authentication",
"emails",
],
"selected_config_choice": "create-new",
"selected_email_theme_id": null,
"selected_payments_country": "US",
"selected_sign_in_methods": [
"credential",
"magicLink",
],
}
`);
};
1 change: 1 addition & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ model Project {
isProductionMode Boolean
ownerTeamId String? @db.Uuid
onboardingStatus String @default("completed")
onboardingState Json?
logoUrl String?
logoFullUrl String?
Expand Down
3 changes: 2 additions & 1 deletion apps/backend/scripts/generate-openapi-fumadocs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { webhookEvents } from '@stackframe/stack-shared/dist/interface/webhooks'
import { writeFileSyncIfChanged } from '@stackframe/stack-shared/dist/utils/fs';
import { HTTP_METHODS } from '@stackframe/stack-shared/dist/utils/http';
import { typedKeys } from '@stackframe/stack-shared/dist/utils/objects';
import { stringCompare } from '@stackframe/stack-shared/dist/utils/strings';
import fs from 'fs';
import { glob } from 'glob';
import path from 'path';
Expand All @@ -29,7 +30,7 @@ async function main() {
// Generate OpenAPI specs for each audience (let parseOpenAPI handle the filtering)
const filePathPrefix = path.resolve(process.platform === "win32" ? "apps/src/app/api/latest" : "src/app/api/latest");
const importPathPrefix = "@/app/api/latest";
const filePaths = [...await glob(filePathPrefix + "/**/route.{js,jsx,ts,tsx}")];
const filePaths = [...await glob(filePathPrefix + "/**/route.{js,jsx,ts,tsx}")].sort((a, b) => stringCompare(a, b));

const endpoints = new Map(await Promise.all(filePaths.map(async (filePath) => {
if (!filePath.startsWith(filePathPrefix)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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);
Expand Down Expand Up @@ -177,6 +191,66 @@ async function getOrCreateCredentials(projectId: string) {
};
}

async function syncLocalEmulatorOnboardingStatus(projectId: string, showOnboarding: boolean): Promise<ProjectOnboardingStatus> {
const onboardingStateColumnExistsRows = await globalPrismaClient.$queryRaw<Array<{ exists: boolean }>>(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<Array<{ onboardingStatus: string }>>(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,
Expand Down Expand Up @@ -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) => {
Expand All @@ -230,17 +306,19 @@ export const POST = createSmartRouteHandler({
throw new StatusError(StatusError.BadRequest, `Config file not found: ${absoluteFilePath}`);
}

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

await assertLocalEmulatorOwnerTeamReadiness();

const { projectId } = await getOrCreateLocalEmulatorProjectId(absoluteFilePath);
const showOnboarding = shouldWriteShowOnboardingConfig || await isLocalEmulatorOnboardingEnabledInConfig(absoluteFilePath);
const onboardingStatus = await syncLocalEmulatorOnboardingStatus(projectId, showOnboarding);
const credentials = await getOrCreateCredentials(projectId);
const fileConfig = await readConfigFromFile(absoluteFilePath);
if (shouldWriteShowOnboardingConfig) {
await writeShowOnboardingConfigToFile(absoluteFilePath);
}

return {
statusCode: 200 as const,
Expand All @@ -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",
},
};
},
Expand Down
47 changes: 46 additions & 1 deletion apps/backend/src/lib/local-emulator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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\"."
);
});

Expand Down Expand Up @@ -81,6 +91,41 @@ 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("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);
Expand Down
Loading
Loading