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/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/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/app/api/latest/internal/local-emulator/project/route.tsx b/apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx index e660c21c75..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,4 +1,5 @@ import { Prisma } from "@/generated/prisma/client"; +import { overrideEnvironmentConfigOverride } 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: { @@ -98,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()) @@ -107,7 +128,7 @@ async function getOrCreateLocalEmulatorProjectId(absoluteFilePath: string): Prom "updatedAt" = NOW() `); - return projectId; + return { projectId, created }; } async function getOrCreateCredentials(projectId: string) { @@ -142,7 +163,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, @@ -150,6 +171,7 @@ async function getOrCreateCredentials(projectId: string) { } return { + publishableClientKey: keySet.publishableClientKey, secretServerKey: keySet.secretServerKey, superSecretAdminKey: keySet.superSecretAdminKey, }; @@ -179,6 +201,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(), @@ -215,7 +238,7 @@ export const POST = createSmartRouteHandler({ await assertLocalEmulatorOwnerTeamReadiness(); - const projectId = await getOrCreateLocalEmulatorProjectId(absoluteFilePath); + const { projectId } = await getOrCreateLocalEmulatorProjectId(absoluteFilePath); const credentials = await getOrCreateCredentials(projectId); const fileConfig = await readConfigFromFile(absoluteFilePath); @@ -224,6 +247,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/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/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 @@ -172,7 +174,7 @@ function PaymentsLayoutInner({ children }: { children: React.ReactNode }) { - ) : !stripeAccountInfo.details_submitted && ( + ) : stripeAccountInfo && !stripeAccountInfo.details_submitted && (
)} - {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/commands/ai-chat-shared.tsx b/apps/dashboard/src/components/commands/ai-chat-shared.tsx index ef955001ea..75723abb4b 100644 --- a/apps/dashboard/src/components/commands/ai-chat-shared.tsx +++ b/apps/dashboard/src/components/commands/ai-chat-shared.tsx @@ -211,8 +211,8 @@ export const ToolInvocationCard = memo(function ToolInvocationCard({ const { label, icon: Icon } = getToolDisplay(); - const input = invocation.input as { query?: string }; - const queryArg = input.query; + const input = invocation.input as { query?: string } | undefined; + const queryArg = input?.query; const result = invocation.output as { success?: boolean, result?: unknown[], error?: string, rowCount?: number }; return ( 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/Dockerfile b/docker/local-emulator/Dockerfile index 3ef9151a59..56deae7882 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... @@ -87,8 +88,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 @@ -159,6 +199,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 @@ -191,6 +234,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 \ @@ -207,17 +258,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/clickhouse-config.xml b/docker/local-emulator/clickhouse-config.xml index 31aa71922c..0ba2c03fb6 100644 --- a/docker/local-emulator/clickhouse-config.xml +++ b/docker/local-emulator/clickhouse-config.xml @@ -15,6 +15,8 @@ 0.5 + SQL_ + users.xml 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/generate-env-development.mjs b/docker/local-emulator/generate-env-development.mjs index f0b0b20d23..1266c2bae8 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"), @@ -159,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/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)" diff --git a/docker/local-emulator/qemu/cloud-init/emulator/user-data b/docker/local-emulator/qemu/cloud-init/emulator/user-data index 07b0bc5f4e..38fe2b0646 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' @@ -231,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 @@ -369,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 \ diff --git a/docker/local-emulator/qemu/run-emulator.sh b/docker/local-emulator/qemu/run-emulator.sh index 0a82c1b883..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}" @@ -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..a30cf03e68 --- /dev/null +++ b/docker/local-emulator/run-cron-jobs.sh @@ -0,0 +1,32 @@ +#!/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}" + +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 + +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 1 + 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 da7214a013..659eb96286 100644 --- a/docker/server/entrypoint.sh +++ b/docker/server/entrypoint.sh @@ -11,14 +11,28 @@ 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 + 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)} + 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} -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} @@ -65,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(); @@ -15,27 +93,63 @@ 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 scriptsDir = emulatorScriptsDir(); + try { + execFileSync(join(scriptsDir, "run-emulator.sh"), ["status"], { + stdio: "pipe", + cwd: scriptsDir, + env: emulatorSpawnEnv(), + }); + return true; + } catch { + return false; + } +} + +async function startEmulator(arch: "arm64" | "amd64"): Promise { + 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); + } + 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; @@ -47,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`; @@ -89,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); @@ -110,14 +224,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); + + 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.warn("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)); + } + }); + + 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); - 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}`); + } + } + + 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; } - await runEmulator("start", { EMULATOR_ARCH: arch }); + + 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/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 2755c1a972..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 @@ -126,6 +126,7 @@ export class _StackAdminAppImplIncomplete, extraOptions?: { uniqueIdentifier?: string, checkString?: string, interface?: StackAdminInterface }) { const resolvedOptions = resolveConstructorOptions(options); + const publishableClientKey = resolvedOptions.publishableClientKey ?? getDefaultPublishableClientKey(); super(resolvedOptions, {