diff --git a/.github/workflows/qemu-emulator-build.yaml b/.github/workflows/qemu-emulator-build.yaml index c88242d73f..1989b6269f 100644 --- a/.github/workflows/qemu-emulator-build.yaml +++ b/.github/workflows/qemu-emulator-build.yaml @@ -148,10 +148,12 @@ jobs: - name: Build stack-cli (for emulator CLI) if: matrix.arch == 'amd64' run: | - pnpm install --frozen-lockfile --filter '@stackframe/stack-cli...' - # Turbo's trailing `...` filter builds stack-cli AND its workspace - # deps (@stackframe/js, @stackframe/stack-shared, etc.) — stack-cli - # imports them at runtime from their dist/ outputs. + # Turbo's task graph for stack-cli#build includes + # @stackframe/dashboard#build:rde-standalone, which transitively + # depends on @stackframe/stack#build (via dashboard → stack). + # The pnpm filter must cover the dashboard dep tree too so that + # devDependencies like tailwindcss are installed for the build. + pnpm install --frozen-lockfile --filter '@stackframe/stack-cli...' --filter '@stackframe/dashboard...' pnpm exec turbo run build --filter='@stackframe/stack-cli...' - name: Start emulator and verify @@ -267,10 +269,8 @@ jobs: - name: Install stack-cli deps + build run: | - pnpm install --frozen-lockfile --filter '@stackframe/stack-cli...' - # Turbo's trailing `...` filter builds stack-cli AND its workspace - # deps (@stackframe/js, @stackframe/stack-shared, etc.) — stack-cli - # imports them at runtime from their dist/ outputs. + # See "Build stack-cli" step comment for why dashboard filter is needed + pnpm install --frozen-lockfile --filter '@stackframe/stack-cli...' --filter '@stackframe/dashboard...' pnpm exec turbo run build --filter='@stackframe/stack-cli...' - name: Download built image diff --git a/apps/e2e/tests/backend/endpoints/api/v1/projects.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/projects.test.ts index 2054e1b75e..a2f3d377b2 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/projects.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/projects.test.ts @@ -39,6 +39,7 @@ it("gets current project (internal)", async ({ expect }) => { "status": 200, "body": { "config": { + "allow_localhost": true, "allow_team_api_keys": false, "allow_user_api_keys": false, "client_team_creation_enabled": true, diff --git a/apps/e2e/tests/js/cross-domain-auth.test.ts b/apps/e2e/tests/js/cross-domain-auth.test.ts index 9c4ccc149a..242199d22c 100644 --- a/apps/e2e/tests/js/cross-domain-auth.test.ts +++ b/apps/e2e/tests/js/cross-domain-auth.test.ts @@ -2,6 +2,34 @@ import { StackClientApp } from "@stackframe/js"; import { afterEach, vi } from "vitest"; import { it, localRedirectUrl } from "../helpers"; +function createMockDocument(): Document { + const cookieJar = new Map(); + return { + get cookie() { + return [...cookieJar.entries()].map(([k, v]) => `${k}=${v}`).join('; '); + }, + set cookie(str: string) { + const parts = str.split(';'); + const [nameValue] = parts; + const eqIndex = nameValue.indexOf('='); + if (eqIndex >= 0) { + const name = nameValue.slice(0, eqIndex).trim(); + const isExpired = parts.some(p => { + const trimmed = p.trim().toLowerCase(); + if (!trimmed.startsWith('expires=')) return false; + return new Date(trimmed.slice('expires='.length)) <= new Date(); + }); + if (isExpired) { + cookieJar.delete(name); + } else { + cookieJar.set(name, nameValue.slice(eqIndex + 1).trim()); + } + } + }, + createElement: () => ({}), + } as any; +} + const withHostedDomainSuffix = async (callback: () => Promise) => { const oldHostedHandlerDomainSuffix = process.env.NEXT_PUBLIC_STACK_HOSTED_HANDLER_DOMAIN_SUFFIX; const oldHostedHandlerUrlTemplate = process.env.NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE; @@ -48,7 +76,7 @@ it("adds secure cross-domain handoff parameters when redirecting to hosted sign- const previousDocument = globalThis.document; let redirectedUrl = ""; - globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.document = createMockDocument(); globalThis.window = { location: { href: `${localRedirectUrl}/private-page?foo=bar`, @@ -90,7 +118,7 @@ it("returns static app.urls.signIn for hosted flows", async ({ expect }) => { const previousWindow = globalThis.window; const previousDocument = globalThis.document; - globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.document = createMockDocument(); globalThis.window = { location: { href: currentHref, @@ -121,7 +149,7 @@ it("returns static app.urls.signOut for hosted flows", async ({ expect }) => { const previousWindow = globalThis.window; const previousDocument = globalThis.document; - globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.document = createMockDocument(); globalThis.window = { location: { href: currentHref, @@ -156,7 +184,7 @@ it("strips stale OAuth callback params from hosted current-page redirect URIs", currentUrl.searchParams.set("message", "Known message"); currentUrl.searchParams.set("details", "{}"); - globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.document = createMockDocument(); globalThis.window = { location: { href: currentUrl.toString(), @@ -178,7 +206,7 @@ it("only treats hosted OAuth callback URLs as Stack callbacks when the matching const previousWindow = globalThis.window; const previousDocument = globalThis.document; - globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.document = createMockDocument(); globalThis.window = { location: { href: `${localRedirectUrl}/callback-page?code=oauth-code&state=oauth-state`, @@ -221,7 +249,7 @@ it("does not await pending auth resolutions when post-callback redirect mints a redirectBackUrl.searchParams.set("stack_cross_domain_auth", "1"); redirectBackUrl.searchParams.set("stack_cross_domain_state", "state"); redirectBackUrl.searchParams.set("stack_cross_domain_code_challenge", "challenge"); - redirectBackUrl.searchParams.set("stack_cross_domain_after_callback_redirect_url", `${localRedirectUrl}/after`); + redirectBackUrl.searchParams.set("stack_cross_domain_after_callback_redirect_url", `https://${projectId}.example-stack-hosted.test/after`); currentUrl.searchParams.set("after_auth_return_to", redirectBackUrl.toString()); const previousWindow = globalThis.window; @@ -230,7 +258,7 @@ it("does not await pending auth resolutions when post-callback redirect mints a .spyOn(clientApp as any, "_createCrossDomainAuthRedirectUrl") .mockResolvedValue(`https://${projectId}.example-stack-hosted.test/handler/final`); - globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.document = createMockDocument(); globalThis.window = { location: { href: currentUrl.toString(), @@ -271,7 +299,7 @@ it("does not await pending auth resolutions when post-callback redirect adds nes const previousWindow = globalThis.window; const previousDocument = globalThis.document; - globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.document = createMockDocument(); globalThis.window = { location: { href: `${localRedirectUrl}/callback-page`, @@ -326,7 +354,7 @@ it("keeps cross-domain handoff working when top-level params are dropped before .spyOn(clientApp as any, "_createCrossDomainAuthRedirectUrl") .mockResolvedValue(crossDomainAuthorizeRedirect); - globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.document = createMockDocument(); globalThis.window = { location: { href: hostedAfterSignInCallbackUrl.toString(), @@ -382,7 +410,7 @@ it("keeps cross-domain handoff working when after_auth_return_to is rewritten to .spyOn(clientApp as any, "_createCrossDomainAuthRedirectUrl") .mockResolvedValue(crossDomainAuthorizeRedirect); - globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.document = createMockDocument(); globalThis.window = { location: { href: hostedAfterSignInCallbackUrl.toString(), @@ -423,7 +451,7 @@ it("adds nested cross-domain auth params when redirecting signed-in users to hos const previousWindow = globalThis.window; const previousDocument = globalThis.document; let redirectedUrl = ""; - globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.document = createMockDocument(); globalThis.window = { location: { href: currentHref, @@ -461,7 +489,7 @@ it("adds nested cross-domain auth params for other cross-domain handler redirect const previousWindow = globalThis.window; const previousDocument = globalThis.document; let redirectedUrl = ""; - globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.document = createMockDocument(); globalThis.window = { location: { href: currentHref, @@ -502,7 +530,7 @@ it("starts nested cross-domain auth from the target domain", async ({ expect }) codeChallenge: "nested-code-challenge", }); - globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.document = createMockDocument(); globalThis.window = { location: { href: currentHref, @@ -554,7 +582,7 @@ it("continues nested cross-domain auth on the source domain", async ({ expect }) .mockResolvedValue(crossDomainRedirect); vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(sourceRefreshTokenId); - globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.document = createMockDocument(); globalThis.window = { location: { href: currentUrl.toString(), @@ -598,7 +626,7 @@ it("rejects nested cross-domain auth when the source redirect URI is untrusted", const createCrossDomainAuthRedirectUrlSpy = vi.spyOn(clientApp as any, "_createCrossDomainAuthRedirectUrl"); vi.spyOn(clientApp as any, "_isTrusted").mockResolvedValue(false); - globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.document = createMockDocument(); globalThis.window = { location: { href: currentUrl.toString(), @@ -627,7 +655,7 @@ it("rejects nested cross-domain auth when the callback URL is untrusted", async vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null); vi.spyOn(clientApp as any, "_isTrusted").mockResolvedValue(false); - globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.document = createMockDocument(); globalThis.window = { location: { href: currentHref, @@ -658,7 +686,7 @@ it("rejects nested cross-domain auth when the source session does not match", as const createCrossDomainAuthRedirectUrlSpy = vi.spyOn(clientApp as any, "_createCrossDomainAuthRedirectUrl"); vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue("different-source-session"); - globalThis.document = { cookie: "", createElement: () => ({}) } as any; + globalThis.document = createMockDocument(); globalThis.window = { location: { href: currentUrl.toString(), diff --git a/scripts/wait-for-dev-package-imports.ts b/scripts/wait-for-dev-package-imports.ts index c9791b6c29..d9208afc51 100644 --- a/scripts/wait-for-dev-package-imports.ts +++ b/scripts/wait-for-dev-package-imports.ts @@ -26,6 +26,12 @@ import { setTimeout as sleep } from "timers/promises"; // This probe waits only for the package imports that the backend-side generator // needs. It does not hide real runtime errors: we retry missing-module failures // while package builds warm up, and fail immediately for other import failures. +// +// In addition to workspace packages, the probe checks that the generated Prisma +// client exists. When `turbo run dev` starts the backend, `codegen-prisma:watch` +// (`prisma generate --watch`) performs an initial generation that briefly removes +// and recreates `src/generated/prisma/`. If `codegen-docs` runs during that +// window it fails with ERR_MODULE_NOT_FOUND for `@/generated/prisma/client`. const repoRoot = path.resolve(__dirname, ".."); const backendDir = path.join(repoRoot, "apps/backend"); const timeoutMs = 60_000; @@ -35,6 +41,13 @@ const probeScript = ` (async () => { await import('@stackframe/stack'); await import('@stackframe/stack-shared/dist/utils/env'); + const { existsSync, readdirSync } = await import('node:fs'); + const { join } = await import('node:path'); + const generatedDir = join(process.cwd(), 'src', 'generated', 'prisma'); + if (!existsSync(generatedDir) || readdirSync(generatedDir).length === 0) { + const err = new Error('ERR_MODULE_NOT_FOUND: Generated Prisma client not yet available at ' + generatedDir); + throw err; + } })().then( () => undefined, (error) => {