Merge branch 'dev' into dario-likes-mcps
Some checks failed
DB migration compat / Check if migrations changed (push) Has been cancelled
DB migration compat / Back-compat — Current branch migrations with ${{ needs.check-migrations-changed.outputs.base_branch }} branch code (push) Has been cancelled
DB migration compat / Forward-compat — Current branch code with ${{ needs.check-migrations-changed.outputs.base_branch }} branch migrations (push) Has been cancelled
DB migration compat / No migration changes (skipped) (push) Has been cancelled

This commit is contained in:
mantrakp04 2026-04-06 09:41:39 -07:00
commit b82efa4210
48 changed files with 3331 additions and 181 deletions

View File

@ -61,6 +61,8 @@ dist
.docusaurus
.cache-loader
**.tsbuildinfo
docker/local-emulator/qemu/images
docker/local-emulator/qemu/run
.xata*
@ -149,4 +151,3 @@ packages/stack/*
!packages/react/package.json
!packages/next/package.json
!packages/stack/package.json

View File

@ -0,0 +1,255 @@
name: Build & Publish QEMU Emulator Images
on:
push:
branches:
- main
- dev
pull_request:
paths:
- 'docker/local-emulator/**'
- '.github/workflows/qemu-emulator-build.yaml'
workflow_dispatch:
inputs:
publish:
description: 'Publish images to GitHub Releases'
type: boolean
default: false
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' && github.ref != 'refs/heads/dev' }}
env:
EMULATOR_IMAGE_NAME: stack-local-emulator
jobs:
build:
name: Build QEMU Image (${{ matrix.arch }})
runs-on: ubicloud-standard-8
timeout-minutes: 120
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
- arch: arm64
steps:
- uses: actions/checkout@v6
- name: Set up QEMU user-mode emulation
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Install QEMU dependencies
run: |
sudo apt-get update
sudo apt-get install -y qemu-system-x86 qemu-system-arm qemu-utils genisoimage socat qemu-efi-aarch64
- name: Build QEMU image
run: |
chmod +x docker/local-emulator/qemu/build-image.sh
EMULATOR_PROVISION_TIMEOUT=6000 \
docker/local-emulator/qemu/build-image.sh ${{ matrix.arch }}
- name: Generate emulator env
run: node docker/local-emulator/generate-env-development.mjs
- name: Start emulator and verify
run: |
chmod +x docker/local-emulator/qemu/run-emulator.sh
EMULATOR_ARCH=${{ matrix.arch }} \
EMULATOR_READY_TIMEOUT=3200 \
docker/local-emulator/qemu/run-emulator.sh start
- name: Verify services are healthy
run: |
EMULATOR_ARCH=${{ matrix.arch }} \
docker/local-emulator/qemu/run-emulator.sh status
- name: Stop emulator
if: always()
run: |
EMULATOR_ARCH=${{ matrix.arch }} \
docker/local-emulator/qemu/run-emulator.sh stop
- name: Package image
run: |
BASE_IMG="docker/local-emulator/qemu/images/stack-emulator-${{ matrix.arch }}.qcow2"
cp "$BASE_IMG" "stack-emulator-${{ matrix.arch }}.qcow2"
- name: Upload image artifact
uses: actions/upload-artifact@v4
with:
name: qemu-emulator-${{ matrix.arch }}
path: stack-emulator-${{ matrix.arch }}.qcow2
retention-days: 30
compression-level: 0
test:
name: Smoke Test (${{ matrix.arch }})
needs: build
runs-on: ubicloud-standard-8
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
steps:
- uses: actions/checkout@v6
- name: Install QEMU dependencies
run: |
sudo apt-get update
sudo apt-get install -y qemu-system-x86 qemu-utils genisoimage socat
- name: Download built image
uses: actions/download-artifact@v4
with:
name: qemu-emulator-${{ matrix.arch }}
path: docker/local-emulator/qemu/images/
- name: Generate emulator env
run: node docker/local-emulator/generate-env-development.mjs
- name: Start emulator from artifact
run: |
chmod +x docker/local-emulator/qemu/run-emulator.sh docker/local-emulator/qemu/common.sh
EMULATOR_ARCH=${{ matrix.arch }} \
EMULATOR_READY_TIMEOUT=600 \
docker/local-emulator/qemu/run-emulator.sh start
- name: Verify services are healthy
run: |
EMULATOR_ARCH=${{ matrix.arch }} \
docker/local-emulator/qemu/run-emulator.sh status
- name: Smoke test — backend health
run: curl -sf http://localhost:26701/health?db=1
- name: Smoke test — dashboard reachable
run: curl -sf -o /dev/null -w "HTTP %{http_code}\n" http://localhost:26700/handler/sign-in
- name: Smoke test — MinIO health
run: curl -sf http://localhost:26702/minio/health/live
- name: Smoke test — Inbucket reachable
run: curl -sf -o /dev/null -w "HTTP %{http_code}\n" http://localhost:26703/
- name: Stop emulator
if: always()
run: |
EMULATOR_ARCH=${{ matrix.arch }} \
docker/local-emulator/qemu/run-emulator.sh stop
- name: Print serial log on failure
if: failure()
run: tail -100 docker/local-emulator/qemu/run/vm/serial.log 2>/dev/null || true
publish:
name: Publish to GitHub Releases
needs: [build, test]
if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dev' || (github.event_name == 'workflow_dispatch' && inputs.publish)
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Prepare release assets
run: |
mkdir -p release
SHORT_SHA="${GITHUB_SHA:0:8}"
BRANCH="${GITHUB_REF_NAME}"
DATE="$(date -u +%Y%m%d)"
TAG="emulator-${BRANCH}-${DATE}-${SHORT_SHA}"
echo "RELEASE_TAG=${TAG}" >> "$GITHUB_ENV"
echo "SHORT_SHA=${SHORT_SHA}" >> "$GITHUB_ENV"
for f in artifacts/qemu-emulator-*/*.qcow2; do
cp "$f" release/
done
cat > release-notes.md <<EOF
## QEMU Emulator Images
Built from \`${BRANCH}\` @ \`${GITHUB_SHA}\`
### Images
| File | Description |
|------|-------------|
| \`stack-emulator-arm64.qcow2\` | ARM64 emulator image |
| \`stack-emulator-amd64.qcow2\` | AMD64 emulator image |
### Usage
\`\`\`bash
stack emulator pull
stack emulator start
\`\`\`
EOF
ls -lh release/
- name: Create or update GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TITLE="QEMU Emulator — ${{ github.ref_name }} ($SHORT_SHA)"
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
gh release edit "$RELEASE_TAG" \
--title "$TITLE" \
--notes-file release-notes.md \
--prerelease
gh release upload "$RELEASE_TAG" release/* --clobber
else
gh release create "$RELEASE_TAG" \
--title "$TITLE" \
--notes-file release-notes.md \
--prerelease \
release/*
fi
- name: Update latest tag for branch
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
LATEST_TAG="emulator-${{ github.ref_name }}-latest"
TITLE="QEMU Emulator — ${{ github.ref_name }} (latest)"
NOTES="Latest emulator images from \`${{ github.ref_name }}\`. Auto-updated on each build."
if gh release view "$LATEST_TAG" >/dev/null 2>&1; then
gh release edit "$LATEST_TAG" \
--draft \
--prerelease \
--target "${{ github.sha }}" \
--title "$TITLE" \
--notes "$NOTES"
else
gh release create "$LATEST_TAG" \
--draft \
--prerelease \
--target "${{ github.sha }}" \
--title "$TITLE" \
--notes "$NOTES" \
|| gh release edit "$LATEST_TAG" \
--draft \
--prerelease \
--target "${{ github.sha }}" \
--title "$TITLE" \
--notes "$NOTES"
fi
gh release upload "$LATEST_TAG" release/* --clobber
gh release edit "$LATEST_TAG" --draft=false --prerelease

1
.gitignore vendored
View File

@ -24,6 +24,7 @@ vite.config.ts.timestamp-*
.eslintcache
.env.local
.env.*.local
docker/local-emulator/.env.development
scratch/
npm-debug.log*

View File

@ -502,6 +502,13 @@ export async function seed() {
} else {
console.log('Ensured emulator user is a member of emulator team');
}
await grantTeamPermission(internalPrisma, {
tenancy: internalTenancy,
teamId: LOCAL_EMULATOR_OWNER_TEAM_ID,
userId: LOCAL_EMULATOR_ADMIN_USER_ID,
permissionId: "team_admin",
});
}
console.log('Seeding complete!');

View File

@ -5,6 +5,7 @@ import {
LOCAL_EMULATOR_OWNER_TEAM_ID,
isLocalEmulatorEnabled,
readConfigFromFile,
resolveEmulatorPath,
writeConfigToFile,
} from "@/lib/local-emulator";
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
@ -192,11 +193,12 @@ export const POST = createSmartRouteHandler({
}
const absoluteFilePath = path.resolve(req.body.absolute_file_path);
const resolvedFilePath = resolveEmulatorPath(absoluteFilePath);
// Validate file exists before creating a project
let fileExists: boolean;
try {
await fs.access(absoluteFilePath);
await fs.access(resolvedFilePath);
fileExists = true;
} catch {
fileExists = false;
@ -206,7 +208,7 @@ export const POST = createSmartRouteHandler({
}
// If the file is empty, write a default config
const fileContent = await fs.readFile(absoluteFilePath, "utf-8");
const fileContent = await fs.readFile(resolvedFilePath, "utf-8");
if (fileContent.trim() === "") {
await writeConfigToFile(absoluteFilePath, {});
}

View File

@ -0,0 +1,92 @@
import fs from "fs/promises";
import os from "os";
import path from "path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV,
readConfigFromFile,
writeConfigToFile,
} from "./local-emulator";
describe("local emulator config", () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
it("reads config from STACK_LOCAL_EMULATOR_CONFIG_CONTENT env var when set", async () => {
const content = `export const config = { auth: { allowLocalhost: true } };\n`;
vi.stubEnv("STACK_LOCAL_EMULATOR_CONFIG_CONTENT", Buffer.from(content).toString("base64"));
await expect(readConfigFromFile("/irrelevant/path/stack.config.ts")).resolves.toMatchInlineSnapshot(`
{
"auth": {
"allowLocalhost": true,
},
}
`);
});
it("returns empty object when env var is not set and file does not exist", async () => {
await expect(readConfigFromFile("/nonexistent/stack.config.ts")).resolves.toEqual({});
});
it("returns empty object when env var content is empty", async () => {
const content = ``;
vi.stubEnv("STACK_LOCAL_EMULATOR_CONFIG_CONTENT", Buffer.from(content).toString("base64"));
await expect(readConfigFromFile("/irrelevant/path/stack.config.ts")).resolves.toEqual({});
});
it("throws when the config module does not export config", async () => {
const content = `export default { auth: { allowLocalhost: true } };\n`;
vi.stubEnv("STACK_LOCAL_EMULATOR_CONFIG_CONTENT", Buffer.from(content).toString("base64"));
await expect(readConfigFromFile("/irrelevant/path/stack.config.ts")).rejects.toThrow(
"Invalid config in /irrelevant/path/stack.config.ts. The file must export a 'config' object."
);
});
it("reads config files from the host mount when configured", async () => {
const hostMountRoot = await fs.mkdtemp(path.join(os.tmpdir(), "stack-host-mount-"));
const absoluteFilePath = "/Users/foo/project/stack.config.ts";
const mountedFilePath = path.join(hostMountRoot, absoluteFilePath);
await fs.mkdir(path.dirname(mountedFilePath), { recursive: true });
await fs.writeFile(mountedFilePath, `export const config = { auth: { allowLocalhost: true } };\n`, "utf-8");
vi.stubEnv(LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV, hostMountRoot);
await expect(readConfigFromFile(absoluteFilePath)).resolves.toMatchInlineSnapshot(`
{
"auth": {
"allowLocalhost": true,
},
}
`);
});
it("writes new config files to the host mount when the mounted parent directory exists", async () => {
const hostMountRoot = await fs.mkdtemp(path.join(os.tmpdir(), "stack-host-mount-"));
const absoluteFilePath = "/Users/foo/project/stack.config.ts";
const mountedParentPath = path.join(hostMountRoot, "/Users/foo/project");
const mountedFilePath = path.join(hostMountRoot, absoluteFilePath);
await fs.mkdir(mountedParentPath, { recursive: true });
vi.stubEnv(LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV, hostMountRoot);
await writeConfigToFile(absoluteFilePath, { auth: { allowLocalhost: true } });
await expect(fs.readFile(mountedFilePath, "utf-8")).resolves.toBe(
`export const config = {\n "auth": {\n "allowLocalhost": true\n }\n};\n`
);
});
it("fails loudly when the QEMU host mount root is configured but unavailable", async () => {
const hostMountRoot = await fs.mkdtemp(path.join(os.tmpdir(), "stack-host-mount-"));
vi.stubEnv(LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV, hostMountRoot);
await expect(writeConfigToFile("/Users/foo/project/stack.config.ts", { auth: { allowLocalhost: true } })).rejects.toThrow(
`Local emulator host mount root ${hostMountRoot} is configured`
);
});
});

View File

@ -1,10 +1,10 @@
import fs from "fs/promises";
import path from "path";
import { createJiti } from "jiti";
import { globalPrismaClient } from "@/prisma-client";
import { isValidConfig } from "@stackframe/stack-shared/dist/config/format";
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { globalPrismaClient } from "@/prisma-client";
import fs from "fs/promises";
import { createJiti } from "jiti";
import path from "path";
export const LOCAL_EMULATOR_ADMIN_USER_ID = "63abbc96-5329-454a-ba56-e0460173c6c1";
export const LOCAL_EMULATOR_OWNER_TEAM_ID = "5a0c858b-d9e9-49d4-9943-8ce385d86428";
@ -15,6 +15,7 @@ export const LOCAL_EMULATOR_ENV_CONFIG_BLOCKED_MESSAGE =
"Environment configuration overrides cannot be changed in the local emulator. Update this in your production deployment instead.";
export const LOCAL_EMULATOR_ONLY_ENDPOINT_MESSAGE =
"This endpoint is only available in local emulator mode (set NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR=true).";
export const LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV = "STACK_LOCAL_EMULATOR_HOST_MOUNT_ROOT";
export function isLocalEmulatorEnabled() {
return getEnvVariable("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR", "") === "true";
@ -44,16 +45,36 @@ export async function getLocalEmulatorFilePath(projectId: string): Promise<strin
return result?.absoluteFilePath ?? null;
}
export async function readConfigFromFile(filePath: string): Promise<Record<string, unknown>> {
let content: string;
try {
content = await fs.readFile(filePath, "utf-8");
} catch (e: any) {
if (e?.code === "ENOENT") {
throw new StatusError(StatusError.BadRequest, `Config file not found: ${filePath}`);
}
throw e;
export function resolveEmulatorPath(filePath: string): string {
const hostMountRoot = getEnvVariable(LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV, "");
if (hostMountRoot) {
return path.join(hostMountRoot, filePath);
}
return filePath;
}
export async function readConfigFromFile(filePath: string): Promise<Record<string, unknown>> {
// Check for base64-encoded config content override from env var
const envContent = getEnvVariable("STACK_LOCAL_EMULATOR_CONFIG_CONTENT", "");
let content: string;
if (envContent) {
content = Buffer.from(envContent, "base64").toString("utf-8");
} else {
const resolvedPath = resolveEmulatorPath(filePath);
try {
content = await fs.readFile(resolvedPath, "utf-8");
} catch (e: any) {
if (e?.code === "ENOENT") {
return {};
}
throw e;
}
}
if (content.trim() === "") {
return {};
}
const jiti = createJiti(import.meta.url, { cache: false });
const mod = jiti.evalModule(content, { filename: filePath }) as Record<string, unknown>;
const config = mod.config;
@ -64,8 +85,18 @@ export async function readConfigFromFile(filePath: string): Promise<Record<strin
}
export async function writeConfigToFile(filePath: string, config: Record<string, unknown>): Promise<void> {
const dir = path.dirname(filePath);
await fs.mkdir(dir, { recursive: true });
const resolvedPath = resolveEmulatorPath(filePath);
const dir = path.dirname(resolvedPath);
const hostMountRoot = getEnvVariable(LOCAL_EMULATOR_HOST_MOUNT_ROOT_ENV, "");
if (hostMountRoot) {
try {
await fs.access(dir);
} catch {
throw new Error(`Local emulator host mount root ${hostMountRoot} is configured but the parent directory for ${filePath} is not available at ${dir}. Ensure the host filesystem is mounted correctly.`);
}
} else {
await fs.mkdir(dir, { recursive: true });
}
const content = `export const config = ${JSON.stringify(config, null, 2)};\n`;
await fs.writeFile(filePath, content, "utf-8");
await fs.writeFile(resolvedPath, content, "utf-8");
}

View File

@ -8,7 +8,7 @@ import { getPublicEnvVar } from "@/lib/env";
import { stackAppInternalsSymbol } from "@/lib/stack-app-internals";
import { GearIcon } from "@phosphor-icons/react";
import { AdminOwnedProject, Team, useStackApp, useUser } from "@stackframe/stack";
import { projectOnboardingStatusValues, strictEmailSchema, type ProjectOnboardingStatus, yupObject } from "@stackframe/stack-shared/dist/schema-fields";
import { projectOnboardingStatusValues, strictEmailSchema, yupObject, type ProjectOnboardingStatus } from "@stackframe/stack-shared/dist/schema-fields";
import { groupBy } from "@stackframe/stack-shared/dist/utils/arrays";
import { runAsynchronously, runAsynchronouslyWithAlert, wait } from "@stackframe/stack-shared/dist/utils/promises";
import { useQueryState } from "@stackframe/stack-shared/dist/utils/react";

View File

@ -4,7 +4,8 @@ import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-a
import { AppStoreEntry } from "@/components/app-store-entry";
import { useRouter } from "@/components/router";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui";
import { ALL_APPS_FRONTEND, getAppPath } from "@/lib/apps-frontend";
import { ALL_APPS_FRONTEND, getAppPath, isSubApp } from "@/lib/apps-frontend";
import { isAppEnabled } from "@/lib/apps-utils";
import { useUpdateConfig } from "@/lib/config-update";
import { AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
import { usePathname } from "next/navigation";
@ -20,7 +21,16 @@ export default function AppDetailsModalPageClient({ appId }: { appId: AppId }) {
const config = project.useConfig();
const updateConfig = useUpdateConfig();
const isEnabled = config.apps.installed[appId]?.enabled ?? false;
const isEnabled = isAppEnabled(config.apps.installed, appId);
const appFrontend = ALL_APPS_FRONTEND[appId];
const appPath = getAppPath(project.id, appFrontend);
const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null;
const parentAppEnabled = parentAppId == null ? false : isAppEnabled(config.apps.installed, parentAppId);
const subAppDestinationPath = parentAppId == null
? null
: parentAppEnabled
? appPath
: `/projects/${project.id}/apps/${parentAppId}`;
// Control modal visibility based on whether we're on a modal route.
// This ensures the modal only closes when navigation actually succeeds,
@ -47,9 +57,8 @@ export default function AppDetailsModalPageClient({ appId }: { appId: AppId }) {
};
const handleOpen = () => {
const path = getAppPath(project.id, ALL_APPS_FRONTEND[appId]);
// Navigate to the app page. Modal stays open until pathname changes.
router.replace(path);
router.replace(subAppDestinationPath ?? appPath);
};
const handleOpenChange = (open: boolean) => {

View File

@ -1,6 +1,7 @@
'use client';
import { useRouter } from "@/components/router";
import { isAppEnabled } from "@/lib/apps-utils";
import { AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
import { Typography } from "@/components/ui";
import type { ReactNode } from "react";
@ -13,7 +14,7 @@ export function AppEnabledGuard(props: { appId: AppId, children: ReactNode }) {
const adminApp = useAdminApp();
const project = adminApp.useProject();
const config = project.useConfig();
const isEnabled = config.apps.installed[props.appId]?.enabled;
const isEnabled = isAppEnabled(config.apps.installed, props.appId);
useEffect(() => {
if (!isEnabled) {

View File

@ -4,7 +4,8 @@ import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-a
import { AppStoreEntry } from "@/components/app-store-entry";
import { useRouter } from "@/components/router";
import { useUpdateConfig } from "@/lib/config-update";
import { ALL_APPS_FRONTEND, getAppPath, type AppId } from "@/lib/apps-frontend";
import { ALL_APPS_FRONTEND, getAppPath, isSubApp, type AppId } from "@/lib/apps-frontend";
import { isAppEnabled } from "@/lib/apps-utils";
import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { PageLayout } from "../../page-layout";
@ -17,13 +18,21 @@ export default function AppDetailsPageClient({ appId }: { appId: AppId }) {
const config = project.useConfig();
const updateConfig = useUpdateConfig();
const isEnabled = config.apps.installed[appId]?.enabled ?? false;
const isEnabled = isAppEnabled(config.apps.installed, appId);
const appFrontend = ALL_APPS_FRONTEND[appId];
if (!(appFrontend as any)) {
throw new StackAssertionError(`App frontend not found for appId: ${appId}`, { appId });
}
const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null;
const parentAppFrontend = parentAppId == null ? null : ALL_APPS_FRONTEND[parentAppId];
const parentAppEnabled = parentAppId == null ? false : isAppEnabled(config.apps.installed, parentAppId);
const appPath = getAppPath(project.id, appFrontend);
const subAppDestinationPath = parentAppFrontend == null
? null
: parentAppEnabled
? appPath
: `/projects/${project.id}/apps/${parentAppId}`;
const handleEnable = async () => {
await updateConfig({
@ -35,7 +44,7 @@ export default function AppDetailsPageClient({ appId }: { appId: AppId }) {
};
const handleOpen = () => {
router.push(appPath);
router.push(subAppDestinationPath ?? appPath);
};
const handleDisable = async () => {

View File

@ -4,6 +4,7 @@ import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-a
import { AppSquare } from "@/components/app-square";
import { DesignAlert, DesignCard, DesignCategoryTabs, DesignInput } from "@/components/design-components";
import { type AppId } from "@/lib/apps-frontend";
import { getEnabledAppIds } from "@/lib/apps-utils";
import { CheckCircleIcon, MagnifyingGlassIcon, SquaresFourIcon } from "@phosphor-icons/react";
import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config";
import { stringCompare } from "@stackframe/stack-shared/dist/utils/strings";
@ -34,9 +35,7 @@ export default function PageClient() {
// Get installed apps
const installedApps = useMemo(() =>
(Object.entries(config.apps.installed) as [string, { enabled?: boolean } | undefined][])
.filter(([_, appConfig]) => appConfig?.enabled)
.map(([appId]) => appId as AppId),
getEnabledAppIds(config.apps.installed),
[config.apps.installed]
);

View File

@ -18,7 +18,8 @@ import {
TooltipTrigger,
Typography,
} from "@/components/ui";
import { ALL_APPS_FRONTEND, DUMMY_ORIGIN, getAppPath, getItemPath, testAppPath, testItemPath } from "@/lib/apps-frontend";
import { ALL_APPS_FRONTEND, DUMMY_ORIGIN, getAppPath, getItemPath, hasNavigationItems, testAppPath, testItemPath, type NavigableAppFrontend } from "@/lib/apps-frontend";
import { getEnabledAppIds, getEnabledNavigableAppIds } from "@/lib/apps-utils";
import { useUpdateConfig } from "@/lib/config-update";
import { cn } from "@/lib/utils";
import {
@ -37,7 +38,6 @@ import {
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { UserButton } from "@stackframe/stack";
import { ALL_APPS, type AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects";
import { usePathname } from "next/navigation";
import { useCallback, useMemo, useRef, useState } from "react";
import { useAdminApp, useProjectId } from "./use-admin-app";
@ -381,10 +381,14 @@ function AppNavItem({
// Memoize the item object to prevent NavItem re-renders
const navItemData = useMemo(() => {
const items = appFrontend.navigationItems.map((navItem) => ({
if (!hasNavigationItems(appFrontend)) {
return null;
}
const navigableFrontend: NavigableAppFrontend = appFrontend;
const items = navigableFrontend.navigationItems.map((navItem) => ({
name: navItem.displayName,
href: getItemPath(projectId, appFrontend, navItem),
match: (fullUrl: URL) => testItemPath(projectId, appFrontend, navItem, fullUrl),
href: getItemPath(projectId, navigableFrontend, navItem),
match: (fullUrl: URL) => testItemPath(projectId, navigableFrontend, navItem, fullUrl),
}));
return {
name: app.displayName,
@ -396,6 +400,10 @@ function AppNavItem({
};
}, [app.displayName, appId, appFrontend, projectId]);
if (navItemData == null) {
return null;
}
return (
<NavItem
item={navItemData}
@ -426,9 +434,7 @@ function SidebarContent({
// Memoize enabledApps to prevent recalculation on every render
const enabledApps = useMemo(() =>
typedEntries(config.apps.installed)
.filter(([appId, appConfig]) => appConfig?.enabled && appId in ALL_APPS)
.map(([appId]) => appId as AppId),
getEnabledNavigableAppIds(config.apps.installed),
[config.apps.installed]
);
@ -608,9 +614,7 @@ function SpotlightSearchWrapper({ projectId }: { projectId: string }) {
const updateConfig = useUpdateConfig();
const enabledApps = useMemo(() =>
typedEntries(config.apps.installed)
.filter(([appId, appConfig]) => appConfig?.enabled && appId in ALL_APPS)
.map(([appId]) => appId as AppId),
getEnabledAppIds(config.apps.installed),
[config.apps.installed]
);

View File

@ -1,6 +1,8 @@
import { useAdminApp, useProjectId } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app";
import { useRouter } from "@/components/router";
import { Button, cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui";
import { ALL_APPS_FRONTEND, AppFrontend, getAppPath } from "@/lib/apps-frontend";
import { ALL_APPS_FRONTEND, AppFrontend, getAppPath, isSubApp } from "@/lib/apps-frontend";
import { isAppEnabled } from "@/lib/apps-utils";
import { useUpdateConfig } from "@/lib/config-update";
import { CheckIcon, DotsThreeVerticalIcon } from "@phosphor-icons/react";
import { ALL_APPS, AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
@ -57,9 +59,20 @@ export function AppSquare({
const project = adminApp.useProject();
const config = project.useConfig();
const updateConfig = useUpdateConfig();
const router = useRouter();
const isEnabled = config.apps.installed[appId]?.enabled ?? false;
const isEnabled = isAppEnabled(config.apps.installed, appId);
const appDetailsPath = `/projects/${projectId}/apps/${appId}`;
const appFrontend = ALL_APPS_FRONTEND[appId];
const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null;
const parentApp = parentAppId == null ? null : ALL_APPS[parentAppId];
const parentAppFrontend = parentAppId == null ? null : ALL_APPS_FRONTEND[parentAppId];
const parentAppEnabled = parentAppId == null ? false : isAppEnabled(config.apps.installed, parentAppId);
const parentDestinationPath = parentAppId == null || parentAppFrontend == null
? null
: parentAppEnabled
? getAppPath(projectId, appFrontend)
: `/projects/${projectId}/apps/${parentAppId}`;
const handleToggleEnabled = async () => {
// Show warning modal for alpha/beta apps when enabling
@ -138,9 +151,15 @@ export function AppSquare({
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[120px]">
<DropdownMenuItem onClick={handleToggleEnabled} className="cursor-pointer">
{isEnabled ? 'Disable' : 'Enable'}
</DropdownMenuItem>
{parentDestinationPath == null ? (
<DropdownMenuItem onClick={handleToggleEnabled} className="cursor-pointer">
{isEnabled ? 'Disable' : 'Enable'}
</DropdownMenuItem>
) : (
<DropdownMenuItem onClick={() => router.push(parentDestinationPath)} className="cursor-pointer">
Go to {parentApp?.displayName ?? "parent app"}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
@ -199,9 +218,19 @@ export function AppListItem({
const project = adminApp.useProject();
const config = project.useConfig();
const isEnabled = config.apps.installed[appId]?.enabled ?? false;
const isEnabled = isAppEnabled(config.apps.installed, appId);
const appPath = getAppPath(project.id, appFrontend);
const appDetailsPath = `/projects/${project.id}/apps/${appId}`;
const router = useRouter();
const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null;
const parentApp = parentAppId == null ? null : ALL_APPS[parentAppId];
const parentAppFrontend = parentAppId == null ? null : ALL_APPS_FRONTEND[parentAppId];
const parentAppEnabled = parentAppId == null ? false : isAppEnabled(config.apps.installed, parentAppId);
const parentDestinationPath = parentAppId == null || parentAppFrontend == null
? null
: parentAppEnabled
? appPath
: `/projects/${project.id}/apps/${parentAppId}`;
const handleEnable = async (e: React.MouseEvent) => {
e.preventDefault();
@ -220,7 +249,7 @@ export function AppListItem({
return (
<Link
href={isEnabled ? appPath : appDetailsPath}
href={parentDestinationPath ?? (isEnabled ? appPath : appDetailsPath)}
className={cn(
"flex items-center gap-3 p-3 rounded-lg transition-all",
"hover:bg-gray-50 dark:hover:bg-gray-800/50",
@ -258,6 +287,19 @@ export function AppListItem({
<div className="flex items-center gap-2">
{isEnabled ? (
<CheckIcon className="w-4 h-4 text-green-500" />
) : parentDestinationPath != null ? (
<Button
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
router.push(parentDestinationPath);
}}
variant="plain"
size="plain"
className="px-3 py-1 text-xs font-medium bg-muted text-foreground rounded-md hover:bg-muted/80 transition-colors"
>
Go to {parentApp?.displayName ?? "parent"}
</Button>
) : (
<Button
onClick={handleEnable}

View File

@ -2,8 +2,8 @@
import { AppIcon } from "@/components/app-square";
import { Badge, Button, Dialog, DialogContent, DialogTitle, ScrollArea, cn } from "@/components/ui";
import { ALL_APPS_FRONTEND, type AppId } from "@/lib/apps-frontend";
import { ArrowSquareOutIcon, CaretLeftIcon, CaretRightIcon, CheckIcon, LightningIcon, ShieldCheckIcon, XIcon } from "@phosphor-icons/react";
import { ALL_APPS_FRONTEND, isSubApp, type AppId } from "@/lib/apps-frontend";
import { ArrowRightIcon, CaretLeftIcon, CaretRightIcon, CheckIcon, LightningIcon, ShieldCheckIcon, XIcon } from "@phosphor-icons/react";
import { ALL_APPS, ALL_APP_TAGS } from "@stackframe/stack-shared/dist/apps/apps-config";
import Image from "next/image";
import { FunctionComponent, useCallback, useEffect, useRef, useState } from "react";
@ -25,6 +25,8 @@ export function AppStoreEntry({
}) {
const app = ALL_APPS[appId];
const appFrontend = ALL_APPS_FRONTEND[appId];
const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null;
const parentApp = parentAppId == null ? null : ALL_APPS[parentAppId];
const screenshotContainerRef = useRef<HTMLDivElement>(null);
const [previewIndex, setPreviewIndex] = useState<number | null>(null);
@ -142,36 +144,52 @@ export function AppStoreEntry({
</div>
{/* CTA Button */}
<div className="flex items-center gap-4">
{isEnabled ? (
<div className={cn("flex gap-4", parentApp == null ? "items-center" : "flex-col items-start")}>
{parentApp == null ? (
isEnabled ? (
<>
<Button
onClick={onOpen}
size="lg"
className="px-8 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium shadow-lg shadow-blue-500/20"
>
<ArrowRightIcon className="w-4 h-4 mr-2" />
Open App
</Button>
{onDisable && (
<Button
onClick={onDisable}
variant="ghost"
size="lg"
className="text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-950 dark:hover:text-red-300"
>
Disable
</Button>
)}
</>
) : (
<Button
onClick={onEnable}
size="lg"
className="px-8 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium shadow-lg shadow-blue-500/20"
>
Enable App
</Button>
)
) : (
<>
<p className="text-xs text-muted-foreground">
This app is part of the {parentApp.displayName} app.
</p>
<Button
onClick={onOpen}
size="lg"
className="px-8 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium shadow-lg shadow-blue-500/20"
>
<ArrowSquareOutIcon className="w-4 h-4 mr-2" />
Open App
<ArrowRightIcon className="w-4 h-4 mr-2" />
Go to {parentApp.displayName}
</Button>
{onDisable && (
<Button
onClick={onDisable}
variant="ghost"
size="lg"
className="text-red-600 hover:bg-red-50 hover:text-red-700 dark:text-red-400 dark:hover:bg-red-950 dark:hover:text-red-300"
>
Disable
</Button>
)}
</>
) : (
<Button
onClick={onEnable}
size="lg"
className="px-8 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium shadow-lg shadow-blue-500/20"
>
Enable App
</Button>
)}
</div>
</div>

View File

@ -1,12 +1,13 @@
"use client";
import { AppIcon } from "@/components/app-square";
import { Link } from "@/components/link";
import { Badge, Button, ScrollArea } from "@/components/ui";
import { ALL_APPS_FRONTEND, getAppPath, getItemPath } from "@/lib/apps-frontend";
import { ALL_APPS_FRONTEND, getAppPath, getItemPath, hasNavigationItems, isSubApp, type NavigableAppFrontend } from "@/lib/apps-frontend";
import { getUninstalledAppIds } from "@/lib/apps-utils";
import { classifyClickHouseSqlVsPrompt } from "@/lib/classify-query";
import { cn } from "@/lib/utils";
import { CheckIcon, CubeIcon, DownloadSimpleIcon, GearIcon, GlobeIcon, InfoIcon, KeyIcon, LayoutIcon, LightningIcon, PlayIcon, ShieldCheckIcon, SparkleIcon } from "@phosphor-icons/react";
import { ChartBarIcon, CheckIcon, CubeIcon, DownloadSimpleIcon, EnvelopeSimpleIcon, GearIcon, GlobeIcon, HardDriveIcon, InfoIcon, KeyIcon, LayoutIcon, LightningIcon, Palette, PlayIcon, PlusIcon, ShieldCheckIcon, SparkleIcon, UsersIcon } from "@phosphor-icons/react";
import { ALL_APPS, ALL_APP_TAGS, type AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import Image from "next/image";
@ -37,15 +38,19 @@ export type CmdKPreviewProps = {
// Available App Preview Component - shows app store page in preview panel
const AvailableAppPreview = memo(function AvailableAppPreview({
appId,
projectId,
onEnable,
goToParentHref,
onClose,
}: {
appId: AppId,
projectId: string,
onEnable: () => Promise<void>,
onEnable?: () => Promise<void>,
goToParentHref?: string,
onClose?: () => void,
}) {
const app = ALL_APPS[appId];
const appFrontend = ALL_APPS_FRONTEND[appId];
const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null;
const parentApp = parentAppId == null ? null : ALL_APPS[parentAppId];
const features = [
{ icon: ShieldCheckIcon, label: "Secure" },
@ -119,18 +124,38 @@ const AvailableAppPreview = memo(function AvailableAppPreview({
{/* Enable Button */}
<div className="flex items-center gap-3">
<Button
onClick={() => runAsynchronouslyWithAlert(onEnable())}
size="sm"
className="flex-1 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium"
>
Enable App
</Button>
{parentApp == null ? (
<Button
onClick={() => {
if (onEnable == null) return;
runAsynchronouslyWithAlert(onEnable());
}}
size="sm"
className="flex-1 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium"
>
Enable App
</Button>
) : (
<Button
asChild
size="sm"
className="flex-1 bg-blue-600 hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600 text-white font-medium"
>
<Link href={goToParentHref ?? "#"} onClick={onClose}>
Go to {parentApp.displayName}
</Link>
</Button>
)}
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
<InfoIcon className="w-3 h-3" />
<span>Free</span>
</div>
</div>
{parentApp != null && (
<p className="text-[11px] text-muted-foreground">
This app is part of the {parentApp.displayName} app.
</p>
)}
{/* Stage Warning */}
{app.stage !== "stable" && (
@ -188,20 +213,29 @@ const AvailableAppPreview = memo(function AvailableAppPreview({
});
// Factory to create available app preview components
function createAvailableAppPreview(appId: AppId, projectId: string, onEnable: () => Promise<void>): React.ComponentType<CmdKPreviewProps> {
return function AvailableAppPreviewWrapper() {
return <AvailableAppPreview appId={appId} projectId={projectId} onEnable={onEnable} />;
function createAvailableAppPreview(
appId: AppId,
onEnable?: () => Promise<void>,
goToParentHref?: string
): React.ComponentType<CmdKPreviewProps> {
return function AvailableAppPreviewWrapper({ onClose }: CmdKPreviewProps) {
return <AvailableAppPreview appId={appId} onEnable={onEnable} goToParentHref={goToParentHref} onClose={onClose} />;
};
}
// Cache for available app preview components
const availableAppPreviewCache = new Map<string, React.ComponentType<CmdKPreviewProps>>();
function getOrCreateAvailableAppPreview(appId: AppId, projectId: string, onEnable: () => Promise<void>): React.ComponentType<CmdKPreviewProps> {
const cacheKey = `${appId}:${projectId}`;
function getOrCreateAvailableAppPreview(
appId: AppId,
projectId: string,
onEnable?: () => Promise<void>,
goToParentHref?: string
): React.ComponentType<CmdKPreviewProps> {
const cacheKey = `${appId}:${projectId}:${goToParentHref ?? "enable"}:${onEnable == null ? "readonly" : "enable"}`;
let preview = availableAppPreviewCache.get(cacheKey);
if (!preview) {
preview = createAvailableAppPreview(appId, projectId, onEnable);
preview = createAvailableAppPreview(appId, onEnable, goToParentHref);
availableAppPreviewCache.set(cacheKey, preview);
}
return preview;
@ -229,11 +263,88 @@ export type CmdKCommand = {
highlightColor?: string,
};
type ProjectShortcutDefinition = {
id: string,
icon: React.FunctionComponent<React.SVGProps<SVGSVGElement>>,
label: string,
description: string,
href: string,
keywords: string[],
requiredApps?: AppId[],
};
const PROJECT_SHORTCUTS: ProjectShortcutDefinition[] = [
{
id: "navigation/users",
icon: UsersIcon,
label: "Users",
description: "Navigation",
href: "/users",
keywords: ["users", "user", "people", "members", "accounts"],
},
{
id: "navigation/dashboards",
icon: ChartBarIcon,
label: "Dashboards",
description: "Navigation",
href: "/dashboards",
keywords: ["dashboards", "dashboard", "charts", "insights", "metrics"],
},
{
id: "settings/trusted-domains",
icon: GlobeIcon,
label: "Trusted Domains",
description: "Settings",
href: "/domains",
keywords: ["domains", "trusted domains", "custom domain", "handler", "allowlist"],
requiredApps: ["authentication"],
},
{
id: "emails/themes",
icon: Palette,
label: "Email Themes",
description: "Emails",
href: "/email-themes",
keywords: ["email themes", "themes", "branding", "style", "templates"],
requiredApps: ["emails"],
},
{
id: "emails/outbox",
icon: EnvelopeSimpleIcon,
label: "Email Outbox",
description: "Emails",
href: "/email-outbox",
keywords: ["email outbox", "outbox", "delivery", "queue", "scheduled emails"],
requiredApps: ["emails"],
},
{
id: "data-vault/stores",
icon: HardDriveIcon,
label: "Data Vault Stores",
description: "Data Vault",
href: "/data-vault/stores",
keywords: ["data vault", "stores", "vault", "secrets", "encrypted storage"],
requiredApps: ["data-vault"],
},
{
id: "payments/new-product",
icon: PlusIcon,
label: "Create Product",
description: "Payments",
href: "/payments/products/new",
keywords: ["create product", "new product", "payments", "pricing", "catalog"],
requiredApps: ["payments"],
},
];
function toCommandIdSegment(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
}
// Factory to create app preview components that show navigation items
function createAppPreview(appId: AppId, projectId: string): React.ComponentType<CmdKPreviewProps> {
function createAppPreview(appId: AppId, projectId: string, appFrontend: NavigableAppFrontend): React.ComponentType<CmdKPreviewProps> {
// Pre-compute these outside the component since they're static per appId
const app = ALL_APPS[appId];
const appFrontend = ALL_APPS_FRONTEND[appId];
// Pre-compute nested commands since they're static
const IconComponent = appFrontend.icon;
@ -273,7 +384,11 @@ function getOrCreateAppPreview(appId: AppId, projectId: string): React.Component
const cacheKey = `${appId}:${projectId}`;
let preview = appPreviewCache.get(cacheKey);
if (!preview) {
preview = createAppPreview(appId, projectId);
const appFrontend = ALL_APPS_FRONTEND[appId];
if (!hasNavigationItems(appFrontend)) {
throw new Error(`App ${appId} has no navigation items`);
}
preview = createAppPreview(appId, projectId, appFrontend);
appPreviewCache.set(cacheKey, preview);
}
return preview;
@ -292,6 +407,21 @@ export function useCmdKCommands({
}): CmdKCommand[] {
return useMemo(() => {
const commands: CmdKCommand[] = [];
const pushUniqueNavigateCommand = (command: CmdKCommand) => {
if (command.onAction.type !== "navigate") {
commands.push(command);
return;
}
const href = command.onAction.href;
const alreadyExists = commands.some((existingCommand) =>
existingCommand.onAction.type === "navigate" &&
existingCommand.onAction.href === href
);
if (!alreadyExists) {
commands.push(command);
}
};
const queryClassification = classifyClickHouseSqlVsPrompt(query, { readonlyOnly: true });
const shouldPrioritizeRunQuery = queryClassification.kind === "sql";
@ -306,6 +436,24 @@ export function useCmdKCommands({
preview: null,
});
// Core navigation and power-tool shortcuts
for (const shortcut of PROJECT_SHORTCUTS) {
if (shortcut.requiredApps != null && !shortcut.requiredApps.every((appId) => enabledApps.includes(appId))) {
continue;
}
const IconComponent = shortcut.icon;
pushUniqueNavigateCommand({
id: shortcut.id,
icon: <IconComponent className="h-3.5 w-3.5 text-muted-foreground" />,
label: shortcut.label,
description: shortcut.description,
keywords: shortcut.keywords,
onAction: { type: "navigate", href: `/projects/${projectId}${shortcut.href}` },
preview: null,
});
}
// Installed apps - with preview for navigation items
for (const appId of enabledApps) {
const app = ALL_APPS[appId];
@ -313,21 +461,77 @@ export function useCmdKCommands({
// Some enabled apps might not have navigation metadata yet
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!app || !appFrontend) continue;
const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null;
const parentApp = parentAppId == null ? null : ALL_APPS[parentAppId];
const IconComponent = appFrontend.icon;
const hasNavigationItems = appFrontend.navigationItems.length > 0;
if (!hasNavigationItems(appFrontend)) {
commands.push({
id: `apps/${appId}`,
icon: <IconComponent className="h-3.5 w-3.5 stroke-emerald-600 dark:stroke-emerald-400" />,
label: app.displayName,
description: parentApp == null ? "Installed app" : `Part of ${parentApp.displayName}`,
keywords: [
app.displayName.toLowerCase(),
app.subtitle.toLowerCase(),
appId,
appFrontend.href.toLowerCase(),
appFrontend.href.toLowerCase().replace(/-/g, " "),
...app.tags,
"installed",
"app",
...(parentApp == null ? [] : [parentApp.displayName.toLowerCase(), "sub-app"]),
],
onAction: { type: "navigate", href: getAppPath(projectId, appFrontend) },
preview: null,
highlightColor: "app",
});
continue;
}
// Add the app itself as a command
const hasNestedNavigation = appFrontend.navigationItems.length > 0;
commands.push({
id: `apps/${appId}`,
icon: <IconComponent className="h-3.5 w-3.5 stroke-emerald-600 dark:stroke-emerald-400" />,
label: app.displayName,
description: "Installed app",
keywords: [app.displayName.toLowerCase(), ...app.tags, "installed", "app"],
description: parentApp == null ? "Installed app" : `Part of ${parentApp.displayName}`,
keywords: [
app.displayName.toLowerCase(),
app.subtitle.toLowerCase(),
appId,
appFrontend.href.toLowerCase(),
appFrontend.href.toLowerCase().replace(/-/g, " "),
...app.tags,
"installed",
"app",
...(parentApp == null ? [] : [parentApp.displayName.toLowerCase(), "sub-app"]),
],
onAction: { type: "navigate", href: getAppPath(projectId, appFrontend) },
preview: hasNavigationItems ? getOrCreateAppPreview(appId, projectId) : null,
preview: hasNestedNavigation ? getOrCreateAppPreview(appId, projectId) : null,
highlightColor: "app",
});
// Flatten app pages so they're directly searchable without nesting
for (const navItem of appFrontend.navigationItems) {
const itemPath = getItemPath(projectId, appFrontend, navItem);
pushUniqueNavigateCommand({
id: `apps/${appId}/page/${toCommandIdSegment(navItem.displayName)}`,
icon: <IconComponent className="h-3.5 w-3.5 text-muted-foreground" />,
label: `${app.displayName}: ${navItem.displayName}`,
description: `Page in ${app.displayName}`,
keywords: [
app.displayName.toLowerCase(),
navItem.displayName.toLowerCase(),
`${app.displayName.toLowerCase()} ${navItem.displayName.toLowerCase()}`,
appId,
"page",
"navigate",
],
onAction: { type: "navigate", href: itemPath },
preview: null,
highlightColor: "app",
});
}
}
// Available (uninstalled) apps
@ -338,6 +542,15 @@ export function useCmdKCommands({
// Some apps might not have frontend metadata yet
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!app || !appFrontend) continue;
const parentAppId = isSubApp(appFrontend) ? appFrontend.parentAppId : null;
const parentApp = parentAppId == null ? null : ALL_APPS[parentAppId];
const parentAppFrontend = parentAppId == null ? null : ALL_APPS_FRONTEND[parentAppId];
const isParentEnabled = parentAppId == null ? false : enabledApps.includes(parentAppId);
const parentDestination = parentAppId == null || parentAppFrontend == null
? null
: isParentEnabled
? getAppPath(projectId, appFrontend)
: `/projects/${projectId}/apps/${parentAppId}`;
const IconComponent = appFrontend.icon;
const hasPreview = onEnableApp !== undefined;
@ -351,15 +564,32 @@ export function useCmdKCommands({
</div>
),
label: app.displayName,
description: "Available to install",
keywords: [app.displayName.toLowerCase(), ...app.tags, "available", "install", "store", "app"],
onAction: hasPreview
? { type: "focus" }
: { type: "navigate", href: `/projects/${projectId}/apps/${appId}` },
preview: hasPreview
? getOrCreateAvailableAppPreview(appId, projectId, () => onEnableApp(appId))
description: parentApp == null ? "Available to install" : `Part of ${parentApp.displayName}`,
keywords: [
app.displayName.toLowerCase(),
app.subtitle.toLowerCase(),
appId,
...app.tags,
"available",
"install",
"store",
"app",
...(parentApp == null ? [] : ["sub-app", parentApp.displayName.toLowerCase()]),
],
onAction: parentApp == null
? hasPreview
? { type: "focus" }
: { type: "navigate", href: `/projects/${projectId}/apps/${appId}` }
: { type: "navigate", href: parentDestination ?? `/projects/${projectId}/apps/${appId}` },
preview: parentApp == null && hasPreview
? getOrCreateAvailableAppPreview(
appId,
projectId,
() => onEnableApp(appId),
undefined
)
: null,
hasVisualPreview: hasPreview,
hasVisualPreview: parentApp == null && hasPreview,
});
}

View File

@ -1,5 +1,5 @@
import { BUNDLED_DASHBOARD_UI_TYPES, BUNDLED_TYPE_DEFINITIONS } from "@/generated/bundled-type-definitions";
import { ALL_APPS_FRONTEND, type AppId, getItemPath } from "@/lib/apps-frontend";
import { ALL_APPS_FRONTEND, type AppId, getItemPath, hasNavigationItems } from "@/lib/apps-frontend";
import { buildStackAuthHeaders, type CurrentUser } from "@/lib/api-headers";
/**
@ -19,6 +19,9 @@ export function buildAvailableRoutes(enabledAppIds: AppId[]): string {
// Dynamic routes from enabled apps
for (const appId of enabledAppIds) {
const appFrontend = ALL_APPS_FRONTEND[appId as keyof typeof ALL_APPS_FRONTEND];
if (!hasNavigationItems(appFrontend)) {
continue;
}
for (const item of appFrontend.navigationItems) {
// Use a placeholder project ID — we only need the path relative to /projects/[id]/
const fullPath = getItemPath("__PROJECT__", appFrontend, item);

View File

@ -1,5 +1,5 @@
import { Link } from "@/components/link";
import { ChartLineIcon, ClipboardTextIcon, CreditCardIcon, EnvelopeSimpleIcon, FingerprintSimpleIcon, KeyIcon, MailboxIcon, RocketIcon, SparkleIcon, TelevisionSimpleIcon, TriangleIcon, UserGearIcon, UsersIcon, VaultIcon, WebhooksLogoIcon } from "@phosphor-icons/react";
import { ChartLineIcon, ClipboardTextIcon, CreditCardIcon, EnvelopeSimpleIcon, FingerprintSimpleIcon, KeyIcon, MailboxIcon, RocketIcon, ShieldCheckIcon, SparkleIcon, TelevisionSimpleIcon, TriangleIcon, UserGearIcon, UsersIcon, VaultIcon, WebhooksLogoIcon } from "@phosphor-icons/react";
import { StackAdminApp } from "@stackframe/stack";
import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config";
import { getRelativePart, isChildUrl } from "@stackframe/stack-shared/dist/utils/urls";
@ -33,34 +33,55 @@ export type AppFrontend = {
icon: React.FunctionComponent<React.SVGProps<SVGSVGElement>>,
logo?: React.FunctionComponent<{}>,
href: string,
matchPath?: (relativePart: string) => boolean,
getBreadcrumbItems?: (stackAdminApp: StackAdminApp<false>, relativePart: string) => Promise<BreadcrumbDefinition | null | undefined>,
navigationItems: AppNavigationItem[],
screenshots: (string | StaticImageData)[],
storeDescription: JSX.Element,
};
} & (
| {
navigationItems: AppNavigationItem[],
matchPath?: (relativePart: string) => boolean,
getBreadcrumbItems?: (stackAdminApp: StackAdminApp<false>, relativePart: string) => Promise<BreadcrumbDefinition | null | undefined>,
}
| {
parentAppId: AppId,
}
)
export type NavigableAppFrontend = Extract<AppFrontend, { navigationItems: AppNavigationItem[] }>;
export type SubAppFrontend = Extract<AppFrontend, { parentAppId: AppId }>;
export function hasNavigationItems(appFrontend: AppFrontend): appFrontend is NavigableAppFrontend {
return "navigationItems" in appFrontend;
}
export function isSubApp(appFrontend: AppFrontend): appFrontend is SubAppFrontend {
return "parentAppId" in appFrontend;
}
export function getAppPath(projectId: string, appFrontend: AppFrontend) {
const url = new URL(appFrontend.href, `${DUMMY_ORIGIN}/projects/${projectId}/`);
return getRelativePart(url);
}
export function getItemPath(projectId: string, appFrontend: AppFrontend, item: AppFrontend["navigationItems"][number]) {
export function getItemPath(projectId: string, appFrontend: NavigableAppFrontend, item: AppNavigationItem) {
const url = new URL(item.href, new URL(appFrontend.href, `${DUMMY_ORIGIN}/projects/${projectId}/`) + "/");
return getRelativePart(url);
}
export function testAppPath(projectId: string, appFrontend: AppFrontend, fullUrl: URL) {
if (appFrontend.matchPath) return appFrontend.matchPath(getRelativePart(fullUrl));
if ("matchPath" in appFrontend && appFrontend.matchPath) {
return appFrontend.matchPath(getRelativePart(fullUrl));
}
for (const item of appFrontend.navigationItems) {
if (testItemPath(projectId, appFrontend, item, fullUrl)) return true;
if (hasNavigationItems(appFrontend)) {
for (const item of appFrontend.navigationItems) {
if (testItemPath(projectId, appFrontend, item, fullUrl)) return true;
}
}
const url = new URL(appFrontend.href, `${DUMMY_ORIGIN}/projects/${projectId}/`);
return isChildUrl(url, fullUrl);
}
export function testItemPath(projectId: string, appFrontend: AppFrontend, item: AppFrontend["navigationItems"][number], fullUrl: URL) {
export function testItemPath(projectId: string, appFrontend: NavigableAppFrontend, item: AppNavigationItem, fullUrl: URL) {
if (item.matchPath) return item.matchPath(getRelativePart(fullUrl));
const url = new URL(getItemPath(projectId, appFrontend, item), fullUrl);
@ -84,6 +105,16 @@ export const ALL_APPS_FRONTEND = {
</>
),
},
"fraud-protection": {
icon: ShieldCheckIcon,
href: "sign-up-rules",
parentAppId: "authentication",
screenshots: [],
storeDescription: <>
<p>Fraud Protection helps you protect your project from fraud and abuse.</p>
<p>Configure sign-up rules and use our built-in fraud protection features to detect bots, free trial abuse, and other fraudulent activity.</p>
</>,
},
onboarding: {
icon: ClipboardTextIcon,
href: "onboarding",

View File

@ -1,7 +1,14 @@
"use client";
import { ALL_APPS_FRONTEND, hasNavigationItems, isSubApp } from "@/lib/apps-frontend";
import { ALL_APPS, type AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
type InstalledAppConfig = {
enabled?: boolean,
} | undefined;
export type InstalledAppsMap = Record<string, InstalledAppConfig>;
/**
* Get all available app IDs, filtering out alpha apps in production
*/
@ -16,6 +23,33 @@ export function getAllAvailableAppIds(): AppId[] {
return apps;
}
/**
* Determines whether an app is enabled.
* - Regular apps are enabled via their own config entry.
* - Sub-apps are enabled when their parent app is enabled.
*/
export function isAppEnabled(installedApps: InstalledAppsMap, appId: AppId): boolean {
const appFrontend = ALL_APPS_FRONTEND[appId];
if (isSubApp(appFrontend)) {
return installedApps[appFrontend.parentAppId]?.enabled ?? false;
}
return installedApps[appId]?.enabled ?? false;
}
/**
* Get all enabled app IDs using centralized enabled/sub-app logic.
*/
export function getEnabledAppIds(installedApps: InstalledAppsMap): AppId[] {
return getAllAvailableAppIds().filter((appId) => isAppEnabled(installedApps, appId));
}
/**
* Get enabled apps that expose sidebar/cmdk navigation items.
*/
export function getEnabledNavigableAppIds(installedApps: InstalledAppsMap): AppId[] {
return getEnabledAppIds(installedApps).filter((appId) => hasNavigationItems(ALL_APPS_FRONTEND[appId]));
}
/**
* Get uninstalled app IDs (available but not installed)
*/

View File

@ -8,6 +8,7 @@ import { describe, beforeAll, afterAll } from "vitest";
import { it, niceFetch, STACK_BACKEND_BASE_URL, STACK_INTERNAL_PROJECT_CLIENT_KEY, STACK_INTERNAL_PROJECT_SERVER_KEY, STACK_INTERNAL_PROJECT_ADMIN_KEY } from "../helpers";
const CLI_BIN = path.resolve("packages/stack-cli/dist/index.js");
const CLI_SRC_BIN = path.resolve("packages/stack-cli/src/index.ts");
function runCli(
args: string[],
@ -464,3 +465,72 @@ describe("Stack CLI", () => {
expect(stdout).toContain("STACK AUTH SETUP INSTRUCTIONS");
});
});
// Emulator CLI tests — no backend required, just validates help/arg parsing
describe("Stack CLI — Emulator", () => {
function runCliBare(
args: string[],
): Promise<{ stdout: string, stderr: string, exitCode: number | null }> {
return new Promise((resolve) => {
execFile("node", [CLI_BIN, ...args], {
env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", CI: "1" },
timeout: 15_000,
}, (error, stdout, stderr) => {
resolve({
stdout: stdout.toString(),
stderr: stderr.toString(),
exitCode: error ? (error as any).code ?? 1 : 0,
});
});
});
}
function runCliBareFromSource(
args: string[],
): Promise<{ stdout: string, stderr: string, exitCode: number | null }> {
return new Promise((resolve) => {
execFile("node", ["--import", "tsx", CLI_SRC_BIN, ...args], {
env: { PATH: process.env.PATH ?? "", HOME: process.env.HOME ?? "", CI: "1" },
timeout: 15_000,
}, (error, stdout, stderr) => {
resolve({
stdout: stdout.toString(),
stderr: stderr.toString(),
exitCode: error ? (error as any).code ?? 1 : 0,
});
});
});
}
it("emulator help shows subcommands", async ({ expect }) => {
const { stdout, exitCode } = await runCliBare(["emulator", "--help"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("pull");
expect(stdout).toContain("start");
expect(stdout).toContain("stop");
expect(stdout).toContain("reset");
expect(stdout).toContain("status");
expect(stdout).toContain("list-releases");
});
it("emulator pull help shows options", async ({ expect }) => {
const { stdout, exitCode } = await runCliBare(["emulator", "pull", "--help"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("--arch");
expect(stdout).toContain("--branch");
expect(stdout).toContain("--tag");
expect(stdout).toContain("--repo");
});
it("emulator pull rejects invalid arch values", async ({ expect }) => {
const { stderr, exitCode } = await runCliBareFromSource(["emulator", "pull", "--arch", "sparc"]);
expect(exitCode).toBe(1);
expect(stderr).toContain("Invalid architecture: sparc. Expected arm64 or amd64.");
});
it("emulator list-releases help shows repo option", async ({ expect }) => {
const { stdout, exitCode } = await runCliBare(["emulator", "list-releases", "--help"]);
expect(exitCode).toBe(0);
expect(stdout).toContain("--repo");
});
});

View File

@ -1,6 +1,8 @@
import { StackClientApp } from "@stackframe/js";
import { encodeBase32 } from "@stackframe/stack-shared/dist/utils/bytes";
import { TextEncoder } from "util";
import { vi } from "vitest";
import { STACK_BACKEND_BASE_URL } from "../helpers";
import { it } from "../helpers";
import { createApp } from "./js-helpers";
@ -302,6 +304,34 @@ it("should omit secure-only defaults when running on http origins", async ({ exp
expect(insecureAttrs?.get("domain")).toBeUndefined();
});
it("should roundtrip domain through custom refresh cookie name encode/decode", async ({ expect }) => {
const { clientApp } = await createApp();
const domains = [
"example.com",
"sub.example.com",
"deep.nested.example.com",
"EXAMPLE.COM",
"my-site.co.uk",
];
for (const domain of domains) {
const cookieName = (clientApp as any)._getCustomRefreshCookieName(domain);
const decoded = (clientApp as any)._getDomainFromCustomRefreshCookieName(cookieName);
expect(decoded).toBe(domain.toLowerCase());
}
});
it("should return null for non-custom refresh cookie names", async ({ expect }) => {
const { clientApp } = await createApp();
const defaultName = getDefaultRefreshCookieName(clientApp.projectId, true);
expect((clientApp as any)._getDomainFromCustomRefreshCookieName(defaultName)).toBeNull();
expect((clientApp as any)._getDomainFromCustomRefreshCookieName("unrelated-cookie")).toBeNull();
expect((clientApp as any)._getDomainFromCustomRefreshCookieName("")).toBeNull();
expect((clientApp as any)._getDomainFromCustomRefreshCookieName(`stack-refresh-${clientApp.projectId}--custom-%%%`)).toBeNull();
});
it("should read the newest refresh token payload from cookie storage", async ({ expect }) => {
const { clientApp } = await createApp();
@ -327,3 +357,72 @@ it("should read the newest refresh token payload from cookie storage", async ({
expect(tokens.refreshToken).toBe("fresh-token");
expect(tokens.accessToken).toBe("fresh-access-token");
});
it("should eagerly create cross-subdomain cookie on construction when session exists but custom cookie is missing", async ({ expect }) => {
const { cookieStore } = setupBrowserCookieEnv({ protocol: "https:" });
const { clientApp, apiKey } = await createApp(
{
config: {
domains: [
{ domain: "https://example.com", handlerPath: "/handler" },
{ domain: "https://**.example.com", handlerPath: "/handler" },
],
},
},
{
client: {
tokenStore: "cookie",
noAutomaticPrefetch: true,
},
},
);
// Sign in to get a valid session
const email = `${crypto.randomUUID()}@eager-cookie.test`;
const password = "password";
await clientApp.signUpWithCredential({ email, password, verificationCallbackUrl: "http://localhost:3000", noRedirect: true });
await clientApp.signInWithCredential({ email, password, noRedirect: true });
const defaultCookieName = getDefaultRefreshCookieName(clientApp.projectId, true);
const customCookieName = getCustomRefreshCookieName(clientApp.projectId, "example.com");
// Wait for the cross-subdomain cookie to be written
const customReady = await waitUntil(() => cookieStore.has(customCookieName), 10_000);
expect(customReady).toBe(true);
// Grab the refresh token before we manipulate cookies
const customCookieValue = cookieStore.get(customCookieName)!;
const parsed = JSON.parse(decodeURIComponent(customCookieValue));
// Simulate state where user was signed in before wildcard domain was added:
// default cookie exists with the session, but no cross-subdomain cookie
cookieStore.delete(customCookieName);
const defaultValue = encodeURIComponent(JSON.stringify({
refresh_token: parsed.refresh_token,
updated_at_millis: parsed.updated_at_millis,
}));
cookieStore.set(defaultCookieName, defaultValue);
expect(cookieStore.has(customCookieName)).toBe(false);
expect(cookieStore.has(defaultCookieName)).toBe(true);
// Construct a new client app (simulates page reload)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const reloadedApp = new StackClientApp({
baseUrl: STACK_BACKEND_BASE_URL,
projectId: clientApp.projectId,
publishableClientKey: apiKey.publishableClientKey,
tokenStore: "cookie",
redirectMethod: "none",
noAutomaticPrefetch: true,
extraRequestHeaders: { "x-stack-disable-artificial-development-delay": "yes" },
});
// The cross-subdomain cookie should be eagerly created on construction
const customRecreated = await waitUntil(() => cookieStore.has(customCookieName), 10_000);
expect(customRecreated).toBe(true);
// Clean up
(reloadedApp as any).dispose?.();
});

View File

@ -141,6 +141,8 @@ A: Only write `onboardingStatus` when the `Project.onboardingStatus` column exis
Q: How does the Stack Auth docs MCP relate to the ask-chat API and doc tools?
A: The public MCP (`/api/internal/mcp` on the docs site) exposes only `ask_stack_auth`, which POSTs to `/api/latest/ai/query/generate` with `tools: ["docs"]` and `systemPrompt: "docs-ask-ai"`. The backend no longer loads doc tools via MCP; `createDocsTools()` calls the docs app `POST /api/internal/docs-tools` with typed actions (same behavior as before). Optional `STACK_INTERNAL_DOCS_TOOLS_SECRET` gates the internal route; `STACK_DOCS_INTERNAL_BASE_URL` overrides the docs origin for the backend.
Q: What caused the March 19, 2026 QEMU local emulator deps startup regression?
A: The QEMU runtime path regressed when it switched from mounting `docker/local-emulator/base.env` into the runtime ISO to mounting the generated hidden file `docker/local-emulator/.env.development` instead. In testing, the `.env.development` QEMU path left cold boot stuck with only PostgreSQL healthy, while restoring the runtime ISO back to `base.env` brought deps startup back to about 12-13 seconds. The env payloads were effectively the same, so the likely issue was the QEMU runtime bundle/path handling for `.env.development`, not the actual env values.
Q: Where is the private sign-up risk engine generated entrypoint in backend now?
A: The generator script writes `apps/backend/src/private/implementation.generated.ts` (not `src/generated/private-sign-up-risk-engine.ts`), and backend runtime imports should target `@/private/implementation.generated`.
@ -158,3 +160,6 @@ A: `app.urls` is now static (`getUrls(...)` only) and no longer injects runtime
Q: How should user signup time be exposed in JWT claims before production rollout?
A: Use `signed_up_at` (OIDC-style naming) in access tokens and encode it as Unix seconds in `apps/backend/src/lib/tokens.tsx` (`Math.floor(user.signed_up_at_millis / 1000)`). Since this is pre-prod, the payload schema can require `signed_up_at` directly without a backward-compat optional shim.
Q: Where should new globally searchable Cmd+K destinations be added in the dashboard?
A: Add project-level shortcuts to `PROJECT_SHORTCUTS` in `apps/dashboard/src/components/cmdk-commands.tsx` (optionally gated with `requiredApps`), and for app subpages rely on the flattened `appFrontend.navigationItems` command generation in the same file so pages are directly searchable without nested preview navigation.

View File

@ -0,0 +1,199 @@
# Stack Auth Local Emulator — All-in-One Image
# Packages: PostgreSQL 16, Redis 7, Inbucket, Svix, ClickHouse, MinIO, QStash
# + built Stack Auth backend and dashboard
ARG NODE_VERSION=22.21.1
# ── Node.js build stages ──────────────────────────────────────────────────────
FROM node:${NODE_VERSION} AS node-base
WORKDIR /app
RUN apt-get update && \
apt-get upgrade -y && \
rm -rf /var/lib/apt/lists
ENV PNPM_HOME=/pnpm
ENV PATH=$PNPM_HOME:$PATH
RUN corepack enable
RUN corepack prepare pnpm@10.23.0 --activate
RUN pnpm add -g turbo
RUN pnpm add -g tsx
FROM node-base AS pruner
COPY . .
RUN tsx ./scripts/generate-sdks.ts
# https://turbo.build/repo/docs/guides/tools/docker
RUN turbo prune --scope=@stackframe/backend --scope=@stackframe/dashboard --docker
FROM node-base AS builder
# copy over package.json files and install dependencies
COPY --from=pruner /app/out/json/ .
COPY --from=pruner /app/out/pnpm-lock.yaml .
COPY .gitignore .
COPY pnpm-workspace.yaml .
COPY turbo.json .
COPY configs ./configs
RUN --mount=type=cache,id=pnpm,target=/pnpm/store STACK_SKIP_TEMPLATE_GENERATION=true pnpm install --frozen-lockfile
# copy over the rest of the code for the build
COPY --from=pruner /app/out/full/ .
# docs are currently required for the NextJS backend build, but won't exist in the final image
COPY docs ./docs
# https://nextjs.org/docs/pages/api-reference/next-config-js/output
ENV NEXT_CONFIG_OUTPUT=standalone
# Build the backend NextJS app
RUN pnpm turbo run docker-build --filter=@stackframe/backend... --filter=@stackframe/dashboard...
# Build the self-host seed script
RUN cd apps/backend && pnpm build-self-host-migration-script
# Prune node_modules for runtime: remove dev tools, heavy UI packages,
# duplicate framework copies, and native binaries not needed by the
# migration script or server at runtime.
FROM builder AS migration-pruner
RUN cp -a /app/node_modules /pruned-node_modules && \
cd /pruned-node_modules/.pnpm && \
rm -rf \
# Dev tools (never needed at runtime)
typescript@* eslint@* eslint-*@* @typescript-eslint+*@* \
prettier@* vitest@* jsdom@* turbo@* turbo-*@* \
tsdown@* @changesets+*@* codebuff@* \
@testing-library+*@* vite@* vite-*@* @vitejs+*@* \
# Heavy UI packages (already traced into Next.js standalone bundles)
monaco-editor@* \
three@* three-globe@* globe.gl@* react-globe*@* \
react-icons@* lucide-react@* @phosphor-icons+*@* \
# Large optional packages not needed by migration script
posthog-js@* \
@prisma+studio-core@* @prisma+dev@* @prisma+query-plan-executor@* \
convex@* @electric-sql+*@* \
# Duplicate Next.js copies (keep only one for next/headers.js resolution)
'next@16.1.5_@babel+core@7.29.0*' 'next@16.1.5_@babel+core@7.28.5*' \
next@14* @next+swc-*@14* \
# Native build binaries not needed at runtime
@esbuild+*@* esbuild@* @rolldown+*@* \
# Duplicate date-fns versions (keep v4 only)
date-fns@2* date-fns@3*
# ── Service binary stages ─────────────────────────────────────────────────────
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
FROM minio/minio:RELEASE.2025-09-07T16-13-09Z AS minio-bin
FROM minio/mc:RELEASE.2025-02-21T16-00-46Z AS mc-bin
FROM bgodil/qstash:latest AS qstash-bin
RUN cp $(which qstash) /qstash-binary 2>/dev/null || \
cp $(find / -name 'qstash' -type f -executable 2>/dev/null | head -1) /qstash-binary || \
{ echo "ERROR: qstash binary not found" >&2; exit 1; }
# ── Final image ───────────────────────────────────────────────────────────────
FROM debian:trixie-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && \
apt-get install -y --no-install-recommends \
gnupg2 \
lsb-release \
curl \
ca-certificates \
&& echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" \
> /etc/apt/sources.list.d/pgdg.list \
&& curl -fsSL https://www.postgresql.org/media/keys/ACCC4CF8.asc \
| gpg --dearmor -o /etc/apt/trusted.gpg.d/postgresql.gpg \
&& apt-get update \
&& apt-get install -y --no-install-recommends \
postgresql-16 \
postgresql-client-16 \
redis-server \
supervisor \
gosu \
procps \
libssl3 \
openssl \
socat \
&& apt-get purge -y --auto-remove gnupg2 lsb-release \
&& rm -rf /var/lib/apt/lists/* /usr/share/doc /usr/share/man /usr/share/i18n
# Node.js runtime (binary only — app bundles include all JS dependencies)
COPY --from=node-base /usr/local/bin/node /usr/local/bin/node
# Inbucket
COPY --from=inbucket-bin /opt/inbucket /opt/inbucket
# Svix
COPY --from=svix-bin /usr/local/bin/svix-server /usr/local/bin/svix-server
# ClickHouse
COPY --from=clickhouse-bin /usr/bin/clickhouse /usr/bin/clickhouse
RUN ln -sf /usr/bin/clickhouse /usr/bin/clickhouse-server && \
ln -sf /usr/bin/clickhouse /usr/bin/clickhouse-client
# MinIO
COPY --from=minio-bin /usr/bin/minio /usr/local/bin/minio
COPY --from=mc-bin /usr/bin/mc /usr/local/bin/mc
# QStash
COPY --from=qstash-bin --chmod=755 /qstash-binary /usr/local/bin/qstash
# App
WORKDIR /app
COPY --from=builder /app/apps/backend/.next/standalone ./
COPY --from=builder /app/apps/backend/.next/static ./apps/backend/.next/static
COPY --from=builder /app/apps/backend/prisma ./apps/backend/prisma
COPY --from=builder /app/apps/backend/dist ./apps/backend/dist
COPY --from=builder /app/apps/backend/node_modules ./apps/backend/node_modules
COPY --from=builder /app/apps/dashboard/.next/standalone ./
COPY --from=builder /app/apps/dashboard/.next/static ./apps/dashboard/.next/static
COPY --from=builder /app/apps/dashboard/public ./apps/dashboard/public
COPY --from=migration-pruner /pruned-node_modules ./node_modules
COPY --from=builder /app/packages ./packages
RUN mkdir -p \
/data/postgres \
/data/redis \
/data/clickhouse \
/data/clickhouse/access \
/data/clickhouse/tmp \
/data/clickhouse/user_files \
/data/clickhouse/format_schemas \
/data/minio \
/data/inbucket \
/var/log/supervisor \
/var/log/clickhouse \
/etc/clickhouse-server \
&& chown -R postgres:postgres /data/postgres
COPY docker/local-emulator/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
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
# 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
ENTRYPOINT ["/entrypoint.sh"]

View File

@ -0,0 +1,26 @@
<clickhouse>
<logger>
<level>warning</level>
<console>1</console>
</logger>
<http_port>8123</http_port>
<tcp_port>9009</tcp_port>
<listen_host>0.0.0.0</listen_host>
<path>/data/clickhouse/</path>
<tmp_path>/data/clickhouse/tmp/</tmp_path>
<user_files_path>/data/clickhouse/user_files/</user_files_path>
<format_schema_path>/data/clickhouse/format_schemas/</format_schema_path>
<max_server_memory_usage_to_ram_ratio>0.5</max_server_memory_usage_to_ram_ratio>
<user_directories>
<users_xml>
<path>users.xml</path>
</users_xml>
<local_directory>
<path>/data/clickhouse/access/</path>
</local_directory>
</user_directories>
</clickhouse>

View File

@ -0,0 +1,35 @@
<clickhouse>
<users>
<default>
<password></password>
<networks><ip>::/0</ip></networks>
<profile>default</profile>
<quota>default</quota>
<access_management>1</access_management>
</default>
<stackframe>
<password>PASSWORD-PLACEHOLDER--9gKyMxJeMx</password>
<networks><ip>::/0</ip></networks>
<profile>default</profile>
<quota>default</quota>
<access_management>1</access_management>
</stackframe>
</users>
<profiles>
<default>
<max_memory_usage>1000000000</max_memory_usage>
</default>
</profiles>
<quotas>
<default>
<interval>
<duration>3600</duration>
<queries>0</queries>
<errors>0</errors>
<result_rows>0</result_rows>
<read_rows>0</read_rows>
<execution_time>0</execution_time>
</interval>
</default>
</quotas>
</clickhouse>

View File

@ -0,0 +1,31 @@
#!/bin/bash
set -e
PGDATA=/data/postgres
PG_BIN=/usr/lib/postgresql/16/bin
if [ -z "$(ls -A "$PGDATA" 2>/dev/null)" ]; then
gosu postgres "$PG_BIN/initdb" -D "$PGDATA" --no-sync --auth-local=trust --auth-host=md5
{
echo "host all all 0.0.0.0/0 md5"
echo "host all all ::/0 md5"
} >> "$PGDATA/pg_hba.conf"
echo "shared_preload_libraries = 'pg_stat_statements'" >> "$PGDATA/postgresql.conf"
echo "pg_stat_statements.track = all" >> "$PGDATA/postgresql.conf"
gosu postgres "$PG_BIN/pg_ctl" -D "$PGDATA" start -w \
-o "-c listen_addresses=127.0.0.1 -c shared_preload_libraries=pg_stat_statements"
gosu postgres psql -c "ALTER USER postgres PASSWORD 'PASSWORD-PLACEHOLDER--uqfEC1hmmv';"
gosu postgres psql -c "CREATE DATABASE stackframe;"
gosu postgres psql -c "CREATE DATABASE svix;"
gosu postgres psql -d stackframe -c "CREATE EXTENSION IF NOT EXISTS pg_stat_statements;"
gosu postgres psql -d stackframe -c "CREATE ROLE anon NOLOGIN;"
gosu postgres psql -d stackframe -c "CREATE ROLE authenticated NOLOGIN;"
gosu postgres "$PG_BIN/pg_ctl" -D "$PGDATA" stop -w
fi
exec /usr/bin/supervisord -n -c /etc/supervisor/conf.d/supervisord.conf

View File

@ -0,0 +1,203 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(scriptDir, "..", "..");
const outputPath = path.join(scriptDir, ".env.development");
const backendEnvPath = path.join(rootDir, "apps", "backend", ".env.development");
const dashboardEnvPath = path.join(rootDir, "apps", "dashboard", ".env.development");
const args = process.argv.slice(2);
if (args.length > 1 || (args[0] != null && args[0] !== "--check")) {
throw new Error("Usage: node docker/local-emulator/generate-env-development.mjs [--check]");
}
const parseEnvFile = (filePath) => {
const env = new Map();
for (const rawLine of fs.readFileSync(filePath, "utf8").split(/\r?\n/)) {
const trimmedLine = rawLine.trim();
if (trimmedLine === "" || trimmedLine.startsWith("#")) {
continue;
}
const separatorIndex = rawLine.indexOf("=");
if (separatorIndex < 0) {
throw new Error(`Invalid env line in ${filePath}: ${rawLine}`);
}
const key = rawLine.slice(0, separatorIndex).trim();
const value = rawLine.slice(separatorIndex + 1);
env.set(key, value);
}
return env;
};
const backendEnv = parseEnvFile(backendEnvPath);
const dashboardEnv = parseEnvFile(dashboardEnvPath);
const getRequiredEnvValue = (sourceName, envMap, key) => {
const value = envMap.get(key);
if (value == null) {
throw new Error(`Missing ${key} in ${sourceName}; update the generator or source env file.`);
}
return value;
};
const fromSource = (sourceName, envMap, key) => ({
type: "entry",
key,
value: getRequiredEnvValue(sourceName, envMap, key),
});
const literal = (key, value) => ({
type: "entry",
key,
value,
});
const comment = (value) => ({
type: "comment",
value,
});
const blank = () => ({
type: "blank",
});
const entries = [
comment("# Generated by docker/local-emulator/generate-env-development.mjs"),
comment("# Do not edit manually; update apps/backend/.env.development, apps/dashboard/.env.development, or this generator."),
blank(),
comment("# Public emulator/app credentials"),
literal("NEXT_PUBLIC_STACK_DOCS_BASE_URL", "https://docs.stack-auth.com"),
literal("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR", "true"),
fromSource("apps/dashboard/.env.development", dashboardEnv, "NEXT_PUBLIC_STACK_PROJECT_ID"),
fromSource("apps/dashboard/.env.development", dashboardEnv, "NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY"),
fromSource("apps/dashboard/.env.development", dashboardEnv, "STACK_SECRET_SERVER_KEY"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_SERVER_SECRET"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_CHANGELOG_URL"),
blank(),
comment("# Seed/project defaults"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_SEED_ENABLE_DUMMY_PROJECT"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_SEED_INTERNAL_PROJECT_OTP_ENABLED"),
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"),
blank(),
comment("# Third-party/test integrations"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_SVIX_API_KEY"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_OPENAI_API_KEY"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_OPENROUTER_API_KEY"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_STRIPE_SECRET_KEY"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_STRIPE_WEBHOOK_SECRET"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_RESEND_API_KEY"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_RESEND_WEBHOOK_SECRET"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_DNSIMPLE_API_TOKEN"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_DNSIMPLE_ACCOUNT_ID"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_DNSIMPLE_API_BASE_URL"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_FREESTYLE_API_KEY"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_VERCEL_SANDBOX_TOKEN"),
fromSource("apps/backend/.env.development", backendEnv, "CRON_SECRET"),
blank(),
comment("# Storage, queueing, and analytics"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_S3_REGION"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_S3_ACCESS_KEY_ID"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_S3_SECRET_ACCESS_KEY"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_S3_BUCKET"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_S3_PRIVATE_BUCKET"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_AWS_REGION"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_AWS_ACCESS_KEY_ID"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_AWS_SECRET_ACCESS_KEY"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_QSTASH_TOKEN"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_QSTASH_CURRENT_SIGNING_KEY"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_QSTASH_NEXT_SIGNING_KEY"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_CLICKHOUSE_ADMIN_USER"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_CLICKHOUSE_ADMIN_PASSWORD"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_CLICKHOUSE_EXTERNAL_PASSWORD"),
blank(),
comment("# Email and dashboard integration"),
literal("STACK_EMAIL_PORT", "2500"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_EMAIL_SECURE"),
literal("STACK_EMAIL_USERNAME", "does-not-matter"),
literal("STACK_EMAIL_PASSWORD", "does-not-matter"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_EMAIL_SENDER"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_DEFAULT_EMAIL_CAPACITY_PER_HOUR"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_EMAIL_MONITOR_PROJECT_ID"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_EMAIL_MONITOR_PUBLISHABLE_CLIENT_KEY"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_EMAIL_MONITOR_RESEND_EMAIL_DOMAIN"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_EMAIL_MONITOR_RESEND_EMAIL_API_KEY"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_EMAIL_MONITOR_SECRET_TOKEN"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_EMAIL_MONITOR_USE_INBUCKET"),
fromSource("apps/dashboard/.env.development", dashboardEnv, "STACK_FEATUREBASE_JWT_SECRET"),
blank(),
comment("# Mock OAuth defaults"),
literal("STACK_FORWARD_MOCK_OAUTH_SERVER", "false"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_GITHUB_CLIENT_ID"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_GITHUB_CLIENT_SECRET"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_GOOGLE_CLIENT_ID"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_GOOGLE_CLIENT_SECRET"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_MICROSOFT_CLIENT_ID"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_MICROSOFT_CLIENT_SECRET"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_SPOTIFY_CLIENT_ID"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_SPOTIFY_CLIENT_SECRET"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_ALLOW_SHARED_OAUTH_ACCESS_TOKENS"),
blank(),
comment("# Internal service endpoints (defaults for docker-compose; overridden in QEMU)"),
literal("STACK_DATABASE_CONNECTION_STRING", "postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@127.0.0.1:5432/stackframe"),
fromSource("apps/backend/.env.development", backendEnv, "STACK_EMAIL_HOST"),
literal("STACK_SVIX_SERVER_URL", "http://127.0.0.1:8071"),
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_EMAIL_MONITOR_INBUCKET_API_URL", "http://127.0.0.1:9001"),
literal("BACKEND_PORT", "8102"),
literal("DASHBOARD_PORT", "8101"),
];
const seenKeys = new Set();
for (const entry of entries) {
if (entry.type !== "entry") {
continue;
}
if (seenKeys.has(entry.key)) {
throw new Error(`Duplicate env key in generator: ${entry.key}`);
}
seenKeys.add(entry.key);
}
const content = `${entries.map((entry) => {
if (entry.type === "blank") {
return "";
}
if (entry.type === "comment") {
return entry.value;
}
return `${entry.key}=${entry.value}`;
}).join("\n")}\n`;
if (args[0] === "--check") {
const currentContent = fs.readFileSync(outputPath, "utf8");
if (currentContent !== content) {
throw new Error(`${path.relative(rootDir, outputPath)} is out of date. Run pnpm run emulator:generate-env.`);
}
console.log(`${path.relative(rootDir, outputPath)} is up to date.`);
} else {
fs.writeFileSync(outputPath, content);
console.log(`Wrote ${path.relative(rootDir, outputPath)}.`);
}

View File

@ -0,0 +1,30 @@
#!/bin/bash
set -e
INIT_SERVICES_DONE_FILE=/var/run/stack-local-init-services.done
INIT_SERVICES_FAILED_FILE=/var/run/stack-local-init-services.failed
rm -f "$INIT_SERVICES_DONE_FILE" "$INIT_SERVICES_FAILED_FILE"
trap 'touch "$INIT_SERVICES_FAILED_FILE"' ERR
wait_for_http() {
local url="$1" attempts=0
while [ "$attempts" -lt 60 ]; do
if curl -sf "$url" > /dev/null 2>&1; then return 0; fi
sleep 1
attempts=$((attempts + 1))
done
echo "Timed out waiting for $url" >&2
exit 1
}
wait_for_http http://127.0.0.1:9090/minio/health/live
mc alias set local http://127.0.0.1:9090 s3mockroot s3mockroot --api S3v4
mc mb --ignore-existing local/stack-storage
mc mb --ignore-existing local/stack-storage-private
wait_for_http http://127.0.0.1:8123/ping
curl -s "http://127.0.0.1:8123/?user=default" --data "CREATE DATABASE IF NOT EXISTS analytics"
rm -f "$INIT_SERVICES_FAILED_FILE"
touch "$INIT_SERVICES_DONE_FILE"

2
docker/local-emulator/qemu/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.DS_Store
run/

View File

@ -0,0 +1,121 @@
# QEMU Local Emulator
The local emulator packages the entire Stack Auth backend (PostgreSQL, Redis, ClickHouse, MinIO, Inbucket, Svix, QStash, Dashboard, and Backend) into a single QEMU virtual machine image. Users run it via the `stack emulator` CLI commands.
## Architecture
```
Host machine
└─ QEMU VM (Debian 13 cloud image)
└─ Docker container (all-in-one image from ../Dockerfile)
├─ PostgreSQL 16
├─ Redis 7
├─ ClickHouse
├─ MinIO
├─ Inbucket
├─ Svix
├─ QStash
├─ Stack Dashboard (→ host:26700)
└─ Stack Backend (→ host:26701)
```
Only four services are exposed to the host via port forwarding:
| Service | Host Port | Description |
|-----------|-----------|--------------------------|
| Dashboard | 26700 | Stack Auth dashboard UI |
| Backend | 26701 | Stack Auth API server |
| MinIO | 26702 | S3-compatible storage |
| Inbucket | 26703 | Email testing interface |
All other services (PostgreSQL, Redis, ClickHouse, Svix, QStash) remain internal to the VM.
## Scripts
| Script | Purpose |
|--------------------|----------------------------------------------------------------|
| `build-image.sh` | Builds a QEMU disk image for a target architecture |
| `run-emulator.sh` | Manages the VM lifecycle: `start`, `stop`, `reset`, `status`, `bench` |
| `common.sh` | Shared helpers: host detection, QEMU binary selection, firmware lookup, ISO creation |
## Building an Image
```bash
# Build for current architecture
./docker/local-emulator/qemu/build-image.sh
# Build for a specific architecture (arm64 or amd64)
./docker/local-emulator/qemu/build-image.sh arm64
# Build both
./docker/local-emulator/qemu/build-image.sh both
```
The build process:
1. Builds the all-in-one Docker image from `../Dockerfile` and exports it as a tarball
2. Downloads a Debian 13 cloud base image
3. Boots a QEMU VM with cloud-init provisioning (`cloud-init/emulator/user-data`)
4. Cloud-init loads the Docker image and runs a full startup cycle to warm caches
5. Shuts down and compresses the disk image to `images/stack-emulator-<arch>.qcow2`
Default resources: 4 CPUs, 4096 MB RAM. Override with `EMULATOR_CPUS` / `EMULATOR_RAM_MB`.
### Why a single Docker image?
The `../Dockerfile` bundles all services into one image rather than using separate containers. This keeps the QEMU disk image size small — separate images would each carry their own base layers, significantly inflating the final qcow2.
## Running the Emulator
```bash
# Via CLI (recommended)
stack emulator start
stack emulator stop
stack emulator reset # wipe data
stack emulator status
# Via script directly
EMULATOR_ARCH=arm64 ./docker/local-emulator/qemu/run-emulator.sh start
```
The VM uses an overlay disk (`run/vm/disk.qcow2`) on top of the base image, so data persists across stop/start cycles. Use `reset` to wipe the overlay and start fresh.
### Hardware acceleration
- **macOS**: Uses HVF (Hypervisor.framework) for native-arch VMs
- **Linux**: Uses KVM when available
- **Cross-arch**: Falls back to TCG (software emulation) — significantly slower
## Optimizations Taken
- **Single bundled Docker image** to minimize qcow2 size
- **Cloud-init provisioning** pre-warms all services during build so first boot is fast
- **Overlay disks** avoid copying the multi-GB base image on each start
- **Compressed qcow2** images (`-c` flag) reduce download size
- **Only 4 ports forwarded** to minimize host-side surface area
## Possible Future Optimizations
- External server for reads and writes to relative dir instead of full host access allowing snapshots
- Or copying the config file on start with --config-file <path> enforced and writing the config file to host directory on stop
## Updating the Image
1. Make changes to the `../Dockerfile`, `../entrypoint.sh`, or cloud-init config
2. Rebuild: `./docker/local-emulator/qemu/build-image.sh <arch>`
3. The CI workflow (`.github/workflows/qemu-emulator-build.yaml`) builds and publishes images on push to `main`/`dev`
4. Users pull the latest via `stack emulator pull`
## Directory Layout
```
qemu/
├── build-image.sh # Image builder
├── run-emulator.sh # VM lifecycle manager
├── common.sh # Shared utilities
├── cloud-init/
│ └── emulator/
│ ├── meta-data # VM instance metadata
│ └── user-data # Provisioning script
├── images/ # Built qcow2 images (gitignored)
└── run/ # Runtime state: overlay disk, PID, logs (gitignored)
```

View File

@ -0,0 +1,290 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
# shellcheck source=common.sh
source "$SCRIPT_DIR/common.sh"
IMAGE_DIR="$SCRIPT_DIR/images"
CLOUD_INIT_ROOT="$SCRIPT_DIR/cloud-init"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
DEBIAN_VERSION="${DEBIAN_VERSION:-13}"
DISK_SIZE="${EMULATOR_DISK_SIZE:-12G}"
RAM="${EMULATOR_BUILD_RAM:-4096}"
CPUS="${EMULATOR_BUILD_CPUS:-$(sysctl -n hw.ncpu 2>/dev/null || nproc 2>/dev/null || echo 4)}"
PROVISION_TIMEOUT="${EMULATOR_PROVISION_TIMEOUT:-3200}"
EMULATOR_IMAGE_NAME="${EMULATOR_IMAGE_NAME:-stack-local-emulator}"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
NC='\033[0m'
log() { echo -e "${GREEN}[build]${NC} $*"; }
warn() { echo -e "${YELLOW}[build]${NC} $*"; }
err() { echo -e "${RED}[build]${NC} $*" >&2; }
detect_host
TARGET_ARCH="${1:-$HOST_ARCH}"
TARGET_ARCHS=()
case "$TARGET_ARCH" in
arm64) TARGET_ARCHS=(arm64) ;;
amd64) TARGET_ARCHS=(amd64) ;;
both) TARGET_ARCHS=(arm64 amd64) ;;
*) err "Usage: $0 [arm64|amd64|both]"; exit 1 ;;
esac
DOCKER_IMAGES=("$EMULATOR_IMAGE_NAME")
check_deps() {
local missing=()
local arch qemu_bin
for arch in "${TARGET_ARCHS[@]}"; do
qemu_bin="$(qemu_binary_for_arch "$arch")"
command -v "$qemu_bin" >/dev/null 2>&1 || missing+=("$qemu_bin")
done
for cmd in qemu-img curl docker gzip; do
command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd")
done
if ! command -v mkisofs >/dev/null 2>&1 && ! command -v genisoimage >/dev/null 2>&1 && ! command -v hdiutil >/dev/null 2>&1; then
missing+=("mkisofs/genisoimage/hdiutil")
fi
if [ "${#missing[@]}" -gt 0 ]; then
err "Missing build dependencies: ${missing[*]}"
exit 1
fi
}
check_deps
mkdir -p "$IMAGE_DIR"
download_cloud_image() {
local arch="$1"
local dest="$2"
local deb_arch
case "$arch" in
arm64) deb_arch="arm64" ;;
amd64) deb_arch="amd64" ;;
*) err "Unsupported target arch: $arch"; exit 1 ;;
esac
local url="https://cloud.debian.org/images/cloud/trixie/daily/latest/debian-${DEBIAN_VERSION}-generic-${deb_arch}-daily.qcow2"
if [ -f "$dest" ]; then
log "Base image already cached: $dest"
return 0
fi
log "Downloading Debian ${DEBIAN_VERSION} cloud image for ${arch}..."
curl -fSL --progress-bar -o "$dest" "$url"
}
docker_platform_for_arch() {
case "$1" in
arm64) echo "linux/arm64" ;;
amd64) echo "linux/amd64" ;;
*) err "Unsupported target arch: $1"; exit 1 ;;
esac
}
build_local_emulator_image() {
local arch="$1"
local platform
platform="$(docker_platform_for_arch "$arch")"
log "Building Docker emulator image (${arch})..."
docker buildx build \
--platform "$platform" \
--tag "$EMULATOR_IMAGE_NAME" \
--load \
-f "$REPO_ROOT/docker/local-emulator/Dockerfile" \
"$REPO_ROOT"
}
qemu_cmd_prefix_for_arch() {
local arch="$1"
case "$arch" in
arm64)
local accel="tcg"
if [ "$HOST_ARCH" = "arm64" ]; then
case "$HOST_OS" in
darwin) accel="hvf" ;;
linux) [ -w /dev/kvm ] && accel="kvm" ;;
esac
fi
local firmware
firmware="$(find_aarch64_firmware)"
echo "qemu-system-aarch64 -machine virt -accel $accel -cpu max -bios $firmware"
;;
amd64)
local accel="tcg"
local cpu="max"
if [ "$HOST_ARCH" = "amd64" ]; then
case "$HOST_OS" in
darwin) accel="hvf" ;;
linux) [ -w /dev/kvm ] && accel="kvm" ;;
esac
else
cpu="qemu64"
fi
echo "qemu-system-x86_64 -machine q35 -accel $accel -cpu $cpu"
;;
esac
}
final_image_name() {
echo "$IMAGE_DIR/stack-emulator-$1.qcow2"
}
prepare_bundle_artifacts() {
local arch="$1"
local bundle_tgz="$IMAGE_DIR/emulator-${arch}-docker-images.tar.gz"
local bundle_meta="$bundle_tgz.image-ids"
local current_ids=""
for img in "${DOCKER_IMAGES[@]}"; do
current_ids+="$(docker image inspect --format '{{.ID}}' "$img")"$'\n'
done
local cached_ids=""
if [ -f "$bundle_meta" ]; then
cached_ids="$(cat "$bundle_meta")"
fi
if [ -f "$bundle_tgz" ] && [ "$cached_ids" = "$current_ids" ]; then
log "Reusing bundle: $bundle_tgz"
return 0
fi
log "Creating Docker image bundle (${arch})..."
for img in "${DOCKER_IMAGES[@]}"; do
if ! docker image inspect "$img" >/dev/null 2>&1; then
err "Missing Docker image: $img. Build the local emulator images first, then rerun the QEMU image build."
exit 1
fi
done
local tmp_bundle="${bundle_tgz}.tmp"
rm -f "$tmp_bundle"
docker save "${DOCKER_IMAGES[@]}" | gzip -c > "$tmp_bundle"
mv "$tmp_bundle" "$bundle_tgz"
printf "%s" "$current_ids" > "$bundle_meta"
}
build_one() {
local arch="$1"
local base_img="$IMAGE_DIR/debian-${DEBIAN_VERSION}-base-${arch}.qcow2"
local bundle_tgz="$IMAGE_DIR/emulator-${arch}-docker-images.tar.gz"
local final_img
final_img="$(final_image_name "$arch")"
log "━━━ Building emulator image (${arch}) ━━━"
local tmp_dir
tmp_dir="$(mktemp -d /tmp/stack-qemu-build-${arch}-XXXXXX)"
local tmp_img="$tmp_dir/disk.qcow2"
local seed_iso="$tmp_dir/seed.iso"
local bundle_iso="$tmp_dir/bundle.iso"
local bundle_dir="$tmp_dir/bundle"
local serial_log="$tmp_dir/serial.log"
local pidfile="$tmp_dir/qemu.pid"
local qemu_base pid elapsed
local start_time=$SECONDS
cp "$base_img" "$tmp_img"
qemu-img resize "$tmp_img" "$DISK_SIZE" >/dev/null 2>&1 || true
local seed_dir
seed_dir="$(mktemp -d)"
mkdir -p "$seed_dir"
cp "$CLOUD_INIT_ROOT/emulator/meta-data" "$seed_dir/meta-data"
cp "$CLOUD_INIT_ROOT/emulator/user-data" "$seed_dir/user-data"
make_iso_from_dir "$seed_iso" "cidata" "$seed_dir"
rm -rf "$seed_dir"
mkdir -p "$bundle_dir"
cp "$bundle_tgz" "$bundle_dir/img.tgz"
make_iso_from_dir "$bundle_iso" "STACKBUNDLE" "$bundle_dir"
: > "$serial_log"
qemu_base="$(qemu_cmd_prefix_for_arch "$arch")"
# shellcheck disable=SC2086
$qemu_base \
-boot order=c \
-m "$RAM" \
-smp "$CPUS" \
-drive "file=$tmp_img,format=qcow2,if=virtio" \
-drive "file=$seed_iso,format=raw,if=virtio,readonly=on" \
-drive "file=$bundle_iso,format=raw,if=virtio,readonly=on" \
-netdev user,id=net0 \
-device virtio-net-pci,netdev=net0 \
-serial "file:$serial_log" \
-display none \
-daemonize \
-pidfile "$pidfile"
pid="$(cat "$pidfile")"
elapsed=0
while [ "$elapsed" -lt "$PROVISION_TIMEOUT" ]; do
if grep -q "STACK_CLOUD_INIT_DONE" "$serial_log" 2>/dev/null; then
break
fi
sleep 5
elapsed=$((SECONDS - start_time))
printf "\r [%3ds / %ds] provisioning emulator..." "$elapsed" "$PROVISION_TIMEOUT"
done
echo ""
if ! grep -q "STACK_CLOUD_INIT_DONE" "$serial_log" 2>/dev/null; then
err "Provisioning timed out for emulator (${arch})"
tail -50 "$serial_log" >&2 || true
if kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
sleep 1
kill -9 "$pid" 2>/dev/null || true
fi
rm -rf "$tmp_dir"
exit 1
fi
local shutdown_wait=0
while [ "$shutdown_wait" -lt 90 ] && kill -0 "$pid" 2>/dev/null; do
sleep 1
shutdown_wait=$((shutdown_wait + 1))
done
if kill -0 "$pid" 2>/dev/null; then
warn "Guest did not power off cleanly; forcing shutdown."
kill "$pid" 2>/dev/null || true
sleep 2
kill -9 "$pid" 2>/dev/null || true
fi
cp "$tmp_img" "$final_img"
cp "$serial_log" "$IMAGE_DIR/provision-emulator-${arch}.log"
rm -rf "$tmp_dir"
log "Compressing final image (this may take several minutes)..."
qemu-img convert -p -O qcow2 -c "$final_img" "$final_img.tmp"
mv "$final_img.tmp" "$final_img"
local size
size="$(du -h "$final_img" | cut -f1)"
log "━━━ Emulator image ready: $final_img (${size}) ━━━"
}
for arch in "${TARGET_ARCHS[@]}"; do
local_base="$IMAGE_DIR/debian-${DEBIAN_VERSION}-base-${arch}.qcow2"
download_cloud_image "$arch" "$local_base"
build_local_emulator_image "$arch"
prepare_bundle_artifacts "$arch"
build_one "$arch"
done
log "Done. Start with: docker/local-emulator/qemu/run-emulator.sh start"

View File

@ -0,0 +1,2 @@
instance-id: stack-emulator-001
local-hostname: stack-emulator

View File

@ -0,0 +1,185 @@
#cloud-config
hostname: stack-emulator
manage_etc_hosts: true
users:
- name: stack
shell: /bin/bash
sudo: ALL=(ALL) NOPASSWD:ALL
lock_passwd: false
chpasswd:
list: |
root:stack-emulator
stack:stack-emulator
expire: false
ssh_pwauth: false
package_update: true
package_upgrade: false
packages:
- docker.io
- ca-certificates
- curl
- netcat-openbsd
- qemu-guest-agent
write_files:
- path: /usr/local/bin/install-emulator-containers
permissions: '0755'
content: |
#!/bin/bash
set -euo pipefail
mkdir -p /mnt/stack-bundle
bundle_device="$(readlink -f /dev/disk/by-label/STACKBUNDLE)"
mount -o ro "$bundle_device" /mnt/stack-bundle
systemctl enable --now docker
until docker info >/dev/null 2>&1; do sleep 1; done
gzip -dc /mnt/stack-bundle/img.tgz | docker load
- path: /usr/local/bin/render-stack-env
permissions: '0755'
content: |
#!/bin/bash
set -euo pipefail
mkdir -p /mnt/stack-runtime /run/stack-auth
runtime_device="$(readlink -f /dev/disk/by-label/STACKCFG)"
mountpoint -q /mnt/stack-runtime || mount -o ro "$runtime_device" /mnt/stack-runtime
set -a
source /mnt/stack-runtime/runtime.env
source /mnt/stack-runtime/base.env
set +a
# 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.
DEPS_HOST=127.0.0.1
HOST_SERVICES_HOST=10.0.2.2
P="$STACK_EMULATOR_PORT_PREFIX"
{
# 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
# Computed vars — depend on port prefix or deps host
cat <<COMPUTED
NEXT_PUBLIC_STACK_PORT_PREFIX=${P}
STACK_RUNTIME_WORK_DIR=/app
STACK_LOCAL_EMULATOR_HOST_MOUNT_ROOT=/host
NEXT_PUBLIC_STACK_API_URL=http://localhost:${P}02
NEXT_PUBLIC_STACK_DASHBOARD_URL=http://localhost:${P}01
NEXT_PUBLIC_BROWSER_STACK_API_URL=http://localhost:${P}02
NEXT_PUBLIC_BROWSER_STACK_DASHBOARD_URL=http://localhost:${P}01
NEXT_PUBLIC_SERVER_STACK_API_URL=http://127.0.0.1:${P}02
NEXT_PUBLIC_SERVER_STACK_DASHBOARD_URL=http://127.0.0.1:${P}01
NEXT_PUBLIC_STACK_SVIX_SERVER_URL=http://localhost:${P}13
STACK_DATABASE_CONNECTION_STRING=postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@${DEPS_HOST}:5432/stackframe
STACK_EMAIL_HOST=${DEPS_HOST}
STACK_SVIX_SERVER_URL=http://${DEPS_HOST}:8071
STACK_S3_ENDPOINT=http://${DEPS_HOST}:9090
STACK_S3_PUBLIC_ENDPOINT=http://localhost:${P}21/stack-storage
STACK_QSTASH_URL=http://${DEPS_HOST}:8080
STACK_CLICKHOUSE_URL=http://${DEPS_HOST}:8123
STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL=http://localhost:${P}01/handler/email-verification
STACK_EMAIL_MONITOR_INBUCKET_API_URL=http://${DEPS_HOST}:9001
STACK_OAUTH_MOCK_URL=http://${HOST_SERVICES_HOST}:${P}14
BACKEND_PORT=${P}02
DASHBOARD_PORT=${P}01
COMPUTED
} > /run/stack-auth/local-emulator.env
- path: /usr/local/bin/mount-host-fs
permissions: '0755'
content: |
#!/bin/bash
set -euo pipefail
mkdir -p /host
if ! mountpoint -q /host; then
if ! mount -t 9p -o trans=virtio,version=9p2000.L hostfs /host; then
echo "Failed to mount host filesystem at /host" >&2
exit 1
fi
fi
- path: /usr/local/bin/run-stack-container
permissions: '0755'
content: |
#!/bin/bash
set -euo pipefail
/usr/local/bin/mount-host-fs
/usr/local/bin/render-stack-env
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
- path: /usr/local/bin/wait-for-deps
permissions: '0755'
content: |
#!/bin/bash
set -euo pipefail
until nc -z 127.0.0.1 5432 >/dev/null 2>&1; do sleep 1; done
until curl -sf http://127.0.0.1:8123/ping >/dev/null 2>&1; do sleep 1; done
until curl -sf http://127.0.0.1:8071/api/v1/health/ >/dev/null 2>&1; do sleep 1; done
until curl -sf http://127.0.0.1:9090/minio/health/live >/dev/null 2>&1; do sleep 1; done
until [ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/ 2>/dev/null || true)" = "401" ]; do sleep 1; done
- path: /etc/systemd/system/stack.service
content: |
[Unit]
Description=Stack Auth local emulator
Wants=network-online.target docker.service
After=network-online.target docker.service
[Service]
Restart=always
RestartSec=5
TimeoutStartSec=0
ExecStart=/usr/local/bin/run-stack-container
ExecStop=/usr/bin/docker stop stack
[Install]
WantedBy=multi-user.target
runcmd:
- systemctl disable --now ssh || true
- systemctl mask ssh || true
- bash /usr/local/bin/install-emulator-containers
- systemctl daemon-reload
- systemctl enable stack.service
- docker run --rm --name stack-build-init
--network host
-e STACK_DEPS_ONLY=true
-v stack-postgres-data:/data/postgres
-v stack-redis-data:/data/redis
-v stack-clickhouse-data:/data/clickhouse
-v stack-minio-data:/data/minio
-v stack-inbucket-data:/data/inbucket
-d stack-local-emulator
- bash /usr/local/bin/wait-for-deps
- docker stop stack-build-init || true
- echo "STACK_CLOUD_INIT_DONE" > /dev/console 2>/dev/null || true
- echo "STACK_CLOUD_INIT_DONE" > /dev/ttyAMA0 2>/dev/null || true
- echo "STACK_CLOUD_INIT_DONE" > /dev/ttyS0 2>/dev/null || true
- shutdown -P now

View File

@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Shared helpers for QEMU emulator scripts.
# Source this file; do not execute it directly.
AARCH64_FIRMWARE_PATHS=(
/opt/homebrew/share/qemu/edk2-aarch64-code.fd
/usr/share/qemu/edk2-aarch64-code.fd
/usr/share/AAVMF/AAVMF_CODE.fd
/usr/share/qemu-efi-aarch64/QEMU_EFI.fd
)
detect_host() {
case "$(uname -m)" in
arm64|aarch64) HOST_ARCH="arm64" ;;
x86_64|amd64) HOST_ARCH="amd64" ;;
*) echo "Unsupported host architecture: $(uname -m)" >&2; exit 1 ;;
esac
case "$(uname -s)" in
Darwin) HOST_OS="darwin" ;;
Linux) HOST_OS="linux" ;;
MINGW*|MSYS*|CYGWIN*) HOST_OS="windows" ;;
*) HOST_OS="unknown" ;;
esac
}
qemu_binary_for_arch() {
case "$1" in
arm64) echo "qemu-system-aarch64" ;;
amd64) echo "qemu-system-x86_64" ;;
*) return 1 ;;
esac
}
find_aarch64_firmware() {
local p
for p in "${AARCH64_FIRMWARE_PATHS[@]}"; do
if [ -f "$p" ]; then
echo "$p"
return 0
fi
done
echo "No aarch64 UEFI firmware found." >&2
return 1
}
make_iso_from_dir() {
local iso_path="$1"
local volume_name="$2"
local source_dir="$3"
rm -f "$iso_path" "${iso_path}.iso"
if command -v hdiutil >/dev/null 2>&1; then
local tmp_dir
tmp_dir="$(mktemp -d /tmp/stack-emulator-iso-XXXXXX)"
cp -R "$source_dir/." "$tmp_dir/"
hdiutil makehybrid -o "$iso_path" "$tmp_dir" -joliet -iso -default-volume-name "$volume_name" 2>/dev/null
if [ -f "${iso_path}.iso" ]; then
mv "${iso_path}.iso" "$iso_path"
fi
rm -rf "$tmp_dir"
elif command -v mkisofs >/dev/null 2>&1; then
mkisofs -output "$iso_path" -volid "$volume_name" -joliet -rock "$source_dir" >/dev/null 2>&1
elif command -v genisoimage >/dev/null 2>&1; then
genisoimage -output "$iso_path" -volid "$volume_name" -joliet -rock "$source_dir" >/dev/null 2>&1
else
echo "Missing ISO creation tool (need hdiutil, mkisofs, or genisoimage)" >&2
exit 1
fi
}

View File

@ -0,0 +1,2 @@
*
!.gitignore

View File

@ -0,0 +1,391 @@
#!/usr/bin/env bash
set -euo pipefail
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}"
VM_RAM="${EMULATOR_RAM:-4096}"
VM_CPUS="${EMULATOR_CPUS:-4}"
PORT_PREFIX="${PORT_PREFIX:-${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}}"
READY_TIMEOUT="${EMULATOR_READY_TIMEOUT:-240}"
# Fixed host-side ports for the QEMU emulator (267xx range).
# Only user-facing services are exposed; internal deps stay inside the VM.
EMULATOR_DASHBOARD_PORT="${EMULATOR_DASHBOARD_PORT:-26700}"
EMULATOR_BACKEND_PORT="${EMULATOR_BACKEND_PORT:-26701}"
EMULATOR_MINIO_PORT="${EMULATOR_MINIO_PORT:-26702}"
EMULATOR_INBUCKET_PORT="${EMULATOR_INBUCKET_PORT:-26703}"
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
CYAN='\033[0;36m'
NC='\033[0m'
log() { echo -e "${GREEN}[emulator]${NC} $*"; }
warn() { echo -e "${YELLOW}[emulator]${NC} $*"; }
err() { echo -e "${RED}[emulator]${NC} $*" >&2; }
info() { echo -e "${CYAN}[emulator]${NC} $*"; }
detect_host
ARCH="${EMULATOR_ARCH:-$HOST_ARCH}"
select_accelerator() {
local accel="tcg"
if [ "$ARCH" = "$HOST_ARCH" ]; then
case "$HOST_OS" in
darwin)
if "$(qemu_binary_for_arch "$ARCH")" -accel help 2>&1 | grep -q hvf; then
accel="hvf"
fi
;;
linux)
if [ -w /dev/kvm ]; then
accel="kvm"
fi
;;
esac
fi
ACCEL="$accel"
}
select_accelerator
VM_DIR="$RUN_DIR/vm"
image_path() {
echo "$IMAGE_DIR/stack-emulator-$ARCH.qcow2"
}
runtime_iso_path() {
echo "$VM_DIR/runtime-config.iso"
}
# Returns a fast fingerprint (size:mtime) of the base QEMU image.
# Used to detect whether the image has changed since the overlay was created.
base_image_fingerprint() {
local img="$1"
case "$HOST_OS" in
darwin) stat -f "%z:%m" "$img" 2>/dev/null ;;
linux) stat -c "%s:%Y" "$img" 2>/dev/null ;;
*) stat -f "%z:%m" "$img" 2>/dev/null || stat -c "%s:%Y" "$img" 2>/dev/null ;;
esac
}
prepare_runtime_config_iso() {
local cfg_dir="$VM_DIR/runtime-config"
local cfg_iso
cfg_iso="$(runtime_iso_path)"
rm -rf "$cfg_dir"
mkdir -p "$cfg_dir"
{
printf "STACK_EMULATOR_PORT_PREFIX=%s\n" "$PORT_PREFIX"
} > "$cfg_dir/runtime.env"
cp "$SCRIPT_DIR/../.env.development" "$cfg_dir/base.env"
make_iso_from_dir "$cfg_iso" "STACKCFG" "$cfg_dir"
}
service_is_up() {
local port="$1"
local proto="$2"
local path="${3:-/}"
local expected_codes="${4:-200}"
if [ "$proto" = "tcp" ]; then
nc -z -w2 127.0.0.1 "$port" 2>/dev/null
return $?
fi
local code
code="$(curl -s -o /dev/null -w "%{http_code}" --max-time 2 "http://127.0.0.1:${port}${path}" 2>/dev/null || true)"
local expected
for expected in ${expected_codes//,/ }; do
if [ "$code" = "$expected" ]; then
return 0
fi
done
return 1
}
deps_ready() {
service_is_up "$EMULATOR_MINIO_PORT" http /minio/health/live &&
service_is_up "$EMULATOR_INBUCKET_PORT" http /
}
app_ready() {
service_is_up "$EMULATOR_BACKEND_PORT" http "/health?db=1" &&
service_is_up "$EMULATOR_DASHBOARD_PORT" http /handler/sign-in
}
all_ready() {
deps_ready && app_ready
}
wait_for_condition() {
local label="$1"
local timeout="$2"
local check_fn="$3"
local started=$SECONDS
local elapsed=0
log "Waiting for ${label}..."
while [ "$elapsed" -lt "$timeout" ]; do
if "$check_fn"; then
echo ""
log "${label} ready in ${elapsed}s"
return 0
fi
sleep 1
elapsed=$((SECONDS - started))
printf "\r [%3ds] %s..." "$elapsed" "$label"
done
echo ""
return 1
}
build_qemu_cmd() {
local base_img
base_img="$(image_path)"
if [ ! -f "$base_img" ]; then
err "Missing QEMU image: $base_img"
err "Run docker/local-emulator/qemu/build-image.sh $ARCH first."
exit 1
fi
mkdir -p "$VM_DIR"
local fingerprint_file="$VM_DIR/base-image.fingerprint"
local current_fp
current_fp="$(base_image_fingerprint "$base_img")"
if [ -f "$VM_DIR/disk.qcow2" ]; then
if [ -f "$fingerprint_file" ] && [ "$(cat "$fingerprint_file")" = "$current_fp" ]; then
log "Reusing existing overlay disk (changes persist)"
else
warn "QEMU base image has changed — recreating overlay."
rm -f "$VM_DIR/disk.qcow2" "$fingerprint_file"
fi
fi
if [ ! -f "$VM_DIR/disk.qcow2" ]; then
qemu-img create -f qcow2 -b "$base_img" -F qcow2 "$VM_DIR/disk.qcow2" >/dev/null
base_image_fingerprint "$base_img" > "$fingerprint_file"
fi
local qemu_bin machine cpu firmware_args=()
qemu_bin="$(qemu_binary_for_arch "$ARCH")"
case "$ARCH" in
arm64)
machine="virt"
cpu="max"
local firmware
firmware="$(find_aarch64_firmware)"
firmware_args=(-bios "$firmware")
;;
amd64)
machine="q35"
if [ "$ACCEL" = "tcg" ] && [ "$HOST_ARCH" != "amd64" ]; then
cpu="qemu64"
else
cpu="max"
fi
;;
esac
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"
QEMU_CMD=(
"$qemu_bin"
-machine "$machine"
-accel "$ACCEL"
-cpu "$cpu"
"${firmware_args[@]}"
-boot order=c
-m "$VM_RAM"
-smp "$VM_CPUS"
-drive "file=$VM_DIR/disk.qcow2,format=qcow2,if=virtio"
-drive "file=$(runtime_iso_path),format=raw,if=virtio,readonly=on"
-netdev "$netdev"
-device virtio-net-pci,netdev=net0
-device virtio-balloon-pci
-virtfs "local,path=/,mount_tag=hostfs,security_model=none"
-chardev "socket,id=monitor,path=$VM_DIR/monitor.sock,server=on,wait=off"
-mon "chardev=monitor,mode=control"
-serial "file:$VM_DIR/serial.log"
-display none
-daemonize
-pidfile "$VM_DIR/qemu.pid"
)
}
is_running() {
if [ ! -f "$VM_DIR/qemu.pid" ]; then
return 1
fi
local pid
pid="$(cat "$VM_DIR/qemu.pid")"
kill -0 "$pid" 2>/dev/null
}
tail_vm_logs() {
if [ -f "$VM_DIR/serial.log" ]; then
echo ""
warn "Last serial log lines:"
tail -40 "$VM_DIR/serial.log" || true
fi
}
ensure_ports_free() {
local ports=("$EMULATOR_DASHBOARD_PORT" "$EMULATOR_BACKEND_PORT" "$EMULATOR_MINIO_PORT" "$EMULATOR_INBUCKET_PORT")
local port
for port in "${ports[@]}"; do
if lsof -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
err "Port $port is already in use. Stop any conflicting services first."
exit 1
fi
done
}
start_vm() {
mkdir -p "$VM_DIR"
: > "$VM_DIR/serial.log"
prepare_runtime_config_iso
build_qemu_cmd
"${QEMU_CMD[@]}"
}
stop_vm() {
if [ ! -f "$VM_DIR/qemu.pid" ]; then
return 0
fi
local pid
pid="$(cat "$VM_DIR/qemu.pid")"
if kill -0 "$pid" 2>/dev/null; then
if [ -S "$VM_DIR/monitor.sock" ]; then
echo '{"execute":"qmp_capabilities"}' | socat - UNIX-CONNECT:"$VM_DIR/monitor.sock" >/dev/null 2>&1 || true
echo '{"execute":"system_powerdown"}' | socat - UNIX-CONNECT:"$VM_DIR/monitor.sock" >/dev/null 2>&1 || true
sleep 3
fi
if kill -0 "$pid" 2>/dev/null; then
kill "$pid" 2>/dev/null || true
sleep 1
kill -9 "$pid" 2>/dev/null || true
fi
fi
rm -f "$VM_DIR/qemu.pid" "$VM_DIR/monitor.sock" "$VM_DIR/serial.log"
rm -rf "$VM_DIR/runtime-config"
rm -f "$VM_DIR/runtime-config.iso"
}
cmd_start() {
ensure_ports_free
mkdir -p "$RUN_DIR"
info "Starting QEMU local emulator"
info "Arch: $ARCH | Accel: $ACCEL"
info "Ports: Dashboard=$EMULATOR_DASHBOARD_PORT Backend=$EMULATOR_BACKEND_PORT MinIO=$EMULATOR_MINIO_PORT Inbucket=$EMULATOR_INBUCKET_PORT"
start_vm
info "VM: ${VM_RAM}MB / ${VM_CPUS} CPUs"
if ! wait_for_condition "deps services" "$READY_TIMEOUT" deps_ready; then
tail_vm_logs
exit 1
fi
if ! wait_for_condition "dashboard/backend" "$READY_TIMEOUT" app_ready; then
tail_vm_logs
exit 1
fi
log "All services are green."
info "Dashboard: http://localhost:${EMULATOR_DASHBOARD_PORT}"
info "Backend: http://localhost:${EMULATOR_BACKEND_PORT}"
}
cmd_stop() {
stop_vm
log "QEMU emulator stopped."
}
cmd_reset() {
cmd_stop 2>/dev/null || true
rm -rf "$RUN_DIR"
log "Emulator state reset. Next start will be a fresh boot."
}
STATUS_FAILED=0
print_service_status() {
local name="$1"
local port="$2"
local proto="$3"
local path="${4:-/}"
local expected_codes="${5:-200}"
if service_is_up "$port" "$proto" "$path" "$expected_codes"; then
echo -e " ${GREEN}${NC} $name (:$port)"
else
echo -e " ${RED}${NC} $name (:$port)"
STATUS_FAILED=1
fi
}
cmd_status() {
STATUS_FAILED=0
echo "VM:"
if is_running; then
echo -e " ${GREEN}${NC} emulator"
else
echo -e " ${RED}${NC} emulator"
STATUS_FAILED=1
fi
echo ""
echo "Services:"
print_service_status "Dashboard" "$EMULATOR_DASHBOARD_PORT" http /handler/sign-in
print_service_status "Backend" "$EMULATOR_BACKEND_PORT" http "/health?db=1"
print_service_status "MinIO" "$EMULATOR_MINIO_PORT" http /minio/health/live
print_service_status "Inbucket HTTP" "$EMULATOR_INBUCKET_PORT" http /
exit "$STATUS_FAILED"
}
cmd_bench() {
local elapsed
cmd_stop >/dev/null 2>&1 || true
SECONDS=0
cmd_start
elapsed="$SECONDS"
printf "Startup time: %.1fs\n" "$elapsed"
}
ACTION="start"
while [[ $# -gt 0 ]]; do
case "$1" in
start|stop|reset|status|bench)
ACTION="$1"
shift
;;
*)
echo "Usage: $0 [start|stop|reset|status|bench]"
exit 1
;;
esac
done
case "$ACTION" in
start) cmd_start ;;
stop) cmd_stop ;;
reset) cmd_reset ;;
status) cmd_status ;;
bench) cmd_bench ;;
esac

View File

@ -0,0 +1,31 @@
#!/bin/bash
set -e
# In deps-only mode (used during QEMU image build), skip app startup entirely.
# The build only needs the infrastructure services to initialize; the app
# requires runtime env vars that are not available at build time.
if [ "${STACK_DEPS_ONLY:-false}" = "true" ]; then
echo "Deps-only mode: app startup skipped."
while true; do sleep 3600; done
fi
# Wait for all infrastructure services to be ready before running migrations
# and starting the backend/dashboard.
INIT_SERVICES_DONE_FILE=/var/run/stack-local-init-services.done
INIT_SERVICES_FAILED_FILE=/var/run/stack-local-init-services.failed
until pg_isready -h 127.0.0.1 -p 5432 -U postgres >/dev/null 2>&1; do sleep 2; done
until curl -sf http://127.0.0.1:8123/ping >/dev/null 2>&1; do sleep 2; done
until curl -sf http://127.0.0.1:8071/api/v1/health/ >/dev/null 2>&1; do sleep 2; done
until curl -sf http://127.0.0.1:9090/minio/health/live >/dev/null 2>&1; do sleep 2; done
until [ "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8080/ 2>/dev/null || true)" = "401" ]; do sleep 2; done
until [ -f "$INIT_SERVICES_DONE_FILE" ]; do
if [ -f "$INIT_SERVICES_FAILED_FILE" ]; then
echo "init-services.sh failed; refusing to start the app." >&2
exit 1
fi
sleep 1
done
exec /app-entrypoint.sh

View File

@ -0,0 +1,148 @@
[supervisord]
nodaemon=true
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
loglevel=info
; --- PostgreSQL ---
[program:postgres]
command=/usr/lib/postgresql/16/bin/postgres
-D /data/postgres
-c listen_addresses=*
-c max_connections=500
-c shared_preload_libraries=pg_stat_statements
-c pg_stat_statements.track=all
-c statement_timeout=30s
user=postgres
autostart=true
autorestart=true
priority=10
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; --- Redis ---
[program:redis]
command=/usr/bin/redis-server
--port 6379
--dir /data/redis
--save 60 500
--appendonly yes
--appendfsync everysec
--requirepass PASSWORD-PLACEHOLDER--oVn8GSD6b9
autostart=true
autorestart=true
priority=10
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; --- Inbucket ---
[program:inbucket]
command=/opt/inbucket/bin/inbucket
environment=
INBUCKET_SMTP_ADDR="0.0.0.0:2500",
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"
autostart=true
autorestart=true
priority=20
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; --- ClickHouse ---
[program:clickhouse]
command=/usr/bin/clickhouse-server --config-file=/etc/clickhouse-server/config.xml
autostart=true
autorestart=true
priority=20
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; --- MinIO ---
[program:minio]
command=/usr/local/bin/minio server /data/minio --address :9090 --console-address :9091
environment=
MINIO_ROOT_USER="s3mockroot",
MINIO_ROOT_PASSWORD="s3mockroot"
autostart=true
autorestart=true
priority=20
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; --- Svix ---
[program:svix]
command=/usr/local/bin/svix-server
environment=
WAIT_FOR="true",
SVIX_DB_DSN="postgres://postgres:PASSWORD-PLACEHOLDER--uqfEC1hmmv@127.0.0.1:5432/svix",
SVIX_REDIS_DSN="redis://:PASSWORD-PLACEHOLDER--oVn8GSD6b9@127.0.0.1:6379",
SVIX_CACHE_TYPE="memory",
SVIX_JWT_SECRET="secret",
SVIX_LOG_LEVEL="info",
SVIX_QUEUE_TYPE="redis"
autostart=true
autorestart=true
priority=30
startsecs=5
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; --- QStash ---
[program:qstash]
command=/usr/local/bin/qstash dev
environment=HOST_ON_HOST="host.docker.internal"
autostart=true
autorestart=true
priority=30
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; --- Post-startup init ---
[program:init-services]
command=/init-services.sh
autostart=true
autorestart=false
startsecs=0
exitcodes=0
priority=50
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
; --- Stack Auth backend + dashboard ---
[program:stack-app]
command=/start-app.sh
autostart=true
autorestart=unexpected
startsecs=0
priority=60
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

View File

@ -67,13 +67,16 @@ fi
# ============= ENV VARS =============
# Create a working directory for our processed files
# This is necessary because we need to replace the env vars in all files and we might want to run the seed script multiple times with different env vars.
WORK_DIR="/tmp/processed"
# Create a working directory for our processed files.
# Keep this off /tmp so local-emulator config sharing can bind-mount /tmp
# without pushing the whole runtime copy step onto the host filesystem.
WORK_DIR="${STACK_RUNTIME_WORK_DIR:-/var/tmp/stack-runtime}"
mkdir -p "$WORK_DIR"
echo "Copying files to working directory..."
cp -vr /app/. "$WORK_DIR"/.
if [ "$WORK_DIR" != "/app" ]; then
echo "Copying files to working directory..."
cp -r /app/. "$WORK_DIR"/.
fi
# Find all files in the apps directory that contain a STACK_ENV_VAR_SENTINEL and extract the unique sentinel strings.
echo "Finding unhandled sentinels..."

View File

@ -2,7 +2,7 @@
import { ALL_APPS, AppId } from "@stackframe/stack-shared/dist/apps/apps-config";
import { AppIcon, appSquarePaddingExpression, appSquareWidthExpression } from "@stackframe/stack-shared/dist/apps/apps-ui";
import { BarChart3, ClipboardList, CreditCard, KeyRound, Mail, Mails, Rocket, ShieldEllipsis, Sparkles, Triangle, Tv, UserCog, Users, Vault, Webhook } from "lucide-react";
import { BarChart3, ClipboardList, CreditCard, KeyRound, Mail, Mails, Rocket, ShieldCheck, ShieldEllipsis, Sparkles, Triangle, Tv, UserCog, Users, Vault, Webhook } from "lucide-react";
import Link from "next/link";
import { cn } from "../../lib/cn";
@ -25,6 +25,7 @@ const APP_ICONS: Record<AppId, React.FunctionComponent<React.SVGProps<SVGSVGElem
webhooks: Webhook,
"tv-mode": Tv,
"launch-checklist": Rocket,
"fraud-protection": ShieldCheck,
catalyst: Sparkles,
neon: createSvgIcon(() => (
<path d="M 21.9999 3.6667 L 21.9999 16.1666 A 1.6667 1.6667 90 0 1 20.3333 17.8333 A 2.5 2.5 90 0 1 18.6666 16.9999 L 12.8333 10.3333 L 12.8333 20.3333 A 1.6667 1.6667 90 0 1 11.1666 21.9999 L 3.6667 21.9999 A 1.6667 1.6667 90 0 1 2 20.3333 L 2 3.6667 A 1.6667 1.6667 90 0 1 3.6667 2 L 20.3333 2 A 1.6667 1.6667 90 0 1 21.9999 3.6667 Z" />

View File

@ -0,0 +1,80 @@
import { NextResponse } from 'next/server';
export const dynamic = 'force-dynamic';
type ServiceCheck = {
name: string;
description: string;
port: number;
protocol: 'http' | 'tcp';
httpPath?: string;
};
const SERVICES: ServiceCheck[] = [
{
name: 'Stack Dashboard',
description: 'Dashboard UI',
port: 26700,
protocol: 'http',
httpPath: '/handler/sign-in',
},
{
name: 'Stack Backend',
description: 'API server',
port: 26701,
protocol: 'http',
httpPath: '/health?db=1',
},
{
name: 'MinIO (S3)',
description: 'Object storage',
port: 26702,
protocol: 'http',
httpPath: '/minio/health/live',
},
{
name: 'Inbucket (HTTP)',
description: 'Email capture UI',
port: 26703,
protocol: 'http',
httpPath: '/',
},
];
async function checkHttp(port: number, path: string, timeoutMs = 3000): Promise<{ up: boolean; latencyMs: number }> {
const start = performance.now();
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
const res = await fetch(`http://127.0.0.1:${port}${path}`, { signal: controller.signal });
clearTimeout(timeout);
return { up: res.ok || res.status < 500, latencyMs: Math.round(performance.now() - start) };
} catch {
return { up: false, latencyMs: Math.round(performance.now() - start) };
}
}
export async function GET() {
const results = await Promise.all(
SERVICES.map(async (svc) => {
const check = await checkHttp(svc.port, svc.httpPath ?? '/');
return {
name: svc.name,
description: svc.description,
port: svc.port,
status: check.up ? 'up' as const : 'down' as const,
latencyMs: check.latencyMs,
};
})
);
return NextResponse.json({
timestamp: new Date().toISOString(),
services: results,
summary: {
total: results.length,
up: results.filter((r) => r.status === 'up').length,
down: results.filter((r) => r.status === 'down').length,
},
});
}

View File

@ -0,0 +1,188 @@
'use client';
import { runAsynchronously } from '@stackframe/stack-shared/dist/utils/promises';
import { Card, CardContent, CardHeader, Typography } from '@stackframe/stack-ui';
import { useCallback, useEffect, useState } from 'react';
type ServiceResult = {
name: string;
description: string;
port: number;
status: 'up' | 'down';
latencyMs: number;
};
type StatusResponse = {
timestamp: string;
services: ServiceResult[];
summary: { total: number; up: number; down: number };
};
function StatusDot({ status }: { status: 'up' | 'down' | 'checking' }) {
const color = status === 'up'
? 'bg-emerald-500'
: status === 'down'
? 'bg-red-500'
: 'bg-yellow-400 animate-pulse';
return (
<span className={`inline-block w-3 h-3 rounded-full ${color}`} />
);
}
function ServiceRow({ service }: { service: ServiceResult }) {
return (
<div className="flex items-center justify-between py-3 px-4 border-b last:border-b-0 dark:border-gray-800">
<div className="flex items-center gap-3">
<StatusDot status={service.status} />
<div>
<span className="font-medium text-sm">{service.name}</span>
<span className="text-xs text-gray-500 dark:text-gray-400 ml-2">{service.description}</span>
</div>
</div>
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
<span className="font-mono">:{service.port}</span>
{service.status === 'up' && (
<span className="text-emerald-600 dark:text-emerald-400">{service.latencyMs}ms</span>
)}
<span className={`px-2 py-0.5 rounded font-medium ${
service.status === 'up'
? 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-300'
: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300'
}`}>
{service.status === 'up' ? 'Online' : 'Offline'}
</span>
</div>
</div>
);
}
export default function EmulatorStatusPage() {
const [data, setData] = useState<StatusResponse | null>(null);
const [loading, setLoading] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(true);
const fetchStatus = useCallback(async () => {
try {
const res = await fetch('/api/emulator-status', { cache: 'no-store' });
const json = await res.json();
setData(json as StatusResponse);
} catch {
// keep last known state
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
runAsynchronously(fetchStatus());
if (!autoRefresh) return;
const interval = setInterval(() => {
runAsynchronously(fetchStatus());
}, 5000);
return () => clearInterval(interval);
}, [fetchStatus, autoRefresh]);
const summary = data?.summary;
const allUp = summary != null && summary.down === 0;
return (
<div className="flex flex-col items-center justify-start w-full p-6 gap-6">
<div className="max-w-2xl w-full space-y-6">
<div className="flex items-center justify-between">
<div>
<Typography type="h3">Local Emulator Status</Typography>
<Typography className="text-sm text-gray-500 dark:text-gray-400">
Monitoring services in the all-in-one dependencies container
</Typography>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-2 text-xs text-gray-500 cursor-pointer select-none">
<input
type="checkbox"
checked={autoRefresh}
onChange={(e) => setAutoRefresh(e.target.checked)}
className="rounded"
/>
Auto-refresh
</label>
<button
onClick={() => runAsynchronously(fetchStatus())}
disabled={loading}
className="text-xs px-3 py-1.5 rounded border dark:border-gray-700 hover:bg-gray-100 dark:hover:bg-gray-800 disabled:opacity-50"
>
Refresh
</button>
</div>
</div>
<Card>
<CardContent className="py-4">
{loading && !data ? (
<div className="flex items-center gap-3">
<StatusDot status="checking" />
<Typography className="text-sm">Checking services...</Typography>
</div>
) : summary ? (
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<StatusDot status={allUp ? 'up' : 'down'} />
<Typography className="text-sm font-medium">
{allUp
? 'All services operational'
: `${summary.down} of ${summary.total} services are offline`}
</Typography>
</div>
<div className="flex items-center gap-4 text-xs text-gray-500">
<span className="text-emerald-600 dark:text-emerald-400 font-medium">{summary.up} up</span>
{summary.down > 0 && (
<span className="text-red-600 dark:text-red-400 font-medium">{summary.down} down</span>
)}
<span>updated {new Date(data.timestamp).toLocaleTimeString()}</span>
</div>
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader>
<Typography type="h4">Services</Typography>
</CardHeader>
<CardContent className="p-0">
{data?.services.map((svc) => (
<ServiceRow key={svc.name} service={svc} />
))}
{!data && loading && (
<div className="p-4 text-center text-sm text-gray-400">Loading...</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<Typography type="h4">Quick Start</Typography>
</CardHeader>
<CardContent className="space-y-3">
<Typography className="text-sm">Start the QEMU local emulator:</Typography>
<pre className="bg-gray-100 dark:bg-gray-900 rounded p-3 text-xs font-mono overflow-x-auto">
{`# Pull the latest image and start the emulator
pnpm run emulator:start
# Check service health
pnpm run emulator:status
# Stop (data is preserved)
pnpm run emulator:stop
# Reset for a fresh boot
pnpm run emulator:reset`}
</pre>
<Typography className="text-sm text-gray-500">
Dashboard: localhost:26700 | Backend: localhost:26701
</Typography>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -24,6 +24,14 @@
"codegen": "pnpm pre && turbo run codegen && pnpm run generate-sdks",
"codegen:backend": "pnpm pre && turbo run codegen --filter=@stackframe/backend...",
"deps-compose": "docker compose -p stack-dependencies-${NEXT_PUBLIC_STACK_PORT_PREFIX:-81} -f docker/dependencies/docker.compose.yaml",
"emulator:generate-env": "node ./docker/local-emulator/generate-env-development.mjs",
"emulator:check-env": "node ./docker/local-emulator/generate-env-development.mjs --check",
"emulator:start": "pnpm run emulator:generate-env && docker/local-emulator/qemu/run-emulator.sh start",
"emulator:stop": "docker/local-emulator/qemu/run-emulator.sh stop",
"emulator:reset": "docker/local-emulator/qemu/run-emulator.sh reset",
"emulator:status": "docker/local-emulator/qemu/run-emulator.sh status",
"emulator:build": "docker/local-emulator/qemu/build-image.sh",
"emulator:bench": "docker/local-emulator/qemu/run-emulator.sh bench",
"stop-deps": "POSTGRES_DELAY_MS=0 pnpm run deps-compose kill && POSTGRES_DELAY_MS=0 pnpm run deps-compose down -v",
"wait-until-postgres-is-ready:pg_isready": "until pg_isready -h localhost -p ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}28 && pg_isready -h localhost -p ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}34; do sleep 1; done",
"wait-until-postgres-is-ready": "command -v pg_isready >/dev/null 2>&1 && pnpm run wait-until-postgres-is-ready:pg_isready || sleep 10 # not everyone has pg_isready installed, so we fallback to sleeping",

View File

@ -0,0 +1,138 @@
import { Command } from "commander";
import { execFileSync, spawn } from "child_process";
import { existsSync, mkdirSync, renameSync, unlinkSync } from "fs";
import { join, resolve } from "path";
import { CliError } from "../lib/errors.js";
function gh(args: string[]): string {
try {
return execFileSync("gh", args, { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] }).trim();
} catch (err: unknown) {
if (err instanceof Error && "stderr" in err && typeof err.stderr === "string") {
throw new CliError(`GitHub CLI error: ${err.stderr}`);
}
throw new CliError("GitHub CLI (gh) is required. Install: https://cli.github.com/");
}
}
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 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], {
stdio: "inherit",
env: { ...process.env, ...env },
cwd: qemuDir,
});
child.on("close", (code) => code === 0 ? resolve() : 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 resolveArch(raw?: string): "arm64" | "amd64" {
const arch = raw ?? (process.arch === "arm64" ? "arm64" : process.arch === "x64" ? "amd64" : null);
if (arch === "arm64" || arch === "amd64") return arch;
throw new CliError(`Invalid architecture: ${raw ?? process.arch}. Expected arm64 or amd64.`);
}
function pullRelease(arch: "arm64" | "amd64", opts: { repo?: string; branch?: string; tag?: string } = {}) {
const repo = opts.repo ?? "stack-auth/stack-auth";
const branch = opts.branch ?? "dev";
const tag = opts.tag ?? `emulator-${branch}-latest`;
const asset = `stack-emulator-${arch}.qcow2`;
const imageDir = join(findQemuDir(), "images");
mkdirSync(imageDir, { recursive: true });
const dest = join(imageDir, asset);
const tmpDest = `${dest}.download`;
console.log(`Pulling ${asset} from release ${tag}...`);
try {
execFileSync("gh", ["release", "download", tag, "--repo", repo, "--pattern", asset, "--output", tmpDest, "--clobber"], { stdio: "inherit" });
} catch (err) {
if (existsSync(tmpDest)) unlinkSync(tmpDest);
throw new CliError(`Failed to download ${asset} from release ${tag}: ${err instanceof Error ? err.message : err}\nRun 'stack emulator list-releases' to see available releases.`);
}
renameSync(tmpDest, dest);
console.log(`Downloaded: ${dest}`);
}
export function registerEmulatorCommand(program: Command) {
const emulator = program.command("emulator").description("Manage the QEMU local emulator");
emulator
.command("pull")
.description("Download an emulator image from GitHub Releases or a PR build")
.option("--arch <arch>", "Target architecture (default: current system arch)")
.option("--branch <branch>", "Release branch (default: dev)")
.option("--tag <tag>", "Specific release tag (default: latest)")
.option("--repo <repo>", "GitHub repository (default: stack-auth/stack-auth)")
.option("--pr <number>", "Pull from a PR's CI artifacts")
.option("--run <id>", "Pull from a specific workflow run's artifacts")
.action(async (opts) => {
const arch = resolveArch(opts.arch);
const repo = opts.repo ?? "stack-auth/stack-auth";
if (opts.run || opts.pr) {
let runId = opts.run as string | undefined;
if (!runId) {
console.log(`Finding latest successful build for PR #${opts.pr}...`);
const { headRefName } = JSON.parse(gh(["pr", "view", opts.pr, "--repo", repo, "--json", "headRefName"]));
const runs = JSON.parse(gh(["run", "list", "--repo", repo, "--workflow", "qemu-emulator-build.yaml", "--branch", headRefName, "--status", "success", "--limit", "1", "--json", "databaseId"]));
if (runs.length === 0) throw new CliError(`No successful build found for PR #${opts.pr} (branch: ${headRefName}).`);
runId = String(runs[0].databaseId);
}
const imageDir = join(findQemuDir(), "images");
mkdirSync(imageDir, { recursive: true });
const dest = join(imageDir, `stack-emulator-${arch}.qcow2`);
if (existsSync(dest)) unlinkSync(dest);
console.log(`Downloading qemu-emulator-${arch} from workflow run ${runId}...`);
try {
execFileSync("gh", ["run", "download", runId, "--repo", repo, "--name", `qemu-emulator-${arch}`, "--dir", imageDir], { stdio: "inherit" });
} catch (err) {
throw new CliError(`Failed to download artifact from run ${runId}: ${err instanceof Error ? err.message : err}`);
}
if (!existsSync(dest)) throw new CliError(`Expected image not found at ${dest} after download.`);
console.log(`Downloaded: ${dest}`);
} else {
pullRelease(arch, { repo, branch: opts.branch, tag: opts.tag });
}
});
emulator
.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) => {
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);
}
await runEmulator("start", { EMULATOR_ARCH: arch });
});
emulator.command("stop").description("Stop the emulator (data preserved; use 'reset' to clear)").action(() => runEmulator("stop"));
emulator.command("reset").description("Reset emulator state for a fresh boot").action(() => runEmulator("reset"));
emulator.command("status").description("Show emulator and service health").action(() => runEmulator("status"));
emulator
.command("list-releases")
.description("List available emulator releases")
.option("--repo <repo>", "GitHub repository (default: stack-auth/stack-auth)")
.action((opts) => {
const repo = opts.repo ?? "stack-auth/stack-auth";
console.log(`Available emulator releases from ${repo}:\n`);
const lines = gh(["release", "list", "--repo", repo, "--limit", "20"]).split("\n").filter((l) => l.toLowerCase().includes("emulator"));
if (lines.length === 0) console.log("No emulator releases found.");
else for (const line of lines) console.log(line);
});
}

View File

@ -9,6 +9,7 @@ import { registerExecCommand } from "./commands/exec.js";
import { registerConfigCommand } from "./commands/config-file.js";
import { registerInitCommand } from "./commands/init.js";
import { registerProjectCommand } from "./commands/project.js";
import { registerEmulatorCommand } from "./commands/emulator.js";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@ -29,6 +30,7 @@ registerExecCommand(program);
registerConfigCommand(program);
registerInitCommand(program);
registerProjectCommand(program);
registerEmulatorCommand(program);
async function main() {
try {

View File

@ -54,6 +54,12 @@ export const ALL_APPS = {
tags: ["auth", "security"],
stage: "stable",
},
"fraud-protection": {
displayName: "Fraud Protection",
subtitle: "Protect your project from fraud and abuse",
tags: ["auth", "security"],
stage: "stable",
},
"onboarding": {
displayName: "Onboarding",
subtitle: "Configure user onboarding requirements",

View File

@ -18,7 +18,7 @@ import { TeamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams";
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
import type { RestrictedReason } from "@stackframe/stack-shared/dist/schema-fields";
import { InternalSession } from "@stackframe/stack-shared/dist/sessions";
import { encodeBase32 } from "@stackframe/stack-shared/dist/utils/bytes";
import { decodeBase32, encodeBase32 } from "@stackframe/stack-shared/dist/utils/bytes";
import { scrambleDuringCompileTime } from "@stackframe/stack-shared/dist/utils/compile-time";
import { isBrowserLike } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
@ -535,6 +535,10 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
this._urlOptions = resolvedOptions.urls ?? {};
this._oauthScopesOnSignIn = resolvedOptions.oauthScopesOnSignIn ?? {};
this._prefetchCrossDomainHandoffParamsIfNeeded();
if (isBrowserLike() && (resolvedOptions.tokenStore === "cookie" || resolvedOptions.tokenStore === "nextjs-cookie")) {
runAsynchronously(this._trustedParentDomainCache.getOrWait([window.location.hostname], "write-only"));
this._ensureCrossSubdomainCookieExists();
}
if (extraOptions && extraOptions.uniqueIdentifier) {
this._uniqueIdentifier = extraOptions.uniqueIdentifier;
@ -620,6 +624,15 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
const encoded = encodeBase32(new TextEncoder().encode(domain.toLowerCase()));
return `${this._refreshTokenCookieName}--custom-${encoded}`;
}
private _getDomainFromCustomRefreshCookieName(name: string): string | null {
const prefix = `${this._refreshTokenCookieName}--custom-`;
if (!name.startsWith(prefix)) return null;
try {
return new TextDecoder().decode(decodeBase32(name.slice(prefix.length)));
} catch {
return null;
}
}
private _formatRefreshCookieValue(refreshToken: string, updatedAt: number): string {
return JSON.stringify({
refresh_token: refreshToken,
@ -763,6 +776,26 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
cookieNamesToDelete: [...cookieNames],
};
}
private _ensureCrossSubdomainCookieExists() {
runAsynchronously(async () => {
const hostname = window.location.hostname;
const domain = await this._trustedParentDomainCache.getOrWait([hostname], "read-write");
if (domain.status === "error" || !domain.data) {
return;
}
const cookies = this._getAllBrowserCookies();
const customCookieName = this._getCustomRefreshCookieName(domain.data);
if (cookies[customCookieName]) {
return;
}
const { refreshToken, updatedAt } = this._extractRefreshTokenFromCookieMap(cookies);
if (refreshToken && updatedAt) {
const value = this._formatRefreshCookieValue(refreshToken, updatedAt);
setOrDeleteCookieClient(customCookieName, value, { maxAge: 60 * 60 * 24 * 365, domain: domain.data });
}
});
}
private _queueCustomRefreshCookieUpdate(refreshToken: string | null, updatedAt: number | null, context: "browser" | "server") {
runAsynchronously(async () => {
this._mostRecentQueuedCookieRefreshIndex++;
@ -855,7 +888,10 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
);
setOrDeleteCookieClient(defaultName, refreshCookieValue, { maxAge: 60 * 60 * 24 * 365, secure });
setOrDeleteCookieClient(this._accessTokenCookieName, accessTokenPayload, { maxAge: 60 * 60 * 24 });
cookieNamesToDelete.forEach((name) => deleteCookieClient(name, {}));
cookieNamesToDelete.forEach((name) => {
const domain = this._getDomainFromCustomRefreshCookieName(name);
deleteCookieClient(name, domain ? { domain } : {});
});
this._queueCustomRefreshCookieUpdate(refreshToken, updatedAt, "browser");
hasSucceededInWriting = true;
} catch (e) {
@ -912,9 +948,10 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
]);
if (cookieNamesToDelete.length > 0) {
await Promise.all(
cookieNamesToDelete.map((name) =>
deleteCookie(name, { noOpIfServerComponent: true }),
),
cookieNamesToDelete.map((name) => {
const domain = this._getDomainFromCustomRefreshCookieName(name);
return deleteCookie(name, { noOpIfServerComponent: true, ...(domain ? { domain } : {}) });
}),
);
}
this._queueCustomRefreshCookieUpdate(refreshToken, updatedAt, "server");

View File

@ -95,7 +95,7 @@ importers:
version: 0.20.3(typescript@5.9.3)
turbo:
specifier: ^2.8.15
version: 2.8.15
version: 2.8.17
typescript:
specifier: 5.9.3
version: 5.9.3
@ -4987,14 +4987,6 @@ packages:
'@types/node':
optional: true
'@isaacs/balanced-match@4.0.1':
resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==}
engines: {node: 20 || >=22}
'@isaacs/brace-expansion@5.0.0':
resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==}
engines: {node: 20 || >=22}
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
@ -13816,10 +13808,6 @@ packages:
resolution: {integrity: sha512-ethXTt3SGGR+95gudmqJ1eNhRO7eGEGIgYA9vnPatK4/etz2MEVDno5GMCibdMTuBMyElzIlgxMna3K94XDIDQ==}
engines: {node: 20 || >=22}
minimatch@10.1.1:
resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==}
engines: {node: 20 || >=22}
minimatch@10.2.4:
resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
engines: {node: 18 || 20 || >=22}
@ -16311,41 +16299,41 @@ packages:
resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==}
engines: {node: '>= 6.0.0'}
turbo-darwin-64@2.8.15:
resolution: {integrity: sha512-EElCh+Ltxex9lXYrouV3hHjKP3HFP31G91KMghpNHR/V99CkFudRcHcnWaorPbzAZizH1m8o2JkLL8rptgb8WQ==}
turbo-darwin-64@2.8.17:
resolution: {integrity: sha512-ZFkv2hv7zHpAPEXBF6ouRRXshllOavYc+jjcrYyVHvxVTTwJWsBZwJ/gpPzmOKGvkSjsEyDO5V6aqqtZzwVF+Q==}
cpu: [x64]
os: [darwin]
turbo-darwin-arm64@2.8.15:
resolution: {integrity: sha512-ORmvtqHiHwvNynSWvLIleyU8dKtwQ4ILk39VsEwfKSEzSHWYWYxZhBmD9GAGRPlNl7l7S1irrziBlDEGVpq+vQ==}
turbo-darwin-arm64@2.8.17:
resolution: {integrity: sha512-5DXqhQUt24ycEryXDfMNKEkW5TBHs+QmU23a2qxXwwFDaJsWcPo2obEhBxxdEPOv7qmotjad+09RGeWCcJ9JDw==}
cpu: [arm64]
os: [darwin]
turbo-linux-64@2.8.15:
resolution: {integrity: sha512-Bk1E61a+PCWUTfhqfXFlhEJMLp6nak0J0Qt14IZX1og1zyaiBLkM6M1GQFbPpiWfbUcdLwRaYQhO0ySB07AJ8w==}
turbo-linux-64@2.8.17:
resolution: {integrity: sha512-KLUbz6w7F73D/Ihh51hVagrKR0/CTsPEbRkvXLXvoND014XJ4BCrQUqSxlQ4/hu+nqp1v5WlM85/h3ldeyujuA==}
cpu: [x64]
os: [linux]
turbo-linux-arm64@2.8.15:
resolution: {integrity: sha512-3BX0Vk+XkP0uiZc8pkjQGNsAWjk5ojC53bQEMp6iuhSdWpEScEFmcT6p7DL7bcJmhP2mZ1HlAu0A48wrTGCtvg==}
turbo-linux-arm64@2.8.17:
resolution: {integrity: sha512-pJK67XcNJH40lTAjFu7s/rUlobgVXyB3A3lDoq+/JccB3hf+SysmkpR4Itlc93s8LEaFAI4mamhFuTV17Z6wOg==}
cpu: [arm64]
os: [linux]
turbo-stream@2.4.0:
resolution: {integrity: sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==}
turbo-windows-64@2.8.15:
resolution: {integrity: sha512-m14ogunMF+grHZ1jzxSCO6q0gEfF1tmr+0LU+j1QNd/M1X33tfKnQqmpkeUR/REsGjfUlkQlh6PAzqlT3cA3Pg==}
turbo-windows-64@2.8.17:
resolution: {integrity: sha512-EijeQ6zszDMmGZLP2vT2RXTs/GVi9rM0zv2/G4rNu2SSRSGFapgZdxgW4b5zUYLVaSkzmkpWlGfPfj76SW9yUg==}
cpu: [x64]
os: [win32]
turbo-windows-arm64@2.8.15:
resolution: {integrity: sha512-HWh6dnzhl7nu5gRwXeqP61xbyDBNmQ4UCeWNa+si4/6RAtHlKEcZTNs7jf4U+oqBnbtv4uxbKZZPf/kN0EK4+A==}
turbo-windows-arm64@2.8.17:
resolution: {integrity: sha512-crpfeMPkfECd4V1PQ/hMoiyVcOy04+bWedu/if89S15WhOalHZ2BYUi6DOJhZrszY+mTT99OwpOsj4wNfb/GHQ==}
cpu: [arm64]
os: [win32]
turbo@2.8.15:
resolution: {integrity: sha512-ERZf7pKOR155NKs/PZt1+83NrSEJfUL7+p9/TGZg/8xzDVMntXEFQlX4CsNJQTyu4h3j+dZYiQWOOlv5pssuHQ==}
turbo@2.8.17:
resolution: {integrity: sha512-YwPsNSqU2f/RXU/+Kcb7cPkPZARxom4+me7LKEdN5jsvy2tpfze3zDZ4EiGrJnvOm9Avu9rK0aaYsP7qZ3iz7A==}
hasBin: true
type-check@0.4.0:
@ -20152,12 +20140,6 @@ snapshots:
optionalDependencies:
'@types/node': 20.17.6
'@isaacs/balanced-match@4.0.1': {}
'@isaacs/brace-expansion@5.0.0':
dependencies:
'@isaacs/balanced-match': 4.0.1
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
@ -26130,7 +26112,7 @@ snapshots:
'@ts-morph/common@0.27.0':
dependencies:
fast-glob: 3.3.3
minimatch: 10.1.1
minimatch: 10.2.4
path-browserify: 1.0.1
'@turf/boolean-point-in-polygon@7.1.0':
@ -30370,7 +30352,7 @@ snapshots:
glob@13.0.0:
dependencies:
minimatch: 10.1.1
minimatch: 10.2.4
minipass: 7.1.2
path-scurry: 2.0.0
@ -32075,10 +32057,6 @@ snapshots:
dependencies:
brace-expansion: 2.0.1
minimatch@10.1.1:
dependencies:
'@isaacs/brace-expansion': 5.0.0
minimatch@10.2.4:
dependencies:
brace-expansion: 5.0.4
@ -35324,34 +35302,34 @@ snapshots:
dependencies:
tslib: 1.14.1
turbo-darwin-64@2.8.15:
turbo-darwin-64@2.8.17:
optional: true
turbo-darwin-arm64@2.8.15:
turbo-darwin-arm64@2.8.17:
optional: true
turbo-linux-64@2.8.15:
turbo-linux-64@2.8.17:
optional: true
turbo-linux-arm64@2.8.15:
turbo-linux-arm64@2.8.17:
optional: true
turbo-stream@2.4.0: {}
turbo-windows-64@2.8.15:
turbo-windows-64@2.8.17:
optional: true
turbo-windows-arm64@2.8.15:
turbo-windows-arm64@2.8.17:
optional: true
turbo@2.8.15:
turbo@2.8.17:
optionalDependencies:
turbo-darwin-64: 2.8.15
turbo-darwin-arm64: 2.8.15
turbo-linux-64: 2.8.15
turbo-linux-arm64: 2.8.15
turbo-windows-64: 2.8.15
turbo-windows-arm64: 2.8.15
turbo-darwin-64: 2.8.17
turbo-darwin-arm64: 2.8.17
turbo-linux-64: 2.8.17
turbo-linux-arm64: 2.8.17
turbo-windows-64: 2.8.17
turbo-windows-arm64: 2.8.17
type-check@0.4.0:
dependencies: