From e2fbe2ca0944ae7bdb9fa13224723716cd31ac49 Mon Sep 17 00:00:00 2001 From: BilalG1 Date: Fri, 3 Apr 2026 17:10:25 -0700 Subject: [PATCH 1/4] Fix cross-subdomain cookie deletion and prefetch trusted parent domain (#1302) Cross-subdomain refresh cookies were not being deleted correctly because the domain option was not passed to deleteCookie/deleteCookieClient. This caused stale cookies to accumulate and auth state to persist across subdomains after sign-out. Also eagerly warms the trusted parent domain cache on app construction to avoid a race condition where navigation after sign-in could prevent the cross-subdomain cookie from being written. ## Summary by CodeRabbit * **New Features** * Automatically recreates a missing cross-subdomain refresh cookie on app startup in browser sessions when applicable. * **Bug Fixes** * Cookie deletions now correctly scope removals to the encoded parent domain when applicable for both browser and server token-store flows. * **Performance** * Pre-warms a domain-resolution cache in browser token-store scenarios to reduce authentication latency. * **Tests** * Added end-to-end tests validating custom refresh-cookie name encoding/decoding, non-custom cookie handling, and eager cookie recreation. --- apps/e2e/tests/js/cookies.test.ts | 99 +++++++++++++++++++ .../apps/implementations/client-app-impl.ts | 47 ++++++++- 2 files changed, 141 insertions(+), 5 deletions(-) diff --git a/apps/e2e/tests/js/cookies.test.ts b/apps/e2e/tests/js/cookies.test.ts index 820e31079..2f5428270 100644 --- a/apps/e2e/tests/js/cookies.test.ts +++ b/apps/e2e/tests/js/cookies.test.ts @@ -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?.(); +}); diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 01c29686b..e2eb3b8c8 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -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 { + 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 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 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"); From ce49eae15513f5edad784785c130511d3bad0c0a Mon Sep 17 00:00:00 2001 From: Mantra <87142457+mantrakp04@users.noreply.github.com> Date: Fri, 3 Apr 2026 17:33:52 -0700 Subject: [PATCH 2/4] emu with a q stuff (#1266) commit 5d43722575b826a8ed8dbb6b828f48eae4bca02c Author: mantrakp04 Date: Wed Mar 18 12:27:01 2026 -0700 Add QEMU emulator snapshot functionality and reset command - Introduced a new `emulator-qemu:reset` command in package.json to clear snapshots and force a fresh boot of the emulator. - Enhanced the `run-emulator.sh` script to support saving and restoring snapshots, significantly reducing restart time from ~62s to ~4s. - Implemented logic to check for existing snapshots and restore them during startup, improving the emulator's efficiency. - Updated documentation in CLAUDE-KNOWLEDGE.md to explain the new snapshot restore process and its benefits. These changes enhance the QEMU emulator's performance and usability for developers, providing a more efficient workflow during development. commit 3877445bdd83cb8690da18c8520bf260d2795172 Author: mantrakp04 Date: Wed Mar 18 11:55:18 2026 -0700 Enhance QEMU emulator performance and configuration management - Added optimizations to the QEMU emulator's app container startup process, reducing startup time from ~92s to ~62s by using qcow2 backing files and setting the working directory to /app. - Updated the build-image.sh script to conditionally wait for background processes, improving robustness. - Modified the run-emulator.sh script to create the disk image using qcow2 format instead of copying, enhancing efficiency. - Adjusted the cloud-init user-data to set STACK_RUNTIME_WORK_DIR to /app, streamlining file operations during container initialization. - Improved the entrypoint script to avoid unnecessary file copying when the working directory is set to /app. These changes significantly enhance the performance and usability of the QEMU emulator for developers. commit e0b86d3f1d5c08e46d0d343bc632e2a8c5777845 Author: mantrakp04 Date: Wed Mar 18 11:07:55 2026 -0700 Refactor local emulator configuration management and enhance Docker setup - Removed redundant comments and improved code clarity in the local emulator's route handling. - Streamlined the Dockerfile and docker-compose.yaml for better readability and maintenance. - Updated entrypoint and initialization scripts to enhance service startup processes. - Introduced a new common script for QEMU emulator to centralize architecture detection and firmware handling. - Enhanced error handling in the host file bridge for improved robustness. - Removed obsolete country code utilities to clean up the codebase. These changes significantly improve the local emulator's configuration management and overall setup experience for developers. commit 4fb0f93c6cc4f749a14acf0228c261e180875609 Author: mantrakp04 Date: Wed Mar 18 10:24:53 2026 -0700 Implement local emulator file bridge for enhanced configuration management - Introduced a new host file bridge to facilitate reading and writing configuration files between the local emulator and the host system. - Refactored the local-emulator module to utilize the file bridge for file operations, improving error handling and response validation. - Added tests to ensure the file bridge functionality works as expected, including handling of non-existent files and writing configurations. - Updated the run-emulator script to start the file bridge automatically, ensuring seamless integration during emulator startup. - Enhanced documentation to reflect the new file bridge capabilities and usage instructions. These changes significantly improve the local emulator's ability to manage configuration files, enhancing the development experience. commit 3d18a7ce5bbf00a62a40a3f48f27856e79ecc62f Author: mantrakp04 Date: Tue Mar 17 22:36:46 2026 -0700 Refactor QEMU local emulator setup and enhance app bundle handling - Introduced a new script for packaging Docker images into a compressed app bundle, improving the emulator's deployment process. - Updated build-image.sh to create a runtime configuration ISO, ensuring better management of environment settings. - Enhanced cloud-init user-data scripts for both dev-server and deps guests, streamlining service setup and configuration. - Improved the run-emulator.sh script to facilitate better handling of runtime configurations and dependencies. - Adjusted the .gitignore to include .DS_Store and removed obsolete entries, cleaning up the repository. These changes significantly enhance the local emulator's functionality and reliability for developers. commit 8a35fb1ce79898d73e2259e256c11b6fd9b0a584 Author: mantrakp04 Date: Tue Mar 17 21:52:24 2026 -0700 Enhance local emulator functionality and configuration - Updated package.json to improve the start-emulator command, providing clearer dashboard and backend URLs. - Added a new wait-until-emulator-is-ready command to ensure the emulator is fully operational before proceeding. - Refactored the local-emulator project route to streamline file existence checks and default config creation. - Enhanced user guidance in the dashboard for local Stack config file handling. - Updated tests to reflect changes in config file handling, ensuring non-existent files are created with default settings. - Improved Docker configurations for the local emulator, including new environment variables and service dependencies. These changes significantly enhance the local development experience and emulator reliability. commit 3910ed4bc40bbb37340c1c316c24c2826ba372bd Author: mantrakp04 Date: Tue Mar 17 19:59:36 2026 -0700 Remove unused stash-0.patch file to clean up the repository. commit 74146d974458037a7a9590120a524629a1a6a162 Author: mantrakp04 Date: Tue Mar 17 19:58:46 2026 -0700 Enhance QEMU local emulator with app bundle support and runtime configuration - Introduced a new script to package the backend and dashboard assets into a standalone app bundle for the QEMU emulator. - Updated the build-image.sh script to create an ISO containing the app bundle, ensuring the guest image includes the full runtime. - Modified cloud-init user-data to handle the new app bundle and runtime configuration, improving the setup process for local development. - Enhanced the run-emulator.sh script to prepare and mount the runtime configuration ISO, facilitating better environment management for the emulator. - Updated the user-data to include necessary environment variables for the stack application, ensuring seamless integration during startup. These changes significantly improve the local emulator's functionality and ease of use for developers. commit 9e865a1cf524398bc58f00e0836278775c4ae936 Author: mantrakp04 Date: Tue Mar 17 16:50:45 2026 -0700 Enhance local emulator setup with new services and configurations - Added Docker support for a local emulator, integrating PostgreSQL, Redis, Inbucket, Svix, ClickHouse, MinIO, and QStash. - Introduced new scripts for managing the emulator lifecycle, including build and run commands. - Implemented cloud-init provisioning for automatic service setup on first boot. - Updated package.json with new commands for emulator management and added dotenv-cli for environment variable management. - Added tests for OAuth authorization flow to return JSON responses. - Included configuration files for ClickHouse and user management. This commit significantly improves the local development experience by providing a comprehensive emulator environment. ## Summary by CodeRabbit ## Release Notes * **New Features** * Introduced a local QEMU-based emulator for development with bundled services (PostgreSQL, Redis, ClickHouse, MinIO, Inbucket, Svix, QStash). * Added CLI commands to manage the emulator (start, stop, reset, status, pull images). * Added emulator status dashboard to monitor service health. * Introduced new configuration system via `stack.config.ts`. * **Tests** * Added configuration read/write tests for the emulator. * Added emulator CLI validation tests. * **Documentation** * Added emulator setup and usage guide. --- .dockerignore | 3 +- .github/workflows/qemu-emulator-build.yaml | 255 ++++++++++++ .gitignore | 1 + apps/backend/prisma/seed.ts | 7 + .../internal/local-emulator/project/route.tsx | 6 +- apps/backend/src/lib/local-emulator.test.ts | 92 +++++ apps/backend/src/lib/local-emulator.ts | 63 ++- .../projects/page-client.tsx | 2 +- apps/e2e/tests/general/cli.test.ts | 70 ++++ claude/CLAUDE-KNOWLEDGE.md | 2 + docker/local-emulator/Dockerfile | 199 +++++++++ docker/local-emulator/clickhouse-config.xml | 26 ++ docker/local-emulator/clickhouse-users.xml | 35 ++ docker/local-emulator/entrypoint.sh | 31 ++ .../generate-env-development.mjs | 203 +++++++++ docker/local-emulator/init-services.sh | 30 ++ docker/local-emulator/qemu/.gitignore | 2 + docker/local-emulator/qemu/README.md | 121 ++++++ docker/local-emulator/qemu/build-image.sh | 290 +++++++++++++ .../qemu/cloud-init/emulator/meta-data | 2 + .../qemu/cloud-init/emulator/user-data | 185 +++++++++ docker/local-emulator/qemu/common.sh | 70 ++++ docker/local-emulator/qemu/images/.gitignore | 2 + docker/local-emulator/qemu/run-emulator.sh | 391 ++++++++++++++++++ docker/local-emulator/start-app.sh | 31 ++ docker/local-emulator/supervisord.conf | 148 +++++++ docker/server/entrypoint.sh | 13 +- .../demo/src/app/api/emulator-status/route.ts | 80 ++++ .../demo/src/app/emulator-status/page.tsx | 188 +++++++++ package.json | 8 + packages/stack-cli/src/commands/emulator.ts | 138 +++++++ packages/stack-cli/src/index.ts | 2 + pnpm-lock.yaml | 82 ++-- 33 files changed, 2701 insertions(+), 77 deletions(-) create mode 100644 .github/workflows/qemu-emulator-build.yaml create mode 100644 apps/backend/src/lib/local-emulator.test.ts create mode 100644 docker/local-emulator/Dockerfile create mode 100644 docker/local-emulator/clickhouse-config.xml create mode 100644 docker/local-emulator/clickhouse-users.xml create mode 100644 docker/local-emulator/entrypoint.sh create mode 100644 docker/local-emulator/generate-env-development.mjs create mode 100644 docker/local-emulator/init-services.sh create mode 100644 docker/local-emulator/qemu/.gitignore create mode 100644 docker/local-emulator/qemu/README.md create mode 100755 docker/local-emulator/qemu/build-image.sh create mode 100644 docker/local-emulator/qemu/cloud-init/emulator/meta-data create mode 100644 docker/local-emulator/qemu/cloud-init/emulator/user-data create mode 100755 docker/local-emulator/qemu/common.sh create mode 100644 docker/local-emulator/qemu/images/.gitignore create mode 100755 docker/local-emulator/qemu/run-emulator.sh create mode 100644 docker/local-emulator/start-app.sh create mode 100644 docker/local-emulator/supervisord.conf create mode 100644 examples/demo/src/app/api/emulator-status/route.ts create mode 100644 examples/demo/src/app/emulator-status/page.tsx create mode 100644 packages/stack-cli/src/commands/emulator.ts diff --git a/.dockerignore b/.dockerignore index 836abeef0..893ad8ac9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -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 - diff --git a/.github/workflows/qemu-emulator-build.yaml b/.github/workflows/qemu-emulator-build.yaml new file mode 100644 index 000000000..e4a42207c --- /dev/null +++ b/.github/workflows/qemu-emulator-build.yaml @@ -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 </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 diff --git a/.gitignore b/.gitignore index 592215d69..a8264f9e7 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ vite.config.ts.timestamp-* .eslintcache .env.local .env.*.local +docker/local-emulator/.env.development scratch/ npm-debug.log* diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 8dd8a33b5..b6ce20cef 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -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!'); diff --git a/apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx b/apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx index 0ad1ea4f8..e660c21c7 100644 --- a/apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx +++ b/apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx @@ -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, {}); } diff --git a/apps/backend/src/lib/local-emulator.test.ts b/apps/backend/src/lib/local-emulator.test.ts new file mode 100644 index 000000000..e5519cae3 --- /dev/null +++ b/apps/backend/src/lib/local-emulator.test.ts @@ -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` + ); + }); +}); diff --git a/apps/backend/src/lib/local-emulator.ts b/apps/backend/src/lib/local-emulator.ts index ec9b27f29..8b433d1d0 100644 --- a/apps/backend/src/lib/local-emulator.ts +++ b/apps/backend/src/lib/local-emulator.ts @@ -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> { - 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> { + // 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; const config = mod.config; @@ -64,8 +85,18 @@ export async function readConfigFromFile(filePath: string): Promise): Promise { - 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"); } diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx index f54638803..7398c6086 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page-client.tsx @@ -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"; diff --git a/apps/e2e/tests/general/cli.test.ts b/apps/e2e/tests/general/cli.test.ts index c2f1176d6..cd23a6da7 100644 --- a/apps/e2e/tests/general/cli.test.ts +++ b/apps/e2e/tests/general/cli.test.ts @@ -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"); + }); +}); diff --git a/claude/CLAUDE-KNOWLEDGE.md b/claude/CLAUDE-KNOWLEDGE.md index 0f5fa27da..320b100e9 100644 --- a/claude/CLAUDE-KNOWLEDGE.md +++ b/claude/CLAUDE-KNOWLEDGE.md @@ -139,6 +139,8 @@ A: Update affected inline snapshots in `apps/e2e/tests/backend/endpoints/api/v1/ Q: How should `createOrUpdateProjectWithLegacyConfig` handle `onboardingStatus` for forward-compat checks? A: Only write `onboardingStatus` when the `Project.onboardingStatus` column exists (for example by checking `information_schema.columns` in-transaction) so current code can still run against older schemas where that column is absent. +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`. diff --git a/docker/local-emulator/Dockerfile b/docker/local-emulator/Dockerfile new file mode 100644 index 000000000..7f9e6d45a --- /dev/null +++ b/docker/local-emulator/Dockerfile @@ -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"] diff --git a/docker/local-emulator/clickhouse-config.xml b/docker/local-emulator/clickhouse-config.xml new file mode 100644 index 000000000..31aa71922 --- /dev/null +++ b/docker/local-emulator/clickhouse-config.xml @@ -0,0 +1,26 @@ + + + warning + 1 + + + 8123 + 9009 + 0.0.0.0 + + /data/clickhouse/ + /data/clickhouse/tmp/ + /data/clickhouse/user_files/ + /data/clickhouse/format_schemas/ + + 0.5 + + + + users.xml + + + /data/clickhouse/access/ + + + diff --git a/docker/local-emulator/clickhouse-users.xml b/docker/local-emulator/clickhouse-users.xml new file mode 100644 index 000000000..3f1e67f1f --- /dev/null +++ b/docker/local-emulator/clickhouse-users.xml @@ -0,0 +1,35 @@ + + + + + ::/0 + default + default + 1 + + + PASSWORD-PLACEHOLDER--9gKyMxJeMx + ::/0 + default + default + 1 + + + + + 1000000000 + + + + + + 3600 + 0 + 0 + 0 + 0 + 0 + + + + diff --git a/docker/local-emulator/entrypoint.sh b/docker/local-emulator/entrypoint.sh new file mode 100644 index 000000000..daa985465 --- /dev/null +++ b/docker/local-emulator/entrypoint.sh @@ -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 diff --git a/docker/local-emulator/generate-env-development.mjs b/docker/local-emulator/generate-env-development.mjs new file mode 100644 index 000000000..f0b0b20d2 --- /dev/null +++ b/docker/local-emulator/generate-env-development.mjs @@ -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)}.`); +} diff --git a/docker/local-emulator/init-services.sh b/docker/local-emulator/init-services.sh new file mode 100644 index 000000000..8fd1f7de3 --- /dev/null +++ b/docker/local-emulator/init-services.sh @@ -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" diff --git a/docker/local-emulator/qemu/.gitignore b/docker/local-emulator/qemu/.gitignore new file mode 100644 index 000000000..6f1a60919 --- /dev/null +++ b/docker/local-emulator/qemu/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +run/ diff --git a/docker/local-emulator/qemu/README.md b/docker/local-emulator/qemu/README.md new file mode 100644 index 000000000..57ed71314 --- /dev/null +++ b/docker/local-emulator/qemu/README.md @@ -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-.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 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 ` +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) +``` diff --git a/docker/local-emulator/qemu/build-image.sh b/docker/local-emulator/qemu/build-image.sh new file mode 100755 index 000000000..8071fb501 --- /dev/null +++ b/docker/local-emulator/qemu/build-image.sh @@ -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" diff --git a/docker/local-emulator/qemu/cloud-init/emulator/meta-data b/docker/local-emulator/qemu/cloud-init/emulator/meta-data new file mode 100644 index 000000000..4a17d62ae --- /dev/null +++ b/docker/local-emulator/qemu/cloud-init/emulator/meta-data @@ -0,0 +1,2 @@ +instance-id: stack-emulator-001 +local-hostname: stack-emulator diff --git a/docker/local-emulator/qemu/cloud-init/emulator/user-data b/docker/local-emulator/qemu/cloud-init/emulator/user-data new file mode 100644 index 000000000..39b8c33cd --- /dev/null +++ b/docker/local-emulator/qemu/cloud-init/emulator/user-data @@ -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 < /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 diff --git a/docker/local-emulator/qemu/common.sh b/docker/local-emulator/qemu/common.sh new file mode 100755 index 000000000..1e3374dad --- /dev/null +++ b/docker/local-emulator/qemu/common.sh @@ -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 +} diff --git a/docker/local-emulator/qemu/images/.gitignore b/docker/local-emulator/qemu/images/.gitignore new file mode 100644 index 000000000..d6b7ef32c --- /dev/null +++ b/docker/local-emulator/qemu/images/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/docker/local-emulator/qemu/run-emulator.sh b/docker/local-emulator/qemu/run-emulator.sh new file mode 100755 index 000000000..f2f3028ca --- /dev/null +++ b/docker/local-emulator/qemu/run-emulator.sh @@ -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 diff --git a/docker/local-emulator/start-app.sh b/docker/local-emulator/start-app.sh new file mode 100644 index 000000000..ad7472732 --- /dev/null +++ b/docker/local-emulator/start-app.sh @@ -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 diff --git a/docker/local-emulator/supervisord.conf b/docker/local-emulator/supervisord.conf new file mode 100644 index 000000000..e8b1fc478 --- /dev/null +++ b/docker/local-emulator/supervisord.conf @@ -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 diff --git a/docker/server/entrypoint.sh b/docker/server/entrypoint.sh index 1b598b1c4..da7214a01 100644 --- a/docker/server/entrypoint.sh +++ b/docker/server/entrypoint.sh @@ -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..." diff --git a/examples/demo/src/app/api/emulator-status/route.ts b/examples/demo/src/app/api/emulator-status/route.ts new file mode 100644 index 000000000..1cf488711 --- /dev/null +++ b/examples/demo/src/app/api/emulator-status/route.ts @@ -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, + }, + }); +} diff --git a/examples/demo/src/app/emulator-status/page.tsx b/examples/demo/src/app/emulator-status/page.tsx new file mode 100644 index 000000000..61c57e22b --- /dev/null +++ b/examples/demo/src/app/emulator-status/page.tsx @@ -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 ( + + ); +} + +function ServiceRow({ service }: { service: ServiceResult }) { + return ( +
+
+ +
+ {service.name} + {service.description} +
+
+
+ :{service.port} + {service.status === 'up' && ( + {service.latencyMs}ms + )} + + {service.status === 'up' ? 'Online' : 'Offline'} + +
+
+ ); +} + +export default function EmulatorStatusPage() { + const [data, setData] = useState(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 ( +
+
+
+
+ Local Emulator Status + + Monitoring services in the all-in-one dependencies container + +
+
+ + +
+
+ + + + {loading && !data ? ( +
+ + Checking services... +
+ ) : summary ? ( +
+
+ + + {allUp + ? 'All services operational' + : `${summary.down} of ${summary.total} services are offline`} + +
+
+ {summary.up} up + {summary.down > 0 && ( + {summary.down} down + )} + updated {new Date(data.timestamp).toLocaleTimeString()} +
+
+ ) : null} +
+
+ + + + Services + + + {data?.services.map((svc) => ( + + ))} + {!data && loading && ( +
Loading...
+ )} +
+
+ + + + Quick Start + + + Start the QEMU local emulator: +
+              {`# 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`}
+            
+ + Dashboard: localhost:26700 | Backend: localhost:26701 + +
+
+
+
+ ); +} diff --git a/package.json b/package.json index 9f93e85fe..0aca2a60c 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/stack-cli/src/commands/emulator.ts b/packages/stack-cli/src/commands/emulator.ts new file mode 100644 index 000000000..a4878b237 --- /dev/null +++ b/packages/stack-cli/src/commands/emulator.ts @@ -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): Promise { + 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 ", "Target architecture (default: current system arch)") + .option("--branch ", "Release branch (default: dev)") + .option("--tag ", "Specific release tag (default: latest)") + .option("--repo ", "GitHub repository (default: stack-auth/stack-auth)") + .option("--pr ", "Pull from a PR's CI artifacts") + .option("--run ", "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 ", "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 ", "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); + }); +} diff --git a/packages/stack-cli/src/index.ts b/packages/stack-cli/src/index.ts index 08af5837e..69f4ddc37 100644 --- a/packages/stack-cli/src/index.ts +++ b/packages/stack-cli/src/index.ts @@ -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 { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 20a52c3b7..f19de820e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: From 9b1284dc9e3cca3dc97769322cb6e3457decf85f Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Sun, 5 Apr 2026 21:34:59 -0700 Subject: [PATCH 3/4] Fraud Protection sub-app --- .../@modal/(.)apps/[appId]/page-client.tsx | 17 +- .../[projectId]/app-enabled-guard.tsx | 3 +- .../[projectId]/apps/[appId]/page-client.tsx | 15 +- .../projects/[projectId]/apps/page-client.tsx | 5 +- .../projects/[projectId]/sidebar-layout.tsx | 26 +-- apps/dashboard/src/components/app-square.tsx | 56 +++++- .../src/components/app-store-entry.tsx | 66 +++++--- .../src/components/cmdk-commands.tsx | 159 ++++++++++++++---- .../src/lib/ai-dashboard/shared-prompt.ts | 5 +- apps/dashboard/src/lib/apps-frontend.tsx | 51 ++++-- apps/dashboard/src/lib/apps-utils.ts | 34 ++++ packages/stack-shared/src/apps/apps-config.ts | 6 + 12 files changed, 346 insertions(+), 97 deletions(-) diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/@modal/(.)apps/[appId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/@modal/(.)apps/[appId]/page-client.tsx index 0061981e0..6ecb0835d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/@modal/(.)apps/[appId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/@modal/(.)apps/[appId]/page-client.tsx @@ -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) => { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/app-enabled-guard.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/app-enabled-guard.tsx index ad98b3cc1..76521cff3 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/app-enabled-guard.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/app-enabled-guard.tsx @@ -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) { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/apps/[appId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/apps/[appId]/page-client.tsx index 0dde34728..ec8cf349e 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/apps/[appId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/apps/[appId]/page-client.tsx @@ -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 () => { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/apps/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/apps/page-client.tsx index 8a691f862..9cb15c64c 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/apps/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/apps/page-client.tsx @@ -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] ); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx index 915d288a4..767a8a7bd 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/sidebar-layout.tsx @@ -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 ( - 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] ); diff --git a/apps/dashboard/src/components/app-square.tsx b/apps/dashboard/src/components/app-square.tsx index f5d1b4455..09537c2b7 100644 --- a/apps/dashboard/src/components/app-square.tsx +++ b/apps/dashboard/src/components/app-square.tsx @@ -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({ - - {isEnabled ? 'Disable' : 'Enable'} - + {parentDestinationPath == null ? ( + + {isEnabled ? 'Disable' : 'Enable'} + + ) : ( + router.push(parentDestinationPath)} className="cursor-pointer"> + Go to {parentApp?.displayName ?? "parent app"} + + )} @@ -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 ( {isEnabled ? ( + ) : parentDestinationPath != null ? ( + ) : ( + {onDisable && ( + + )} + + ) : ( + + ) + ) : ( <> +

+ This app is part of the {parentApp.displayName} app. +

- {onDisable && ( - - )} - ) : ( - )} diff --git a/apps/dashboard/src/components/cmdk-commands.tsx b/apps/dashboard/src/components/cmdk-commands.tsx index febaa2baf..a91aeaeec 100644 --- a/apps/dashboard/src/components/cmdk-commands.tsx +++ b/apps/dashboard/src/components/cmdk-commands.tsx @@ -1,8 +1,9 @@ "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"; @@ -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, + onEnable?: () => Promise, + 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 */}
- + {parentApp == null ? ( + + ) : ( + + )}
Free
+ {parentApp != null && ( +

+ This app is part of the {parentApp.displayName} app. +

+ )} {/* 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): React.ComponentType { - return function AvailableAppPreviewWrapper() { - return ; +function createAvailableAppPreview( + appId: AppId, + onEnable?: () => Promise, + goToParentHref?: string +): React.ComponentType { + return function AvailableAppPreviewWrapper({ onClose }: CmdKPreviewProps) { + return ; }; } // Cache for available app preview components const availableAppPreviewCache = new Map>(); -function getOrCreateAvailableAppPreview(appId: AppId, projectId: string, onEnable: () => Promise): React.ComponentType { - const cacheKey = `${appId}:${projectId}`; +function getOrCreateAvailableAppPreview( + appId: AppId, + projectId: string, + onEnable?: () => Promise, + goToParentHref?: string +): React.ComponentType { + 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; @@ -230,10 +264,9 @@ export type CmdKCommand = { }; // Factory to create app preview components that show navigation items -function createAppPreview(appId: AppId, projectId: string): React.ComponentType { +function createAppPreview(appId: AppId, projectId: string, appFrontend: NavigableAppFrontend): React.ComponentType { // 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 +306,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; @@ -313,19 +350,49 @@ 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: , + label: app.displayName, + description: parentApp == null ? "Installed app" : `Part of ${parentApp.displayName}`, + keywords: [ + app.displayName.toLowerCase(), + app.subtitle.toLowerCase(), + appId, + ...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: , 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, + ...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", }); } @@ -338,6 +405,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 +427,32 @@ export function useCmdKCommands({ ), 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, }); } diff --git a/apps/dashboard/src/lib/ai-dashboard/shared-prompt.ts b/apps/dashboard/src/lib/ai-dashboard/shared-prompt.ts index 275bfeb11..18798cede 100644 --- a/apps/dashboard/src/lib/ai-dashboard/shared-prompt.ts +++ b/apps/dashboard/src/lib/ai-dashboard/shared-prompt.ts @@ -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); diff --git a/apps/dashboard/src/lib/apps-frontend.tsx b/apps/dashboard/src/lib/apps-frontend.tsx index d67af0c2f..b4bb95498 100644 --- a/apps/dashboard/src/lib/apps-frontend.tsx +++ b/apps/dashboard/src/lib/apps-frontend.tsx @@ -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>, logo?: React.FunctionComponent<{}>, href: string, - matchPath?: (relativePart: string) => boolean, - getBreadcrumbItems?: (stackAdminApp: StackAdminApp, relativePart: string) => Promise, - navigationItems: AppNavigationItem[], screenshots: (string | StaticImageData)[], storeDescription: JSX.Element, -}; +} & ( + | { + navigationItems: AppNavigationItem[], + matchPath?: (relativePart: string) => boolean, + getBreadcrumbItems?: (stackAdminApp: StackAdminApp, relativePart: string) => Promise, + } + | { + parentAppId: AppId, + } +) + +export type NavigableAppFrontend = Extract; +export type SubAppFrontend = Extract; + +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: <> +

Fraud Protection helps you protect your project from fraud and abuse.

+

Configure sign-up rules and use our built-in fraud protection features to detect bots, free trial abuse, and other fraudulent activity.

+ , + }, onboarding: { icon: ClipboardTextIcon, href: "onboarding", diff --git a/apps/dashboard/src/lib/apps-utils.ts b/apps/dashboard/src/lib/apps-utils.ts index 5d6c6e1d8..cf405df3a 100644 --- a/apps/dashboard/src/lib/apps-utils.ts +++ b/apps/dashboard/src/lib/apps-utils.ts @@ -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; + /** * 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) */ diff --git a/packages/stack-shared/src/apps/apps-config.ts b/packages/stack-shared/src/apps/apps-config.ts index 429cb91ed..92a5cb972 100644 --- a/packages/stack-shared/src/apps/apps-config.ts +++ b/packages/stack-shared/src/apps/apps-config.ts @@ -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", From add0d56be0fb57b834d67037801b10f7bee86d42 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 6 Apr 2026 09:12:14 -0700 Subject: [PATCH 4/4] More CMD+K actions --- .../src/components/cmdk-commands.tsx | 139 +++++++++++++++++- claude/CLAUDE-KNOWLEDGE.md | 3 + docs/src/components/mdx/app-card.tsx | 3 +- 3 files changed, 143 insertions(+), 2 deletions(-) diff --git a/apps/dashboard/src/components/cmdk-commands.tsx b/apps/dashboard/src/components/cmdk-commands.tsx index a91aeaeec..509e3e3f3 100644 --- a/apps/dashboard/src/components/cmdk-commands.tsx +++ b/apps/dashboard/src/components/cmdk-commands.tsx @@ -7,7 +7,7 @@ import { ALL_APPS_FRONTEND, getAppPath, getItemPath, hasNavigationItems, isSubAp 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"; @@ -263,6 +263,84 @@ export type CmdKCommand = { highlightColor?: string, }; +type ProjectShortcutDefinition = { + id: string, + icon: React.FunctionComponent>, + 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, appFrontend: NavigableAppFrontend): React.ComponentType { // Pre-compute these outside the component since they're static per appId @@ -329,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"; @@ -343,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: , + 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]; @@ -364,6 +475,8 @@ export function useCmdKCommands({ app.displayName.toLowerCase(), app.subtitle.toLowerCase(), appId, + appFrontend.href.toLowerCase(), + appFrontend.href.toLowerCase().replace(/-/g, " "), ...app.tags, "installed", "app", @@ -386,6 +499,8 @@ export function useCmdKCommands({ app.displayName.toLowerCase(), app.subtitle.toLowerCase(), appId, + appFrontend.href.toLowerCase(), + appFrontend.href.toLowerCase().replace(/-/g, " "), ...app.tags, "installed", "app", @@ -395,6 +510,28 @@ export function useCmdKCommands({ 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: , + 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 diff --git a/claude/CLAUDE-KNOWLEDGE.md b/claude/CLAUDE-KNOWLEDGE.md index 320b100e9..10fb018fb 100644 --- a/claude/CLAUDE-KNOWLEDGE.md +++ b/claude/CLAUDE-KNOWLEDGE.md @@ -158,3 +158,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. diff --git a/docs/src/components/mdx/app-card.tsx b/docs/src/components/mdx/app-card.tsx index d4f8191b1..a175a79f7 100644 --- a/docs/src/components/mdx/app-card.tsx +++ b/docs/src/components/mdx/app-card.tsx @@ -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 (