Merge branch 'dev' into promptless/changelog-oauth-retry-reliability

This commit is contained in:
promptless[bot] 2026-04-14 22:37:26 +00:00
commit 03aeb1953b
27 changed files with 661 additions and 109 deletions

View File

@ -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:

View File

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

View File

@ -130,7 +130,7 @@ export const GET = createSmartRouteHandler({
async function processRequest(request: OutgoingRequest): Promise<void> {
// 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();

View File

@ -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<string> {
async function getOrCreateLocalEmulatorProjectId(absoluteFilePath: string): Promise<{ projectId: string, created: boolean }> {
const existingRows = await globalPrismaClient.$queryRaw<LocalEmulatorProjectMappingRow[]>(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),

View File

@ -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<string> = 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({

View File

@ -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.");
}

View File

@ -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<typeof productSchema>;
type ProductWithMetadata = yup.InferType<typeof productSchemaWithMetadata>;

View File

@ -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) */

View File

@ -28,6 +28,12 @@ export async function ensureUpstashSignature(fullReq: SmartRequest): Promise<voi
if ((nodeEnv.includes("development") || nodeEnv.includes("test")) && url.hostname === "localhost") {
url.hostname = "host.docker.internal";
}
// The backend binds to 0.0.0.0, so Next.js reports the incoming URL with that
// hostname. QStash signs the URL we told it to call (e.g. localhost), so
// normalize 0.0.0.0 back to localhost for signature verification.
if (url.hostname === "0.0.0.0") {
url.hostname = "localhost";
}
const isValid = await upstashReceiver.verify({
signature: upstashSignature,

View File

@ -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 (
<div className="mx-auto max-w-sm h-full flex items-center">
<Card className="w-full">
@ -172,7 +174,7 @@ function PaymentsLayoutInner({ children }: { children: React.ReactNode }) {
</div>
</div>
</div>
) : !stripeAccountInfo.details_submitted && (
) : stripeAccountInfo && !stripeAccountInfo.details_submitted && (
<div className="flex justify-center px-4 pt-4 sm:px-6 sm:pt-6">
<div className={cn(
"w-full max-w-[1250px] rounded-2xl p-4 sm:p-5",
@ -236,7 +238,7 @@ function PaymentsLayoutInner({ children }: { children: React.ReactNode }) {
</div>
</div>
)}
{getPublicEnvVar("NEXT_PUBLIC_STACK_IS_PREVIEW") !== "true" && (
{getPublicEnvVar("NEXT_PUBLIC_STACK_IS_PREVIEW") !== "true" && getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") !== "true" && (
<div className={cn(bannerHasItems && "p-4", "flex justify-center")}>
<div style={{ maxWidth: 1250, width: '100%' }}>
<ConnectNotificationBanner

View File

@ -8,12 +8,13 @@ import { StripeConnectProvider } from "@/components/payments/stripe-connect-prov
export default function PageClient() {
const isPreview = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_PREVIEW") === "true";
const isLocalEmulator = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true";
return (
<PageLayout title="Payouts">
{isPreview ? (
{isPreview || isLocalEmulator ? (
<Alert>
Payouts are unavailable in preview mode.
Payouts are unavailable in {isLocalEmulator ? "the local emulator" : "preview mode"}.
</Alert>
) : (
<StripeConnectProvider>

View File

@ -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 (

View File

@ -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}</>;
}

View File

@ -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"]

View File

@ -15,6 +15,8 @@
<max_server_memory_usage_to_ram_ratio>0.5</max_server_memory_usage_to_ram_ratio>
<custom_settings_prefixes>SQL_</custom_settings_prefixes>
<user_directories>
<users_xml>
<path>users.xml</path>

View File

@ -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

View File

@ -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"),

View File

@ -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)"

View File

@ -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 \

View File

@ -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

View File

@ -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

View File

@ -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]

View File

@ -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 <<SQL
INSERT INTO "ApiKeySet" ("projectId", id, description, "expiresAt", "createdAt", "updatedAt", "publishableClientKey", "secretServerKey", "superSecretAdminKey")
VALUES ('internal', '3142e763-b230-44b5-8636-aa62f7489c26', 'Internal API key set', '2099-12-31T23:59:59Z', NOW(), NOW(),
'${STACK_SEED_INTERNAL_PROJECT_PUBLISHABLE_CLIENT_KEY}',
'${STACK_SEED_INTERNAL_PROJECT_SECRET_SERVER_KEY}',
'${STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY}')
ON CONFLICT ("projectId", id) DO UPDATE SET
"publishableClientKey" = EXCLUDED."publishableClientKey",
"secretServerKey" = EXCLUDED."secretServerKey",
"superSecretAdminKey" = EXCLUDED."superSecretAdminKey",
"updatedAt" = NOW();
SQL
fi
# ============= ENV VARS =============
# Create a working directory for our processed files.

View File

@ -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"

View File

@ -0,0 +1,27 @@
#!/usr/bin/env node
import { execFileSync } from "child_process";
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 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");
execFileSync(process.execPath, [envGenScript], { stdio: "inherit" });
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}).`);

View File

@ -1,9 +1,87 @@
import { Command } from "commander";
import { execFileSync, spawn } from "child_process";
import { existsSync, mkdirSync, renameSync, unlinkSync } from "fs";
import { join, resolve } from "path";
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync } from "fs";
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;
function emulatorBackendPort(): number {
const raw = process.env.EMULATOR_BACKEND_PORT;
if (!raw) return DEFAULT_EMULATOR_BACKEND_PORT;
const parsed = Number(raw);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new CliError(`Invalid EMULATOR_BACKEND_PORT: ${raw}`);
}
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(emulatorRunDir(), "vm", "internal-pck");
}
async function readInternalPck(timeoutMs = 60_000): Promise<string> {
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<EmulatorCredentials> {
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<string, string>): NodeJS.ProcessEnv {
return {
...process.env,
EMULATOR_RUN_DIR: emulatorRunDir(),
EMULATOR_IMAGE_DIR: emulatorImageDir(),
...extra,
};
}
function runEmulator(action: string, env?: Record<string, string>): Promise<void> {
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<void> {
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 <arch>", "Target architecture (default: current system arch). Non-native uses software emulation and is significantly slower.")
.action(async (opts) => {
.option("--config-file <path>", "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}`);
}
}
await runEmulator("start", { EMULATOR_ARCH: arch });
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("<cmd>", "Command to run (e.g. \"npm run dev\")")
.option("--arch <arch>", "Target architecture")
.option("--config-file <path>", "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<string, string> = { ...process.env as Record<string, string> };
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"));

View File

@ -126,6 +126,7 @@ export class _StackAdminAppImplIncomplete<HasTokenStore extends boolean, Project
constructor(options: StackAdminAppConstructorOptions<HasTokenStore, ProjectId>, extraOptions?: { uniqueIdentifier?: string, checkString?: string, interface?: StackAdminInterface }) {
const resolvedOptions = resolveConstructorOptions(options);
const publishableClientKey = resolvedOptions.publishableClientKey ?? getDefaultPublishableClientKey();
super(resolvedOptions, {