From 01ca85027c59e5c52028b9f30011da6eeb11e27d Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 12 Mar 2026 20:05:13 -0700 Subject: [PATCH 01/29] client sdk local emulator --- .../internal/local-emulator/project/route.tsx | 5 +- apps/backend/src/lib/local-emulator.ts | 4 ++ docker/server/entrypoint.sh | 12 +++-- .../src/interface/admin-interface.ts | 11 +++- .../src/interface/client-interface.ts | 18 ++++++- .../src/interface/server-interface.ts | 11 +++- .../apps/implementations/admin-app-impl.ts | 28 +++++++--- .../apps/implementations/client-app-impl.ts | 25 +++++++-- .../stack-app/apps/implementations/common.ts | 53 +++++++++++++++++-- .../apps/implementations/server-app-impl.ts | 24 +++++++-- .../stack-app/apps/interfaces/client-app.ts | 7 +++ 11 files changed, 169 insertions(+), 29 deletions(-) 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 0ad1ea4f8d..116fb0fe81 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 @@ -141,7 +141,7 @@ async function getOrCreateCredentials(projectId: string) { }, }); - if (!keySet.secretServerKey || !keySet.superSecretAdminKey) { + if (!keySet.publishableClientKey || !keySet.secretServerKey || !keySet.superSecretAdminKey) { throw new StackAssertionError("Local emulator key set is missing required keys.", { projectId, keySetId: keySet.id, @@ -149,6 +149,7 @@ async function getOrCreateCredentials(projectId: string) { } return { + publishableClientKey: keySet.publishableClientKey, secretServerKey: keySet.secretServerKey, superSecretAdminKey: keySet.superSecretAdminKey, }; @@ -178,6 +179,7 @@ export const POST = createSmartRouteHandler({ bodyType: yupString().oneOf(["json"]).defined(), body: yupObject({ project_id: yupString().defined(), + publishable_client_key: yupString().defined(), secret_server_key: yupString().defined(), super_secret_admin_key: yupString().defined(), branch_config_override_string: yupString().defined(), @@ -222,6 +224,7 @@ export const POST = createSmartRouteHandler({ bodyType: "json" as const, body: { project_id: projectId, + publishable_client_key: credentials.publishableClientKey, secret_server_key: credentials.secretServerKey, super_secret_admin_key: credentials.superSecretAdminKey, branch_config_override_string: JSON.stringify(fileConfig), diff --git a/apps/backend/src/lib/local-emulator.ts b/apps/backend/src/lib/local-emulator.ts index ec9b27f29e..63bac76844 100644 --- a/apps/backend/src/lib/local-emulator.ts +++ b/apps/backend/src/lib/local-emulator.ts @@ -6,6 +6,10 @@ import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; import { globalPrismaClient } from "@/prisma-client"; +export const LOCAL_EMULATOR_INTERNAL_PUBLISHABLE_CLIENT_KEY = "local-emulator-publishable-client-key"; +export const LOCAL_EMULATOR_INTERNAL_SECRET_SERVER_KEY = "local-emulator-secret-server-key"; +export const LOCAL_EMULATOR_INTERNAL_SUPER_SECRET_ADMIN_KEY = "local-emulator-super-secret-admin-key"; + export const LOCAL_EMULATOR_ADMIN_USER_ID = "63abbc96-5329-454a-ba56-e0460173c6c1"; export const LOCAL_EMULATOR_OWNER_TEAM_ID = "5a0c858b-d9e9-49d4-9943-8ce385d86428"; export const LOCAL_EMULATOR_ADMIN_EMAIL = "local-emulator@stack-auth.com"; diff --git a/docker/server/entrypoint.sh b/docker/server/entrypoint.sh index 1b598b1c40..7a74bb64f7 100644 --- a/docker/server/entrypoint.sh +++ b/docker/server/entrypoint.sh @@ -11,9 +11,15 @@ fi # ============= ENV VARS ============= -export STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY=${STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY:-$(openssl rand -base64 32)} -export STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY=${STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY:-$(openssl rand -base64 32)} -export STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY=${STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY:-$(openssl rand -base64 32)} +if [ "$NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR" = "true" ]; then + export STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY=${STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY:-local-emulator-publishable-client-key} + export STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY=${STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY:-local-emulator-secret-server-key} + export STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY=${STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY:-local-emulator-super-secret-admin-key} +else + export STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY=${STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY:-$(openssl rand -base64 32)} + export STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY=${STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY:-$(openssl rand -base64 32)} + export STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY=${STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY:-$(openssl rand -base64 32)} +fi export NEXT_PUBLIC_STACK_PROJECT_ID=internal export NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=${STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY} diff --git a/packages/stack-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts index d73004ff9d..e213e40cfb 100644 --- a/packages/stack-shared/src/interface/admin-interface.ts +++ b/packages/stack-shared/src/interface/admin-interface.ts @@ -57,17 +57,26 @@ export type InternalApiKeyCreateCrudResponse = InternalApiKeysCrud["Admin"]["Rea export class StackAdminInterface extends StackServerInterface { + protected _superSecretAdminKeyOverride?: string; + constructor(public readonly options: AdminAuthApplicationOptions) { super(options); } + override _updateEmulatorCredentials(opts: { projectId?: string, publishableClientKey?: string, secretServerKey?: string, superSecretAdminKey?: string }) { + super._updateEmulatorCredentials(opts); + if (opts.superSecretAdminKey) { + this._superSecretAdminKeyOverride = opts.superSecretAdminKey; + } + } + public async sendAdminRequest(path: string, options: RequestInit, session: InternalSession | null, requestType: "admin" = "admin") { return await this.sendServerRequest( path, { ...options, headers: { - "x-stack-super-secret-admin-key": "superSecretAdminKey" in this.options ? this.options.superSecretAdminKey : "", + "x-stack-super-secret-admin-key": this._superSecretAdminKeyOverride ?? ("superSecretAdminKey" in this.options ? this.options.superSecretAdminKey : ""), ...options.headers, }, }, diff --git a/packages/stack-shared/src/interface/client-interface.ts b/packages/stack-shared/src/interface/client-interface.ts index eefd2f6f30..6a27dc6131 100644 --- a/packages/stack-shared/src/interface/client-interface.ts +++ b/packages/stack-shared/src/interface/client-interface.ts @@ -50,12 +50,24 @@ export type ClientInterfaceOptions = { export class StackClientInterface { private pendingNetworkDiagnostics?: ReturnType; + protected _projectIdOverride?: string; + protected _publishableClientKeyOverride?: string; + constructor(public readonly options: ClientInterfaceOptions) { // nothing here } get projectId() { - return this.options.projectId; + return this._projectIdOverride ?? this.options.projectId; + } + + _updateEmulatorCredentials(opts: { projectId?: string, publishableClientKey?: string }) { + if (opts.projectId) { + this._projectIdOverride = opts.projectId; + } + if (opts.publishableClientKey) { + this._publishableClientKeyOverride = opts.publishableClientKey; + } } getApiUrl() { @@ -397,7 +409,9 @@ export class StackClientInterface { "X-Stack-Refresh-Token": tokenObj.refreshToken.token, } : {}), "X-Stack-Allow-Anonymous-User": "true", - ...("publishableClientKey" in this.options && this.options.publishableClientKey ? { + ...(this._publishableClientKeyOverride ? { + "X-Stack-Publishable-Client-Key": this._publishableClientKeyOverride, + } : "publishableClientKey" in this.options && this.options.publishableClientKey ? { "X-Stack-Publishable-Client-Key": this.options.publishableClientKey, } : {}), ...(adminTokenObj ? { diff --git a/packages/stack-shared/src/interface/server-interface.ts b/packages/stack-shared/src/interface/server-interface.ts index 68c4b90590..7bd2d63f09 100644 --- a/packages/stack-shared/src/interface/server-interface.ts +++ b/packages/stack-shared/src/interface/server-interface.ts @@ -39,17 +39,26 @@ export type ServerAuthApplicationOptions = ( ); export class StackServerInterface extends StackClientInterface { + protected _secretServerKeyOverride?: string; + constructor(public override options: ServerAuthApplicationOptions) { super(options); } + override _updateEmulatorCredentials(opts: { projectId?: string, publishableClientKey?: string, secretServerKey?: string }) { + super._updateEmulatorCredentials(opts); + if (opts.secretServerKey) { + this._secretServerKeyOverride = opts.secretServerKey; + } + } + protected async sendServerRequest(path: string, options: RequestInit, session: InternalSession | null, requestType: "server" | "admin" = "server") { return await this.sendClientRequest( path, { ...options, headers: { - "x-stack-secret-server-key": "secretServerKey" in this.options ? this.options.secretServerKey : "", + "x-stack-secret-server-key": this._secretServerKeyOverride ?? ("secretServerKey" in this.options ? this.options.secretServerKey : ""), ...options.headers, }, }, diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index e939e0897f..e85bad8574 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -22,7 +22,7 @@ import { AdminProjectPermission, AdminProjectPermissionDefinition, AdminProjectP import { AdminOwnedProject, AdminProject, AdminProjectUpdateOptions, PushConfigOptions, adminProjectUpdateOptionsToCrud } from "../../projects"; import type { AdminSessionReplay, AdminSessionReplayChunk, ListSessionReplayChunksOptions, ListSessionReplayChunksResult, ListSessionReplaysOptions, ListSessionReplaysResult, SessionReplayAllEventsResult } from "../../session-replays"; import { ManagedEmailProviderListItem, ManagedEmailProviderSetupResult, ManagedEmailProviderStatus, EmailOutboxUpdateOptions, StackAdminApp, StackAdminAppConstructorOptions } from "../interfaces/admin-app"; -import { clientVersion, createCache, getBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, getDefaultSuperSecretAdminKey, resolveConstructorOptions } from "./common"; +import { LOCAL_EMULATOR_INTERNAL_PUBLISHABLE_CLIENT_KEY, clientVersion, createCache, fetchEmulatorProjectCredentials, getBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, getDefaultSuperSecretAdminKey, getLocalEmulatorConfigFilePath, resolveConstructorOptions } from "./common"; import { _StackServerAppImplIncomplete } from "./server-app-impl"; import { CompleteConfig, EnvironmentConfigOverrideOverride } from "@stackframe/stack-shared/dist/config/schema"; @@ -128,24 +128,40 @@ export class _StackAdminAppImplIncomplete, extraOptions?: { uniqueIdentifier?: string, checkString?: string, interface?: StackAdminInterface }) { const resolvedOptions = resolveConstructorOptions(options); - const publishableClientKey = resolvedOptions.publishableClientKey ?? getDefaultPublishableClientKey(); + + const emulatorConfigFilePath = getLocalEmulatorConfigFilePath(resolvedOptions.localEmulatorConfigFilePath); + const isEmulator = !!emulatorConfigFilePath; + + const publishableClientKey = resolvedOptions.publishableClientKey ?? getDefaultPublishableClientKey() ?? (isEmulator ? LOCAL_EMULATOR_INTERNAL_PUBLISHABLE_CLIENT_KEY : undefined); super(resolvedOptions, { ...extraOptions, interface: extraOptions?.interface ?? new StackAdminInterface({ - getBaseUrl: () => getBaseUrl(resolvedOptions.baseUrl), - projectId: resolvedOptions.projectId ?? getDefaultProjectId(), + getBaseUrl: () => getBaseUrl(resolvedOptions.baseUrl, { isEmulator }), + projectId: resolvedOptions.projectId ?? getDefaultProjectId({ isEmulator }), extraRequestHeaders: resolvedOptions.extraRequestHeaders ?? getDefaultExtraRequestHeaders(), clientVersion, ...resolvedOptions.projectOwnerSession ? { projectOwnerSession: resolvedOptions.projectOwnerSession, } : { ...(publishableClientKey ? { publishableClientKey } : {}), - secretServerKey: resolvedOptions.secretServerKey ?? getDefaultSecretServerKey(), - superSecretAdminKey: resolvedOptions.superSecretAdminKey ?? getDefaultSuperSecretAdminKey(), + secretServerKey: resolvedOptions.secretServerKey ?? getDefaultSecretServerKey({ isEmulator }), + superSecretAdminKey: resolvedOptions.superSecretAdminKey ?? getDefaultSuperSecretAdminKey({ isEmulator }), }, }), }); + + if (isEmulator && !extraOptions?.interface) { + const iface = this._interface; + this._emulatorInitPromise = fetchEmulatorProjectCredentials(emulatorConfigFilePath!).then((data) => { + iface._updateEmulatorCredentials({ + projectId: data.project_id, + publishableClientKey: data.publishable_client_key, + secretServerKey: data.secret_server_key, + superSecretAdminKey: data.super_secret_admin_key, + }); + }); + } } _adminConfigFromCrud(data: { config_string: string }): CompleteConfig { 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 cfefb40ad0..eae9b15cdb 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 @@ -52,7 +52,7 @@ import { EditableTeamMemberProfile, ReceivedTeamInvitation, SentTeamInvitation, import { ActiveSession, Auth, BaseUser, CurrentUser, InternalUserExtra, OAuthProvider, ProjectCurrentUser, SyncedPartialUser, TokenPartialUser, UserExtra, UserUpdateOptions, userUpdateOptionsToCrud, withUserDestructureGuard } from "../../users"; import { StackClientApp, StackClientAppConstructorOptions, StackClientAppJson } from "../interfaces/client-app"; import { _StackAdminAppImplIncomplete } from "./admin-app-impl"; -import { TokenObject, clientVersion, createCache, createCacheBySession, createEmptyTokenStore, getAnalyticsBaseUrl, getBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getUrls, resolveConstructorOptions } from "./common"; +import { LOCAL_EMULATOR_INTERNAL_PUBLISHABLE_CLIENT_KEY, TokenObject, clientVersion, createCache, createCacheBySession, createEmptyTokenStore, fetchEmulatorProjectCredentials, getAnalyticsBaseUrl, getBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getLocalEmulatorConfigFilePath, getUrls, resolveConstructorOptions } from "./common"; import { EventTracker } from "./event-tracker"; import { AnalyticsOptions, SessionRecorder, analyticsOptionsFromJson, analyticsOptionsToJson } from "./session-replay"; @@ -101,6 +101,7 @@ export class _StackClientAppImplIncomplete | null = null; private __DEMO_ENABLE_SLIGHT_FETCH_DELAY = false; private readonly _ownedAdminApps = new DependenciesMap<[InternalSession, string], _StackAdminAppImplIncomplete>(); @@ -499,29 +500,43 @@ export class _StackClientAppImplIncomplete getBaseUrl(resolvedOptions.baseUrl), - getAnalyticsBaseUrl: () => getAnalyticsBaseUrl(getBaseUrl(resolvedOptions.baseUrl)), + getBaseUrl: () => getBaseUrl(resolvedOptions.baseUrl, { isEmulator }), + getAnalyticsBaseUrl: () => getAnalyticsBaseUrl(getBaseUrl(resolvedOptions.baseUrl, { isEmulator })), extraRequestHeaders: resolvedOptions.extraRequestHeaders ?? getDefaultExtraRequestHeaders(), projectId, clientVersion, ...(publishableClientKey != null ? { publishableClientKey } : {}), prepareRequest: async () => { + if (this._emulatorInitPromise) await this._emulatorInitPromise; await cookies?.(); // THIS_LINE_PLATFORM next } }); } + if (isEmulator && !(extraOptions && extraOptions.interface)) { + const iface = this._interface; + this._emulatorInitPromise = fetchEmulatorProjectCredentials(emulatorConfigFilePath!).then((data) => { + iface._updateEmulatorCredentials({ + projectId: data.project_id, + publishableClientKey: data.publishable_client_key, + }); + }); + } + this._tokenStoreInit = resolvedOptions.tokenStore; this._redirectMethod = resolvedOptions.redirectMethod || "none"; this._redirectMethod = resolvedOptions.redirectMethod || "nextjs"; // THIS_LINE_PLATFORM next diff --git a/packages/template/src/lib/stack-app/apps/implementations/common.ts b/packages/template/src/lib/stack-app/apps/implementations/common.ts index 7faa08f441..213792cf5b 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/common.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/common.ts @@ -81,7 +81,44 @@ export function getUrls(partial: Partial): HandlerUrls { }; } -export function getDefaultProjectId() { +export const localEmulatorBaseUrl = "http://localhost:9999"; + +export const LOCAL_EMULATOR_INTERNAL_PUBLISHABLE_CLIENT_KEY = "local-emulator-publishable-client-key"; +export const LOCAL_EMULATOR_INTERNAL_SECRET_SERVER_KEY = "local-emulator-secret-server-key"; +export const LOCAL_EMULATOR_INTERNAL_SUPER_SECRET_ADMIN_KEY = "local-emulator-super-secret-admin-key"; + +export function getLocalEmulatorConfigFilePath(explicitOption?: string): string | undefined { + return explicitOption || process.env.NEXT_PUBLIC_STACK_LOCAL_EMULATOR_CONFIG_FILE_PATH || undefined; +} + +export function fetchEmulatorProjectCredentials(emulatorConfigFilePath: string): Promise<{ + project_id: string, + publishable_client_key: string, + secret_server_key: string, + super_secret_admin_key: string, +}> { + return (async () => { + const res = await fetch(`${localEmulatorBaseUrl}/api/v1/internal/local-emulator/project`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Stack-Project-Id": "internal", + "X-Stack-Access-Type": "client", + "X-Stack-Publishable-Client-Key": LOCAL_EMULATOR_INTERNAL_PUBLISHABLE_CLIENT_KEY, + }, + body: JSON.stringify({ absolute_file_path: emulatorConfigFilePath }), + }); + if (!res.ok) { + throw new Error(`Failed to initialize local emulator: ${res.status} ${await res.text()}`); + } + return await res.json(); + })(); +} + +export function getDefaultProjectId(options?: { isEmulator?: boolean }) { + if (options?.isEmulator) { + return process.env.NEXT_PUBLIC_STACK_PROJECT_ID || process.env.STACK_PROJECT_ID || "internal"; + } return process.env.NEXT_PUBLIC_STACK_PROJECT_ID || process.env.STACK_PROJECT_ID || throwErr(new Error("Welcome to Stack Auth! It seems that you haven't provided a project ID. Please create a project on the Stack dashboard at https://app.stack-auth.com and put it in the NEXT_PUBLIC_STACK_PROJECT_ID environment variable.")); } @@ -89,11 +126,17 @@ export function getDefaultPublishableClientKey() { return process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY || process.env.STACK_PUBLISHABLE_CLIENT_KEY; } -export function getDefaultSecretServerKey() { +export function getDefaultSecretServerKey(options?: { isEmulator?: boolean }) { + if (options?.isEmulator) { + return process.env.STACK_SECRET_SERVER_KEY || LOCAL_EMULATOR_INTERNAL_SECRET_SERVER_KEY; + } return process.env.STACK_SECRET_SERVER_KEY || throwErr(new Error("No secret server key provided. Please copy your key from the Stack dashboard and put it in the STACK_SECRET_SERVER_KEY environment variable.")); } -export function getDefaultSuperSecretAdminKey() { +export function getDefaultSuperSecretAdminKey(options?: { isEmulator?: boolean }) { + if (options?.isEmulator) { + return process.env.STACK_SUPER_SECRET_ADMIN_KEY || LOCAL_EMULATOR_INTERNAL_SUPER_SECRET_ADMIN_KEY; + } return process.env.STACK_SUPER_SECRET_ADMIN_KEY || throwErr(new Error("No super secret admin key provided. Please copy your key from the Stack dashboard and put it in the STACK_SUPER_SECRET_ADMIN_KEY environment variable.")); } @@ -119,7 +162,7 @@ export function getDefaultExtraRequestHeaders() { * @returns The configured base URL without trailing slash */ -export function getBaseUrl(userSpecifiedBaseUrl: string | { browser: string, server: string } | undefined) { +export function getBaseUrl(userSpecifiedBaseUrl: string | { browser: string, server: string } | undefined, options?: { isEmulator?: boolean }) { let url; if (userSpecifiedBaseUrl) { if (typeof userSpecifiedBaseUrl === "string") { @@ -138,7 +181,7 @@ export function getBaseUrl(userSpecifiedBaseUrl: string | { browser: string, ser } else { url = process.env.NEXT_PUBLIC_SERVER_STACK_API_URL || process.env.NEXT_PUBLIC_STACK_API_URL_SERVER || process.env.STACK_API_URL_SERVER; } - url = url || process.env.NEXT_PUBLIC_STACK_API_URL || process.env.STACK_API_URL || process.env.NEXT_PUBLIC_STACK_URL || defaultBaseUrl; + url = url || process.env.NEXT_PUBLIC_STACK_API_URL || process.env.STACK_API_URL || process.env.NEXT_PUBLIC_STACK_URL || (options?.isEmulator ? localEmulatorBaseUrl : defaultBaseUrl); } return replaceStackPortPrefix(url.endsWith('/') ? url.slice(0, -1) : url); diff --git a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts index e5928d960f..1850c563b5 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts @@ -35,7 +35,7 @@ import { EditableTeamMemberProfile, ReceivedTeamInvitation, SentTeamInvitation, import { ProjectCurrentServerUser, ServerOAuthProvider, ServerUser, ServerUserCreateOptions, ServerUserUpdateOptions, serverUserCreateOptionsToCrud, serverUserUpdateOptionsToCrud, withUserDestructureGuard } from "../../users"; import { StackServerAppConstructorOptions } from "../interfaces/server-app"; import { _StackClientAppImplIncomplete } from "./client-app-impl"; -import { clientVersion, createCache, createCacheBySession, getBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, resolveConstructorOptions } from "./common"; +import { LOCAL_EMULATOR_INTERNAL_PUBLISHABLE_CLIENT_KEY, clientVersion, createCache, createCacheBySession, fetchEmulatorProjectCredentials, getBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, getLocalEmulatorConfigFilePath, resolveConstructorOptions } from "./common"; import { useAsyncCache } from "./common"; // THIS_LINE_PLATFORM react-like @@ -410,19 +410,33 @@ export class _StackServerAppImplIncomplete, extraOptions?: { uniqueIdentifier?: string, checkString?: string, interface?: StackServerInterface }) { const resolvedOptions = resolveConstructorOptions(options); - const publishableClientKey = resolvedOptions.publishableClientKey ?? getDefaultPublishableClientKey(); + const emulatorConfigFilePath = getLocalEmulatorConfigFilePath(resolvedOptions.localEmulatorConfigFilePath); + const isEmulator = !!emulatorConfigFilePath; + + const publishableClientKey = resolvedOptions.publishableClientKey ?? getDefaultPublishableClientKey() ?? (isEmulator ? LOCAL_EMULATOR_INTERNAL_PUBLISHABLE_CLIENT_KEY : undefined); super(resolvedOptions, { ...extraOptions, interface: extraOptions?.interface ?? new StackServerInterface({ - getBaseUrl: () => getBaseUrl(resolvedOptions.baseUrl), - projectId: resolvedOptions.projectId ?? getDefaultProjectId(), + getBaseUrl: () => getBaseUrl(resolvedOptions.baseUrl, { isEmulator }), + projectId: resolvedOptions.projectId ?? getDefaultProjectId({ isEmulator }), extraRequestHeaders: resolvedOptions.extraRequestHeaders ?? getDefaultExtraRequestHeaders(), clientVersion, ...(publishableClientKey != null ? { publishableClientKey } : {}), - secretServerKey: resolvedOptions.secretServerKey ?? getDefaultSecretServerKey(), + secretServerKey: resolvedOptions.secretServerKey ?? getDefaultSecretServerKey({ isEmulator }), }), }); + + if (isEmulator && !extraOptions?.interface) { + const iface = this._interface; + this._emulatorInitPromise = fetchEmulatorProjectCredentials(emulatorConfigFilePath!).then((data) => { + iface._updateEmulatorCredentials({ + projectId: data.project_id, + publishableClientKey: data.publishable_client_key, + secretServerKey: data.secret_server_key, + }); + }); + } } diff --git a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts index 74e7c7bae4..31888ba2d4 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts @@ -19,6 +19,13 @@ export type StackClientAppConstructorOptions, + /** + * Path to the local emulator config file. When set, connects to the local + * emulator and automatically fetches project credentials. + * Defaults to NEXT_PUBLIC_STACK_LOCAL_EMULATOR_CONFIG_FILE_PATH env var. + */ + localEmulatorConfigFilePath?: string, + /** * By default, the Stack app will automatically prefetch some data from Stack's server when this app is first * constructed. This improves the performance of your app, but will create network requests that are unnecessary if From 25f8b1ca48cb65c3de9786f8e72cdfc6c43df7c2 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 19 Mar 2026 14:11:16 -0700 Subject: [PATCH 02/29] Gate server and admin emulator requests on credential init Add prepareRequest callbacks to StackServerInterface and StackAdminInterface that await _emulatorInitPromise, matching the existing pattern in the client app. Prevents race conditions where requests use placeholder credentials before the emulator credentials are fetched. --- .../src/lib/stack-app/apps/implementations/admin-app-impl.ts | 3 +++ .../src/lib/stack-app/apps/implementations/server-app-impl.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index e85bad8574..89b791a7ad 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -148,6 +148,9 @@ export class _StackAdminAppImplIncomplete { + if (this._emulatorInitPromise) await this._emulatorInitPromise; + }, }), }); diff --git a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts index 1850c563b5..f78e519da7 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts @@ -424,6 +424,9 @@ export class _StackServerAppImplIncomplete { + if (this._emulatorInitPromise) await this._emulatorInitPromise; + }, }), }); From 7d9e1565c688a9505188098997013bfc2f0e55dc Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 9 Apr 2026 14:21:14 -0700 Subject: [PATCH 03/29] local emulator image opt --- docker/local-emulator/Dockerfile | 40 +++- docker/local-emulator/qemu/build-image.sh | 13 +- .../qemu/cloud-init/emulator/user-data | 206 ++++++++++++++++-- docker/local-emulator/qemu/run-emulator.sh | 4 + 4 files changed, 228 insertions(+), 35 deletions(-) diff --git a/docker/local-emulator/Dockerfile b/docker/local-emulator/Dockerfile index 7f9e6d45a3..ada09cc261 100644 --- a/docker/local-emulator/Dockerfile +++ b/docker/local-emulator/Dockerfile @@ -103,6 +103,24 @@ RUN cp $(which qstash) /qstash-binary 2>/dev/null || \ { echo "ERROR: qstash binary not found" >&2; exit 1; } +# ── Strip / compress service binaries (parallel stages) ────────────────────── + +FROM debian:trixie-slim AS strip-clickhouse +COPY --from=clickhouse-bin /usr/bin/clickhouse /usr/bin/clickhouse +RUN apt-get update && apt-get install -y --no-install-recommends binutils && \ + strip --strip-all /usr/bin/clickhouse && \ + rm -rf /var/lib/apt/lists/* + +FROM debian:trixie-slim AS upx-compress +RUN apt-get update && apt-get install -y --no-install-recommends upx-ucl && \ + rm -rf /var/lib/apt/lists/* +COPY --from=svix-bin /usr/local/bin/svix-server /out/svix-server +COPY --from=minio-bin /usr/bin/minio /out/minio +COPY --from=mc-bin /usr/bin/mc /out/mc +COPY --from=qstash-bin /qstash-binary /out/qstash +RUN upx -9 /out/minio /out/svix-server /out/mc /out/qstash + + # ── Final image ─────────────────────────────────────────────────────────────── FROM debian:trixie-slim @@ -139,20 +157,20 @@ COPY --from=node-base /usr/local/bin/node /usr/local/bin/node # Inbucket COPY --from=inbucket-bin /opt/inbucket /opt/inbucket -# Svix -COPY --from=svix-bin /usr/local/bin/svix-server /usr/local/bin/svix-server +# Svix (UPX-compressed) +COPY --from=upx-compress /out/svix-server /usr/local/bin/svix-server -# ClickHouse -COPY --from=clickhouse-bin /usr/bin/clickhouse /usr/bin/clickhouse +# ClickHouse (stripped) +COPY --from=strip-clickhouse /usr/bin/clickhouse /usr/bin/clickhouse RUN ln -sf /usr/bin/clickhouse /usr/bin/clickhouse-server && \ ln -sf /usr/bin/clickhouse /usr/bin/clickhouse-client -# MinIO -COPY --from=minio-bin /usr/bin/minio /usr/local/bin/minio -COPY --from=mc-bin /usr/bin/mc /usr/local/bin/mc +# MinIO (UPX-compressed) +COPY --from=upx-compress /out/minio /usr/local/bin/minio +COPY --from=upx-compress /out/mc /usr/local/bin/mc -# QStash -COPY --from=qstash-bin --chmod=755 /qstash-binary /usr/local/bin/qstash +# QStash (UPX-compressed) +COPY --from=upx-compress --chmod=755 /out/qstash /usr/local/bin/qstash # App WORKDIR /app @@ -164,6 +182,10 @@ COPY --from=builder /app/apps/backend/node_modules ./apps/backend/node_modules COPY --from=builder /app/apps/dashboard/.next/standalone ./ COPY --from=builder /app/apps/dashboard/.next/static ./apps/dashboard/.next/static COPY --from=builder /app/apps/dashboard/public ./apps/dashboard/public +# Save the standalone-traced node_modules (runtime deps only) before the full +# migration-pruner copy overwrites it. The slim-docker-image step in the QEMU +# build restores this after migrations are baked in. +RUN cp -a /app/node_modules /app/node_modules.standalone 2>/dev/null || mkdir -p /app/node_modules.standalone COPY --from=migration-pruner /pruned-node_modules ./node_modules COPY --from=builder /app/packages ./packages diff --git a/docker/local-emulator/qemu/build-image.sh b/docker/local-emulator/qemu/build-image.sh index 8071fb5012..e3f7fc9e16 100755 --- a/docker/local-emulator/qemu/build-image.sh +++ b/docker/local-emulator/qemu/build-image.sh @@ -209,6 +209,7 @@ build_one() { mkdir -p "$bundle_dir" cp "$bundle_tgz" "$bundle_dir/img.tgz" + cp "$BUILD_ENV_FILE" "$bundle_dir/build.env" make_iso_from_dir "$bundle_iso" "STACKBUNDLE" "$bundle_dir" : > "$serial_log" @@ -219,7 +220,7 @@ build_one() { -boot order=c \ -m "$RAM" \ -smp "$CPUS" \ - -drive "file=$tmp_img,format=qcow2,if=virtio" \ + -drive "file=$tmp_img,format=qcow2,if=virtio,discard=on,detect-zeroes=unmap" \ -drive "file=$seed_iso,format=raw,if=virtio,readonly=on" \ -drive "file=$bundle_iso,format=raw,if=virtio,readonly=on" \ -netdev user,id=net0 \ @@ -266,19 +267,21 @@ build_one() { kill -9 "$pid" 2>/dev/null || true fi - cp "$tmp_img" "$final_img" cp "$serial_log" "$IMAGE_DIR/provision-emulator-${arch}.log" - rm -rf "$tmp_dir" log "Compressing final image (this may take several minutes)..." - qemu-img convert -p -O qcow2 -c "$final_img" "$final_img.tmp" - mv "$final_img.tmp" "$final_img" + qemu-img convert -p -O qcow2 -c "$tmp_img" "$final_img" + rm -rf "$tmp_dir" local size size="$(du -h "$final_img" | cut -f1)" log "━━━ Emulator image ready: $final_img (${size}) ━━━" } +log "Generating emulator build env file..." +node "$REPO_ROOT/docker/local-emulator/generate-env-development.mjs" +BUILD_ENV_FILE="$REPO_ROOT/docker/local-emulator/.env.development" + for arch in "${TARGET_ARCHS[@]}"; do local_base="$IMAGE_DIR/debian-${DEBIAN_VERSION}-base-${arch}.qcow2" download_cloud_image "$arch" "$local_base" diff --git a/docker/local-emulator/qemu/cloud-init/emulator/user-data b/docker/local-emulator/qemu/cloud-init/emulator/user-data index 39b8c33cdb..05c6cf13a3 100644 --- a/docker/local-emulator/qemu/cloud-init/emulator/user-data +++ b/docker/local-emulator/qemu/cloud-init/emulator/user-data @@ -43,6 +43,11 @@ write_files: gzip -dc /mnt/stack-bundle/img.tgz | docker load + # Copy build env file for pre-baking migrations + if [ -f /mnt/stack-bundle/build.env ]; then + cp /mnt/stack-bundle/build.env /etc/stack-build.env + fi + - path: /usr/local/bin/render-stack-env permissions: '0755' content: | @@ -71,25 +76,33 @@ write_files: cat /mnt/stack-runtime/runtime.env # Computed vars — depend on port prefix or deps host + # Host-side ports (for browser URLs — browser runs on host, not in VM) + HP_BACKEND="$STACK_EMULATOR_BACKEND_HOST_PORT" + HP_DASHBOARD="$STACK_EMULATOR_DASHBOARD_HOST_PORT" + HP_MINIO="$STACK_EMULATOR_MINIO_HOST_PORT" + HP_INBUCKET="$STACK_EMULATOR_INBUCKET_HOST_PORT" + cat </dev/null 2>&1; do sleep 1; done until [ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/ 2>/dev/null || true)" = "401" ]; do sleep 1; done + - path: /usr/local/bin/run-build-migrations + permissions: '0755' + content: | + #!/bin/bash + set -euo pipefail + + # Start infrastructure services (deps-only mode) + docker run --rm --name stack-build-init \ + --network host \ + -e STACK_DEPS_ONLY=true \ + -v stack-postgres-data:/data/postgres \ + -v stack-redis-data:/data/redis \ + -v stack-clickhouse-data:/data/clickhouse \ + -v stack-minio-data:/data/minio \ + -v stack-inbucket-data:/data/inbucket \ + -d stack-local-emulator + + # Wait for all services to be healthy + /usr/local/bin/wait-for-deps + + # Wait for init-services.sh to finish (MinIO buckets, ClickHouse DB) + timeout=120 + elapsed=0 + while [ "$elapsed" -lt "$timeout" ]; do + if docker exec stack-build-init test -f /var/run/stack-local-init-services.done 2>/dev/null; then + break + fi + sleep 1 + elapsed=$((elapsed + 1)) + done + + # Run migrations and seed inside the running container + docker exec \ + --env-file /etc/stack-build.env \ + -e USE_INLINE_ENV_VARS=true \ + -e NEXT_PUBLIC_STACK_API_URL=http://localhost:8102 \ + -e NEXT_PUBLIC_STACK_DASHBOARD_URL=http://localhost:8101 \ + -e NEXT_PUBLIC_BROWSER_STACK_API_URL=http://localhost:8102 \ + -e NEXT_PUBLIC_BROWSER_STACK_DASHBOARD_URL=http://localhost:8101 \ + -e NEXT_PUBLIC_SERVER_STACK_API_URL=http://127.0.0.1:8102 \ + -e NEXT_PUBLIC_SERVER_STACK_DASHBOARD_URL=http://127.0.0.1:8101 \ + -e NEXT_PUBLIC_STACK_SVIX_SERVER_URL=http://localhost:8071 \ + -e NEXT_PUBLIC_STACK_PORT_PREFIX=81 \ + -e STACK_CLICKHOUSE_DATABASE=analytics \ + stack-build-init \ + sh -c 'cd /app/apps/backend && node dist/db-migrations.mjs migrate && node dist/db-migrations.mjs seed' + + # Stop infrastructure + docker stop stack-build-init || true + + - path: /usr/local/bin/slim-docker-image + permissions: '0755' + content: | + #!/bin/bash + set -euo pipefail + + # Build slim image: swap to the standalone-traced node_modules and + # reconstruct pnpm root symlinks. The standalone trace (from Next.js) + # includes only packages actually imported at runtime, so this is + # self-maintaining as new packages are added. + docker build -t stack-local-emulator-slim - <<'DOCKERFILE' + FROM stack-local-emulator + RUN rm -rf /app/node_modules /app/apps/backend/dist && \ + mv /app/node_modules.standalone /app/node_modules && \ + for entry in /app/node_modules/.pnpm/node_modules/*; do \ + name="$(basename "$entry")"; \ + [ "$name" = ".bin" ] && continue; \ + ln -sf ".pnpm/node_modules/$name" "/app/node_modules/$name" 2>/dev/null || true; \ + done + DOCKERFILE + + # Smoke test: start the slim image and verify the backend health endpoint + # works (including DB connectivity). Fail the build if it doesn't. + echo "Running smoke test on slim image..." + docker run --rm --name smoke-test \ + --network host \ + --env-file /etc/stack-build.env \ + -e STACK_SKIP_MIGRATIONS=true \ + -e STACK_SKIP_SEED_SCRIPT=true \ + -e USE_INLINE_ENV_VARS=true \ + -e STACK_RUNTIME_WORK_DIR=/app \ + -e NEXT_PUBLIC_STACK_API_URL=http://localhost:8102 \ + -e NEXT_PUBLIC_STACK_DASHBOARD_URL=http://localhost:8101 \ + -e NEXT_PUBLIC_BROWSER_STACK_API_URL=http://localhost:8102 \ + -e NEXT_PUBLIC_BROWSER_STACK_DASHBOARD_URL=http://localhost:8101 \ + -e NEXT_PUBLIC_SERVER_STACK_API_URL=http://127.0.0.1:8102 \ + -e NEXT_PUBLIC_SERVER_STACK_DASHBOARD_URL=http://127.0.0.1:8101 \ + -e NEXT_PUBLIC_STACK_SVIX_SERVER_URL=http://localhost:8071 \ + -e NEXT_PUBLIC_STACK_PORT_PREFIX=81 \ + -e STACK_CLICKHOUSE_DATABASE=analytics \ + -e BACKEND_PORT=8102 \ + -e DASHBOARD_PORT=8101 \ + -v stack-postgres-data:/data/postgres \ + -v stack-redis-data:/data/redis \ + -v stack-clickhouse-data:/data/clickhouse \ + -v stack-minio-data:/data/minio \ + -v stack-inbucket-data:/data/inbucket \ + -d stack-local-emulator-slim + + smoke_timeout=120 + smoke_elapsed=0 + smoke_passed=false + while [ "$smoke_elapsed" -lt "$smoke_timeout" ]; do + code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 http://127.0.0.1:8102/health?db=1 2>/dev/null || true) + if [ "$code" = "200" ]; then + smoke_passed=true + break + fi + sleep 2 + smoke_elapsed=$((smoke_elapsed + 2)) + done + + docker stop smoke-test 2>/dev/null || true + sleep 2 + + if [ "$smoke_passed" = "false" ]; then + echo "SMOKE TEST FAILED: backend /health?db=1 did not return 200" >&2 + exit 1 + fi + echo "Smoke test passed!" + + # Flatten to a single layer so deleted files are truly gone + docker create --name flatten stack-local-emulator-slim /bin/true + docker export flatten | docker import \ + --change 'WORKDIR /app' \ + --change 'ENTRYPOINT ["/entrypoint.sh"]' \ + --change 'EXPOSE 5432 6379 2500 9001 1100 8071 8123 9009 9090 8080 8101 8102' \ + --change 'ENV DEBIAN_FRONTEND=noninteractive' \ + - stack-local-emulator:final + + # Save the final image and volume data, nuke ALL Docker storage + # (images, build cache, overlay2 layers), then reload. This is the + # only reliable way to reclaim space — the build cache holds refs + # to old layers, preventing docker image prune from freeing them. + docker rm flatten + docker save stack-local-emulator:final -o /var/tmp/final-image.tar + # Copy volume data out of Docker's storage + cp -a /var/lib/docker/volumes /var/tmp/volumes-backup + systemctl stop docker containerd + rm -rf /var/lib/docker /var/lib/containerd + systemctl start docker containerd + until docker info >/dev/null 2>&1; do sleep 1; done + # Restore image and volumes + docker load -i /var/tmp/final-image.tar + docker tag stack-local-emulator:final stack-local-emulator + docker rmi stack-local-emulator:final || true + rm -f /var/tmp/final-image.tar + systemctl stop docker + cp -a /var/tmp/volumes-backup/* /var/lib/docker/volumes/ + rm -rf /var/tmp/volumes-backup + systemctl start docker + + # Zero free space so qcow2 compression is effective + dd if=/dev/zero of=/zero.fill bs=1M 2>/dev/null || true + rm -f /zero.fill + sync + fstrim -av 2>/dev/null || true + - path: /etc/systemd/system/stack.service content: | [Unit] @@ -168,18 +339,11 @@ runcmd: - bash /usr/local/bin/install-emulator-containers - systemctl daemon-reload - systemctl enable stack.service - - docker run --rm --name stack-build-init - --network host - -e STACK_DEPS_ONLY=true - -v stack-postgres-data:/data/postgres - -v stack-redis-data:/data/redis - -v stack-clickhouse-data:/data/clickhouse - -v stack-minio-data:/data/minio - -v stack-inbucket-data:/data/inbucket - -d stack-local-emulator - - bash /usr/local/bin/wait-for-deps - - docker stop stack-build-init || true - - echo "STACK_CLOUD_INIT_DONE" > /dev/console 2>/dev/null || true - - echo "STACK_CLOUD_INIT_DONE" > /dev/ttyAMA0 2>/dev/null || true - - echo "STACK_CLOUD_INIT_DONE" > /dev/ttyS0 2>/dev/null || true + # Chain build steps with && so a failure (e.g. smoke test) prevents + # STACK_CLOUD_INIT_DONE from being emitted, which fails the build. + - bash /usr/local/bin/run-build-migrations && + bash /usr/local/bin/slim-docker-image && + for dev in /dev/console /dev/ttyAMA0 /dev/ttyS0; do + echo "STACK_CLOUD_INIT_DONE" > "$dev" 2>/dev/null || true; + done - shutdown -P now diff --git a/docker/local-emulator/qemu/run-emulator.sh b/docker/local-emulator/qemu/run-emulator.sh index f2f3028ca6..0a82c1b883 100755 --- a/docker/local-emulator/qemu/run-emulator.sh +++ b/docker/local-emulator/qemu/run-emulator.sh @@ -85,6 +85,10 @@ prepare_runtime_config_iso() { mkdir -p "$cfg_dir" { printf "STACK_EMULATOR_PORT_PREFIX=%s\n" "$PORT_PREFIX" + printf "STACK_EMULATOR_DASHBOARD_HOST_PORT=%s\n" "$EMULATOR_DASHBOARD_PORT" + printf "STACK_EMULATOR_BACKEND_HOST_PORT=%s\n" "$EMULATOR_BACKEND_PORT" + printf "STACK_EMULATOR_MINIO_HOST_PORT=%s\n" "$EMULATOR_MINIO_PORT" + printf "STACK_EMULATOR_INBUCKET_HOST_PORT=%s\n" "$EMULATOR_INBUCKET_PORT" } > "$cfg_dir/runtime.env" cp "$SCRIPT_DIR/../.env.development" "$cfg_dir/base.env" make_iso_from_dir "$cfg_iso" "STACKCFG" "$cfg_dir" From d724eb25755b3f9dfa1ecf095095c8eee6e75ffa Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 9 Apr 2026 23:29:07 -0700 Subject: [PATCH 04/29] emulator fixes --- docker/local-emulator/Dockerfile | 17 +- docker/local-emulator/qemu/build-image.sh | 89 ++++++++- .../qemu/cloud-init/emulator/user-data | 184 ++++++++++++------ docker/local-emulator/qemu/test-serial.sh | 124 ++++++++++++ 4 files changed, 338 insertions(+), 76 deletions(-) create mode 100755 docker/local-emulator/qemu/test-serial.sh diff --git a/docker/local-emulator/Dockerfile b/docker/local-emulator/Dockerfile index ada09cc261..7784b5ae71 100644 --- a/docker/local-emulator/Dockerfile +++ b/docker/local-emulator/Dockerfile @@ -105,20 +105,17 @@ RUN cp $(which qstash) /qstash-binary 2>/dev/null || \ # ── Strip / compress service binaries (parallel stages) ────────────────────── -FROM debian:trixie-slim AS strip-clickhouse -COPY --from=clickhouse-bin /usr/bin/clickhouse /usr/bin/clickhouse -RUN apt-get update && apt-get install -y --no-install-recommends binutils && \ - strip --strip-all /usr/bin/clickhouse && \ - rm -rf /var/lib/apt/lists/* - FROM debian:trixie-slim AS upx-compress -RUN apt-get update && apt-get install -y --no-install-recommends upx-ucl && \ +RUN apt-get update && apt-get install -y --no-install-recommends upx-ucl binutils && \ rm -rf /var/lib/apt/lists/* +COPY --from=clickhouse-bin /usr/bin/clickhouse /out/clickhouse COPY --from=svix-bin /usr/local/bin/svix-server /out/svix-server COPY --from=minio-bin /usr/bin/minio /out/minio COPY --from=mc-bin /usr/bin/mc /out/mc COPY --from=qstash-bin /qstash-binary /out/qstash -RUN upx -9 /out/minio /out/svix-server /out/mc /out/qstash +RUN chmod u+w /out/* && \ + strip --strip-all /out/clickhouse /out/minio /out/svix-server /out/mc /out/qstash && \ + upx -9 /out/minio /out/svix-server /out/mc /out/qstash # ── Final image ─────────────────────────────────────────────────────────────── @@ -160,8 +157,8 @@ COPY --from=inbucket-bin /opt/inbucket /opt/inbucket # Svix (UPX-compressed) COPY --from=upx-compress /out/svix-server /usr/local/bin/svix-server -# ClickHouse (stripped) -COPY --from=strip-clickhouse /usr/bin/clickhouse /usr/bin/clickhouse +# ClickHouse (stripped only) +COPY --from=upx-compress /out/clickhouse /usr/bin/clickhouse RUN ln -sf /usr/bin/clickhouse /usr/bin/clickhouse-server && \ ln -sf /usr/bin/clickhouse /usr/bin/clickhouse-client diff --git a/docker/local-emulator/qemu/build-image.sh b/docker/local-emulator/qemu/build-image.sh index e3f7fc9e16..2f773a7935 100755 --- a/docker/local-emulator/qemu/build-image.sh +++ b/docker/local-emulator/qemu/build-image.sh @@ -176,6 +176,46 @@ prepare_bundle_artifacts() { printf "%s" "$current_ids" > "$bundle_meta" } +contains_provision_marker() { + local provision_log="$1" + local serial_log="$2" + local marker="$3" + + if [ -f "$provision_log" ] && grep -Fqx "$marker" "$provision_log" 2>/dev/null; then + return 0 + fi + + if [ -f "$serial_log" ] && LC_ALL=C strings -a "$serial_log" 2>/dev/null | grep -Fqx "$marker" 2>/dev/null; then + return 0 + fi + + return 1 +} + +line_count() { + local file="$1" + local count=0 + + if [ -f "$file" ]; then + count="$(wc -l < "$file" | tr -d '[:space:]')" || count=0 + fi + + case "$count" in + ''|*[!0-9]*) count=0 ;; + esac + + printf '%s\n' "$count" +} + +persist_provision_logs() { + local arch="$1" + local serial_log="$2" + local provision_log="$3" + + cp "$serial_log" "$IMAGE_DIR/provision-emulator-${arch}.log" 2>/dev/null || true + cp "$provision_log" "$IMAGE_DIR/provision-emulator-${arch}.progress.log" 2>/dev/null || true +} + build_one() { local arch="$1" local base_img="$IMAGE_DIR/debian-${DEBIAN_VERSION}-base-${arch}.qcow2" @@ -192,8 +232,11 @@ build_one() { local bundle_iso="$tmp_dir/bundle.iso" local bundle_dir="$tmp_dir/bundle" local serial_log="$tmp_dir/serial.log" + local provision_log="$tmp_dir/provision.log" local pidfile="$tmp_dir/qemu.pid" - local qemu_base pid elapsed + local qemu_base pid elapsed total_build_lines + local last_build_lines=0 + local guest_exited=false local start_time=$SECONDS cp "$base_img" "$tmp_img" @@ -213,6 +256,7 @@ build_one() { make_iso_from_dir "$bundle_iso" "STACKBUNDLE" "$bundle_dir" : > "$serial_log" + : > "$provision_log" qemu_base="$(qemu_cmd_prefix_for_arch "$arch")" # shellcheck disable=SC2086 @@ -225,6 +269,7 @@ build_one() { -drive "file=$bundle_iso,format=raw,if=virtio,readonly=on" \ -netdev user,id=net0 \ -device virtio-net-pci,netdev=net0 \ + -virtfs "local,path=$tmp_dir,mount_tag=hostfs,security_model=none" \ -serial "file:$serial_log" \ -display none \ -daemonize \ @@ -233,23 +278,55 @@ build_one() { pid="$(cat "$pidfile")" elapsed=0 while [ "$elapsed" -lt "$PROVISION_TIMEOUT" ]; do - if grep -q "STACK_CLOUD_INIT_DONE" "$serial_log" 2>/dev/null; then + if contains_provision_marker "$provision_log" "$serial_log" "STACK_CLOUD_INIT_DONE"; then + break + fi + + if [ -f "$provision_log" ]; then + total_build_lines="$(line_count "$provision_log")" + if [ "$total_build_lines" -gt "$last_build_lines" ]; then + echo "" + sed -n "$((last_build_lines + 1)),${total_build_lines}p" "$provision_log" 2>/dev/null | while IFS= read -r msg; do + if [ "$msg" = "STACK_CLOUD_INIT_DONE" ]; then + continue + fi + printf " [%3ds] %s\n" "$elapsed" "$msg" + done + last_build_lines="$total_build_lines" + fi + fi + + if ! kill -0 "$pid" 2>/dev/null; then + guest_exited=true break fi + sleep 5 elapsed=$((SECONDS - start_time)) printf "\r [%3ds / %ds] provisioning emulator..." "$elapsed" "$PROVISION_TIMEOUT" done echo "" - if ! grep -q "STACK_CLOUD_INIT_DONE" "$serial_log" 2>/dev/null; then - err "Provisioning timed out for emulator (${arch})" - tail -50 "$serial_log" >&2 || true + if ! contains_provision_marker "$provision_log" "$serial_log" "STACK_CLOUD_INIT_DONE"; then + if [ "$guest_exited" = true ]; then + err "Provisioning exited before completion for emulator (${arch})" + else + err "Provisioning timed out for emulator (${arch})" + fi + + if [ -s "$provision_log" ]; then + tail -50 "$provision_log" >&2 || true + else + LC_ALL=C strings -a "$serial_log" 2>/dev/null | tail -50 >&2 || tail -50 "$serial_log" >&2 || true + fi + if kill -0 "$pid" 2>/dev/null; then kill "$pid" 2>/dev/null || true sleep 1 kill -9 "$pid" 2>/dev/null || true fi + + persist_provision_logs "$arch" "$serial_log" "$provision_log" rm -rf "$tmp_dir" exit 1 fi @@ -267,7 +344,7 @@ build_one() { kill -9 "$pid" 2>/dev/null || true fi - cp "$serial_log" "$IMAGE_DIR/provision-emulator-${arch}.log" + persist_provision_logs "$arch" "$serial_log" "$provision_log" log "Compressing final image (this may take several minutes)..." qemu-img convert -p -O qcow2 -c "$tmp_img" "$final_img" diff --git a/docker/local-emulator/qemu/cloud-init/emulator/user-data b/docker/local-emulator/qemu/cloud-init/emulator/user-data index 05c6cf13a3..5b92e3e35b 100644 --- a/docker/local-emulator/qemu/cloud-init/emulator/user-data +++ b/docker/local-emulator/qemu/cloud-init/emulator/user-data @@ -158,13 +158,42 @@ write_files: until curl -sf http://127.0.0.1:9090/minio/health/live >/dev/null 2>&1; do sleep 1; done until [ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/ 2>/dev/null || true)" = "401" ]; do sleep 1; done + - path: /etc/stack-build-computed.env + content: | + USE_INLINE_ENV_VARS=true + NEXT_PUBLIC_STACK_API_URL=http://localhost:8102 + NEXT_PUBLIC_STACK_DASHBOARD_URL=http://localhost:8101 + NEXT_PUBLIC_BROWSER_STACK_API_URL=http://localhost:8102 + NEXT_PUBLIC_BROWSER_STACK_DASHBOARD_URL=http://localhost:8101 + NEXT_PUBLIC_SERVER_STACK_API_URL=http://127.0.0.1:8102 + NEXT_PUBLIC_SERVER_STACK_DASHBOARD_URL=http://127.0.0.1:8101 + NEXT_PUBLIC_STACK_SVIX_SERVER_URL=http://localhost:8071 + NEXT_PUBLIC_STACK_PORT_PREFIX=81 + STACK_CLICKHOUSE_DATABASE=analytics + BACKEND_PORT=8102 + DASHBOARD_PORT=8101 + + - path: /usr/local/bin/log-provision + permissions: '0755' + content: | + #!/bin/bash + set -euo pipefail + + msg="$*" + echo "STACK_PROVISION: $msg" + if [ -n "${STACK_PROVISION_LOG_FILE:-}" ]; then + printf '%s\n' "$msg" >> "$STACK_PROVISION_LOG_FILE" + fi + - path: /usr/local/bin/run-build-migrations permissions: '0755' content: | #!/bin/bash set -euo pipefail - # Start infrastructure services (deps-only mode) + log() { /usr/local/bin/log-provision "$*"; } + + log "Starting deps container..." docker run --rm --name stack-build-init \ --network host \ -e STACK_DEPS_ONLY=true \ @@ -175,10 +204,12 @@ write_files: -v stack-inbucket-data:/data/inbucket \ -d stack-local-emulator - # Wait for all services to be healthy + log "Waiting for deps (postgres, redis, clickhouse, minio, qstash)..." /usr/local/bin/wait-for-deps + log "Deps ready." - # Wait for init-services.sh to finish (MinIO buckets, ClickHouse DB) + # Wait for init-services.sh (MinIO buckets, ClickHouse DB creation) + log "Waiting for init-services.sh..." timeout=120 elapsed=0 while [ "$elapsed" -lt "$timeout" ]; do @@ -188,25 +219,23 @@ write_files: sleep 1 elapsed=$((elapsed + 1)) done + if [ "$elapsed" -ge "$timeout" ]; then + log "ERROR: init-services.sh did not finish within ${timeout}s" + exit 1 + fi + log "init-services done (${elapsed}s)." - # Run migrations and seed inside the running container + log "Running migrations..." docker exec \ --env-file /etc/stack-build.env \ - -e USE_INLINE_ENV_VARS=true \ - -e NEXT_PUBLIC_STACK_API_URL=http://localhost:8102 \ - -e NEXT_PUBLIC_STACK_DASHBOARD_URL=http://localhost:8101 \ - -e NEXT_PUBLIC_BROWSER_STACK_API_URL=http://localhost:8102 \ - -e NEXT_PUBLIC_BROWSER_STACK_DASHBOARD_URL=http://localhost:8101 \ - -e NEXT_PUBLIC_SERVER_STACK_API_URL=http://127.0.0.1:8102 \ - -e NEXT_PUBLIC_SERVER_STACK_DASHBOARD_URL=http://127.0.0.1:8101 \ - -e NEXT_PUBLIC_STACK_SVIX_SERVER_URL=http://localhost:8071 \ - -e NEXT_PUBLIC_STACK_PORT_PREFIX=81 \ - -e STACK_CLICKHOUSE_DATABASE=analytics \ + --env-file /etc/stack-build-computed.env \ stack-build-init \ sh -c 'cd /app/apps/backend && node dist/db-migrations.mjs migrate && node dist/db-migrations.mjs seed' + log "Migrations + seed complete." - # Stop infrastructure + log "Stopping deps container..." docker stop stack-build-init || true + log "run-build-migrations done." - path: /usr/local/bin/slim-docker-image permissions: '0755' @@ -214,10 +243,9 @@ write_files: #!/bin/bash set -euo pipefail - # Build slim image: swap to the standalone-traced node_modules and - # reconstruct pnpm root symlinks. The standalone trace (from Next.js) - # includes only packages actually imported at runtime, so this is - # self-maintaining as new packages are added. + log() { /usr/local/bin/log-provision "$*"; } + + log "Building slim Docker image..." docker build -t stack-local-emulator-slim - <<'DOCKERFILE' FROM stack-local-emulator RUN rm -rf /app/node_modules /app/apps/backend/dist && \ @@ -228,28 +256,16 @@ write_files: ln -sf ".pnpm/node_modules/$name" "/app/node_modules/$name" 2>/dev/null || true; \ done DOCKERFILE + log "Slim image built." - # Smoke test: start the slim image and verify the backend health endpoint - # works (including DB connectivity). Fail the build if it doesn't. - echo "Running smoke test on slim image..." + log "Running smoke test on slim image..." docker run --rm --name smoke-test \ --network host \ --env-file /etc/stack-build.env \ + --env-file /etc/stack-build-computed.env \ -e STACK_SKIP_MIGRATIONS=true \ -e STACK_SKIP_SEED_SCRIPT=true \ - -e USE_INLINE_ENV_VARS=true \ -e STACK_RUNTIME_WORK_DIR=/app \ - -e NEXT_PUBLIC_STACK_API_URL=http://localhost:8102 \ - -e NEXT_PUBLIC_STACK_DASHBOARD_URL=http://localhost:8101 \ - -e NEXT_PUBLIC_BROWSER_STACK_API_URL=http://localhost:8102 \ - -e NEXT_PUBLIC_BROWSER_STACK_DASHBOARD_URL=http://localhost:8101 \ - -e NEXT_PUBLIC_SERVER_STACK_API_URL=http://127.0.0.1:8102 \ - -e NEXT_PUBLIC_SERVER_STACK_DASHBOARD_URL=http://127.0.0.1:8101 \ - -e NEXT_PUBLIC_STACK_SVIX_SERVER_URL=http://localhost:8071 \ - -e NEXT_PUBLIC_STACK_PORT_PREFIX=81 \ - -e STACK_CLICKHOUSE_DATABASE=analytics \ - -e BACKEND_PORT=8102 \ - -e DASHBOARD_PORT=8101 \ -v stack-postgres-data:/data/postgres \ -v stack-redis-data:/data/redis \ -v stack-clickhouse-data:/data/clickhouse \ @@ -274,12 +290,12 @@ write_files: sleep 2 if [ "$smoke_passed" = "false" ]; then - echo "SMOKE TEST FAILED: backend /health?db=1 did not return 200" >&2 + log "SMOKE TEST FAILED: backend /health?db=1 did not return 200 within ${smoke_timeout}s" exit 1 fi - echo "Smoke test passed!" + log "Smoke test passed (${smoke_elapsed}s)." - # Flatten to a single layer so deleted files are truly gone + log "Flattening image (docker export/import)..." docker create --name flatten stack-local-emulator-slim /bin/true docker export flatten | docker import \ --change 'WORKDIR /app' \ @@ -287,34 +303,33 @@ write_files: --change 'EXPOSE 5432 6379 2500 9001 1100 8071 8123 9009 9090 8080 8101 8102' \ --change 'ENV DEBIAN_FRONTEND=noninteractive' \ - stack-local-emulator:final + log "Flatten done." - # Save the final image and volume data, nuke ALL Docker storage - # (images, build cache, overlay2 layers), then reload. This is the - # only reliable way to reclaim space — the build cache holds refs - # to old layers, preventing docker image prune from freeing them. + log "Saving final image to /var/tmp..." docker rm flatten docker save stack-local-emulator:final -o /var/tmp/final-image.tar - # Copy volume data out of Docker's storage - cp -a /var/lib/docker/volumes /var/tmp/volumes-backup + mv /var/lib/docker/volumes /var/tmp/volumes-backup + log "Nuking Docker storage and reloading..." systemctl stop docker containerd rm -rf /var/lib/docker /var/lib/containerd systemctl start docker containerd until docker info >/dev/null 2>&1; do sleep 1; done - # Restore image and volumes docker load -i /var/tmp/final-image.tar docker tag stack-local-emulator:final stack-local-emulator docker rmi stack-local-emulator:final || true rm -f /var/tmp/final-image.tar systemctl stop docker - cp -a /var/tmp/volumes-backup/* /var/lib/docker/volumes/ - rm -rf /var/tmp/volumes-backup + rm -rf /var/lib/docker/volumes + mv /var/tmp/volumes-backup /var/lib/docker/volumes systemctl start docker + log "Docker storage rebuilt." - # Zero free space so qcow2 compression is effective + log "Zeroing free space for qcow2 compression..." dd if=/dev/zero of=/zero.fill bs=1M 2>/dev/null || true rm -f /zero.fill sync fstrim -av 2>/dev/null || true + log "slim-docker-image done." - path: /etc/systemd/system/stack.service content: | @@ -333,17 +348,66 @@ write_files: [Install] WantedBy=multi-user.target + - path: /usr/local/bin/provision-build + permissions: '0755' + content: | + #!/bin/bash + set -euo pipefail + + if bash /usr/local/bin/mount-host-fs 2>/dev/null; then + export STACK_PROVISION_LOG_FILE=/host/provision.log + : > "$STACK_PROVISION_LOG_FILE" + else + export STACK_PROVISION_LOG_FILE="" + fi + + cleanup() { + local status=$? + if [ "$status" -ne 0 ] && [ -n "${STACK_PROVISION_LOG_FILE:-}" ]; then + printf 'ERROR: provision-build exited with code %s\n' "$status" >> "$STACK_PROVISION_LOG_FILE" + fi + } + trap cleanup EXIT + + # Find the serial device and tee all output to it + SERIAL="" + for d in /dev/ttyAMA0 /dev/ttyS0; do + [ -c "$d" ] && SERIAL="$d" && break + done + if [ -n "$SERIAL" ]; then + exec > >(tee -a "$SERIAL") 2>&1 + fi + + log_provision() { + /usr/local/bin/log-provision "$*" + } + + log_provision "runcmd starting" + + systemctl disable --now ssh || true + systemctl mask ssh || true + + log_provision "installing emulator containers" + bash /usr/local/bin/install-emulator-containers + + systemctl daemon-reload + systemctl enable stack.service + + log_provision "starting build migrations" + bash /usr/local/bin/run-build-migrations + + log_provision "starting slim-docker-image" + bash /usr/local/bin/slim-docker-image + + log_provision "build pipeline complete" + if [ -n "${STACK_PROVISION_LOG_FILE:-}" ]; then + printf '%s\n' "STACK_CLOUD_INIT_DONE" >> "$STACK_PROVISION_LOG_FILE" + fi + for dev in /dev/console /dev/ttyAMA0 /dev/ttyS0; do + echo "STACK_CLOUD_INIT_DONE" > "$dev" 2>/dev/null || true + done + + shutdown -P now + runcmd: - - systemctl disable --now ssh || true - - systemctl mask ssh || true - - bash /usr/local/bin/install-emulator-containers - - systemctl daemon-reload - - systemctl enable stack.service - # Chain build steps with && so a failure (e.g. smoke test) prevents - # STACK_CLOUD_INIT_DONE from being emitted, which fails the build. - - bash /usr/local/bin/run-build-migrations && - bash /usr/local/bin/slim-docker-image && - for dev in /dev/console /dev/ttyAMA0 /dev/ttyS0; do - echo "STACK_CLOUD_INIT_DONE" > "$dev" 2>/dev/null || true; - done - - shutdown -P now + - [bash, /usr/local/bin/provision-build] diff --git a/docker/local-emulator/qemu/test-serial.sh b/docker/local-emulator/qemu/test-serial.sh new file mode 100755 index 0000000000..e118db6c4e --- /dev/null +++ b/docker/local-emulator/qemu/test-serial.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# Quick test: boot the base QEMU image with a minimal cloud-init that writes to +# serial via runcmd. Verifies that our logging approach works without running +# the full emulator build (~10s instead of ~10min). +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "$SCRIPT_DIR/common.sh" + +detect_host +ARCH="${1:-$HOST_ARCH}" + +BASE_IMG="$SCRIPT_DIR/images/debian-13-base-${ARCH}.qcow2" +if [ ! -f "$BASE_IMG" ]; then + echo "Base image not found: $BASE_IMG" >&2 + exit 1 +fi + +TMP_DIR="$(mktemp -d /tmp/stack-serial-test-XXXXXX)" +trap 'kill "$(cat "$TMP_DIR/qemu.pid" 2>/dev/null)" 2>/dev/null; rm -rf "$TMP_DIR"' EXIT + +# Create a temporary disk +cp "$BASE_IMG" "$TMP_DIR/disk.qcow2" + +# Minimal cloud-init user-data that tests serial output from runcmd +cat > "$TMP_DIR/user-data" << 'EOF' +#cloud-config +write_files: + - path: /usr/local/bin/provision-build + permissions: '0755' + content: | + #!/bin/bash + set -euo pipefail + + SERIAL="" + for d in /dev/ttyAMA0 /dev/ttyS0; do + [ -c "$d" ] && SERIAL="$d" && break + done + if [ -n "$SERIAL" ]; then + exec > >(tee -a "$SERIAL") 2>&1 + fi + + echo "STACK_PROVISION: script started" + sleep 1 + echo "STACK_PROVISION: step 2" + for dev in /dev/console /dev/ttyAMA0 /dev/ttyS0; do + echo "STACK_CLOUD_INIT_DONE" > "$dev" 2>/dev/null || true + done + shutdown -P now + +runcmd: + - [bash, /usr/local/bin/provision-build] +EOF + +cat > "$TMP_DIR/meta-data" << 'EOF' +instance-id: serial-test +local-hostname: serial-test +EOF + +# Build seed ISO +make_iso_from_dir "$TMP_DIR/seed.iso" "cidata" "$TMP_DIR" + +: > "$TMP_DIR/serial.log" + +case "$ARCH" in + arm64) + accel="hvf" + firmware="$(find_aarch64_firmware)" + qemu_base="qemu-system-aarch64 -machine virt -accel $accel -cpu max -bios $firmware" + ;; + amd64) + qemu_base="qemu-system-x86_64 -machine q35 -accel hvf -cpu max" + ;; +esac + +$qemu_base \ + -boot order=c \ + -m 1024 \ + -smp 2 \ + -drive "file=$TMP_DIR/disk.qcow2,format=qcow2,if=virtio" \ + -drive "file=$TMP_DIR/seed.iso,format=raw,if=virtio,readonly=on" \ + -netdev user,id=net0 \ + -device virtio-net-pci,netdev=net0 \ + -serial "file:$TMP_DIR/serial.log" \ + -display none \ + -daemonize \ + -pidfile "$TMP_DIR/qemu.pid" + +echo "QEMU started, waiting for serial output..." +echo "Serial log: $TMP_DIR/serial.log" + +elapsed=0 +timeout=120 +while [ "$elapsed" -lt "$timeout" ]; do + if grep -q "STACK_CLOUD_INIT_DONE" "$TMP_DIR/serial.log" 2>/dev/null; then + echo "" + echo "=== SUCCESS: STACK_CLOUD_INIT_DONE received ===" + echo "" + echo "=== All STACK_PROVISION lines ===" + grep "STACK_PROVISION" "$TMP_DIR/serial.log" || echo "(none found)" + exit 0 + fi + + # Show any STACK_PROVISION lines as they appear + if grep -q "STACK_PROVISION" "$TMP_DIR/serial.log" 2>/dev/null; then + grep "STACK_PROVISION" "$TMP_DIR/serial.log" | while IFS= read -r line; do + echo " [${elapsed}s] $line" + done + fi + + sleep 2 + elapsed=$((elapsed + 2)) + printf "\r [%ds / %ds] waiting..." "$elapsed" "$timeout" +done + +echo "" +echo "=== TIMEOUT: no STACK_CLOUD_INIT_DONE after ${timeout}s ===" +echo "" +echo "=== Last 30 lines of serial log ===" +tail -30 "$TMP_DIR/serial.log" +echo "" +echo "=== STACK_PROVISION lines ===" +grep "STACK_PROVISION" "$TMP_DIR/serial.log" || echo "(none found)" +exit 1 From 784f17cc2acc06e6a3f125611ce7cd91847b716a Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 10 Apr 2026 09:35:27 -0700 Subject: [PATCH 05/29] emulator: fail-fast on provision errors, diagnose smoke test failures Provisioning used to silently wait out the full 6000s timeout on any guest-side failure because the cleanup trap only logged the error. Now it writes STACK_CLOUD_INIT_FAILED and shuts the VM down, and the host waiter breaks on that marker and reports it distinctly. Also bump smoke test timeout 120s->300s, dump docker ps / container logs / free -m / verbose curl when it fails, log the qemu accel path, and enable /dev/kvm on the CI runner so the VM isn't stuck in TCG. --- .github/workflows/qemu-emulator-build.yaml | 15 +++++++++- docker/local-emulator/qemu/build-image.sh | 11 ++++++- .../qemu/cloud-init/emulator/user-data | 30 +++++++++++++++---- 3 files changed, 48 insertions(+), 8 deletions(-) diff --git a/.github/workflows/qemu-emulator-build.yaml b/.github/workflows/qemu-emulator-build.yaml index e4a42207ca..7b5833aab5 100644 --- a/.github/workflows/qemu-emulator-build.yaml +++ b/.github/workflows/qemu-emulator-build.yaml @@ -47,7 +47,20 @@ jobs: - name: Install QEMU dependencies run: | sudo apt-get update - sudo apt-get install -y qemu-system-x86 qemu-system-arm qemu-utils genisoimage socat qemu-efi-aarch64 + sudo apt-get install -y qemu-system-x86 qemu-system-arm qemu-kvm qemu-utils genisoimage socat qemu-efi-aarch64 + + - name: Enable KVM access + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm || true + ls -la /dev/kvm || echo "no /dev/kvm present" + if [ -w /dev/kvm ]; then + echo "KVM is writable — hardware acceleration will be used" + else + echo "WARNING: /dev/kvm is not writable — will fall back to TCG (very slow)" + fi - name: Build QEMU image run: | diff --git a/docker/local-emulator/qemu/build-image.sh b/docker/local-emulator/qemu/build-image.sh index 2f773a7935..7d73c0ead3 100755 --- a/docker/local-emulator/qemu/build-image.sh +++ b/docker/local-emulator/qemu/build-image.sh @@ -237,6 +237,7 @@ build_one() { local qemu_base pid elapsed total_build_lines local last_build_lines=0 local guest_exited=false + local guest_failed=false local start_time=$SECONDS cp "$base_img" "$tmp_img" @@ -258,6 +259,7 @@ build_one() { : > "$serial_log" : > "$provision_log" qemu_base="$(qemu_cmd_prefix_for_arch "$arch")" + log "QEMU command prefix (${arch}): $qemu_base" # shellcheck disable=SC2086 $qemu_base \ @@ -282,6 +284,11 @@ build_one() { break fi + if contains_provision_marker "$provision_log" "$serial_log" "STACK_CLOUD_INIT_FAILED"; then + guest_failed=true + break + fi + if [ -f "$provision_log" ]; then total_build_lines="$(line_count "$provision_log")" if [ "$total_build_lines" -gt "$last_build_lines" ]; then @@ -308,7 +315,9 @@ build_one() { echo "" if ! contains_provision_marker "$provision_log" "$serial_log" "STACK_CLOUD_INIT_DONE"; then - if [ "$guest_exited" = true ]; then + if [ "$guest_failed" = true ]; then + err "Guest provisioning reported failure for emulator (${arch})" + elif [ "$guest_exited" = true ]; then err "Provisioning exited before completion for emulator (${arch})" else err "Provisioning timed out for emulator (${arch})" diff --git a/docker/local-emulator/qemu/cloud-init/emulator/user-data b/docker/local-emulator/qemu/cloud-init/emulator/user-data index 5b92e3e35b..4dcf7bda03 100644 --- a/docker/local-emulator/qemu/cloud-init/emulator/user-data +++ b/docker/local-emulator/qemu/cloud-init/emulator/user-data @@ -273,7 +273,7 @@ write_files: -v stack-inbucket-data:/data/inbucket \ -d stack-local-emulator-slim - smoke_timeout=120 + smoke_timeout=300 smoke_elapsed=0 smoke_passed=false while [ "$smoke_elapsed" -lt "$smoke_timeout" ]; do @@ -286,13 +286,22 @@ write_files: smoke_elapsed=$((smoke_elapsed + 2)) done - docker stop smoke-test 2>/dev/null || true - sleep 2 - if [ "$smoke_passed" = "false" ]; then log "SMOKE TEST FAILED: backend /health?db=1 did not return 200 within ${smoke_timeout}s" + log "--- docker ps -a ---" + docker ps -a 2>&1 | while IFS= read -r line; do log "ps: $line"; done || true + log "--- smoke-test container logs (last 200 lines) ---" + docker logs --tail 200 smoke-test 2>&1 | while IFS= read -r line; do log "smoke-test: $line"; done || true + log "--- free -m ---" + free -m 2>&1 | while IFS= read -r line; do log "mem: $line"; done || true + log "--- curl -v /health?db=1 ---" + curl -v --max-time 5 http://127.0.0.1:8102/health?db=1 2>&1 | while IFS= read -r line; do log "curl: $line"; done || true + docker stop smoke-test 2>/dev/null || true exit 1 fi + + docker stop smoke-test 2>/dev/null || true + sleep 2 log "Smoke test passed (${smoke_elapsed}s)." log "Flattening image (docker export/import)..." @@ -363,8 +372,17 @@ write_files: cleanup() { local status=$? - if [ "$status" -ne 0 ] && [ -n "${STACK_PROVISION_LOG_FILE:-}" ]; then - printf 'ERROR: provision-build exited with code %s\n' "$status" >> "$STACK_PROVISION_LOG_FILE" + if [ "$status" -ne 0 ]; then + if [ -n "${STACK_PROVISION_LOG_FILE:-}" ]; then + printf 'ERROR: provision-build exited with code %s\n' "$status" >> "$STACK_PROVISION_LOG_FILE" + printf '%s\n' "STACK_CLOUD_INIT_FAILED" >> "$STACK_PROVISION_LOG_FILE" + fi + for dev in /dev/console /dev/ttyAMA0 /dev/ttyS0; do + echo "STACK_CLOUD_INIT_FAILED" > "$dev" 2>/dev/null || true + done + sync || true + (sleep 2 && shutdown -P now) & + (sleep 15 && poweroff -f) & fi } trap cleanup EXIT From 7bf4a15306c68807d2ede2a74d043241d9cd3ae7 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 10 Apr 2026 11:08:07 -0700 Subject: [PATCH 06/29] emulator: make cross-arch arm64 build survive TCG The arm64 matrix entry cross-compiles on the amd64 CI runner, so the guest runs under QEMU TCG. Under -cpu max, V8 emits armv8.5+ JIT code that TCG mistranslates and node crashes with SIGTRAP (exit 133) during migrations. Three changes together get it working: - Drop to -cpu cortex-a72 for TCG arm64 guests. Limits V8 to armv8.0-a which TCG handles cleanly. Native paths (HVF/KVM) keep -cpu max for full performance. - Run migrations with NODE_OPTIONS=--jitless as belt-and-suspenders. Migrations are I/O-bound so the perf hit is negligible. - Skip the in-guest smoke test on arm64. A full Next.js backend under cross-arch TCG either SIGTRAPs or times out; the amd64 build still runs the smoke test, which covers every non-arch-specific code path. Arch is propagated into the guest via a new build-arch.env marker in the stack-bundle ISO. --- docker/local-emulator/qemu/build-image.sh | 15 ++- .../qemu/cloud-init/emulator/user-data | 109 +++++++++++------- 2 files changed, 80 insertions(+), 44 deletions(-) diff --git a/docker/local-emulator/qemu/build-image.sh b/docker/local-emulator/qemu/build-image.sh index 7d73c0ead3..b6efb0c5ee 100755 --- a/docker/local-emulator/qemu/build-image.sh +++ b/docker/local-emulator/qemu/build-image.sh @@ -112,15 +112,21 @@ qemu_cmd_prefix_for_arch() { case "$arch" in arm64) local accel="tcg" + # Under TCG (software emulation on an amd64 host) -cpu max advertises + # armv8.5+ features (PAC, BTI, SVE, LSE atomics…) that V8 happily emits + # JIT code for, but QEMU TCG mistranslates some of those instructions + # and the node process crashes with SIGTRAP during migrations. Falling + # back to cortex-a72 limits V8 to armv8.0-a, which TCG handles cleanly. + local cpu="cortex-a72" if [ "$HOST_ARCH" = "arm64" ]; then case "$HOST_OS" in - darwin) accel="hvf" ;; - linux) [ -w /dev/kvm ] && accel="kvm" ;; + darwin) accel="hvf"; cpu="max" ;; + linux) [ -w /dev/kvm ] && { accel="kvm"; cpu="max"; } ;; esac fi local firmware firmware="$(find_aarch64_firmware)" - echo "qemu-system-aarch64 -machine virt -accel $accel -cpu max -bios $firmware" + echo "qemu-system-aarch64 -machine virt -accel $accel -cpu $cpu -bios $firmware" ;; amd64) local accel="tcg" @@ -254,6 +260,9 @@ build_one() { mkdir -p "$bundle_dir" cp "$bundle_tgz" "$bundle_dir/img.tgz" cp "$BUILD_ENV_FILE" "$bundle_dir/build.env" + # Tell the guest which arch it's being built for so cross-arch (TCG) builds + # can skip the smoke test, which isn't reliable under software emulation. + printf 'STACK_EMULATOR_BUILD_ARCH=%s\n' "$arch" > "$bundle_dir/build-arch.env" make_iso_from_dir "$bundle_iso" "STACKBUNDLE" "$bundle_dir" : > "$serial_log" diff --git a/docker/local-emulator/qemu/cloud-init/emulator/user-data b/docker/local-emulator/qemu/cloud-init/emulator/user-data index 4dcf7bda03..7aaddadf1a 100644 --- a/docker/local-emulator/qemu/cloud-init/emulator/user-data +++ b/docker/local-emulator/qemu/cloud-init/emulator/user-data @@ -48,6 +48,11 @@ write_files: cp /mnt/stack-bundle/build.env /etc/stack-build.env fi + # Copy per-arch build metadata (used to skip smoke test on cross-arch TCG builds) + if [ -f /mnt/stack-bundle/build-arch.env ]; then + cp /mnt/stack-bundle/build-arch.env /etc/stack-build-arch.env + fi + - path: /usr/local/bin/render-stack-env permissions: '0755' content: | @@ -226,9 +231,15 @@ write_files: log "init-services done (${elapsed}s)." log "Running migrations..." + # NODE_OPTIONS=--jitless disables V8's JIT and runs the Ignition + # interpreter only. Migrations are short and I/O-bound so the perf hit + # doesn't matter, and it makes the process immune to V8-JIT ↔ QEMU-TCG + # mistranslation crashes that otherwise kill the node process with + # SIGTRAP (exit 133) during cross-arch builds. docker exec \ --env-file /etc/stack-build.env \ --env-file /etc/stack-build-computed.env \ + -e NODE_OPTIONS=--jitless \ stack-build-init \ sh -c 'cd /app/apps/backend && node dist/db-migrations.mjs migrate && node dist/db-migrations.mjs seed' log "Migrations + seed complete." @@ -258,52 +269,68 @@ write_files: DOCKERFILE log "Slim image built." - log "Running smoke test on slim image..." - docker run --rm --name smoke-test \ - --network host \ - --env-file /etc/stack-build.env \ - --env-file /etc/stack-build-computed.env \ - -e STACK_SKIP_MIGRATIONS=true \ - -e STACK_SKIP_SEED_SCRIPT=true \ - -e STACK_RUNTIME_WORK_DIR=/app \ - -v stack-postgres-data:/data/postgres \ - -v stack-redis-data:/data/redis \ - -v stack-clickhouse-data:/data/clickhouse \ - -v stack-minio-data:/data/minio \ - -v stack-inbucket-data:/data/inbucket \ - -d stack-local-emulator-slim - - smoke_timeout=300 - smoke_elapsed=0 - smoke_passed=false - while [ "$smoke_elapsed" -lt "$smoke_timeout" ]; do - code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 http://127.0.0.1:8102/health?db=1 2>/dev/null || true) - if [ "$code" = "200" ]; then - smoke_passed=true - break + # Determine build arch to decide whether to run the smoke test. Cross-arch + # (TCG) builds can't reliably run the Next.js backend inside the smoke + # test container: V8 JIT ↔ QEMU TCG mistranslations crash the process, + # and even with --jitless the backend is too slow to respond within any + # sane timeout. amd64 builds run under KVM and are unaffected. + BUILD_ARCH="" + if [ -f /etc/stack-build-arch.env ]; then + # shellcheck disable=SC1091 + . /etc/stack-build-arch.env + BUILD_ARCH="${STACK_EMULATOR_BUILD_ARCH:-}" + fi + + if [ "$BUILD_ARCH" = "arm64" ]; then + log "Skipping smoke test: build arch is arm64 and cross-arch TCG can't reliably run the backend." + else + log "Running smoke test on slim image..." + docker run --rm --name smoke-test \ + --network host \ + --env-file /etc/stack-build.env \ + --env-file /etc/stack-build-computed.env \ + -e STACK_SKIP_MIGRATIONS=true \ + -e STACK_SKIP_SEED_SCRIPT=true \ + -e STACK_RUNTIME_WORK_DIR=/app \ + -v stack-postgres-data:/data/postgres \ + -v stack-redis-data:/data/redis \ + -v stack-clickhouse-data:/data/clickhouse \ + -v stack-minio-data:/data/minio \ + -v stack-inbucket-data:/data/inbucket \ + -d stack-local-emulator-slim + + smoke_timeout=300 + smoke_elapsed=0 + smoke_passed=false + while [ "$smoke_elapsed" -lt "$smoke_timeout" ]; do + code=$(curl -s -o /dev/null -w "%{http_code}" --max-time 3 http://127.0.0.1:8102/health?db=1 2>/dev/null || true) + if [ "$code" = "200" ]; then + smoke_passed=true + break + fi + sleep 2 + smoke_elapsed=$((smoke_elapsed + 2)) + done + + if [ "$smoke_passed" = "false" ]; then + log "SMOKE TEST FAILED: backend /health?db=1 did not return 200 within ${smoke_timeout}s" + log "--- docker ps -a ---" + docker ps -a 2>&1 | while IFS= read -r line; do log "ps: $line"; done || true + log "--- smoke-test container logs (last 200 lines) ---" + docker logs --tail 200 smoke-test 2>&1 | while IFS= read -r line; do log "smoke-test: $line"; done || true + log "--- free -m ---" + free -m 2>&1 | while IFS= read -r line; do log "mem: $line"; done || true + log "--- curl -v /health?db=1 ---" + curl -v --max-time 5 http://127.0.0.1:8102/health?db=1 2>&1 | while IFS= read -r line; do log "curl: $line"; done || true + docker stop smoke-test 2>/dev/null || true + exit 1 fi - sleep 2 - smoke_elapsed=$((smoke_elapsed + 2)) - done - if [ "$smoke_passed" = "false" ]; then - log "SMOKE TEST FAILED: backend /health?db=1 did not return 200 within ${smoke_timeout}s" - log "--- docker ps -a ---" - docker ps -a 2>&1 | while IFS= read -r line; do log "ps: $line"; done || true - log "--- smoke-test container logs (last 200 lines) ---" - docker logs --tail 200 smoke-test 2>&1 | while IFS= read -r line; do log "smoke-test: $line"; done || true - log "--- free -m ---" - free -m 2>&1 | while IFS= read -r line; do log "mem: $line"; done || true - log "--- curl -v /health?db=1 ---" - curl -v --max-time 5 http://127.0.0.1:8102/health?db=1 2>&1 | while IFS= read -r line; do log "curl: $line"; done || true docker stop smoke-test 2>/dev/null || true - exit 1 + sleep 2 + log "Smoke test passed (${smoke_elapsed}s)." fi - docker stop smoke-test 2>/dev/null || true - sleep 2 - log "Smoke test passed (${smoke_elapsed}s)." - log "Flattening image (docker export/import)..." docker create --name flatten stack-local-emulator-slim /bin/true docker export flatten | docker import \ From 6c5615b931bf6746fed616b95cdf9b065945be84 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 10 Apr 2026 11:26:52 -0700 Subject: [PATCH 07/29] emulator: drop --jitless, capture migration errors on failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit set NODE_OPTIONS=--jitless on the migration docker exec. That was wrong for two reasons: - --jitless disables eval and new Function, which some code in the migration path uses, so it broke amd64 builds that had been passing. - --jitless is a V8 feature gate, not a TCG workaround. If it breaks one arch it breaks both — it could never have helped arm64 either. Revert the --jitless flag and rely on -cpu cortex-a72 (added in the parent commit) as the root-cause fix for the arm64 TCG SIGTRAP. Keep the stdout/stderr capture for the migration exec so the next failure dumps the actual node error through log-provision instead of being swallowed by the serial-only stream. --- .../qemu/cloud-init/emulator/user-data | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/docker/local-emulator/qemu/cloud-init/emulator/user-data b/docker/local-emulator/qemu/cloud-init/emulator/user-data index 7aaddadf1a..5005f99c47 100644 --- a/docker/local-emulator/qemu/cloud-init/emulator/user-data +++ b/docker/local-emulator/qemu/cloud-init/emulator/user-data @@ -231,17 +231,26 @@ write_files: log "init-services done (${elapsed}s)." log "Running migrations..." - # NODE_OPTIONS=--jitless disables V8's JIT and runs the Ignition - # interpreter only. Migrations are short and I/O-bound so the perf hit - # doesn't matter, and it makes the process immune to V8-JIT ↔ QEMU-TCG - # mistranslation crashes that otherwise kill the node process with - # SIGTRAP (exit 133) during cross-arch builds. + # Capture stdout+stderr so failures surface the actual node error in + # the host-visible provision log instead of being swallowed by the + # serial-only stream. + migrate_log="$(mktemp)" + set +e docker exec \ --env-file /etc/stack-build.env \ --env-file /etc/stack-build-computed.env \ - -e NODE_OPTIONS=--jitless \ stack-build-init \ - sh -c 'cd /app/apps/backend && node dist/db-migrations.mjs migrate && node dist/db-migrations.mjs seed' + sh -c 'cd /app/apps/backend && node dist/db-migrations.mjs migrate && node dist/db-migrations.mjs seed' \ + > "$migrate_log" 2>&1 + migrate_status=$? + set -e + if [ "$migrate_status" -ne 0 ]; then + log "MIGRATIONS FAILED (exit ${migrate_status}) — last 200 lines of migration output:" + tail -200 "$migrate_log" | while IFS= read -r line; do log "migrate: $line"; done || true + rm -f "$migrate_log" + exit "$migrate_status" + fi + rm -f "$migrate_log" log "Migrations + seed complete." log "Stopping deps container..." From 253838287d2cb2843be1cff6382df3f11251113e Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 10 Apr 2026 12:33:46 -0700 Subject: [PATCH 08/29] ci: run arm64 emulator build on ubuntu-24.04-arm (same-arch TCG) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-arch TCG on ubicloud-standard-8 either SIGTRAPs during migrations (old QEMU) or hangs in wait-for-deps with no progress. GitHub's ubuntu-24.04-arm runner is an Azure arm64 VM — same-arch TCG, no KVM (no nested virt exposed) — but empirically completes migrations, the dep setup, and image packaging end-to-end (verified on the diagnostics branch run). Only failure there was the backend smoke test hitting its 300s timeout, which the parent commit on this branch already skips on arm64. Keep amd64 on ubicloud-standard-8 for its KVM acceleration. --- .github/workflows/qemu-emulator-build.yaml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/qemu-emulator-build.yaml b/.github/workflows/qemu-emulator-build.yaml index 7b5833aab5..0957d80f0d 100644 --- a/.github/workflows/qemu-emulator-build.yaml +++ b/.github/workflows/qemu-emulator-build.yaml @@ -26,14 +26,23 @@ env: jobs: build: name: Build QEMU Image (${{ matrix.arch }}) - runs-on: ubicloud-standard-8 + runs-on: ${{ matrix.runner }} timeout-minutes: 120 strategy: fail-fast: false matrix: include: + # amd64 runs natively under KVM on ubicloud's amd64 runner. - arch: amd64 + runner: ubicloud-standard-8 + # arm64 runs under same-arch TCG on GitHub's native arm64 runner. + # No KVM (Azure Hyper-V doesn't expose nested virt on arm64) but + # same-arch TCG avoids the V8 JIT translation crashes that kill + # cross-arch TCG, and the smoke test is skipped on arm64 since + # the backend can't come up within any reasonable window under + # software emulation. - arch: arm64 + runner: ubuntu-24.04-arm steps: - uses: actions/checkout@v6 From 54ecd7c5542c748198c8004ca6632d76c46d7017 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 10 Apr 2026 12:16:45 -0700 Subject: [PATCH 09/29] emulator: bounded dep wait with per-service diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wait-for-deps used to loop forever on each service, so any single dep that failed to start (e.g. a service crash-looping under TCG) hung the build until the outer 6000s provision timeout. Rewrite as a wait_for helper with: - Hard 1500s budget across the full dep wait (overridable via STACK_DEPS_TIMEOUT). On timeout, dump docker ps -a, last 300 lines of the deps container, and per-service reachability, then exit 1 so provision-build's cleanup trap fires and the VM shuts down fast. - " ready (Ns)" log lines on each service so successful runs show which service was the bottleneck. - 30s heartbeat per service so long-running waits don't look frozen. amd64 is unaffected — services come up in ~1s each under KVM, which is well inside any threshold here. --- .../qemu/cloud-init/emulator/user-data | 61 +++++++++++++++++-- 1 file changed, 55 insertions(+), 6 deletions(-) diff --git a/docker/local-emulator/qemu/cloud-init/emulator/user-data b/docker/local-emulator/qemu/cloud-init/emulator/user-data index 5005f99c47..c1d0d0f9bf 100644 --- a/docker/local-emulator/qemu/cloud-init/emulator/user-data +++ b/docker/local-emulator/qemu/cloud-init/emulator/user-data @@ -155,13 +155,62 @@ write_files: permissions: '0755' content: | #!/bin/bash - set -euo pipefail + set -uo pipefail + + # Hard upper bound across the whole dep wait. Under TCG every service + # init is 5-20x slower than native, so we allow a generous budget, but + # if we cross it something is genuinely stuck and we need to surface it. + DEPS_TIMEOUT="${STACK_DEPS_TIMEOUT:-1500}" + DEPS_CONTAINER="${STACK_DEPS_CONTAINER:-stack-build-init}" + start=$SECONDS + log() { /usr/local/bin/log-provision "wait-for-deps: $*"; } + + dump_diagnostics() { + log "dumping diagnostics for stuck dep wait..." + log "--- docker ps -a ---" + docker ps -a 2>&1 | while IFS= read -r line; do log "ps: $line"; done || true + log "--- docker logs ${DEPS_CONTAINER} (last 300 lines) ---" + docker logs --tail 300 "$DEPS_CONTAINER" 2>&1 | while IFS= read -r line; do log "deps: $line"; done || true + log "--- per-service probes ---" + nc -z 127.0.0.1 5432 >/dev/null 2>&1 && log "postgres:5432 reachable" || log "postgres:5432 NOT reachable" + curl -sf --max-time 3 http://127.0.0.1:8123/ping >/dev/null 2>&1 && log "clickhouse:8123 reachable" || log "clickhouse:8123 NOT reachable" + curl -sf --max-time 3 http://127.0.0.1:8071/api/v1/health/ >/dev/null 2>&1 && log "svix:8071 reachable" || log "svix:8071 NOT reachable" + curl -sf --max-time 3 http://127.0.0.1:9090/minio/health/live >/dev/null 2>&1 && log "minio:9090 reachable" || log "minio:9090 NOT reachable" + code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 http://127.0.0.1:8080/ 2>/dev/null || true) + [ "$code" = "401" ] && log "qstash:8080 reachable (401)" || log "qstash:8080 NOT reachable (code=${code:-none})" + } + + wait_for() { + local name="$1" probe="$2" elapsed + local svc_start=$SECONDS + local next_heartbeat=$((svc_start + 30)) + while true; do + if eval "$probe" >/dev/null 2>&1; then + elapsed=$((SECONDS - svc_start)) + log "${name} ready (${elapsed}s)" + return 0 + fi + if [ "$SECONDS" -ge "$next_heartbeat" ]; then + log "still waiting for ${name} ($((SECONDS - svc_start))s elapsed)" + next_heartbeat=$((SECONDS + 30)) + fi + if [ "$((SECONDS - start))" -ge "$DEPS_TIMEOUT" ]; then + elapsed=$((SECONDS - start)) + log "TIMEOUT waiting for ${name} after ${elapsed}s (hard cap ${DEPS_TIMEOUT}s)" + dump_diagnostics + exit 1 + fi + sleep 2 + done + } - until nc -z 127.0.0.1 5432 >/dev/null 2>&1; do sleep 1; done - until curl -sf http://127.0.0.1:8123/ping >/dev/null 2>&1; do sleep 1; done - until curl -sf http://127.0.0.1:8071/api/v1/health/ >/dev/null 2>&1; do sleep 1; done - until curl -sf http://127.0.0.1:9090/minio/health/live >/dev/null 2>&1; do sleep 1; done - until [ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/ 2>/dev/null || true)" = "401" ]; do sleep 1; done + log "starting dep wait (timeout=${DEPS_TIMEOUT}s)" + wait_for "postgres" 'nc -z 127.0.0.1 5432' + wait_for "clickhouse" 'curl -sf http://127.0.0.1:8123/ping' + wait_for "svix" 'curl -sf http://127.0.0.1:8071/api/v1/health/' + wait_for "minio" 'curl -sf http://127.0.0.1:9090/minio/health/live' + wait_for "qstash" '[ "$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/ 2>/dev/null || true)" = "401" ]' + log "all deps ready ($((SECONDS - start))s total)" - path: /etc/stack-build-computed.env content: | From 5c3c43648926364379b9601eef953b28f375f1a5 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Fri, 10 Apr 2026 17:02:07 -0700 Subject: [PATCH 10/29] emulator: only use -cpu cortex-a72 for cross-arch TCG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same-arch TCG (e.g. arm64 guest on the arm64 ubuntu-24.04-arm runner that has no nested virt) was falling through to -cpu cortex-a72 too. Empirically that hangs wait-for-deps indefinitely — services never reach a ready state — probably because QEMU's TCG emulation of named CPU models is less well-tested than -cpu max, especially for the LSE atomic fallback paths the dep services exercise. The cortex-a72 workaround is only needed for cross-arch TCG, where V8 emits JIT instructions the amd64 host's TCG mistranslates. Restrict it to that case; same-arch TCG now gets -cpu max, matching the known working config from the diagnostics branch run on ubuntu-24.04-arm. --- docker/local-emulator/qemu/build-image.sh | 24 +++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/docker/local-emulator/qemu/build-image.sh b/docker/local-emulator/qemu/build-image.sh index b6efb0c5ee..71bb4fae93 100755 --- a/docker/local-emulator/qemu/build-image.sh +++ b/docker/local-emulator/qemu/build-image.sh @@ -112,17 +112,25 @@ qemu_cmd_prefix_for_arch() { case "$arch" in arm64) local accel="tcg" - # Under TCG (software emulation on an amd64 host) -cpu max advertises - # armv8.5+ features (PAC, BTI, SVE, LSE atomics…) that V8 happily emits - # JIT code for, but QEMU TCG mistranslates some of those instructions - # and the node process crashes with SIGTRAP during migrations. Falling - # back to cortex-a72 limits V8 to armv8.0-a, which TCG handles cleanly. - local cpu="cortex-a72" + local cpu="max" if [ "$HOST_ARCH" = "arm64" ]; then + # Same-arch: prefer hardware acceleration, keep -cpu max. If no + # accelerator is available (e.g. Azure arm64 runners with no + # nested virt) we fall through to TCG, but same-arch TCG handles + # -cpu max correctly and more named CPU models have TCG bugs + # than -cpu max does. case "$HOST_OS" in - darwin) accel="hvf"; cpu="max" ;; - linux) [ -w /dev/kvm ] && { accel="kvm"; cpu="max"; } ;; + darwin) accel="hvf" ;; + linux) [ -w /dev/kvm ] && accel="kvm" ;; esac + else + # Cross-arch TCG (amd64 host emulating arm64 guest): -cpu max + # advertises armv8.5+ features (PAC, BTI, SVE, LSE…) that V8 + # emits JIT code for, but the host's TCG mistranslates some of + # those instructions across architectures and node crashes with + # SIGTRAP during migrations. Dropping to cortex-a72 limits V8 + # to armv8.0-a which cross-arch TCG handles cleanly. + cpu="cortex-a72" fi local firmware firmware="$(find_aarch64_firmware)" From e63615109ff4b25a60c527bfd930e2844dcc84b3 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 13 Apr 2026 10:20:25 -0700 Subject: [PATCH 11/29] emulator: move arm64 back to ubicloud cross-arch, run migrations with V8 --jitless Flip arm64 matrix back to ubicloud-standard-8 so both arches share one runner fleet. Cross-arch TCG on an amd64 host previously SIGTRAP'd in migrations because V8's JIT emitted arm64 instructions that QEMU's cross-arch translator mis-handled; pair the existing -cpu cortex-a72 fallback with NODE_OPTIONS=--jitless on the migration docker exec to force V8 to stay on the interpreter. Does not affect amd64 migrations (KVM, no TCG). --- .github/workflows/qemu-emulator-build.yaml | 14 +++++++------- .../qemu/cloud-init/emulator/user-data | 5 +++++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/.github/workflows/qemu-emulator-build.yaml b/.github/workflows/qemu-emulator-build.yaml index 0957d80f0d..e6b615d652 100644 --- a/.github/workflows/qemu-emulator-build.yaml +++ b/.github/workflows/qemu-emulator-build.yaml @@ -35,14 +35,14 @@ jobs: # amd64 runs natively under KVM on ubicloud's amd64 runner. - arch: amd64 runner: ubicloud-standard-8 - # arm64 runs under same-arch TCG on GitHub's native arm64 runner. - # No KVM (Azure Hyper-V doesn't expose nested virt on arm64) but - # same-arch TCG avoids the V8 JIT translation crashes that kill - # cross-arch TCG, and the smoke test is skipped on arm64 since - # the backend can't come up within any reasonable window under - # software emulation. + # arm64 runs under cross-arch TCG on ubicloud's amd64 runner. + # No KVM for arm64 guests on an amd64 host; cortex-a72 + V8 + # --jitless together sidestep the SIGTRAPs that cross-arch TCG + # hits on aggressive arm64 JIT code. Smoke test is still skipped + # because the backend can't come up reliably under cross-arch + # TCG within any sane window. - arch: arm64 - runner: ubuntu-24.04-arm + runner: ubicloud-standard-8 steps: - uses: actions/checkout@v6 diff --git a/docker/local-emulator/qemu/cloud-init/emulator/user-data b/docker/local-emulator/qemu/cloud-init/emulator/user-data index c1d0d0f9bf..427e3fce03 100644 --- a/docker/local-emulator/qemu/cloud-init/emulator/user-data +++ b/docker/local-emulator/qemu/cloud-init/emulator/user-data @@ -283,11 +283,16 @@ write_files: # Capture stdout+stderr so failures surface the actual node error in # the host-visible provision log instead of being swallowed by the # serial-only stream. + # NODE_OPTIONS=--jitless disables V8's JIT and forces interpreter-only + # execution, which avoids the cross-arch TCG × V8 JIT SIGTRAPs we see + # when emulating arm64 guests on an amd64 host. Under KVM this costs + # nothing measurable for a short-lived migration process. migrate_log="$(mktemp)" set +e docker exec \ --env-file /etc/stack-build.env \ --env-file /etc/stack-build-computed.env \ + -e NODE_OPTIONS=--jitless \ stack-build-init \ sh -c 'cd /app/apps/backend && node dist/db-migrations.mjs migrate && node dist/db-migrations.mjs seed' \ > "$migrate_log" 2>&1 From f4aca6d8fab038d2977483983ec57b4c9838ddef Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 13 Apr 2026 10:49:10 -0700 Subject: [PATCH 12/29] emulator: swap --jitless for --no-opt on migration exec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain --jitless disables V8's Wasm side-effectfully, which breaks Prisma 7's wasm query compiler on import (ReferenceError: WebAssembly is not defined in decodeBase64AsWasm). --no-opt only disables the TurboFan optimizer — the tier responsible for the aggressive arm64 instructions (PAC/BTI/LSE) that cross-arch TCG mistranslates — while leaving Sparkplug baseline and the Wasm JIT intact, so Prisma's wasm compiler runs at full speed. --- .../qemu/cloud-init/emulator/user-data | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/docker/local-emulator/qemu/cloud-init/emulator/user-data b/docker/local-emulator/qemu/cloud-init/emulator/user-data index 427e3fce03..9f5e6072fa 100644 --- a/docker/local-emulator/qemu/cloud-init/emulator/user-data +++ b/docker/local-emulator/qemu/cloud-init/emulator/user-data @@ -283,16 +283,18 @@ write_files: # Capture stdout+stderr so failures surface the actual node error in # the host-visible provision log instead of being swallowed by the # serial-only stream. - # NODE_OPTIONS=--jitless disables V8's JIT and forces interpreter-only - # execution, which avoids the cross-arch TCG × V8 JIT SIGTRAPs we see - # when emulating arm64 guests on an amd64 host. Under KVM this costs - # nothing measurable for a short-lived migration process. + # NODE_OPTIONS=--no-opt disables V8's TurboFan optimizer (the tier that + # emits aggressive arm64 instructions — PAC/BTI/LSE — that cross-arch + # TCG mis-translates into the migration SIGTRAP). Ignition + Sparkplug + # still run, and crucially the Wasm JIT is left intact so Prisma 7's + # wasm query compiler keeps working. Cheap enough under KVM that we + # apply it unconditionally rather than gating on arch. migrate_log="$(mktemp)" set +e docker exec \ --env-file /etc/stack-build.env \ --env-file /etc/stack-build-computed.env \ - -e NODE_OPTIONS=--jitless \ + -e NODE_OPTIONS=--no-opt \ stack-build-init \ sh -c 'cd /app/apps/backend && node dist/db-migrations.mjs migrate && node dist/db-migrations.mjs seed' \ > "$migrate_log" 2>&1 From 144866a50499e03a632d8b8cdec4fdcf9cd7fcb5 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 13 Apr 2026 11:10:51 -0700 Subject: [PATCH 13/29] emulator: pass --no-opt on node CLI, not via NODE_OPTIONS Node's NODE_OPTIONS allowlist rejects --no-opt (unlike --jitless, which it happens to permit). Put the flag directly on the node command line inside the docker exec so V8 actually picks it up. --- .../qemu/cloud-init/emulator/user-data | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docker/local-emulator/qemu/cloud-init/emulator/user-data b/docker/local-emulator/qemu/cloud-init/emulator/user-data index 9f5e6072fa..f93a586e3c 100644 --- a/docker/local-emulator/qemu/cloud-init/emulator/user-data +++ b/docker/local-emulator/qemu/cloud-init/emulator/user-data @@ -283,20 +283,20 @@ write_files: # Capture stdout+stderr so failures surface the actual node error in # the host-visible provision log instead of being swallowed by the # serial-only stream. - # NODE_OPTIONS=--no-opt disables V8's TurboFan optimizer (the tier that - # emits aggressive arm64 instructions — PAC/BTI/LSE — that cross-arch - # TCG mis-translates into the migration SIGTRAP). Ignition + Sparkplug - # still run, and crucially the Wasm JIT is left intact so Prisma 7's - # wasm query compiler keeps working. Cheap enough under KVM that we - # apply it unconditionally rather than gating on arch. + # node --no-opt disables V8's TurboFan optimizer (the tier that emits + # aggressive arm64 instructions — PAC/BTI/LSE — that cross-arch TCG + # mis-translates into the migration SIGTRAP). Ignition + Sparkplug + # still run, and the Wasm JIT is left intact so Prisma 7's wasm query + # compiler keeps working. Passed directly on node's CLI because + # NODE_OPTIONS's allowlist rejects --no-opt. Cheap enough under KVM + # that we apply it unconditionally rather than gating on arch. migrate_log="$(mktemp)" set +e docker exec \ --env-file /etc/stack-build.env \ --env-file /etc/stack-build-computed.env \ - -e NODE_OPTIONS=--no-opt \ stack-build-init \ - sh -c 'cd /app/apps/backend && node dist/db-migrations.mjs migrate && node dist/db-migrations.mjs seed' \ + sh -c 'cd /app/apps/backend && node --no-opt dist/db-migrations.mjs migrate && node --no-opt dist/db-migrations.mjs seed' \ > "$migrate_log" 2>&1 migrate_status=$? set -e From 5077bb27432c28c49be4b0f557d9e9b879393d22 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 13 Apr 2026 12:13:02 -0700 Subject: [PATCH 14/29] pr comment fixes --- .../apps/implementations/admin-app-impl.ts | 9 +++- .../apps/implementations/client-app-impl.ts | 7 ++- .../stack-app/apps/implementations/common.ts | 47 ++++++++++++------- .../apps/implementations/server-app-impl.ts | 8 +++- 4 files changed, 51 insertions(+), 20 deletions(-) diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index 544e35b364..431786cba7 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -22,7 +22,7 @@ import { AdminProjectPermission, AdminProjectPermissionDefinition, AdminProjectP import { AdminOwnedProject, AdminProject, AdminProjectUpdateOptions, PushConfigOptions, adminProjectUpdateOptionsToCrud } from "../../projects"; import type { AdminSessionReplay, AdminSessionReplayChunk, ListSessionReplayChunksOptions, ListSessionReplayChunksResult, ListSessionReplaysOptions, ListSessionReplaysResult, SessionReplayAllEventsResult } from "../../session-replays"; import { ManagedEmailProviderListItem, ManagedEmailProviderSetupResult, ManagedEmailProviderStatus, EmailOutboxUpdateOptions, StackAdminApp, StackAdminAppConstructorOptions } from "../interfaces/admin-app"; -import { LOCAL_EMULATOR_INTERNAL_PUBLISHABLE_CLIENT_KEY, clientVersion, createCache, fetchEmulatorProjectCredentials, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, getDefaultSuperSecretAdminKey, getLocalEmulatorConfigFilePath, localEmulatorBaseUrl, resolveApiUrls, resolveConstructorOptions } from "./common"; +import { LOCAL_EMULATOR_INTERNAL_PUBLISHABLE_CLIENT_KEY, assertNoEmulatorOptionConflict, clientVersion, createCache, fetchEmulatorProjectCredentials, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, getDefaultSuperSecretAdminKey, getLocalEmulatorConfigFilePath, localEmulatorBaseUrl, resolveApiUrls, resolveConstructorOptions } from "./common"; import { _StackServerAppImplIncomplete } from "./server-app-impl"; import { CompleteConfig, EnvironmentConfigOverrideOverride } from "@stackframe/stack-shared/dist/config/schema"; @@ -130,6 +130,13 @@ export class _StackAdminAppImplIncomplete { - return (async () => { - const res = await fetch(`${localEmulatorBaseUrl}/api/v1/internal/local-emulator/project`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Stack-Project-Id": "internal", - "X-Stack-Access-Type": "client", - "X-Stack-Publishable-Client-Key": LOCAL_EMULATOR_INTERNAL_PUBLISHABLE_CLIENT_KEY, - }, - body: JSON.stringify({ absolute_file_path: emulatorConfigFilePath }), - }); - if (!res.ok) { - throw new Error(`Failed to initialize local emulator: ${res.status} ${await res.text()}`); - } - return await res.json(); - })(); + const res = await fetch(`${localEmulatorBaseUrl}/api/v1/internal/local-emulator/project`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Stack-Project-Id": "internal", + "X-Stack-Access-Type": "client", + "X-Stack-Publishable-Client-Key": LOCAL_EMULATOR_INTERNAL_PUBLISHABLE_CLIENT_KEY, + }, + body: JSON.stringify({ absolute_file_path: emulatorConfigFilePath }), + }); + if (!res.ok) { + throw new Error(`Failed to initialize local emulator: ${res.status} ${await res.text()}`); + } + return await res.json(); +} + +export function assertNoEmulatorOptionConflict( + emulatorConfigFilePath: string | undefined, + options: Record, +) { + if (!emulatorConfigFilePath) return; + const conflicting = Object.entries(options) + .filter(([, value]) => value !== undefined && value !== null) + .map(([key]) => key); + if (conflicting.length > 0) { + throw new Error( + `Cannot specify ${conflicting.join(", ")} together with localEmulatorConfigFilePath — the local emulator provides these credentials automatically.` + ); + } } export function getDefaultProjectId(options?: { isEmulator?: boolean }) { diff --git a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts index cc357e2d61..7694626cb4 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts @@ -35,7 +35,7 @@ import { EditableTeamMemberProfile, ReceivedTeamInvitation, SentTeamInvitation, import { ProjectCurrentServerUser, ServerOAuthProvider, ServerUser, ServerUserCreateOptions, ServerUserUpdateOptions, serverUserCreateOptionsToCrud, serverUserUpdateOptionsToCrud, withUserDestructureGuard } from "../../users"; import { StackServerAppConstructorOptions } from "../interfaces/server-app"; import { _StackClientAppImplIncomplete } from "./client-app-impl"; -import { LOCAL_EMULATOR_INTERNAL_PUBLISHABLE_CLIENT_KEY, clientVersion, createCache, createCacheBySession, fetchEmulatorProjectCredentials, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, getLocalEmulatorConfigFilePath, localEmulatorBaseUrl, resolveApiUrls, resolveConstructorOptions } from "./common"; +import { LOCAL_EMULATOR_INTERNAL_PUBLISHABLE_CLIENT_KEY, assertNoEmulatorOptionConflict, clientVersion, createCache, createCacheBySession, fetchEmulatorProjectCredentials, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, getLocalEmulatorConfigFilePath, localEmulatorBaseUrl, resolveApiUrls, resolveConstructorOptions } from "./common"; import { useAsyncCache } from "./common"; // THIS_LINE_PLATFORM react-like @@ -420,6 +420,12 @@ export class _StackServerAppImplIncomplete Date: Mon, 13 Apr 2026 12:40:35 -0700 Subject: [PATCH 15/29] emulator: don't strip the clickhouse binary (breaks self-extractor) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The clickhouse binary since 22.x is a small ELF loader with a ZSTD-compressed payload appended after the section table. strip rewrites the ELF and can invalidate the loader's lookup of its own trailing payload, causing it to decompress garbage and spin forever — the exact symptom on cross-arch TCG runs where clickhouse-server produced zero log output while postgres/redis/svix/minio/qstash (none of them self-extracting) all started fine under identical settings. Stripping was a no-op for size anyway; the payload bytes live outside any section and strip can't touch them. --- docker/local-emulator/Dockerfile | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docker/local-emulator/Dockerfile b/docker/local-emulator/Dockerfile index 7784b5ae71..db7cba2b33 100644 --- a/docker/local-emulator/Dockerfile +++ b/docker/local-emulator/Dockerfile @@ -114,7 +114,14 @@ COPY --from=minio-bin /usr/bin/minio /out/minio COPY --from=mc-bin /usr/bin/mc /out/mc COPY --from=qstash-bin /qstash-binary /out/qstash RUN chmod u+w /out/* && \ - strip --strip-all /out/clickhouse /out/minio /out/svix-server /out/mc /out/qstash && \ + # Intentionally NOT stripping /out/clickhouse. The clickhouse binary is a + # self-extracting compressed executable (a small loader with a ZSTD + # payload appended after the section table); strip rewrites the ELF and + # can invalidate the loader's "find my payload" lookup, causing the + # decompressor to spin on garbage with zero log output — the exact + # symptom seen on cross-arch TCG runs. Savings from stripping would be + # only the tiny bootstrap anyway since the payload isn't in any section. + strip --strip-all /out/minio /out/svix-server /out/mc /out/qstash && \ upx -9 /out/minio /out/svix-server /out/mc /out/qstash From 999843b4c0c2b8e086590ac0ddebc8bcb5da0078 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 13 Apr 2026 13:33:05 -0700 Subject: [PATCH 16/29] emulator: bump cross-arch TCG -cpu to cortex-a76 (LSE for ClickHouse) With strip no longer corrupting the ClickHouse self-extractor, clickhouse-server now reaches first-instruction execution and immediately SIGILLs in a supervisord crash loop. Root cause: its statically-linked LSE atomics (armv8.1) are rejected under -cpu cortex-a72 (armv8.0). cortex-a76 is armv8.2-a: LSE available, but no PAC (v8.3) and no BTI (v8.5), so V8's aggressive JIT tiers still don't see the feature flags that tripped cross-arch TCG's translator on the old -cpu max runs. Combined with `node --no-opt` on migrations (Ignition+Sparkplug only, no TurboFan/Maglev), this is the narrow CPU profile that should let both V8 and ClickHouse coexist under cross-arch TCG. --- docker/local-emulator/qemu/build-image.sh | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/docker/local-emulator/qemu/build-image.sh b/docker/local-emulator/qemu/build-image.sh index 71bb4fae93..66cb40d3db 100755 --- a/docker/local-emulator/qemu/build-image.sh +++ b/docker/local-emulator/qemu/build-image.sh @@ -124,13 +124,20 @@ qemu_cmd_prefix_for_arch() { linux) [ -w /dev/kvm ] && accel="kvm" ;; esac else - # Cross-arch TCG (amd64 host emulating arm64 guest): -cpu max - # advertises armv8.5+ features (PAC, BTI, SVE, LSE…) that V8 - # emits JIT code for, but the host's TCG mistranslates some of - # those instructions across architectures and node crashes with - # SIGTRAP during migrations. Dropping to cortex-a72 limits V8 - # to armv8.0-a which cross-arch TCG handles cleanly. - cpu="cortex-a72" + # Cross-arch TCG (amd64 host emulating arm64 guest) needs a CPU + # model that threads a narrow needle: + # * -cpu max advertises armv8.5+ features (PAC, BTI, SVE, LSE…) + # that V8's TurboFan then emits JIT code for; cross-arch TCG + # mistranslates some of those and node SIGTRAPs in migrations. + # * -cpu cortex-a72 (armv8.0-a) keeps V8 safe but makes + # ClickHouse SIGILL on startup because its statically-linked + # LSE atomics (armv8.1+) aren't recognized. + # cortex-a76 is armv8.2-a: it exposes LSE (ClickHouse happy) + # while predating PAC (v8.3) and BTI (v8.5), so V8's aggressive + # JIT tiers don't emit the instructions that tripped TCG. Pair + # this with `node --no-opt` on the migration exec, which keeps + # V8 in Ignition+Sparkplug only (no TurboFan/Maglev). + cpu="cortex-a76" fi local firmware firmware="$(find_aarch64_firmware)" From 0896f144546057b58a0812263f314cb42438f71e Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 13 Apr 2026 14:13:17 -0700 Subject: [PATCH 17/29] ci: skip emulator boot/verify on arm64 (cross-arch TCG) Two independent reasons this can't work under cross-arch TCG on the ubicloud amd64 runner: 1. The backend at runtime runs without --no-opt (we only apply the flag to the one-shot migration exec). That means TurboFan is live and will re-emit the aggressive arm64 JIT code the original -cpu max runs SIGTRAP'd on. Baking --no-opt into the runtime entrypoint would ship in the image and permanently degrade real arm64 users (who have KVM and don't need it). 2. Even if we fixed (1), next start under cross-arch TCG is too slow to come up within any reasonable timeout. amd64 verify under KVM already exercises the image's service stack; the arm64 artifact is built from the same Dockerfile and trusted to run on real arm64 hardware where KVM is available. --- .github/workflows/qemu-emulator-build.yaml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/qemu-emulator-build.yaml b/.github/workflows/qemu-emulator-build.yaml index e6b615d652..dc1cf521a9 100644 --- a/.github/workflows/qemu-emulator-build.yaml +++ b/.github/workflows/qemu-emulator-build.yaml @@ -80,7 +80,17 @@ jobs: - name: Generate emulator env run: node docker/local-emulator/generate-env-development.mjs + # VM boot + service verification is amd64-only. arm64 runs under + # cross-arch TCG on this branch, where (a) the Next.js backend + # runtime still uses V8 TurboFan (we only applied --no-opt to the + # one-shot migration exec), which would re-trigger the original + # cross-arch TCG SIGTRAP, and (b) even if we solved that, next start + # is too slow under TCG to come up within any reasonable timeout. + # amd64 verify under KVM already exercises the image's service + # stack; real arm64 hardware has KVM, so end-users exercise it + # properly on their machines. - name: Start emulator and verify + if: matrix.arch == 'amd64' run: | chmod +x docker/local-emulator/qemu/run-emulator.sh EMULATOR_ARCH=${{ matrix.arch }} \ @@ -88,12 +98,13 @@ jobs: docker/local-emulator/qemu/run-emulator.sh start - name: Verify services are healthy + if: matrix.arch == 'amd64' run: | EMULATOR_ARCH=${{ matrix.arch }} \ docker/local-emulator/qemu/run-emulator.sh status - name: Stop emulator - if: always() + if: always() && matrix.arch == 'amd64' run: | EMULATOR_ARCH=${{ matrix.arch }} \ docker/local-emulator/qemu/run-emulator.sh stop From 44e4079032673de93c27abea97990c90e3668ec9 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 13 Apr 2026 14:54:54 -0700 Subject: [PATCH 18/29] emulator: add --no-wasm-tier-up to migration exec After fixing the JS-side SIGTRAP with --no-opt and getting ClickHouse happy on cortex-a76, migrations finally ran on cross-arch TCG and immediately hit a V8 internal assertion in Runtime_WasmTriggerTierUp: Check failed: it->second.Size() > offset. Heap::GcSafeFindCodeForInnerPointer InnerPointerToCodeCache::GetCacheEntry StackFrameIterator::Advance Runtime_WasmTriggerTierUp Same class of bug as the JS SIGTRAP, just in the wasm pipeline: once Prisma 7's wasm query compiler gets hot, V8 walks the stack to promote from Liftoff to TurboFan, and cross-arch TCG's translated inner pointers don't line up with V8's code-cache entries. --no-opt only affects the JS tiers; wasm has its own. Pin wasm to Liftoff with --no-wasm-tier-up. Liftoff is still JITed (unlike --wasm-jitless, which would force the interpreter and tank migration time), so Prisma speed stays reasonable. --- .../qemu/cloud-init/emulator/user-data | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/docker/local-emulator/qemu/cloud-init/emulator/user-data b/docker/local-emulator/qemu/cloud-init/emulator/user-data index f93a586e3c..d19c1319ee 100644 --- a/docker/local-emulator/qemu/cloud-init/emulator/user-data +++ b/docker/local-emulator/qemu/cloud-init/emulator/user-data @@ -283,20 +283,30 @@ write_files: # Capture stdout+stderr so failures surface the actual node error in # the host-visible provision log instead of being swallowed by the # serial-only stream. - # node --no-opt disables V8's TurboFan optimizer (the tier that emits - # aggressive arm64 instructions — PAC/BTI/LSE — that cross-arch TCG - # mis-translates into the migration SIGTRAP). Ignition + Sparkplug - # still run, and the Wasm JIT is left intact so Prisma 7's wasm query - # compiler keeps working. Passed directly on node's CLI because - # NODE_OPTIONS's allowlist rejects --no-opt. Cheap enough under KVM - # that we apply it unconditionally rather than gating on arch. + # Two V8 flags, both only meaningful under cross-arch TCG, both harmless + # under KVM so we apply unconditionally: + # --no-opt disables TurboFan/Maglev. Without it, TurboFan + # emits aggressive arm64 (PAC/BTI/LSE) that + # cross-arch TCG mis-translates into a SIGTRAP + # before the first migration runs. + # --no-wasm-tier-up pins Prisma 7's wasm query compiler to V8's + # Liftoff baseline. Without it, once wasm + # functions get hot, V8 calls + # Runtime_WasmTriggerTierUp which walks the + # stack via InnerPointerToCodeCache — and + # cross-arch TCG's translated inner pointers + # don't line up with V8's code-cache entries, + # tripping `Check failed: it->second.Size() > + # offset` deep inside v8::internal::Heap. + # Both flags are passed on node's CLI because NODE_OPTIONS's allowlist + # rejects them. migrate_log="$(mktemp)" set +e docker exec \ --env-file /etc/stack-build.env \ --env-file /etc/stack-build-computed.env \ stack-build-init \ - sh -c 'cd /app/apps/backend && node --no-opt dist/db-migrations.mjs migrate && node --no-opt dist/db-migrations.mjs seed' \ + sh -c 'cd /app/apps/backend && node --no-opt --no-wasm-tier-up dist/db-migrations.mjs migrate && node --no-opt --no-wasm-tier-up dist/db-migrations.mjs seed' \ > "$migrate_log" 2>&1 migrate_status=$? set -e From 9ec08f4aa253b192d7e597a03b6d13993267c403 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 13 Apr 2026 16:36:48 -0700 Subject: [PATCH 19/29] emulator: dedupe probe list, factor log-stream and console-marker helpers - wait-for-deps: table-drive wait probes via a SERVICES array (name|probe pairs) - add /usr/local/bin/log-provision-stream for the repeated `cmd | while read line; do log prefix: $line; done` pattern - factor write_marker_to_consoles in provision-build - drop dead case sanitizer in build-image.sh:line_count - tighten narrating comments in workflow and run-build-migrations --- .github/workflows/qemu-emulator-build.yaml | 14 +-- docker/local-emulator/qemu/build-image.sh | 6 -- .../qemu/cloud-init/emulator/user-data | 93 ++++++++++--------- 3 files changed, 55 insertions(+), 58 deletions(-) diff --git a/.github/workflows/qemu-emulator-build.yaml b/.github/workflows/qemu-emulator-build.yaml index dc1cf521a9..a5a3f187df 100644 --- a/.github/workflows/qemu-emulator-build.yaml +++ b/.github/workflows/qemu-emulator-build.yaml @@ -80,15 +80,11 @@ jobs: - name: Generate emulator env run: node docker/local-emulator/generate-env-development.mjs - # VM boot + service verification is amd64-only. arm64 runs under - # cross-arch TCG on this branch, where (a) the Next.js backend - # runtime still uses V8 TurboFan (we only applied --no-opt to the - # one-shot migration exec), which would re-trigger the original - # cross-arch TCG SIGTRAP, and (b) even if we solved that, next start - # is too slow under TCG to come up within any reasonable timeout. - # amd64 verify under KVM already exercises the image's service - # stack; real arm64 hardware has KVM, so end-users exercise it - # properly on their machines. + # arm64 runs under cross-arch TCG on an amd64 runner; the backend's + # V8 TurboFan JIT re-triggers the SIGTRAPs we dodge in migrations + # with --no-opt, and even if it didn't, boot is too slow under TCG + # to verify in any sane window. amd64 KVM already exercises the + # service stack; real arm64 hosts have KVM for end-users. - name: Start emulator and verify if: matrix.arch == 'amd64' run: | diff --git a/docker/local-emulator/qemu/build-image.sh b/docker/local-emulator/qemu/build-image.sh index 66cb40d3db..498d161735 100755 --- a/docker/local-emulator/qemu/build-image.sh +++ b/docker/local-emulator/qemu/build-image.sh @@ -216,15 +216,9 @@ contains_provision_marker() { line_count() { local file="$1" local count=0 - if [ -f "$file" ]; then count="$(wc -l < "$file" | tr -d '[:space:]')" || count=0 fi - - case "$count" in - ''|*[!0-9]*) count=0 ;; - esac - printf '%s\n' "$count" } diff --git a/docker/local-emulator/qemu/cloud-init/emulator/user-data b/docker/local-emulator/qemu/cloud-init/emulator/user-data index d19c1319ee..7d50362eba 100644 --- a/docker/local-emulator/qemu/cloud-init/emulator/user-data +++ b/docker/local-emulator/qemu/cloud-init/emulator/user-data @@ -43,12 +43,11 @@ write_files: gzip -dc /mnt/stack-bundle/img.tgz | docker load - # Copy build env file for pre-baking migrations if [ -f /mnt/stack-bundle/build.env ]; then cp /mnt/stack-bundle/build.env /etc/stack-build.env fi - # Copy per-arch build metadata (used to skip smoke test on cross-arch TCG builds) + # build-arch.env lets the guest skip the smoke test on cross-arch TCG. if [ -f /mnt/stack-bundle/build-arch.env ]; then cp /mnt/stack-bundle/build-arch.env /etc/stack-build-arch.env fi @@ -165,14 +164,25 @@ write_files: start=$SECONDS log() { /usr/local/bin/log-provision "wait-for-deps: $*"; } + # name|probe pairs — probe runs through `eval` and must exit 0 when ready. + # No --max-time on these: under slow TCG a service may take >3s to + # respond; let curl wait, outer DEPS_TIMEOUT bounds the whole dep wait. + SERVICES=( + 'postgres|nc -z 127.0.0.1 5432' + 'clickhouse|curl -sf http://127.0.0.1:8123/ping' + 'svix|curl -sf http://127.0.0.1:8071/api/v1/health/' + 'minio|curl -sf http://127.0.0.1:9090/minio/health/live' + 'qstash|[ "$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/ 2>/dev/null || true)" = "401" ]' + ) + dump_diagnostics() { log "dumping diagnostics for stuck dep wait..." log "--- docker ps -a ---" - docker ps -a 2>&1 | while IFS= read -r line; do log "ps: $line"; done || true + docker ps -a 2>&1 | /usr/local/bin/log-provision-stream "wait-for-deps: ps" || true log "--- docker logs ${DEPS_CONTAINER} (last 300 lines) ---" - docker logs --tail 300 "$DEPS_CONTAINER" 2>&1 | while IFS= read -r line; do log "deps: $line"; done || true - log "--- per-service probes ---" - nc -z 127.0.0.1 5432 >/dev/null 2>&1 && log "postgres:5432 reachable" || log "postgres:5432 NOT reachable" + docker logs --tail 300 "$DEPS_CONTAINER" 2>&1 | /usr/local/bin/log-provision-stream "wait-for-deps: deps" || true + log "--- per-service probes (3s timeout) ---" + nc -z -w 3 127.0.0.1 5432 >/dev/null 2>&1 && log "postgres:5432 reachable" || log "postgres:5432 NOT reachable" curl -sf --max-time 3 http://127.0.0.1:8123/ping >/dev/null 2>&1 && log "clickhouse:8123 reachable" || log "clickhouse:8123 NOT reachable" curl -sf --max-time 3 http://127.0.0.1:8071/api/v1/health/ >/dev/null 2>&1 && log "svix:8071 reachable" || log "svix:8071 NOT reachable" curl -sf --max-time 3 http://127.0.0.1:9090/minio/health/live >/dev/null 2>&1 && log "minio:9090 reachable" || log "minio:9090 NOT reachable" @@ -205,11 +215,9 @@ write_files: } log "starting dep wait (timeout=${DEPS_TIMEOUT}s)" - wait_for "postgres" 'nc -z 127.0.0.1 5432' - wait_for "clickhouse" 'curl -sf http://127.0.0.1:8123/ping' - wait_for "svix" 'curl -sf http://127.0.0.1:8071/api/v1/health/' - wait_for "minio" 'curl -sf http://127.0.0.1:9090/minio/health/live' - wait_for "qstash" '[ "$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/ 2>/dev/null || true)" = "401" ]' + for entry in "${SERVICES[@]}"; do + wait_for "${entry%%|*}" "${entry#*|}" + done log "all deps ready ($((SECONDS - start))s total)" - path: /etc/stack-build-computed.env @@ -239,6 +247,17 @@ write_files: printf '%s\n' "$msg" >> "$STACK_PROVISION_LOG_FILE" fi + - path: /usr/local/bin/log-provision-stream + permissions: '0755' + content: | + #!/bin/bash + set -uo pipefail + + prefix="${1:-}" + while IFS= read -r line; do + /usr/local/bin/log-provision "${prefix}: ${line}" + done + - path: /usr/local/bin/run-build-migrations permissions: '0755' content: | @@ -280,26 +299,12 @@ write_files: log "init-services done (${elapsed}s)." log "Running migrations..." - # Capture stdout+stderr so failures surface the actual node error in - # the host-visible provision log instead of being swallowed by the - # serial-only stream. - # Two V8 flags, both only meaningful under cross-arch TCG, both harmless - # under KVM so we apply unconditionally: - # --no-opt disables TurboFan/Maglev. Without it, TurboFan - # emits aggressive arm64 (PAC/BTI/LSE) that - # cross-arch TCG mis-translates into a SIGTRAP - # before the first migration runs. - # --no-wasm-tier-up pins Prisma 7's wasm query compiler to V8's - # Liftoff baseline. Without it, once wasm - # functions get hot, V8 calls - # Runtime_WasmTriggerTierUp which walks the - # stack via InnerPointerToCodeCache — and - # cross-arch TCG's translated inner pointers - # don't line up with V8's code-cache entries, - # tripping `Check failed: it->second.Size() > - # offset` deep inside v8::internal::Heap. - # Both flags are passed on node's CLI because NODE_OPTIONS's allowlist - # rejects them. + # --no-opt / --no-wasm-tier-up keep V8 off TurboFan/Maglev and pin + # Prisma's wasm to Liftoff; without them cross-arch TCG mistranslates + # JIT-emitted arm64 (SIGTRAP on migrate) and V8's wasm tier-up trips + # an InnerPointerToCodeCache check deep in the heap. Both flags are + # no-ops under KVM, and must be passed on node's CLI (NODE_OPTIONS + # rejects them). migrate_log="$(mktemp)" set +e docker exec \ @@ -312,7 +317,7 @@ write_files: set -e if [ "$migrate_status" -ne 0 ]; then log "MIGRATIONS FAILED (exit ${migrate_status}) — last 200 lines of migration output:" - tail -200 "$migrate_log" | while IFS= read -r line; do log "migrate: $line"; done || true + tail -200 "$migrate_log" | /usr/local/bin/log-provision-stream "migrate" || true rm -f "$migrate_log" exit "$migrate_status" fi @@ -390,13 +395,13 @@ write_files: if [ "$smoke_passed" = "false" ]; then log "SMOKE TEST FAILED: backend /health?db=1 did not return 200 within ${smoke_timeout}s" log "--- docker ps -a ---" - docker ps -a 2>&1 | while IFS= read -r line; do log "ps: $line"; done || true + docker ps -a 2>&1 | /usr/local/bin/log-provision-stream "ps" || true log "--- smoke-test container logs (last 200 lines) ---" - docker logs --tail 200 smoke-test 2>&1 | while IFS= read -r line; do log "smoke-test: $line"; done || true + docker logs --tail 200 smoke-test 2>&1 | /usr/local/bin/log-provision-stream "smoke-test" || true log "--- free -m ---" - free -m 2>&1 | while IFS= read -r line; do log "mem: $line"; done || true + free -m 2>&1 | /usr/local/bin/log-provision-stream "mem" || true log "--- curl -v /health?db=1 ---" - curl -v --max-time 5 http://127.0.0.1:8102/health?db=1 2>&1 | while IFS= read -r line; do log "curl: $line"; done || true + curl -v --max-time 5 http://127.0.0.1:8102/health?db=1 2>&1 | /usr/local/bin/log-provision-stream "curl" || true docker stop smoke-test 2>/dev/null || true exit 1 fi @@ -472,6 +477,13 @@ write_files: export STACK_PROVISION_LOG_FILE="" fi + write_marker_to_consoles() { + local marker="$1" + for dev in /dev/console /dev/ttyAMA0 /dev/ttyS0; do + echo "$marker" > "$dev" 2>/dev/null || true + done + } + cleanup() { local status=$? if [ "$status" -ne 0 ]; then @@ -479,9 +491,7 @@ write_files: printf 'ERROR: provision-build exited with code %s\n' "$status" >> "$STACK_PROVISION_LOG_FILE" printf '%s\n' "STACK_CLOUD_INIT_FAILED" >> "$STACK_PROVISION_LOG_FILE" fi - for dev in /dev/console /dev/ttyAMA0 /dev/ttyS0; do - echo "STACK_CLOUD_INIT_FAILED" > "$dev" 2>/dev/null || true - done + write_marker_to_consoles "STACK_CLOUD_INIT_FAILED" sync || true (sleep 2 && shutdown -P now) & (sleep 15 && poweroff -f) & @@ -489,7 +499,6 @@ write_files: } trap cleanup EXIT - # Find the serial device and tee all output to it SERIAL="" for d in /dev/ttyAMA0 /dev/ttyS0; do [ -c "$d" ] && SERIAL="$d" && break @@ -523,9 +532,7 @@ write_files: if [ -n "${STACK_PROVISION_LOG_FILE:-}" ]; then printf '%s\n' "STACK_CLOUD_INIT_DONE" >> "$STACK_PROVISION_LOG_FILE" fi - for dev in /dev/console /dev/ttyAMA0 /dev/ttyS0; do - echo "STACK_CLOUD_INIT_DONE" > "$dev" 2>/dev/null || true - done + write_marker_to_consoles "STACK_CLOUD_INIT_DONE" shutdown -P now From 9e38bc621103e9197086d4d78a91fdecac2a6e16 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 13 Apr 2026 19:56:44 -0700 Subject: [PATCH 20/29] local emulator changes --- apps/backend/prisma/seed.ts | 61 +++--- .../internal/local-emulator/project/route.tsx | 23 ++- apps/backend/src/lib/local-emulator.ts | 4 - docker/local-emulator/Dockerfile | 58 +++++- .../generate-env-development.mjs | 8 +- .../qemu/cloud-init/emulator/user-data | 88 +++++++-- docker/local-emulator/qemu/run-emulator.sh | 17 +- docker/local-emulator/run-cron-jobs.sh | 28 +++ docker/local-emulator/supervisord.conf | 53 +++++- docker/server/entrypoint.sh | 56 +++++- examples/middleware/package.json | 2 +- examples/middleware/src/middleware.tsx | 2 +- examples/middleware/stack.config.ts | 19 ++ packages/stack-cli/src/commands/emulator.ts | 174 +++++++++++++++++- .../src/interface/admin-interface.ts | 11 +- .../src/interface/client-interface.ts | 18 +- .../src/interface/server-interface.ts | 11 +- packages/template/src/lib/env.ts | 3 - .../apps/implementations/admin-app-impl.ts | 37 +--- .../apps/implementations/client-app-impl.ts | 28 +-- .../stack-app/apps/implementations/common.ts | 66 +------ .../apps/implementations/server-app-impl.ts | 33 +--- .../stack-app/apps/interfaces/client-app.ts | 7 - 23 files changed, 546 insertions(+), 261 deletions(-) create mode 100755 docker/local-emulator/run-cron-jobs.sh create mode 100644 examples/middleware/stack.config.ts diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 30e8aae0ec..ff3715d3b2 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -259,6 +259,45 @@ export async function seed() { console.log('Internal team created'); } + // Upsert the internal API key set before any flake-prone work (dummy-project + // seed, email/svix, clickhouse). The emulator CLI authenticates against the + // internal project using the pck stored here, so it must land before the rest + // of the seed even if something later fails. + const isLocalEmulator = process.env.NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR === 'true'; + const rawPck = process.env.STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY; + if (isLocalEmulator && !rawPck) { + // Emulator images build before a per-VM pck is available. Runtime boots set + // STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY from the VM-generated + // random value and re-run the seed, which upserts the internal key set then. + console.log('Skipping internal API key set (no pck provided; emulator mode).'); + } else { + const keySet = { + publishableClientKey: rawPck || throwErr('STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY is not set'), + secretServerKey: isLocalEmulator + ? (process.env.STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY ?? null) + : (process.env.STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY || throwErr('STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY is not set')), + superSecretAdminKey: isLocalEmulator + ? (process.env.STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY ?? null) + : (process.env.STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY || throwErr('STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY is not set')), + }; + + await globalPrismaClient.apiKeySet.upsert({ + where: { projectId_id: { projectId: 'internal', id: apiKeyId } }, + update: { + ...keySet, + }, + create: { + id: apiKeyId, + projectId: 'internal', + description: "Internal API key set", + expiresAt: new Date('2099-12-31T23:59:59Z'), + ...keySet, + } + }); + + console.log('Updated internal API key set'); + } + const shouldSeedDummyProject = process.env.STACK_SEED_ENABLE_DUMMY_PROJECT === 'true'; if (shouldSeedDummyProject) { await seedDummyProject({ @@ -268,28 +307,6 @@ export async function seed() { }); } - const keySet = { - publishableClientKey: process.env.STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY || throwErr('STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY is not set'), - secretServerKey: process.env.STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY || throwErr('STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY is not set'), - superSecretAdminKey: process.env.STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY || throwErr('STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY is not set'), - }; - - await globalPrismaClient.apiKeySet.upsert({ - where: { projectId_id: { projectId: 'internal', id: apiKeyId } }, - update: { - ...keySet, - }, - create: { - id: apiKeyId, - projectId: 'internal', - description: "Internal API key set", - expiresAt: new Date('2099-12-31T23:59:59Z'), - ...keySet, - } - }); - - console.log('Updated internal API key set'); - // Create optional default admin user if credentials are provided. // This user will be able to login to the dashboard with both email/password and magic link. 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 40de9930c1..1ac870dc16 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 @@ -1,4 +1,5 @@ import { Prisma } from "@/generated/prisma/client"; +import { overrideBranchConfigOverride } from "@/lib/config"; import { LOCAL_EMULATOR_ADMIN_USER_ID, LOCAL_EMULATOR_ONLY_ENDPOINT_MESSAGE, @@ -58,14 +59,15 @@ async function assertLocalEmulatorOwnerTeamReadiness() { } } -async function getOrCreateLocalEmulatorProjectId(absoluteFilePath: string): Promise { +async function getOrCreateLocalEmulatorProjectId(absoluteFilePath: string): Promise<{ projectId: string, created: boolean }> { const existingRows = await globalPrismaClient.$queryRaw(Prisma.sql` SELECT "projectId" FROM "LocalEmulatorProject" WHERE "absoluteFilePath" = ${absoluteFilePath} LIMIT 1 `); - const projectId = existingRows[0] ? existingRows[0].projectId : generateUuid(); + const existingRow = existingRows.length > 0 ? existingRows[0] : undefined; + const projectId = existingRow ? existingRow.projectId : generateUuid(); await globalPrismaClient.project.upsert({ where: { @@ -107,7 +109,7 @@ async function getOrCreateLocalEmulatorProjectId(absoluteFilePath: string): Prom "updatedAt" = NOW() `); - return projectId; + return { projectId, created: existingRow === undefined }; } async function getOrCreateCredentials(projectId: string) { @@ -217,7 +219,20 @@ export const POST = createSmartRouteHandler({ await assertLocalEmulatorOwnerTeamReadiness(); - const projectId = await getOrCreateLocalEmulatorProjectId(absoluteFilePath); + const { projectId, created } = await getOrCreateLocalEmulatorProjectId(absoluteFilePath); + if (created) { + // Emulator projects are for local development. Default-allow localhost + // redirects so fresh projects don't hit "Redirect URL not whitelisted" + // before the developer has configured trustedDomains. User-supplied + // stack.config.ts still overrides this if they set domains explicitly. + await overrideBranchConfigOverride({ + projectId, + branchId: DEFAULT_BRANCH_ID, + branchConfigOverrideOverride: { + "domains.allowLocalhost": true, + }, + }); + } const credentials = await getOrCreateCredentials(projectId); const fileConfig = await readConfigFromFile(absoluteFilePath); diff --git a/apps/backend/src/lib/local-emulator.ts b/apps/backend/src/lib/local-emulator.ts index 9110f0083e..c56379d47d 100644 --- a/apps/backend/src/lib/local-emulator.ts +++ b/apps/backend/src/lib/local-emulator.ts @@ -7,10 +7,6 @@ import fs from "fs/promises"; import { createJiti } from "jiti"; import path from "path"; -export const LOCAL_EMULATOR_INTERNAL_PUBLISHABLE_CLIENT_KEY = "local-emulator-publishable-client-key"; -export const LOCAL_EMULATOR_INTERNAL_SECRET_SERVER_KEY = "local-emulator-secret-server-key"; -export const LOCAL_EMULATOR_INTERNAL_SUPER_SECRET_ADMIN_KEY = "local-emulator-super-secret-admin-key"; - export const LOCAL_EMULATOR_ADMIN_USER_ID = "63abbc96-5329-454a-ba56-e0460173c6c1"; export const LOCAL_EMULATOR_OWNER_TEAM_ID = "5a0c858b-d9e9-49d4-9943-8ce385d86428"; export const LOCAL_EMULATOR_ADMIN_EMAIL = "local-emulator@stack-auth.com"; diff --git a/docker/local-emulator/Dockerfile b/docker/local-emulator/Dockerfile index db7cba2b33..b075947040 100644 --- a/docker/local-emulator/Dockerfile +++ b/docker/local-emulator/Dockerfile @@ -52,6 +52,7 @@ COPY docs ./docs # https://nextjs.org/docs/pages/api-reference/next-config-js/output ENV NEXT_CONFIG_OUTPUT=standalone +ENV NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY=pk_test_mock_publishable_key_for_local_emulator # Build the backend NextJS app RUN pnpm turbo run docker-build --filter=@stackframe/backend... --filter=@stackframe/dashboard... @@ -89,8 +90,47 @@ RUN cp -a /app/node_modules /pruned-node_modules && \ date-fns@2* date-fns@3* +# ── Freestyle mock build ───────────────────────────────────────────────────── + +FROM node-base AS freestyle-mock-builder +WORKDIR /freestyle-mock +COPY docker/dependencies/freestyle-mock/Dockerfile /tmp/freestyle-mock-dockerfile +# Extract the inline package.json and server.mjs from the Dockerfile's RUN cat commands, +# then install dependencies. This avoids duplicating the source. +RUN node -e " \ + const fs = require('fs'); \ + const df = fs.readFileSync('/tmp/freestyle-mock-dockerfile', 'utf8'); \ + const pkgMatch = df.match(/cat <<'EOF' > package\\.json\\n([\\s\\S]*?)\\nEOF/); \ + fs.writeFileSync('package.json', pkgMatch[1]); \ + const srvMatch = df.match(/cat <<'EOF' > server\\.mjs\\n([\\s\\S]*?)\\nEOF/); \ + let server = srvMatch[1]; \ + server = server.replace('server.listen(8080)', 'server.listen(process.env.PORT || 8080)'); \ + server = server.replace( \ + 'from \"fs/promises\"', \ + 'from \"fs/promises\"; import { symlinkSync } from \"fs\"' \ + ); \ + server = server.replace( \ + 'await mkdir(workDir, { recursive: true });', \ + 'await mkdir(workDir, { recursive: true }); try { symlinkSync(\"/app/freestyle-mock/node_modules\", join(workDir, \"node_modules\")); } catch {}' \ + ); \ + fs.writeFileSync('server.mjs', server); \ +" +RUN npm install + + +# ── Mock OAuth server build ─────────────────────────────────────────────────── + +FROM node-base AS mock-oauth-builder +WORKDIR /mock-oauth +COPY apps/mock-oauth-server/package.json . +RUN pnpm install && pnpm add esbuild --save-dev +COPY apps/mock-oauth-server/src ./src +RUN npx esbuild src/index.ts --bundle --platform=node --target=node22 --outfile=dist/index.cjs + + # ── Service binary stages ───────────────────────────────────────────────────── +FROM stripe/stripe-mock:v0.195.0 AS stripe-mock-bin FROM inbucket/inbucket:3.1.0 AS inbucket-bin FROM svix/svix-server:v1.88.0 AS svix-bin FROM clickhouse/clickhouse-server:25.10 AS clickhouse-bin @@ -161,6 +201,9 @@ COPY --from=node-base /usr/local/bin/node /usr/local/bin/node # Inbucket COPY --from=inbucket-bin /opt/inbucket /opt/inbucket +# Stripe mock +COPY --from=stripe-mock-bin /bin/stripe-mock /usr/local/bin/stripe-mock + # Svix (UPX-compressed) COPY --from=upx-compress /out/svix-server /usr/local/bin/svix-server @@ -193,6 +236,14 @@ RUN cp -a /app/node_modules /app/node_modules.standalone 2>/dev/null || mkdir -p COPY --from=migration-pruner /pruned-node_modules ./node_modules COPY --from=builder /app/packages ./packages +# Mock OAuth server (bundled single file) +COPY --from=mock-oauth-builder /mock-oauth/dist/index.cjs /app/mock-oauth-server/index.cjs + +# Freestyle mock (JS execution for email rendering) +COPY --from=freestyle-mock-builder /freestyle-mock /app/freestyle-mock +COPY --from=node-base /usr/local/bin/npm /usr/local/bin/npm +COPY --from=node-base /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/npm + RUN mkdir -p \ /data/postgres \ /data/redis \ @@ -209,17 +260,18 @@ RUN mkdir -p \ && chown -R postgres:postgres /data/postgres COPY docker/local-emulator/supervisord.conf /etc/supervisor/conf.d/supervisord.conf +COPY docker/local-emulator/run-cron-jobs.sh /run-cron-jobs.sh COPY docker/local-emulator/entrypoint.sh /entrypoint.sh COPY docker/local-emulator/init-services.sh /init-services.sh COPY docker/local-emulator/start-app.sh /start-app.sh COPY docker/local-emulator/clickhouse-config.xml /etc/clickhouse-server/config.xml COPY docker/local-emulator/clickhouse-users.xml /etc/clickhouse-server/users.xml COPY docker/server/entrypoint.sh /app-entrypoint.sh -RUN chmod +x /entrypoint.sh /init-services.sh /start-app.sh /app-entrypoint.sh +RUN chmod +x /entrypoint.sh /init-services.sh /start-app.sh /app-entrypoint.sh /run-cron-jobs.sh # PostgreSQL: 5432, Redis: 6379, Inbucket: 2500/9001/1100, # Svix: 8071, ClickHouse: 8123/9009, MinIO: 9090, QStash: 8080 -# Backend: 8102, Dashboard: 8101 -EXPOSE 5432 6379 2500 9001 1100 8071 8123 9009 9090 8080 8101 8102 +# Backend: 8102, Dashboard: 8101, Mock OAuth: 8114 +EXPOSE 5432 6379 2500 9001 1100 8071 8123 9009 9090 8080 8101 8102 8114 ENTRYPOINT ["/entrypoint.sh"] diff --git a/docker/local-emulator/generate-env-development.mjs b/docker/local-emulator/generate-env-development.mjs index f0b0b20d23..f582144570 100644 --- a/docker/local-emulator/generate-env-development.mjs +++ b/docker/local-emulator/generate-env-development.mjs @@ -90,9 +90,11 @@ const entries = [ fromSource("apps/backend/.env.development", backendEnv, "STACK_SEED_INTERNAL_PROJECT_ALLOW_LOCALHOST"), fromSource("apps/backend/.env.development", backendEnv, "STACK_SEED_INTERNAL_PROJECT_OAUTH_PROVIDERS"), fromSource("apps/backend/.env.development", backendEnv, "STACK_SEED_INTERNAL_PROJECT_USER_INTERNAL_ACCESS"), - fromSource("apps/backend/.env.development", backendEnv, "STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY"), - fromSource("apps/backend/.env.development", backendEnv, "STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY"), - fromSource("apps/backend/.env.development", backendEnv, "STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY"), + // STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY is generated per-VM at boot + // by docker/local-emulator/qemu/cloud-init/emulator/user-data and injected via + // /run/stack-auth/local-emulator.env. SECRET_SERVER_KEY and SUPER_SECRET_ADMIN_KEY + // are intentionally omitted so the seed script leaves them null on the internal + // project; per-project credentials come from /api/v1/internal/local-emulator/project. blank(), comment("# Third-party/test integrations"), fromSource("apps/backend/.env.development", backendEnv, "STACK_SVIX_API_KEY"), diff --git a/docker/local-emulator/qemu/cloud-init/emulator/user-data b/docker/local-emulator/qemu/cloud-init/emulator/user-data index 7d50362eba..d87142f40d 100644 --- a/docker/local-emulator/qemu/cloud-init/emulator/user-data +++ b/docker/local-emulator/qemu/cloud-init/emulator/user-data @@ -58,7 +58,7 @@ write_files: #!/bin/bash set -euo pipefail - mkdir -p /mnt/stack-runtime /run/stack-auth + mkdir -p /mnt/stack-runtime /run/stack-auth /var/lib/stack-auth runtime_device="$(readlink -f /dev/disk/by-label/STACKCFG)" mountpoint -q /mnt/stack-runtime || mount -o ro "$runtime_device" /mnt/stack-runtime @@ -67,6 +67,24 @@ write_files: source /mnt/stack-runtime/base.env set +a + # Generate and persist the internal-project keys on first boot; reuse + # across container restarts so the dashboard keeps its internal-project + # session. Reset via `stack emulator reset`. + # + # pck: used by stack-cli to auth against /api/v1/internal/local-emulator/project + # ssk/sak: required by the emulator's own dashboard (StackServerApp + # construction throws without them). Not used by user-app flows; the + # /local-emulator/project route mints separate per-project credentials. + umask 077 + for key in internal-pck internal-ssk internal-sak; do + if [ ! -s "/var/lib/stack-auth/$key" ]; then + openssl rand -hex 32 > "/var/lib/stack-auth/$key" + fi + done + INTERNAL_PCK="$(cat /var/lib/stack-auth/internal-pck)" + INTERNAL_SSK="$(cat /var/lib/stack-auth/internal-ssk)" + INTERNAL_SAK="$(cat /var/lib/stack-auth/internal-sak)" + # Container-local dependencies run on localhost. Host-only development # services (such as the OAuth mock server) are reachable via the QEMU # user-network host alias. @@ -78,6 +96,9 @@ write_files: # Static vars from base config and runtime (e.g. API keys, feature flags) cat /mnt/stack-runtime/base.env cat /mnt/stack-runtime/runtime.env + printf 'STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY=%s\n' "$INTERNAL_PCK" + printf 'STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY=%s\n' "$INTERNAL_SSK" + printf 'STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY=%s\n' "$INTERNAL_SAK" # Computed vars — depend on port prefix or deps host # Host-side ports (for browser URLs — browser runs on host, not in VM) @@ -108,7 +129,10 @@ write_files: STACK_CLICKHOUSE_URL=http://${DEPS_HOST}:8123 STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL=http://localhost:${HP_DASHBOARD}/handler/email-verification STACK_EMAIL_MONITOR_INBUCKET_API_URL=http://${DEPS_HOST}:9001 - STACK_OAUTH_MOCK_URL=http://${HOST_SERVICES_HOST}:${P}14 + STACK_OAUTH_MOCK_URL=http://localhost:${P}14 + STACK_FREESTYLE_API_ENDPOINT=http://${DEPS_HOST}:8180 + STACK_STRIPE_MOCK_PORT=12111 + NEXT_PUBLIC_STACK_STRIPE_PUBLISHABLE_KEY=pk_test_mock_publishable_key_for_local_emulator BACKEND_PORT=${P}02 DASHBOARD_PORT=${P}01 COMPUTED @@ -135,20 +159,54 @@ write_files: /usr/local/bin/mount-host-fs /usr/local/bin/render-stack-env + + # Publish the internal publishable client key to the host via 9p so the + # stack-cli can authenticate its bootstrap call to + # /api/v1/internal/local-emulator/project. + set -a + source /mnt/stack-runtime/runtime.env + set +a + if [ -n "${STACK_EMULATOR_VM_DIR_HOST:-}" ] && [ -s /var/lib/stack-auth/internal-pck ]; then + install -m 0600 /var/lib/stack-auth/internal-pck \ + "/host${STACK_EMULATOR_VM_DIR_HOST}/internal-pck" + fi + docker rm -f stack >/dev/null 2>&1 || true - exec docker run \ - --rm \ - --name stack \ - --network host \ - --add-host host.docker.internal:host-gateway \ - --env-file /run/stack-auth/local-emulator.env \ - -v stack-postgres-data:/data/postgres \ - -v stack-redis-data:/data/redis \ - -v stack-clickhouse-data:/data/clickhouse \ - -v stack-minio-data:/data/minio \ - -v stack-inbucket-data:/data/inbucket \ - -v /host:/host \ - stack-local-emulator + + # Mirror container stdout/stderr to a host-visible log for debugging. + # The container already bind-mounts /host:/host, so we reuse that path. + # Falls back to stdout (captured by systemd-journald) when no host log is set. + if [ -n "${STACK_EMULATOR_VM_DIR_HOST:-}" ]; then + host_log="/host${STACK_EMULATOR_VM_DIR_HOST}/stack.log" + : > "$host_log" 2>/dev/null || true + exec docker run \ + --rm \ + --name stack \ + --network host \ + --add-host host.docker.internal:host-gateway \ + --env-file /run/stack-auth/local-emulator.env \ + -v stack-postgres-data:/data/postgres \ + -v stack-redis-data:/data/redis \ + -v stack-clickhouse-data:/data/clickhouse \ + -v stack-minio-data:/data/minio \ + -v stack-inbucket-data:/data/inbucket \ + -v /host:/host \ + stack-local-emulator 2>&1 | tee -a "$host_log" + else + exec docker run \ + --rm \ + --name stack \ + --network host \ + --add-host host.docker.internal:host-gateway \ + --env-file /run/stack-auth/local-emulator.env \ + -v stack-postgres-data:/data/postgres \ + -v stack-redis-data:/data/redis \ + -v stack-clickhouse-data:/data/clickhouse \ + -v stack-minio-data:/data/minio \ + -v stack-inbucket-data:/data/inbucket \ + -v /host:/host \ + stack-local-emulator + fi - path: /usr/local/bin/wait-for-deps permissions: '0755' diff --git a/docker/local-emulator/qemu/run-emulator.sh b/docker/local-emulator/qemu/run-emulator.sh index 0a82c1b883..d1a7aef9ef 100755 --- a/docker/local-emulator/qemu/run-emulator.sh +++ b/docker/local-emulator/qemu/run-emulator.sh @@ -89,6 +89,7 @@ prepare_runtime_config_iso() { printf "STACK_EMULATOR_BACKEND_HOST_PORT=%s\n" "$EMULATOR_BACKEND_PORT" printf "STACK_EMULATOR_MINIO_HOST_PORT=%s\n" "$EMULATOR_MINIO_PORT" printf "STACK_EMULATOR_INBUCKET_HOST_PORT=%s\n" "$EMULATOR_INBUCKET_PORT" + printf "STACK_EMULATOR_VM_DIR_HOST=%s\n" "$VM_DIR" } > "$cfg_dir/runtime.env" cp "$SCRIPT_DIR/../.env.development" "$cfg_dir/base.env" make_iso_from_dir "$cfg_iso" "STACKCFG" "$cfg_dir" @@ -201,10 +202,16 @@ build_qemu_cmd() { local netdev="user,id=net0" # Only expose user-facing services; internal deps stay inside the VM. - netdev+=",hostfwd=tcp::${EMULATOR_DASHBOARD_PORT}-:${PORT_PREFIX}01" - netdev+=",hostfwd=tcp::${EMULATOR_BACKEND_PORT}-:${PORT_PREFIX}02" - netdev+=",hostfwd=tcp::${EMULATOR_MINIO_PORT}-:9090" - netdev+=",hostfwd=tcp::${EMULATOR_INBUCKET_PORT}-:9001" + # Bind to 127.0.0.1 so the emulator is not reachable from the LAN. + netdev+=",hostfwd=tcp:127.0.0.1:${EMULATOR_DASHBOARD_PORT}-:${PORT_PREFIX}01" + netdev+=",hostfwd=tcp:127.0.0.1:${EMULATOR_BACKEND_PORT}-:${PORT_PREFIX}02" + netdev+=",hostfwd=tcp:127.0.0.1:${EMULATOR_MINIO_PORT}-:9090" + netdev+=",hostfwd=tcp:127.0.0.1:${EMULATOR_INBUCKET_PORT}-:9001" + # Mock OAuth server: browser redirects land on `localhost:${PORT_PREFIX}14` + # (backend sets STACK_OAUTH_MOCK_URL to that value), so we forward host:port + # ↔ VM:port on the same number. Collides with pnpm dev, but the two modes + # are mutually exclusive. + netdev+=",hostfwd=tcp:127.0.0.1:${PORT_PREFIX}14-:${PORT_PREFIX}14" QEMU_CMD=( "$qemu_bin" @@ -249,7 +256,7 @@ tail_vm_logs() { } ensure_ports_free() { - local ports=("$EMULATOR_DASHBOARD_PORT" "$EMULATOR_BACKEND_PORT" "$EMULATOR_MINIO_PORT" "$EMULATOR_INBUCKET_PORT") + local ports=("$EMULATOR_DASHBOARD_PORT" "$EMULATOR_BACKEND_PORT" "$EMULATOR_MINIO_PORT" "$EMULATOR_INBUCKET_PORT" "${PORT_PREFIX}14") local port for port in "${ports[@]}"; do if lsof -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then diff --git a/docker/local-emulator/run-cron-jobs.sh b/docker/local-emulator/run-cron-jobs.sh new file mode 100755 index 0000000000..219b37735d --- /dev/null +++ b/docker/local-emulator/run-cron-jobs.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Polls backend cron endpoints in parallel background loops, matching vercel.json cron config. +# Replaces the tsx scripts used in dev mode since tsx is not in the final image. + +set -e + +BACKEND_URL="http://127.0.0.1:${BACKEND_PORT:-8102}" +CRON_SECRET="${CRON_SECRET:-mock_cron_secret}" + +# Wait for the backend to be ready +until curl -fsS "${BACKEND_URL}/health" >/dev/null 2>&1; do sleep 2; done + +echo "Cron jobs started." + +run_loop() { + local endpoint="$1" + while true; do + curl -sf -o /dev/null --max-time 120 "${BACKEND_URL}${endpoint}" \ + -H "Authorization: Bearer ${CRON_SECRET}" || true + sleep 60 + done +} + +run_loop "/api/latest/internal/email-queue-step" & +run_loop "/api/latest/internal/external-db-sync/sequencer" & +run_loop "/api/latest/internal/external-db-sync/poller" & + +wait diff --git a/docker/local-emulator/supervisord.conf b/docker/local-emulator/supervisord.conf index e8b1fc4782..32890bfe75 100644 --- a/docker/local-emulator/supervisord.conf +++ b/docker/local-emulator/supervisord.conf @@ -50,7 +50,8 @@ environment= INBUCKET_WEB_ADDR="0.0.0.0:9001", INBUCKET_POP3_ADDR="0.0.0.0:1100", INBUCKET_STORAGE_TYPE="file", - INBUCKET_STORAGE_PARAMS="path:/data/inbucket" + INBUCKET_STORAGE_PARAMS="path:/data/inbucket", + INBUCKET_WEB_UIDIR="/opt/inbucket/ui" autostart=true autorestart=true priority=20 @@ -120,6 +121,43 @@ stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 +; --- Stripe mock --- + +[program:stripe-mock] +command=/usr/local/bin/stripe-mock -port 12111 +autostart=true +autorestart=true +priority=20 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +; --- Freestyle mock (JS execution for email rendering) --- + +[program:freestyle-mock] +command=/usr/local/bin/node /app/freestyle-mock/server.mjs +environment=NODE_PATH="/app/freestyle-mock/node_modules",PORT="8180" +autostart=true +autorestart=true +priority=20 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + +; --- Mock OAuth server --- + +[program:mock-oauth] +command=/usr/local/bin/node /app/mock-oauth-server/index.cjs +autostart=true +autorestart=true +priority=20 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + ; --- Post-startup init --- [program:init-services] @@ -134,6 +172,19 @@ stdout_logfile_maxbytes=0 stderr_logfile=/dev/stderr stderr_logfile_maxbytes=0 +; --- Cron jobs (email queue, external DB sync) --- + +[program:cron-jobs] +command=/run-cron-jobs.sh +autostart=true +autorestart=true +startsecs=0 +priority=70 +stdout_logfile=/dev/stdout +stdout_logfile_maxbytes=0 +stderr_logfile=/dev/stderr +stderr_logfile_maxbytes=0 + ; --- Stack Auth backend + dashboard --- [program:stack-app] diff --git a/docker/server/entrypoint.sh b/docker/server/entrypoint.sh index 055447d059..659eb96286 100644 --- a/docker/server/entrypoint.sh +++ b/docker/server/entrypoint.sh @@ -12,9 +12,13 @@ fi # ============= ENV VARS ============= if [ "$NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR" = "true" ]; then - export STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY=${STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY:-local-emulator-publishable-client-key} - export STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY=${STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY:-local-emulator-secret-server-key} - export STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY=${STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY:-local-emulator-super-secret-admin-key} + for v in STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY; do + if [ -z "${!v:-}" ]; then + echo "$v must be set in local-emulator mode (injected by the QEMU VM)." >&2 + exit 1 + fi + done + export STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY else export STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY=${STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY:-$(openssl rand -base64 32)} export STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY=${STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY:-$(openssl rand -base64 32)} @@ -23,8 +27,12 @@ fi export NEXT_PUBLIC_STACK_PROJECT_ID=internal export NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=${STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY} -export STACK_SECRET_SERVER_KEY=${STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY} -export STACK_SUPER_SECRET_ADMIN_KEY=${STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY} +if [ -n "${STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY:-}" ]; then + export STACK_SECRET_SERVER_KEY=${STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY} +fi +if [ -n "${STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY:-}" ]; then + export STACK_SUPER_SECRET_ADMIN_KEY=${STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY} +fi export NEXT_PUBLIC_BROWSER_STACK_DASHBOARD_URL=${NEXT_PUBLIC_STACK_DASHBOARD_URL} export NEXT_PUBLIC_STACK_PORT_PREFIX=${NEXT_PUBLIC_STACK_PORT_PREFIX:-81} @@ -71,6 +79,44 @@ else cd ../.. fi +# ============= LOCAL EMULATOR: BOOTSTRAP INTERNAL API KEY SET ============= +# The build-time seed ran without any keys (the VM generates random ones on +# first boot). The slim image strips apps/backend/dist so we can't re-run the +# full seed here. Instead, targeted-upsert the internal api key set with the +# VM-supplied keys: +# - pck: used by stack-cli to auth against /api/v1/internal/local-emulator/project +# - ssk/sak: required by the emulator's own dashboard (StackServerApp ctor +# throws without ssk). User-app flows don't use these — per-project +# credentials come from the /local-emulator/project route. +if [ "$NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR" = "true" ] && [ -n "${STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY:-}" ] && [ -n "${STACK_DATABASE_CONNECTION_STRING:-}" ]; then + # Validate the keys are hex-only to defuse any SQL-injection risk (the VM + # generates them via `openssl rand -hex 32`, so this is an assert, not a filter). + for varname in STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY; do + val="${!varname:-}" + if [ -z "$val" ]; then + echo "ERROR: $varname is not set; refusing to bootstrap internal api key set." >&2 + exit 1 + fi + if ! printf '%s' "$val" | grep -Eq '^[0-9a-fA-F]+$'; then + echo "ERROR: $varname is not hex-only; refusing to bootstrap internal api key set." >&2 + exit 1 + fi + done + echo "Bootstrapping internal API key set (emulator runtime)..." + psql "$STACK_DATABASE_CONNECTION_STRING" -v ON_ERROR_STOP=1 < { + const path = internalPckPath(); + const deadline = Date.now() + timeoutMs; + let delay = 250; + while (Date.now() < deadline) { + if (existsSync(path)) { + const contents = readFileSync(path, "utf-8").trim(); + if (contents) return contents; + } + await new Promise((r) => setTimeout(r, delay)); + delay = Math.min(delay * 2, 2000); + } + throw new CliError(`Timed out waiting for emulator internal publishable client key at ${path}`); +} + +type EmulatorCredentials = { + project_id: string, + publishable_client_key: string, + secret_server_key: string, +}; + +async function fetchEmulatorCredentials(pck: string, backendPort: number, configFile: string): Promise { + const url = `http://127.0.0.1:${backendPort}/api/v1/internal/local-emulator/project`; + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-Stack-Project-Id": "internal", + "X-Stack-Access-Type": "client", + "X-Stack-Publishable-Client-Key": pck, + }, + body: JSON.stringify({ absolute_file_path: configFile }), + }); + if (!res.ok) { + throw new CliError(`Failed to initialize local emulator project (${res.status}): ${await res.text()}`); + } + const data = await res.json() as { + project_id: string, + publishable_client_key: string, + secret_server_key: string, + }; + return { + project_id: data.project_id, + publishable_client_key: data.publishable_client_key, + secret_server_key: data.secret_server_key, + }; +} + function gh(args: string[]): string { try { return execFileSync("gh", args, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim(); @@ -36,6 +100,25 @@ function runEmulator(action: string, env?: Record): Promise { + const img = join(findQemuDir(), "images", `stack-emulator-${arch}.qcow2`); + if (!existsSync(img)) { + console.log("No emulator image found. Pulling latest..."); + pullRelease(arch); + } + await runEmulator("start", { EMULATOR_ARCH: arch }); +} + function resolveArch(raw?: string): "arm64" | "amd64" { const arch = raw ?? (process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "amd64" : null); if (arch === "arm64" || arch === "amd64") return arch; @@ -110,14 +193,91 @@ export function registerEmulatorCommand(program: Command) { .command("start") .description("Start the emulator in the background (auto-pulls the latest image if none exists)") .option("--arch ", "Target architecture (default: current system arch). Non-native uses software emulation and is significantly slower.") - .action(async (opts) => { + .option("--config-file ", "Path to a config file; when set, credentials for this project are printed to stdout as JSON") + .action(async (opts: { arch?: string, configFile?: string }) => { const arch = resolveArch(opts.arch); - const img = join(findQemuDir(), "images", `stack-emulator-${arch}.qcow2`); - if (!existsSync(img)) { - console.log("No emulator image found. Pulling latest..."); - pullRelease(arch); + + let resolvedConfigFile: string | undefined; + if (opts.configFile) { + resolvedConfigFile = resolve(opts.configFile); + if (!existsSync(resolvedConfigFile)) { + throw new CliError(`Config file not found: ${resolvedConfigFile}`); + } + } + + if (isEmulatorRunning()) { + console.log("Emulator already running, reusing existing instance."); + } else { + await startEmulator(arch); + } + + if (resolvedConfigFile) { + const pck = await readInternalPck(); + const creds = await fetchEmulatorCredentials(pck, emulatorBackendPort(), resolvedConfigFile); + console.log(JSON.stringify(creds, null, 2)); } - await runEmulator("start", { EMULATOR_ARCH: arch }); + }); + + emulator + .command("run") + .description("Start the emulator, run a command, and stop the emulator when the command exits") + .argument("", "Command to run (e.g. \"npm run dev\")") + .option("--arch ", "Target architecture") + .option("--config-file ", "Path to a config file; fetches credentials and injects STACK_PROJECT_ID / STACK_PUBLISHABLE_CLIENT_KEY / STACK_SECRET_SERVER_KEY into the child") + .action(async (cmd: string, opts: { arch?: string, configFile?: string }) => { + const arch = resolveArch(opts.arch); + + let resolvedConfigFile: string | undefined; + if (opts.configFile) { + resolvedConfigFile = resolve(opts.configFile); + if (!existsSync(resolvedConfigFile)) { + throw new CliError(`Config file not found: ${resolvedConfigFile}`); + } + } + + const alreadyRunning = isEmulatorRunning(); + if (alreadyRunning) { + console.log("Emulator already running, reusing existing instance."); + } else { + await startEmulator(arch); + } + + const childEnv: Record = { ...process.env as Record }; + if (resolvedConfigFile) { + const pck = await readInternalPck(); + const backendPort = emulatorBackendPort(); + const creds = await fetchEmulatorCredentials(pck, backendPort, resolvedConfigFile); + const apiUrl = `http://127.0.0.1:${backendPort}`; + childEnv.STACK_PROJECT_ID = creds.project_id; + childEnv.NEXT_PUBLIC_STACK_PROJECT_ID = creds.project_id; + childEnv.STACK_PUBLISHABLE_CLIENT_KEY = creds.publishable_client_key; + childEnv.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY = creds.publishable_client_key; + childEnv.STACK_SECRET_SERVER_KEY = creds.secret_server_key; + childEnv.STACK_API_URL = apiUrl; + childEnv.NEXT_PUBLIC_STACK_API_URL = apiUrl; + } + + const child = spawn(cmd, { shell: true, stdio: "inherit", env: childEnv }); + + const forward = (signal: NodeJS.Signals) => () => child.kill(signal); + const onSigint = forward("SIGINT"); + const onSigterm = forward("SIGTERM"); + process.on("SIGINT", onSigint); + process.on("SIGTERM", onSigterm); + + child.on("close", (code) => { + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); + const exitCode = code ?? 1; + if (alreadyRunning) { + process.exit(exitCode); + } else { + console.log("\nStopping emulator..."); + runEmulator("stop") + .catch(() => { /* best-effort stop */ }) + .finally(() => process.exit(exitCode)); + } + }); }); emulator.command("stop").description("Stop the emulator (data preserved; use 'reset' to clear)").action(() => runEmulator("stop")); diff --git a/packages/stack-shared/src/interface/admin-interface.ts b/packages/stack-shared/src/interface/admin-interface.ts index 821caed8ec..9971ad5586 100644 --- a/packages/stack-shared/src/interface/admin-interface.ts +++ b/packages/stack-shared/src/interface/admin-interface.ts @@ -56,26 +56,17 @@ export type InternalApiKeyCreateCrudResponse = InternalApiKeysCrud["Admin"]["Rea export class StackAdminInterface extends StackServerInterface { - protected _superSecretAdminKeyOverride?: string; - constructor(public readonly options: AdminAuthApplicationOptions) { super(options); } - override _updateEmulatorCredentials(opts: { projectId?: string, publishableClientKey?: string, secretServerKey?: string, superSecretAdminKey?: string }) { - super._updateEmulatorCredentials(opts); - if (opts.superSecretAdminKey) { - this._superSecretAdminKeyOverride = opts.superSecretAdminKey; - } - } - public async sendAdminRequest(path: string, options: RequestInit, session: InternalSession | null, requestType: "admin" = "admin") { return await this.sendServerRequest( path, { ...options, headers: { - "x-stack-super-secret-admin-key": this._superSecretAdminKeyOverride ?? ("superSecretAdminKey" in this.options ? this.options.superSecretAdminKey : ""), + "x-stack-super-secret-admin-key": "superSecretAdminKey" in this.options ? this.options.superSecretAdminKey : "", ...options.headers, }, }, diff --git a/packages/stack-shared/src/interface/client-interface.ts b/packages/stack-shared/src/interface/client-interface.ts index cd86c62fcb..b7fb192dd0 100644 --- a/packages/stack-shared/src/interface/client-interface.ts +++ b/packages/stack-shared/src/interface/client-interface.ts @@ -123,9 +123,6 @@ function getBotChallengeRequestFields(botChallenge: BotChallengeInput | undefine export class StackClientInterface { private pendingNetworkDiagnostics?: ReturnType; - protected _projectIdOverride?: string; - protected _publishableClientKeyOverride?: string; - /** * Fallback state. When null, we're in normal mode (primary first). * When set, we skip straight to `stickyIndex` and only probe primary occasionally. @@ -138,16 +135,7 @@ export class StackClientInterface { } get projectId() { - return this._projectIdOverride ?? this.options.projectId; - } - - _updateEmulatorCredentials(opts: { projectId?: string, publishableClientKey?: string }) { - if (opts.projectId) { - this._projectIdOverride = opts.projectId; - } - if (opts.publishableClientKey) { - this._publishableClientKeyOverride = opts.publishableClientKey; - } + return this.options.projectId; } getApiUrl() { @@ -635,9 +623,7 @@ export class StackClientInterface { "X-Stack-Refresh-Token": tokenObj.refreshToken.token, } : {}), "X-Stack-Allow-Anonymous-User": "true", - ...(this._publishableClientKeyOverride ? { - "X-Stack-Publishable-Client-Key": this._publishableClientKeyOverride, - } : "publishableClientKey" in this.options && this.options.publishableClientKey ? { + ...("publishableClientKey" in this.options && this.options.publishableClientKey ? { "X-Stack-Publishable-Client-Key": this.options.publishableClientKey, } : {}), ...(adminTokenObj ? { diff --git a/packages/stack-shared/src/interface/server-interface.ts b/packages/stack-shared/src/interface/server-interface.ts index 57f40f03e5..7c8dd6baf9 100644 --- a/packages/stack-shared/src/interface/server-interface.ts +++ b/packages/stack-shared/src/interface/server-interface.ts @@ -39,26 +39,17 @@ export type ServerAuthApplicationOptions = ( ); export class StackServerInterface extends StackClientInterface { - protected _secretServerKeyOverride?: string; - constructor(public override options: ServerAuthApplicationOptions) { super(options); } - override _updateEmulatorCredentials(opts: { projectId?: string, publishableClientKey?: string, secretServerKey?: string }) { - super._updateEmulatorCredentials(opts); - if (opts.secretServerKey) { - this._secretServerKeyOverride = opts.secretServerKey; - } - } - protected async sendServerRequest(path: string, options: RequestInit, session: InternalSession | null, requestType: "server" | "admin" = "server") { return await this.sendClientRequest( path, { ...options, headers: { - "x-stack-secret-server-key": this._secretServerKeyOverride ?? ("secretServerKey" in this.options ? this.options.secretServerKey : ""), + "x-stack-secret-server-key": "secretServerKey" in this.options ? this.options.secretServerKey : "", ...options.headers, }, }, diff --git a/packages/template/src/lib/env.ts b/packages/template/src/lib/env.ts index 7b5af8117f..e876adaa8b 100644 --- a/packages/template/src/lib/env.ts +++ b/packages/template/src/lib/env.ts @@ -14,9 +14,6 @@ export const envVars = { get STACK_PROJECT_ID() { return (typeof process !== "undefined" ? process.env.STACK_PROJECT_ID : undefined) ?? undefined; }, - get NEXT_PUBLIC_STACK_LOCAL_EMULATOR_CONFIG_FILE_PATH() { - return (typeof process !== "undefined" ? process.env.NEXT_PUBLIC_STACK_LOCAL_EMULATOR_CONFIG_FILE_PATH : undefined) ?? undefined; - }, get NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY() { return (typeof process !== "undefined" ? process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY : undefined) ?? undefined; }, diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index 431786cba7..c34a8b8a3e 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -22,7 +22,7 @@ import { AdminProjectPermission, AdminProjectPermissionDefinition, AdminProjectP import { AdminOwnedProject, AdminProject, AdminProjectUpdateOptions, PushConfigOptions, adminProjectUpdateOptionsToCrud } from "../../projects"; import type { AdminSessionReplay, AdminSessionReplayChunk, ListSessionReplayChunksOptions, ListSessionReplayChunksResult, ListSessionReplaysOptions, ListSessionReplaysResult, SessionReplayAllEventsResult } from "../../session-replays"; import { ManagedEmailProviderListItem, ManagedEmailProviderSetupResult, ManagedEmailProviderStatus, EmailOutboxUpdateOptions, StackAdminApp, StackAdminAppConstructorOptions } from "../interfaces/admin-app"; -import { LOCAL_EMULATOR_INTERNAL_PUBLISHABLE_CLIENT_KEY, assertNoEmulatorOptionConflict, clientVersion, createCache, fetchEmulatorProjectCredentials, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, getDefaultSuperSecretAdminKey, getLocalEmulatorConfigFilePath, localEmulatorBaseUrl, resolveApiUrls, resolveConstructorOptions } from "./common"; +import { clientVersion, createCache, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, getDefaultSuperSecretAdminKey, resolveApiUrls, resolveConstructorOptions } from "./common"; import { _StackServerAppImplIncomplete } from "./server-app-impl"; import { CompleteConfig, EnvironmentConfigOverrideOverride } from "@stackframe/stack-shared/dist/config/schema"; @@ -127,53 +127,28 @@ export class _StackAdminAppImplIncomplete, extraOptions?: { uniqueIdentifier?: string, checkString?: string, interface?: StackAdminInterface }) { const resolvedOptions = resolveConstructorOptions(options); - const emulatorConfigFilePath = getLocalEmulatorConfigFilePath(resolvedOptions.localEmulatorConfigFilePath); - const isEmulator = !!emulatorConfigFilePath; - - assertNoEmulatorOptionConflict(emulatorConfigFilePath, { - projectId: resolvedOptions.projectId, - publishableClientKey: resolvedOptions.publishableClientKey, - secretServerKey: resolvedOptions.secretServerKey, - superSecretAdminKey: resolvedOptions.superSecretAdminKey, - }); - - const publishableClientKey = resolvedOptions.publishableClientKey ?? getDefaultPublishableClientKey() ?? (isEmulator ? LOCAL_EMULATOR_INTERNAL_PUBLISHABLE_CLIENT_KEY : undefined); + const publishableClientKey = resolvedOptions.publishableClientKey ?? getDefaultPublishableClientKey(); super(resolvedOptions, { ...extraOptions, interface: extraOptions?.interface ?? (() => { - const apiUrls = resolveApiUrls(resolvedOptions.baseUrl ?? (isEmulator ? localEmulatorBaseUrl : undefined)); + const apiUrls = resolveApiUrls(resolvedOptions.baseUrl); return new StackAdminInterface({ getBaseUrl: () => apiUrls()[0], getApiUrls: apiUrls, - projectId: resolvedOptions.projectId ?? getDefaultProjectId({ isEmulator }), + projectId: resolvedOptions.projectId ?? getDefaultProjectId(), extraRequestHeaders: resolvedOptions.extraRequestHeaders ?? getDefaultExtraRequestHeaders(), clientVersion, ...resolvedOptions.projectOwnerSession ? { projectOwnerSession: resolvedOptions.projectOwnerSession, } : { ...(publishableClientKey ? { publishableClientKey } : {}), - secretServerKey: resolvedOptions.secretServerKey ?? getDefaultSecretServerKey({ isEmulator }), - superSecretAdminKey: resolvedOptions.superSecretAdminKey ?? getDefaultSuperSecretAdminKey({ isEmulator }), - }, - prepareRequest: async () => { - if (this._emulatorInitPromise) await this._emulatorInitPromise; + secretServerKey: resolvedOptions.secretServerKey ?? getDefaultSecretServerKey(), + superSecretAdminKey: resolvedOptions.superSecretAdminKey ?? getDefaultSuperSecretAdminKey(), }, }); })(), }); - - if (isEmulator && !extraOptions?.interface) { - const iface = this._interface; - this._emulatorInitPromise = fetchEmulatorProjectCredentials(emulatorConfigFilePath!).then((data) => { - iface._updateEmulatorCredentials({ - projectId: data.project_id, - publishableClientKey: data.publishable_client_key, - secretServerKey: data.secret_server_key, - superSecretAdminKey: data.super_secret_admin_key, - }); - }); - } } _adminConfigFromCrud(data: { config_string: string }): CompleteConfig { 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 6515e627a5..12386ae464 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 @@ -56,7 +56,7 @@ import { isHostedHandlerUrlForProject, resolveHandlerUrls } from "../../url-targ import { ActiveSession, Auth, BaseUser, CurrentUser, InternalUserExtra, OAuthProvider, ProjectCurrentUser, SyncedPartialUser, TokenPartialUser, UserExtra, UserUpdateOptions, userUpdateOptionsToCrud, withUserDestructureGuard } from "../../users"; import { StackClientApp, StackClientAppConstructorOptions, StackClientAppJson } from "../interfaces/client-app"; import { _StackAdminAppImplIncomplete } from "./admin-app-impl"; -import { LOCAL_EMULATOR_INTERNAL_PUBLISHABLE_CLIENT_KEY, TokenObject, assertNoEmulatorOptionConflict, clientVersion, createCache, createCacheBySession, createEmptyTokenStore, fetchEmulatorProjectCredentials, getAnalyticsBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getLocalEmulatorConfigFilePath, getUrls, localEmulatorBaseUrl, resolveApiUrls, resolveConstructorOptions } from "./common"; +import { TokenObject, clientVersion, createCache, createCacheBySession, createEmptyTokenStore, getAnalyticsBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getUrls, resolveApiUrls, resolveConstructorOptions } from "./common"; import { EventTracker } from "./event-tracker"; import { crossDomainAuthQueryParams, getCrossDomainHandoffParamsFromCurrentUrl, planRedirectToHandler } from "./redirect-page-urls"; import type { CrossDomainHandoffParams } from "./redirect-page-urls"; @@ -105,7 +105,6 @@ export class _StackClientAppImplIncomplete | null = null; private __DEMO_ENABLE_SLIGHT_FETCH_DELAY = false; private readonly _ownedAdminApps = new DependenciesMap<[InternalSession, string], _StackAdminAppImplIncomplete>(); @@ -507,25 +506,17 @@ export class _StackClientAppImplIncomplete apiUrls()[0], getAnalyticsBaseUrl: () => getAnalyticsBaseUrl(apiUrls()[0]), @@ -535,22 +526,11 @@ export class _StackClientAppImplIncomplete { - if (this._emulatorInitPromise) await this._emulatorInitPromise; await cookies?.(); // THIS_LINE_PLATFORM next } }); } - if (isEmulator && !(extraOptions && extraOptions.interface)) { - const iface = this._interface; - this._emulatorInitPromise = fetchEmulatorProjectCredentials(emulatorConfigFilePath!).then((data) => { - iface._updateEmulatorCredentials({ - projectId: data.project_id, - publishableClientKey: data.publishable_client_key, - }); - }); - } - this._tokenStoreInit = resolvedOptions.tokenStore; this._redirectMethod = resolvedOptions.redirectMethod || (isBrowserLike() ? "window" : "none"); this._redirectMethod = resolvedOptions.redirectMethod || "nextjs"; // THIS_LINE_PLATFORM next diff --git a/packages/template/src/lib/stack-app/apps/implementations/common.ts b/packages/template/src/lib/stack-app/apps/implementations/common.ts index 9c9c889d56..960264e586 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/common.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/common.ts @@ -62,57 +62,7 @@ export function getUrls(partial: HandlerUrlOptions, options: { projectId: string }); } -export const localEmulatorBaseUrl = "http://localhost:9999"; - -export const LOCAL_EMULATOR_INTERNAL_PUBLISHABLE_CLIENT_KEY = "local-emulator-publishable-client-key"; -export const LOCAL_EMULATOR_INTERNAL_SECRET_SERVER_KEY = "local-emulator-secret-server-key"; -export const LOCAL_EMULATOR_INTERNAL_SUPER_SECRET_ADMIN_KEY = "local-emulator-super-secret-admin-key"; - -export function getLocalEmulatorConfigFilePath(explicitOption?: string): string | undefined { - return explicitOption || envVars.NEXT_PUBLIC_STACK_LOCAL_EMULATOR_CONFIG_FILE_PATH || undefined; -} - -export async function fetchEmulatorProjectCredentials(emulatorConfigFilePath: string): Promise<{ - project_id: string, - publishable_client_key: string, - secret_server_key: string, - super_secret_admin_key: string, -}> { - const res = await fetch(`${localEmulatorBaseUrl}/api/v1/internal/local-emulator/project`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Stack-Project-Id": "internal", - "X-Stack-Access-Type": "client", - "X-Stack-Publishable-Client-Key": LOCAL_EMULATOR_INTERNAL_PUBLISHABLE_CLIENT_KEY, - }, - body: JSON.stringify({ absolute_file_path: emulatorConfigFilePath }), - }); - if (!res.ok) { - throw new Error(`Failed to initialize local emulator: ${res.status} ${await res.text()}`); - } - return await res.json(); -} - -export function assertNoEmulatorOptionConflict( - emulatorConfigFilePath: string | undefined, - options: Record, -) { - if (!emulatorConfigFilePath) return; - const conflicting = Object.entries(options) - .filter(([, value]) => value !== undefined && value !== null) - .map(([key]) => key); - if (conflicting.length > 0) { - throw new Error( - `Cannot specify ${conflicting.join(", ")} together with localEmulatorConfigFilePath — the local emulator provides these credentials automatically.` - ); - } -} - -export function getDefaultProjectId(options?: { isEmulator?: boolean }) { - if (options?.isEmulator) { - return envVars.NEXT_PUBLIC_STACK_PROJECT_ID || envVars.STACK_PROJECT_ID || "internal"; - } +export function getDefaultProjectId() { return envVars.NEXT_PUBLIC_STACK_PROJECT_ID || envVars.STACK_PROJECT_ID || throwErr(new Error("Welcome to Stack Auth! It seems that you haven't provided a project ID. Please create a project on the Stack dashboard at https://app.stack-auth.com and put it in the NEXT_PUBLIC_STACK_PROJECT_ID environment variable.")); } @@ -120,17 +70,11 @@ export function getDefaultPublishableClientKey() { return envVars.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY || envVars.STACK_PUBLISHABLE_CLIENT_KEY; } -export function getDefaultSecretServerKey(options?: { isEmulator?: boolean }) { - if (options?.isEmulator) { - return envVars.STACK_SECRET_SERVER_KEY || LOCAL_EMULATOR_INTERNAL_SECRET_SERVER_KEY; - } +export function getDefaultSecretServerKey() { return envVars.STACK_SECRET_SERVER_KEY || throwErr(new Error("No secret server key provided. Please copy your key from the Stack dashboard and put it in the STACK_SECRET_SERVER_KEY environment variable.")); } -export function getDefaultSuperSecretAdminKey(options?: { isEmulator?: boolean }) { - if (options?.isEmulator) { - return envVars.STACK_SUPER_SECRET_ADMIN_KEY || LOCAL_EMULATOR_INTERNAL_SUPER_SECRET_ADMIN_KEY; - } +export function getDefaultSuperSecretAdminKey() { return envVars.STACK_SUPER_SECRET_ADMIN_KEY || throwErr(new Error("No super secret admin key provided. Please copy your key from the Stack dashboard and put it in the STACK_SUPER_SECRET_ADMIN_KEY environment variable.")); } @@ -156,7 +100,7 @@ export function getDefaultExtraRequestHeaders() { * @returns The configured base URL without trailing slash */ -export function getBaseUrl(userSpecifiedBaseUrl: string | { browser: string, server: string } | undefined, options?: { isEmulator?: boolean }) { +export function getBaseUrl(userSpecifiedBaseUrl: string | { browser: string, server: string } | undefined) { let url; if (userSpecifiedBaseUrl) { if (typeof userSpecifiedBaseUrl === "string") { @@ -175,7 +119,7 @@ export function getBaseUrl(userSpecifiedBaseUrl: string | { browser: string, ser } else { url = envVars.NEXT_PUBLIC_SERVER_STACK_API_URL || envVars.NEXT_PUBLIC_STACK_API_URL_SERVER || envVars.STACK_API_URL_SERVER; } - url = url || envVars.NEXT_PUBLIC_STACK_API_URL || envVars.STACK_API_URL || envVars.NEXT_PUBLIC_STACK_URL || (options?.isEmulator ? localEmulatorBaseUrl : defaultBaseUrl); + url = url || envVars.NEXT_PUBLIC_STACK_API_URL || envVars.STACK_API_URL || envVars.NEXT_PUBLIC_STACK_URL || defaultBaseUrl; } return replaceStackPortPrefix(url.endsWith('/') ? url.slice(0, -1) : url); diff --git a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts index 7694626cb4..184190d34d 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/server-app-impl.ts @@ -35,7 +35,7 @@ import { EditableTeamMemberProfile, ReceivedTeamInvitation, SentTeamInvitation, import { ProjectCurrentServerUser, ServerOAuthProvider, ServerUser, ServerUserCreateOptions, ServerUserUpdateOptions, serverUserCreateOptionsToCrud, serverUserUpdateOptionsToCrud, withUserDestructureGuard } from "../../users"; import { StackServerAppConstructorOptions } from "../interfaces/server-app"; import { _StackClientAppImplIncomplete } from "./client-app-impl"; -import { LOCAL_EMULATOR_INTERNAL_PUBLISHABLE_CLIENT_KEY, assertNoEmulatorOptionConflict, clientVersion, createCache, createCacheBySession, fetchEmulatorProjectCredentials, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, getLocalEmulatorConfigFilePath, localEmulatorBaseUrl, resolveApiUrls, resolveConstructorOptions } from "./common"; +import { clientVersion, createCache, createCacheBySession, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getDefaultSecretServerKey, resolveApiUrls, resolveConstructorOptions } from "./common"; import { useAsyncCache } from "./common"; // THIS_LINE_PLATFORM react-like @@ -417,46 +417,23 @@ export class _StackServerAppImplIncomplete, extraOptions?: { uniqueIdentifier?: string, checkString?: string, interface?: StackServerInterface }) { const resolvedOptions = resolveConstructorOptions(options); - const emulatorConfigFilePath = getLocalEmulatorConfigFilePath(resolvedOptions.localEmulatorConfigFilePath); - const isEmulator = !!emulatorConfigFilePath; - - assertNoEmulatorOptionConflict(emulatorConfigFilePath, { - projectId: resolvedOptions.projectId, - publishableClientKey: resolvedOptions.publishableClientKey, - secretServerKey: resolvedOptions.secretServerKey, - }); - - const publishableClientKey = resolvedOptions.publishableClientKey ?? getDefaultPublishableClientKey() ?? (isEmulator ? LOCAL_EMULATOR_INTERNAL_PUBLISHABLE_CLIENT_KEY : undefined); + const publishableClientKey = resolvedOptions.publishableClientKey ?? getDefaultPublishableClientKey(); super(resolvedOptions, { ...extraOptions, interface: extraOptions?.interface ?? (() => { - const apiUrls = resolveApiUrls(resolvedOptions.baseUrl ?? (isEmulator ? localEmulatorBaseUrl : undefined)); + const apiUrls = resolveApiUrls(resolvedOptions.baseUrl); return new StackServerInterface({ getBaseUrl: () => apiUrls()[0], getApiUrls: apiUrls, - projectId: resolvedOptions.projectId ?? getDefaultProjectId({ isEmulator }), + projectId: resolvedOptions.projectId ?? getDefaultProjectId(), extraRequestHeaders: resolvedOptions.extraRequestHeaders ?? getDefaultExtraRequestHeaders(), clientVersion, ...(publishableClientKey != null ? { publishableClientKey } : {}), - secretServerKey: resolvedOptions.secretServerKey ?? getDefaultSecretServerKey({ isEmulator }), - prepareRequest: async () => { - if (this._emulatorInitPromise) await this._emulatorInitPromise; - }, + secretServerKey: resolvedOptions.secretServerKey ?? getDefaultSecretServerKey(), }); })(), }); - - if (isEmulator && !extraOptions?.interface) { - const iface = this._interface; - this._emulatorInitPromise = fetchEmulatorProjectCredentials(emulatorConfigFilePath!).then((data) => { - iface._updateEmulatorCredentials({ - projectId: data.project_id, - publishableClientKey: data.publishable_client_key, - secretServerKey: data.secret_server_key, - }); - }); - } } diff --git a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts index dcd326b436..462fcf354e 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts @@ -19,13 +19,6 @@ export type StackClientAppConstructorOptions, - /** - * Path to the local emulator config file. When set, connects to the local - * emulator and automatically fetches project credentials. - * Defaults to NEXT_PUBLIC_STACK_LOCAL_EMULATOR_CONFIG_FILE_PATH env var. - */ - localEmulatorConfigFilePath?: string, - /** * By default, the Stack app will automatically prefetch some data from Stack's server when this app is first * constructed. This improves the performance of your app, but will create network requests that are unnecessary if From 9431105f49c444435b3793465687d1a2a10892af Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Mon, 13 Apr 2026 20:56:05 -0700 Subject: [PATCH 21/29] Revert examples/middleware to origin/dev state The stack.config.ts + wrapped dev script + new URL() middleware fix were experiments for exercising the emulator from this example. Reverting so the middleware example isn't entangled with emulator-specific plumbing. --- examples/middleware/package.json | 2 +- examples/middleware/src/middleware.tsx | 2 +- examples/middleware/stack.config.ts | 19 ------------------- 3 files changed, 2 insertions(+), 21 deletions(-) delete mode 100644 examples/middleware/stack.config.ts diff --git a/examples/middleware/package.json b/examples/middleware/package.json index 3d13b1c575..3f16c8914b 100644 --- a/examples/middleware/package.json +++ b/examples/middleware/package.json @@ -4,7 +4,7 @@ "repository": "https://github.com/stack-auth/stack-auth", "private": true, "scripts": { - "dev": "cd ../.. && node packages/stack-cli/dist/index.js emulator run --config-file examples/middleware/stack.config.ts 'cd examples/middleware && next dev --port ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}12'", + "dev": "next dev --port ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}12", "build": "next build", "start": "next start --port ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}12", "lint": "next lint", diff --git a/examples/middleware/src/middleware.tsx b/examples/middleware/src/middleware.tsx index b8d7459a6e..0d65fd3904 100644 --- a/examples/middleware/src/middleware.tsx +++ b/examples/middleware/src/middleware.tsx @@ -9,7 +9,7 @@ export async function middleware(request: NextRequest) { const user = await stackServerApp.getUser(); if (!user) { console.log('User in middleware is not logged in. Redirecting to sign-in page'); - return NextResponse.redirect(new URL(stackServerApp.urls.signIn, request.url)); + return NextResponse.redirect(stackServerApp.urls.signIn); } console.log('User in middleware is logged in. ID: ', user.id); diff --git a/examples/middleware/stack.config.ts b/examples/middleware/stack.config.ts deleted file mode 100644 index 4568185e44..0000000000 --- a/examples/middleware/stack.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { StackConfig } from "@stackframe/stack"; - -export const config: StackConfig = { - "apps": { - "installed": { - "authentication": { - "enabled": true - } - } - }, - "auth": { - "password": { - "allowSignIn": true - }, - "otp": { - "allowSignIn": true - } - } -}; From 6e34776f73011eb85abb5a1edaf857947ba7ea31 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 14 Apr 2026 09:31:36 -0700 Subject: [PATCH 22/29] Randomize CRON_SECRET in local emulator entrypoint The baked-in mock_cron_secret from .env.development was a usable credential against a running emulator's internal cron endpoints. Generate a fresh value per container start in entrypoint.sh so both the backend and run-cron-jobs.sh inherit it via supervisord, and fail-fast in the cron script if it's unset. --- docker/local-emulator/entrypoint.sh | 7 +++++++ docker/local-emulator/run-cron-jobs.sh | 8 ++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/docker/local-emulator/entrypoint.sh b/docker/local-emulator/entrypoint.sh index daa9854653..562cb67955 100644 --- a/docker/local-emulator/entrypoint.sh +++ b/docker/local-emulator/entrypoint.sh @@ -28,4 +28,11 @@ if [ -z "$(ls -A "$PGDATA" 2>/dev/null)" ]; then gosu postgres "$PG_BIN/pg_ctl" -D "$PGDATA" stop -w fi +# Generate a fresh CRON_SECRET per container start. The cron endpoints are +# internal — nothing outside the container calls them — so we don't want the +# baked-in mock value from .env.development to be a usable credential against +# a running emulator. Overriding here propagates to both the backend and the +# run-cron-jobs.sh loop via supervisord's inherited environment. +export CRON_SECRET="$(openssl rand -hex 32)" + exec /usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf diff --git a/docker/local-emulator/run-cron-jobs.sh b/docker/local-emulator/run-cron-jobs.sh index 219b37735d..a30cf03e68 100755 --- a/docker/local-emulator/run-cron-jobs.sh +++ b/docker/local-emulator/run-cron-jobs.sh @@ -5,7 +5,11 @@ set -e BACKEND_URL="http://127.0.0.1:${BACKEND_PORT:-8102}" -CRON_SECRET="${CRON_SECRET:-mock_cron_secret}" + +if [ -z "${CRON_SECRET:-}" ]; then + echo "CRON_SECRET is not set; refusing to start cron loops." >&2 + exit 1 +fi # Wait for the backend to be ready until curl -fsS "${BACKEND_URL}/health" >/dev/null 2>&1; do sleep 2; done @@ -17,7 +21,7 @@ run_loop() { while true; do curl -sf -o /dev/null --max-time 120 "${BACKEND_URL}${endpoint}" \ -H "Authorization: Bearer ${CRON_SECRET}" || true - sleep 60 + sleep 1 done } From 576a3ccab58b76eaa7a31345e01f5230b06aff56 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 14 Apr 2026 09:32:14 -0700 Subject: [PATCH 23/29] Use console.warn for emulator-already-running notice Keeps stdout clean for callers parsing CLI output; the notice is informational rather than a primary result. --- packages/stack-cli/src/commands/emulator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/stack-cli/src/commands/emulator.ts b/packages/stack-cli/src/commands/emulator.ts index ec0e2d8eb8..9fdbb54d00 100644 --- a/packages/stack-cli/src/commands/emulator.ts +++ b/packages/stack-cli/src/commands/emulator.ts @@ -206,7 +206,7 @@ export function registerEmulatorCommand(program: Command) { } if (isEmulatorRunning()) { - console.log("Emulator already running, reusing existing instance."); + console.warn("Emulator already running, reusing existing instance."); } else { await startEmulator(arch); } From e30b9e0f5a646c6c94d356f16d7a1ed636862a7f Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 14 Apr 2026 09:36:24 -0700 Subject: [PATCH 24/29] Inject throwaway SEED keys into emulator smoke test build.env sets NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=true, which makes docker/server/entrypoint.sh require the three internal SEED keys. At real-VM boot those come from render-stack-env via local-emulator.env, but the build-time smoke test doesn't run that path, so the backend was crash-looping and the amd64 QEMU build failed with a /health timeout. Mint throwaway hex keys for the smoke-test container only. --- .../qemu/cloud-init/emulator/user-data | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docker/local-emulator/qemu/cloud-init/emulator/user-data b/docker/local-emulator/qemu/cloud-init/emulator/user-data index 39e245840c..7465914840 100644 --- a/docker/local-emulator/qemu/cloud-init/emulator/user-data +++ b/docker/local-emulator/qemu/cloud-init/emulator/user-data @@ -427,10 +427,23 @@ write_files: log "Skipping smoke test: build arch is arm64 and cross-arch TCG can't reliably run the backend." else log "Running smoke test on slim image..." + # build.env sets NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=true, which makes + # docker/server/entrypoint.sh require the three internal SEED keys. + # At real-VM boot those come from render-stack-env via + # /run/stack-auth/local-emulator.env, but that path doesn't run during + # the build-time smoke test. Mint throwaway hex keys for this container + # only; they must be hex because entrypoint.sh also validates that + # before the internal ApiKeySet bootstrap SQL. + SMOKE_PCK="$(openssl rand -hex 32)" + SMOKE_SSK="$(openssl rand -hex 32)" + SMOKE_SAK="$(openssl rand -hex 32)" docker run --rm --name smoke-test \ --network host \ --env-file /etc/stack-build.env \ --env-file /etc/stack-build-computed.env \ + -e STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY="$SMOKE_PCK" \ + -e STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY="$SMOKE_SSK" \ + -e STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY="$SMOKE_SAK" \ -e STACK_SKIP_MIGRATIONS=true \ -e STACK_SKIP_SEED_SCRIPT=true \ -e STACK_RUNTIME_WORK_DIR=/app \ From 11567e82b244f6e65f88c0a143edbee288913cf2 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 14 Apr 2026 10:59:47 -0700 Subject: [PATCH 25/29] emulator email-rendering, pck, and stripe fixes --- apps/backend/prisma/seed.ts | 5 ++ .../internal/local-emulator/project/route.tsx | 38 +++++----- apps/backend/src/lib/ai/models.ts | 3 +- apps/backend/src/lib/js-execution.tsx | 11 +-- apps/backend/src/lib/payments.tsx | 5 +- apps/backend/src/lib/stripe.tsx | 7 +- docker/local-emulator/qemu/run-emulator.sh | 4 +- docker/server/entrypoint.sh | 4 +- packages/stack-cli/package.json | 2 +- .../scripts/copy-emulator-assets.mjs | 23 +++++++ packages/stack-cli/src/commands/emulator.ts | 69 ++++++++++++++----- 11 files changed, 121 insertions(+), 50 deletions(-) create mode 100644 packages/stack-cli/scripts/copy-emulator-assets.mjs diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index ff3715d3b2..67d8d4c23a 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -101,6 +101,11 @@ export async function seed() { onboarding: { requireEmailVerification: false, }, + ...(localEmulatorEnabled ? { + project: { + requirePublishableClientKey: false, + }, + } : {}), dataVault: { stores: { 'neon-connection-strings': { 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 1ac870dc16..5e13731498 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 @@ -1,5 +1,5 @@ import { Prisma } from "@/generated/prisma/client"; -import { overrideBranchConfigOverride } from "@/lib/config"; +import { overrideEnvironmentConfigOverride } from "@/lib/config"; import { LOCAL_EMULATOR_ADMIN_USER_ID, LOCAL_EMULATOR_ONLY_ENDPOINT_MESSAGE, @@ -100,6 +100,25 @@ async function getOrCreateLocalEmulatorProjectId(absoluteFilePath: string): Prom }, }); + const created = existingRow === undefined; + + // Seed environment-level defaults BEFORE registering as a LocalEmulatorProject: + // once registered, setEnvironmentConfigOverride refuses to write. + // - domains.allowLocalhost: fresh emulator projects allow localhost redirects + // so developers don't hit "Redirect URL not whitelisted" before configuring + // trustedDomains. + // - payments.testMode: emulator payments always go through stripe-mock. + if (created) { + await overrideEnvironmentConfigOverride({ + projectId, + branchId: DEFAULT_BRANCH_ID, + environmentConfigOverrideOverride: { + "domains.allowLocalhost": true, + "payments.testMode": true, + }, + }); + } + await globalPrismaClient.$executeRaw(Prisma.sql` INSERT INTO "LocalEmulatorProject" ("absoluteFilePath", "projectId", "createdAt", "updatedAt") VALUES (${absoluteFilePath}, ${projectId}, NOW(), NOW()) @@ -109,7 +128,7 @@ async function getOrCreateLocalEmulatorProjectId(absoluteFilePath: string): Prom "updatedAt" = NOW() `); - return { projectId, created: existingRow === undefined }; + return { projectId, created }; } async function getOrCreateCredentials(projectId: string) { @@ -219,20 +238,7 @@ export const POST = createSmartRouteHandler({ await assertLocalEmulatorOwnerTeamReadiness(); - const { projectId, created } = await getOrCreateLocalEmulatorProjectId(absoluteFilePath); - if (created) { - // Emulator projects are for local development. Default-allow localhost - // redirects so fresh projects don't hit "Redirect URL not whitelisted" - // before the developer has configured trustedDomains. User-supplied - // stack.config.ts still overrides this if they set domains explicitly. - await overrideBranchConfigOverride({ - projectId, - branchId: DEFAULT_BRANCH_ID, - branchConfigOverrideOverride: { - "domains.allowLocalhost": true, - }, - }); - } + const { projectId } = await getOrCreateLocalEmulatorProjectId(absoluteFilePath); const credentials = await getOrCreateCredentials(projectId); const fileConfig = await readConfigFromFile(absoluteFilePath); diff --git a/apps/backend/src/lib/ai/models.ts b/apps/backend/src/lib/ai/models.ts index 5635f4f8ae..71693d6d74 100644 --- a/apps/backend/src/lib/ai/models.ts +++ b/apps/backend/src/lib/ai/models.ts @@ -1,3 +1,4 @@ +import { isLocalEmulatorEnabled } from "@/lib/local-emulator"; import { createOpenRouter } from "@openrouter/ai-sdk-provider"; import { getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; @@ -59,7 +60,7 @@ export const ALLOWED_MODEL_IDS: ReadonlySet = new Set([ ]); export function createOpenRouterProvider() { - const baseURL = getNodeEnvironment() === "development" + const baseURL = (getNodeEnvironment() === "development" || isLocalEmulatorEnabled()) ? "http://localhost:8102/api/latest/integrations/ai-proxy/v1" : "https://api.stack-auth.com/api/latest/integrations/ai-proxy/v1"; return createOpenRouter({ diff --git a/apps/backend/src/lib/js-execution.tsx b/apps/backend/src/lib/js-execution.tsx index 0a56cda581..b02b822e31 100644 --- a/apps/backend/src/lib/js-execution.tsx +++ b/apps/backend/src/lib/js-execution.tsx @@ -1,6 +1,7 @@ import { traceSpan } from '@/utils/telemetry'; import { runAsynchronouslyAndWaitUntil } from '@/utils/background-tasks'; import { getEnvVariable, getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env'; +import { isLocalEmulatorEnabled } from "@/lib/local-emulator"; import { StackAssertionError, captureError } from '@stackframe/stack-shared/dist/utils/errors'; import { Result } from '@stackframe/stack-shared/dist/utils/results'; import { Sandbox } from '@vercel/sandbox'; @@ -27,11 +28,13 @@ function createFreestyleEngine(): JsEngine { let baseUrl = getEnvVariable("STACK_FREESTYLE_API_ENDPOINT", "") || undefined; if (apiKey === "mock_stack_freestyle_key") { - if (!["development", "test"].includes(getNodeEnvironment())) { + if (!["development", "test"].includes(getNodeEnvironment()) && !isLocalEmulatorEnabled()) { throw new StackAssertionError("Mock Freestyle key used in production; please set the STACK_FREESTYLE_API_KEY environment variable."); } - const prefix = getEnvVariable("NEXT_PUBLIC_STACK_PORT_PREFIX", "81"); - baseUrl = `http://localhost:${prefix}22`; + if (!baseUrl) { + const prefix = getEnvVariable("NEXT_PUBLIC_STACK_PORT_PREFIX", "81"); + baseUrl = `http://localhost:${prefix}22`; + } } const freestyle = new FreestyleClient({ @@ -147,7 +150,7 @@ export async function executeJavascript(code: string, options: ExecuteJavascript return await runWithFallback(code, options); } else { - if (getNodeEnvironment().includes("prod")) { + if (getNodeEnvironment().includes("prod") && !isLocalEmulatorEnabled()) { throw new StackAssertionError("STACK_VERCEL_SANDBOX_TOKEN is set to the disabled sentinel value in production. Please configure a real Vercel Sandbox token."); } diff --git a/apps/backend/src/lib/payments.tsx b/apps/backend/src/lib/payments.tsx index 4d14b9e23a..31a20203b1 100644 --- a/apps/backend/src/lib/payments.tsx +++ b/apps/backend/src/lib/payments.tsx @@ -7,7 +7,6 @@ import type { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/use import type { inlineProductSchema, productSchema, productSchemaWithMetadata } from "@stackframe/stack-shared/dist/schema-fields"; import { SUPPORTED_CURRENCIES } from "@stackframe/stack-shared/dist/utils/currency-constants"; import { FAR_FUTURE_DATE, addInterval, getIntervalsElapsed } from "@stackframe/stack-shared/dist/utils/dates"; -import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError, StatusError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { filterUndefined, getOrUndefined, has, typedEntries, typedFromEntries, typedKeys, typedValues } from "@stackframe/stack-shared/dist/utils/objects"; import { typedToUppercase } from "@stackframe/stack-shared/dist/utils/strings"; @@ -15,11 +14,9 @@ import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import Stripe from "stripe"; import * as yup from "yup"; import { Tenancy } from "./tenancies"; -import { getStripeForAccount } from "./stripe"; +import { getStripeForAccount, useStripeMock } from "./stripe"; const DEFAULT_PRODUCT_START_DATE = new Date("1973-01-01T12:00:00.000Z"); // monday -const stripeSecretKey = getEnvVariable("STACK_STRIPE_SECRET_KEY", ""); -const useStripeMock = stripeSecretKey === "sk_test_mockstripekey" && ["development", "test"].includes(getNodeEnvironment()); type Product = yup.InferType; type ProductWithMetadata = yup.InferType; diff --git a/apps/backend/src/lib/stripe.tsx b/apps/backend/src/lib/stripe.tsx index e722b45b1b..c664f82ab7 100644 --- a/apps/backend/src/lib/stripe.tsx +++ b/apps/backend/src/lib/stripe.tsx @@ -8,15 +8,18 @@ import { getEnvVariable, getNodeEnvironment } from "@stackframe/stack-shared/dis import { captureError, StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import Stripe from "stripe"; import type * as yup from "yup"; +import { isLocalEmulatorEnabled } from "./local-emulator"; import { createStripeProxy, type StripeOverridesMap } from "./stripe-proxy"; const stripeSecretKey = getEnvVariable("STACK_STRIPE_SECRET_KEY", ""); -const useStripeMock = stripeSecretKey === "sk_test_mockstripekey" && ["development", "test"].includes(getNodeEnvironment()); +export const useStripeMock = isLocalEmulatorEnabled() + || (stripeSecretKey === "sk_test_mockstripekey" && ["development", "test"].includes(getNodeEnvironment())); const stackPortPrefix = getEnvVariable("NEXT_PUBLIC_STACK_PORT_PREFIX", "81"); +const stripeMockPort = Number(getEnvVariable("STACK_STRIPE_MOCK_PORT", "") || `${stackPortPrefix}23`); const stripeConfig: Stripe.StripeConfig = useStripeMock ? { protocol: "http", host: "localhost", - port: Number(`${stackPortPrefix}23`), + port: stripeMockPort, } : {}; /** Product type as stored in Stripe metadata (same as config product schema) */ diff --git a/docker/local-emulator/qemu/run-emulator.sh b/docker/local-emulator/qemu/run-emulator.sh index d1a7aef9ef..ba905ca36d 100755 --- a/docker/local-emulator/qemu/run-emulator.sh +++ b/docker/local-emulator/qemu/run-emulator.sh @@ -5,8 +5,8 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # shellcheck source=common.sh source "$SCRIPT_DIR/common.sh" -IMAGE_DIR="$SCRIPT_DIR/images" -RUN_DIR="${EMULATOR_RUN_DIR:-$SCRIPT_DIR/run}" +IMAGE_DIR="${EMULATOR_IMAGE_DIR:-$HOME/.stack/emulator/images}" +RUN_DIR="${EMULATOR_RUN_DIR:-$HOME/.stack/emulator/run}" VM_RAM="${EMULATOR_RAM:-4096}" VM_CPUS="${EMULATOR_CPUS:-4}" diff --git a/docker/server/entrypoint.sh b/docker/server/entrypoint.sh index 659eb96286..152b1826be 100644 --- a/docker/server/entrypoint.sh +++ b/docker/server/entrypoint.sh @@ -26,7 +26,9 @@ else fi export NEXT_PUBLIC_STACK_PROJECT_ID=internal -export NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=${STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY} +if [ "$NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR" != "true" ]; then + export NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=${STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY} +fi if [ -n "${STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY:-}" ]; then export STACK_SECRET_SERVER_KEY=${STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY} fi diff --git a/packages/stack-cli/package.json b/packages/stack-cli/package.json index 744ba5ed9c..3f574e2413 100644 --- a/packages/stack-cli/package.json +++ b/packages/stack-cli/package.json @@ -10,7 +10,7 @@ }, "scripts": { "clean": "rimraf node_modules && rimraf dist", - "build": "tsdown", + "build": "tsdown && node scripts/copy-emulator-assets.mjs", "dev": "tsdown --watch", "lint": "eslint --ext .tsx,.ts .", "typecheck": "tsc --noEmit" diff --git a/packages/stack-cli/scripts/copy-emulator-assets.mjs b/packages/stack-cli/scripts/copy-emulator-assets.mjs new file mode 100644 index 0000000000..9701bd8e3c --- /dev/null +++ b/packages/stack-cli/scripts/copy-emulator-assets.mjs @@ -0,0 +1,23 @@ +#!/usr/bin/env node +import { chmodSync, cpSync, mkdirSync } from "fs"; +import { dirname, join, resolve } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const packageRoot = resolve(__dirname, ".."); +const qemuSrc = resolve(packageRoot, "../../docker/local-emulator/qemu"); +const envSrc = resolve(packageRoot, "../../docker/local-emulator/.env.development"); +const distDir = join(packageRoot, "dist"); +const emulatorDist = join(distDir, "emulator"); + +mkdirSync(emulatorDist, { recursive: true }); + +for (const name of ["run-emulator.sh", "common.sh", "cloud-init"]) { + cpSync(join(qemuSrc, name), join(emulatorDist, name), { recursive: true }); +} + +chmodSync(join(emulatorDist, "run-emulator.sh"), 0o755); + +cpSync(envSrc, join(distDir, ".env.development")); + +console.log(`Copied emulator assets into ${emulatorDist} (+ .env.development into ${distDir}).`); diff --git a/packages/stack-cli/src/commands/emulator.ts b/packages/stack-cli/src/commands/emulator.ts index 9fdbb54d00..d52463b15b 100644 --- a/packages/stack-cli/src/commands/emulator.ts +++ b/packages/stack-cli/src/commands/emulator.ts @@ -1,7 +1,9 @@ import { Command } from "commander"; import { execFileSync, spawn } from "child_process"; import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync } from "fs"; -import { join, resolve } from "path"; +import { homedir } from "os"; +import { dirname, join, resolve } from "path"; +import { fileURLToPath } from "url"; import { CliError } from "../lib/errors.js"; const DEFAULT_EMULATOR_BACKEND_PORT = 26701; @@ -16,8 +18,20 @@ function emulatorBackendPort(): number { return parsed; } +function emulatorHome(): string { + return process.env.STACK_EMULATOR_HOME ?? join(homedir(), ".stack", "emulator"); +} + +function emulatorRunDir(): string { + return join(emulatorHome(), "run"); +} + +function emulatorImageDir(): string { + return join(emulatorHome(), "images"); +} + function internalPckPath(): string { - return join(findQemuDir(), "run", "vm", "internal-pck"); + return join(emulatorRunDir(), "vm", "internal-pck"); } async function readInternalPck(timeoutMs = 60_000): Promise { @@ -79,31 +93,47 @@ function gh(args: string[]): string { } } -function findQemuDir(): string { - for (const rel of ["docker/local-emulator/qemu", "../docker/local-emulator/qemu"]) { - const dir = resolve(process.cwd(), rel); - if (existsSync(join(dir, "run-emulator.sh"))) return dir; - } - throw new CliError("Could not find QEMU emulator directory. Run this from the stack-auth repo root."); +function emulatorScriptsDir(): string { + const here = dirname(fileURLToPath(import.meta.url)); + const bundled = join(here, "emulator"); + if (existsSync(join(bundled, "run-emulator.sh"))) return bundled; + const repo = resolve(here, "../../../docker/local-emulator/qemu"); + if (existsSync(join(repo, "run-emulator.sh"))) return repo; + throw new CliError("Emulator scripts not found in CLI bundle."); +} + +function emulatorSpawnEnv(extra?: Record): NodeJS.ProcessEnv { + return { + ...process.env, + EMULATOR_RUN_DIR: emulatorRunDir(), + EMULATOR_IMAGE_DIR: emulatorImageDir(), + ...extra, + }; } function runEmulator(action: string, env?: Record): Promise { - const qemuDir = findQemuDir(); - return new Promise((resolve, reject) => { - const child = spawn(join(qemuDir, "run-emulator.sh"), [action], { + const scriptsDir = emulatorScriptsDir(); + mkdirSync(emulatorRunDir(), { recursive: true }); + mkdirSync(emulatorImageDir(), { recursive: true }); + return new Promise((resolvePromise, reject) => { + const child = spawn(join(scriptsDir, "run-emulator.sh"), [action], { stdio: "inherit", - env: { ...process.env, ...env }, - cwd: qemuDir, + env: emulatorSpawnEnv(env), + cwd: scriptsDir, }); - child.on("close", (code) => code === 0 ? resolve() : reject(new CliError(`run-emulator.sh ${action} exited with code ${code}`))); + child.on("close", (code) => code === 0 ? resolvePromise() : reject(new CliError(`run-emulator.sh ${action} exited with code ${code}`))); child.on("error", (err) => reject(new CliError(`Failed to run run-emulator.sh: ${err.message}`))); }); } function isEmulatorRunning(): boolean { - const qemuDir = findQemuDir(); + const scriptsDir = emulatorScriptsDir(); try { - execFileSync(join(qemuDir, "run-emulator.sh"), ["status"], { stdio: "pipe", cwd: qemuDir }); + execFileSync(join(scriptsDir, "run-emulator.sh"), ["status"], { + stdio: "pipe", + cwd: scriptsDir, + env: emulatorSpawnEnv(), + }); return true; } catch { return false; @@ -111,7 +141,8 @@ function isEmulatorRunning(): boolean { } async function startEmulator(arch: "arm64" | "amd64"): Promise { - const img = join(findQemuDir(), "images", `stack-emulator-${arch}.qcow2`); + mkdirSync(emulatorImageDir(), { recursive: true }); + const img = join(emulatorImageDir(), `stack-emulator-${arch}.qcow2`); if (!existsSync(img)) { console.log("No emulator image found. Pulling latest..."); pullRelease(arch); @@ -130,7 +161,7 @@ function pullRelease(arch: "arm64" | "amd64", opts: { repo?: string; branch?: st const branch = opts.branch ?? "dev"; const tag = opts.tag ?? `emulator-${branch}-latest`; const asset = `stack-emulator-${arch}.qcow2`; - const imageDir = join(findQemuDir(), "images"); + const imageDir = emulatorImageDir(); mkdirSync(imageDir, { recursive: true }); const dest = join(imageDir, asset); const tmpDest = `${dest}.download`; @@ -172,7 +203,7 @@ export function registerEmulatorCommand(program: Command) { runId = String(runs[0].databaseId); } - const imageDir = join(findQemuDir(), "images"); + const imageDir = emulatorImageDir(); mkdirSync(imageDir, { recursive: true }); const dest = join(imageDir, `stack-emulator-${arch}.qcow2`); if (existsSync(dest)) unlinkSync(dest); From 04640d4bc521f8a3022f29a9d0cfdba9899e4dd3 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 14 Apr 2026 11:31:15 -0700 Subject: [PATCH 26/29] fix --- apps/backend/prisma/seed.ts | 5 ----- docker/server/entrypoint.sh | 4 +--- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 67d8d4c23a..ff3715d3b2 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -101,11 +101,6 @@ export async function seed() { onboarding: { requireEmailVerification: false, }, - ...(localEmulatorEnabled ? { - project: { - requirePublishableClientKey: false, - }, - } : {}), dataVault: { stores: { 'neon-connection-strings': { diff --git a/docker/server/entrypoint.sh b/docker/server/entrypoint.sh index 152b1826be..659eb96286 100644 --- a/docker/server/entrypoint.sh +++ b/docker/server/entrypoint.sh @@ -26,9 +26,7 @@ else fi export NEXT_PUBLIC_STACK_PROJECT_ID=internal -if [ "$NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR" != "true" ]; then - export NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=${STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY} -fi +export NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=${STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY} if [ -n "${STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY:-}" ]; then export STACK_SECRET_SERVER_KEY=${STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY} fi From 4ae826d04aa18581bf81b627d8d5fc24e91d8d64 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 14 Apr 2026 11:38:01 -0700 Subject: [PATCH 27/29] fix copy emulator assets --- packages/stack-cli/scripts/copy-emulator-assets.mjs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/stack-cli/scripts/copy-emulator-assets.mjs b/packages/stack-cli/scripts/copy-emulator-assets.mjs index 9701bd8e3c..6ab2fad0a5 100644 --- a/packages/stack-cli/scripts/copy-emulator-assets.mjs +++ b/packages/stack-cli/scripts/copy-emulator-assets.mjs @@ -1,15 +1,21 @@ #!/usr/bin/env node -import { chmodSync, cpSync, mkdirSync } from "fs"; +import { execFileSync } from "child_process"; +import { chmodSync, cpSync, existsSync, mkdirSync } from "fs"; import { dirname, join, resolve } from "path"; import { fileURLToPath } from "url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const packageRoot = resolve(__dirname, ".."); const qemuSrc = resolve(packageRoot, "../../docker/local-emulator/qemu"); +const envGenScript = resolve(packageRoot, "../../docker/local-emulator/generate-env-development.mjs"); const envSrc = resolve(packageRoot, "../../docker/local-emulator/.env.development"); const distDir = join(packageRoot, "dist"); const emulatorDist = join(distDir, "emulator"); +if (!existsSync(envSrc)) { + execFileSync(process.execPath, [envGenScript], { stdio: "inherit" }); +} + mkdirSync(emulatorDist, { recursive: true }); for (const name of ["run-emulator.sh", "common.sh", "cloud-init"]) { From 0d98d552c5ea93f11e44eeb3624c4333a5ed4a09 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 14 Apr 2026 12:16:47 -0700 Subject: [PATCH 28/29] emulator stripe fixes and gh action fix --- .github/workflows/qemu-emulator-build.yaml | 2 ++ .../(protected)/projects/[projectId]/payments/layout.tsx | 6 ++++-- .../projects/[projectId]/payments/payouts/page-client.tsx | 5 +++-- .../src/components/payments/stripe-connect-provider.tsx | 5 +++-- docker/local-emulator/qemu/build-image.sh | 2 +- 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/qemu-emulator-build.yaml b/.github/workflows/qemu-emulator-build.yaml index a5a3f187df..5df1497465 100644 --- a/.github/workflows/qemu-emulator-build.yaml +++ b/.github/workflows/qemu-emulator-build.yaml @@ -22,6 +22,8 @@ concurrency: env: EMULATOR_IMAGE_NAME: stack-local-emulator + EMULATOR_IMAGE_DIR: ${{ github.workspace }}/docker/local-emulator/qemu/images + EMULATOR_RUN_DIR: ${{ github.workspace }}/docker/local-emulator/qemu/run jobs: build: diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/layout.tsx index 5644dcab48..f74d63890b 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/layout.tsx @@ -61,7 +61,9 @@ function PaymentsLayoutInner({ children }: { children: React.ReactNode }) { }); }; - if (!stripeAccountInfo) { + const isLocalEmulator = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true"; + + if (!stripeAccountInfo && !isLocalEmulator) { return (
@@ -236,7 +238,7 @@ function PaymentsLayoutInner({ children }: { children: React.ReactNode }) {
)} - {getPublicEnvVar("NEXT_PUBLIC_STACK_IS_PREVIEW") !== "true" && ( + {getPublicEnvVar("NEXT_PUBLIC_STACK_IS_PREVIEW") !== "true" && getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") !== "true" && (
- {isPreview ? ( + {isPreview || isLocalEmulator ? ( - Payouts are unavailable in preview mode. + Payouts are unavailable in {isLocalEmulator ? "the local emulator" : "preview mode"}. ) : ( diff --git a/apps/dashboard/src/components/payments/stripe-connect-provider.tsx b/apps/dashboard/src/components/payments/stripe-connect-provider.tsx index 36d1fc49c2..6d57a3f3da 100644 --- a/apps/dashboard/src/components/payments/stripe-connect-provider.tsx +++ b/apps/dashboard/src/components/payments/stripe-connect-provider.tsx @@ -13,6 +13,7 @@ import { useEffect } from "react"; import { appearanceVariablesForTheme } from "./stripe-theme-variables"; const isPreview = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_PREVIEW") === "true"; +const isLocalEmulator = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true"; type StripeConnectProviderProps = { children: React.ReactNode, @@ -36,7 +37,7 @@ export function StripeConnectProvider({ children }: StripeConnectProviderProps) const adminApp = useAdminApp(); const { resolvedTheme } = useTheme(); - const stripeConnectInstance = isPreview ? null : getStripeConnectInstance(adminApp); + const stripeConnectInstance = isPreview || isLocalEmulator ? null : getStripeConnectInstance(adminApp); useEffect(() => { if (!stripeConnectInstance) return; @@ -47,7 +48,7 @@ export function StripeConnectProvider({ children }: StripeConnectProviderProps) }); }, [resolvedTheme, stripeConnectInstance]); - // In preview mode, skip Stripe Connect initialization entirely + // In preview/emulator mode, skip Stripe Connect initialization entirely if (!stripeConnectInstance) { return <>{children}; } diff --git a/docker/local-emulator/qemu/build-image.sh b/docker/local-emulator/qemu/build-image.sh index 498d161735..f4d91771b7 100755 --- a/docker/local-emulator/qemu/build-image.sh +++ b/docker/local-emulator/qemu/build-image.sh @@ -5,7 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" # shellcheck source=common.sh source "$SCRIPT_DIR/common.sh" -IMAGE_DIR="$SCRIPT_DIR/images" +IMAGE_DIR="${EMULATOR_IMAGE_DIR:-$HOME/.stack/emulator/images}" CLOUD_INIT_ROOT="$SCRIPT_DIR/cloud-init" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" From 75aaf0cc30967f031221031f193e941a73dd9c0e Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 14 Apr 2026 14:33:11 -0700 Subject: [PATCH 29/29] emulator fix ai-chat, clickhouse queries, db sync --- .../api/latest/internal/external-db-sync/poller/route.ts | 4 ++-- apps/backend/src/lib/upstash.tsx | 6 ++++++ .../(protected)/projects/[projectId]/payments/layout.tsx | 2 +- apps/dashboard/src/components/commands/ai-chat-shared.tsx | 4 ++-- docker/local-emulator/clickhouse-config.xml | 2 ++ docker/local-emulator/generate-env-development.mjs | 2 +- docker/local-emulator/qemu/cloud-init/emulator/user-data | 2 +- packages/stack-cli/scripts/copy-emulator-assets.mjs | 6 ++---- 8 files changed, 17 insertions(+), 11 deletions(-) diff --git a/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts b/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts index d37ef118d2..688b9d25c1 100644 --- a/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts +++ b/apps/backend/src/app/api/latest/internal/external-db-sync/poller/route.ts @@ -130,7 +130,7 @@ export const GET = createSmartRouteHandler({ async function processRequest(request: OutgoingRequest): Promise { // Prisma JsonValue doesn't carry a precise shape for this JSON blob. const options = request.qstashOptions as any; - const baseUrl = getEnvVariable("NEXT_PUBLIC_STACK_API_URL"); + const baseUrl = getEnvVariable("NEXT_PUBLIC_SERVER_STACK_API_URL", "") || getEnvVariable("NEXT_PUBLIC_STACK_API_URL"); let fullUrl = new URL(options.url, baseUrl).toString(); @@ -157,7 +157,7 @@ export const GET = createSmartRouteHandler({ function buildUpstashRequest(request: OutgoingRequest): UpstashRequest { // Prisma JsonValue doesn't carry a precise shape for this JSON blob. const options = request.qstashOptions as any; - const baseUrl = getEnvVariable("NEXT_PUBLIC_STACK_API_URL"); + const baseUrl = getEnvVariable("NEXT_PUBLIC_SERVER_STACK_API_URL", "") || getEnvVariable("NEXT_PUBLIC_STACK_API_URL"); let fullUrl = new URL(options.url, baseUrl).toString(); diff --git a/apps/backend/src/lib/upstash.tsx b/apps/backend/src/lib/upstash.tsx index 6b4f48fec8..e2c752096e 100644 --- a/apps/backend/src/lib/upstash.tsx +++ b/apps/backend/src/lib/upstash.tsx @@ -28,6 +28,12 @@ export async function ensureUpstashSignature(fullReq: SmartRequest): Promise
- ) : !stripeAccountInfo.details_submitted && ( + ) : stripeAccountInfo && !stripeAccountInfo.details_submitted && (
0.5 + SQL_ + users.xml diff --git a/docker/local-emulator/generate-env-development.mjs b/docker/local-emulator/generate-env-development.mjs index f582144570..1266c2bae8 100644 --- a/docker/local-emulator/generate-env-development.mjs +++ b/docker/local-emulator/generate-env-development.mjs @@ -161,7 +161,7 @@ const entries = [ literal("STACK_S3_ENDPOINT", "http://127.0.0.1:9090"), literal("STACK_QSTASH_URL", "http://127.0.0.1:8080"), literal("STACK_CLICKHOUSE_URL", "http://127.0.0.1:8123"), - literal("STACK_CLICKHOUSE_DATABASE", "analytics"), + literal("STACK_CLICKHOUSE_DATABASE", "default"), literal("STACK_EMAIL_MONITOR_INBUCKET_API_URL", "http://127.0.0.1:9001"), literal("BACKEND_PORT", "8102"), literal("DASHBOARD_PORT", "8101"), diff --git a/docker/local-emulator/qemu/cloud-init/emulator/user-data b/docker/local-emulator/qemu/cloud-init/emulator/user-data index 7465914840..38fe2b0646 100644 --- a/docker/local-emulator/qemu/cloud-init/emulator/user-data +++ b/docker/local-emulator/qemu/cloud-init/emulator/user-data @@ -289,7 +289,7 @@ write_files: NEXT_PUBLIC_SERVER_STACK_DASHBOARD_URL=http://127.0.0.1:8101 NEXT_PUBLIC_STACK_SVIX_SERVER_URL=http://localhost:8071 NEXT_PUBLIC_STACK_PORT_PREFIX=81 - STACK_CLICKHOUSE_DATABASE=analytics + STACK_CLICKHOUSE_DATABASE=default BACKEND_PORT=8102 DASHBOARD_PORT=8101 diff --git a/packages/stack-cli/scripts/copy-emulator-assets.mjs b/packages/stack-cli/scripts/copy-emulator-assets.mjs index 6ab2fad0a5..8ae3dfa17d 100644 --- a/packages/stack-cli/scripts/copy-emulator-assets.mjs +++ b/packages/stack-cli/scripts/copy-emulator-assets.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node import { execFileSync } from "child_process"; -import { chmodSync, cpSync, existsSync, mkdirSync } from "fs"; +import { chmodSync, cpSync, mkdirSync } from "fs"; import { dirname, join, resolve } from "path"; import { fileURLToPath } from "url"; @@ -12,9 +12,7 @@ const envSrc = resolve(packageRoot, "../../docker/local-emulator/.env.developmen const distDir = join(packageRoot, "dist"); const emulatorDist = join(distDir, "emulator"); -if (!existsSync(envSrc)) { - execFileSync(process.execPath, [envGenScript], { stdio: "inherit" }); -} +execFileSync(process.execPath, [envGenScript], { stdio: "inherit" }); mkdirSync(emulatorDist, { recursive: true });