From 6875b64120b2e2ad64733516b558b8db399858f9 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 15 May 2026 13:06:01 -0700 Subject: [PATCH 1/7] Remote development environments --- .claude/CLAUDE-KNOWLEDGE.md | 24 + AGENTS.md | 1 + .../migration.sql | 9 + .../tests/default-and-updates.ts | 39 ++ apps/backend/prisma/schema.prisma | 1 + apps/backend/prisma/seed.ts | 46 ++ .../override/[level]/reset-keys/route.tsx | 7 +- .../config/override/[level]/route.tsx | 12 +- .../internal/local-emulator/project/route.tsx | 27 +- apps/backend/src/lib/config.tsx | 53 +- .../src/lib/development-environment.ts | 38 ++ apps/backend/src/lib/local-emulator.test.ts | 2 +- apps/backend/src/lib/local-emulator.ts | 27 +- apps/backend/src/lib/projects.tsx | 11 +- .../src/route-handlers/smart-request.tsx | 19 +- apps/dashboard/next.config.mjs | 4 + apps/dashboard/package.json | 1 + .../new-project/page-client-parts/content.tsx | 14 +- .../link-existing-onboarding.tsx | 5 +- .../project-onboarding-wizard.tsx | 33 +- .../new-project/page-client-parts/shared.ts | 12 +- .../(outside-dashboard)/projects/actions.ts | 2 +- .../projects/page-client.tsx | 66 +- .../(outside-dashboard)/projects/page.tsx | 49 +- .../app/(main)/(protected)/layout-client.tsx | 14 +- .../-selector-/[...path]/page-client.tsx | 4 +- .../projects/[projectId]/(overview)/globe.tsx | 5 +- .../projects/[projectId]/analytics/shared.tsx | 6 +- .../dashboards/[dashboardId]/page-client.tsx | 6 +- .../email-drafts/[draftId]/page-client.tsx | 4 +- .../[templateId]/page-client.tsx | 4 +- .../email-themes/[themeId]/page-client.tsx | 4 +- .../[projectId]/emails/page-client.tsx | 17 +- .../projects/[projectId]/payments/layout.tsx | 6 +- .../payments/payouts/page-client.tsx | 8 +- .../project-settings/page-client.tsx | 5 +- .../projects/[projectId]/use-admin-app.tsx | 5 +- .../app/(main)/handler/[...stack]/page.tsx | 3 - .../integrations/featurebase/sso/page.tsx | 2 +- .../integrations/oauth-confirm-card.tsx | 5 +- .../integrations/oauth-confirm-page.tsx | 2 +- .../development-environment/health/route.ts | 83 +++ .../auth/route.ts | 70 ++ .../config/apply-update/route.ts | 28 + .../sessions/[sessionId]/heartbeat/route.ts | 16 + .../sessions/[sessionId]/route.ts | 14 + .../sessions/route.ts | 39 ++ apps/dashboard/src/app/layout-client.tsx | 179 +++++ apps/dashboard/src/app/layout.tsx | 32 +- ...mote-development-environment-auth-gate.tsx | 191 ++++++ .../create-dashboard-preview.tsx | 4 +- .../dashboard-sandbox-host.tsx | 4 +- apps/dashboard/src/components/navbar.tsx | 5 +- .../payments/stripe-connect-provider.tsx | 6 +- .../src/components/project-switcher.tsx | 4 +- apps/dashboard/src/instrumentation.ts | 20 +- apps/dashboard/src/lib/config-update.tsx | 15 +- apps/dashboard/src/lib/dashboard-user.ts | 26 + apps/dashboard/src/lib/env.tsx | 2 + .../src/lib/prefetch/url-prefetcher.tsx | 7 +- .../config-file.ts | 54 ++ .../lib/remote-development-environment/env.ts | 15 + .../remote-development-environment/manager.ts | 609 ++++++++++++++++++ .../security.test.ts | 125 ++++ .../security.ts | 58 ++ .../remote-development-environment/state.ts | 79 +++ .../src/{stack.tsx => stack/client.tsx} | 14 +- apps/dashboard/src/stack/server.tsx | 14 + package.json | 3 +- packages/stack-cli/package.json | 2 +- .../scripts/copy-emulator-assets.mjs | 27 - .../stack-cli/scripts/copy-runtime-assets.mjs | 63 ++ packages/stack-cli/src/commands/dev.ts | 482 ++++++++++++++ .../stack-cli/src/commands/emulator.test.ts | 14 + packages/stack-cli/src/commands/emulator.ts | 197 ++++-- packages/stack-cli/src/commands/whoami.ts | 44 ++ packages/stack-cli/src/index.ts | 9 +- .../stack-cli/src/lib/dev-env-state.test.ts | 101 +++ packages/stack-cli/src/lib/dev-env-state.ts | 88 +++ packages/stack-shared/src/config-rendering.ts | 29 + .../src/interface/crud/projects.ts | 2 + packages/stack-shared/src/sessions.ts | 1 - .../stack-shared/src/stack-config-file.ts | 91 +++ .../src/utils/dev-env-state-path.ts | 14 + .../apps/implementations/admin-app-impl.ts | 1 + .../apps/implementations/client-app-impl.ts | 6 +- .../apps/implementations/session-replay.ts | 6 + .../src/lib/stack-app/projects/index.ts | 3 + turbo.json | 19 + 89 files changed, 3133 insertions(+), 384 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260513000000_add_project_development_environment/migration.sql create mode 100644 apps/backend/prisma/migrations/20260513000000_add_project_development_environment/tests/default-and-updates.ts create mode 100644 apps/backend/src/lib/development-environment.ts create mode 100644 apps/dashboard/src/app/api/development-environment/health/route.ts create mode 100644 apps/dashboard/src/app/api/remote-development-environment/auth/route.ts create mode 100644 apps/dashboard/src/app/api/remote-development-environment/config/apply-update/route.ts create mode 100644 apps/dashboard/src/app/api/remote-development-environment/sessions/[sessionId]/heartbeat/route.ts create mode 100644 apps/dashboard/src/app/api/remote-development-environment/sessions/[sessionId]/route.ts create mode 100644 apps/dashboard/src/app/api/remote-development-environment/sessions/route.ts create mode 100644 apps/dashboard/src/app/layout-client.tsx create mode 100644 apps/dashboard/src/app/remote-development-environment-auth-gate.tsx create mode 100644 apps/dashboard/src/lib/dashboard-user.ts create mode 100644 apps/dashboard/src/lib/remote-development-environment/config-file.ts create mode 100644 apps/dashboard/src/lib/remote-development-environment/env.ts create mode 100644 apps/dashboard/src/lib/remote-development-environment/manager.ts create mode 100644 apps/dashboard/src/lib/remote-development-environment/security.test.ts create mode 100644 apps/dashboard/src/lib/remote-development-environment/security.ts create mode 100644 apps/dashboard/src/lib/remote-development-environment/state.ts rename apps/dashboard/src/{stack.tsx => stack/client.tsx} (65%) create mode 100644 apps/dashboard/src/stack/server.tsx delete mode 100644 packages/stack-cli/scripts/copy-emulator-assets.mjs create mode 100644 packages/stack-cli/scripts/copy-runtime-assets.mjs create mode 100644 packages/stack-cli/src/commands/dev.ts create mode 100644 packages/stack-cli/src/commands/whoami.ts create mode 100644 packages/stack-cli/src/lib/dev-env-state.test.ts create mode 100644 packages/stack-cli/src/lib/dev-env-state.ts create mode 100644 packages/stack-shared/src/stack-config-file.ts create mode 100644 packages/stack-shared/src/utils/dev-env-state-path.ts diff --git a/.claude/CLAUDE-KNOWLEDGE.md b/.claude/CLAUDE-KNOWLEDGE.md index e6ffc701be..6e15859883 100644 --- a/.claude/CLAUDE-KNOWLEDGE.md +++ b/.claude/CLAUDE-KNOWLEDGE.md @@ -457,3 +457,27 @@ A: `docs-mintlify/apps-sidebar-filter.js` injects the Apps filter with inline st ## Q: How should `StackAssertionError` preserve an underlying thrown error? A: Pass the underlying error as the `cause` property in the second argument. The `StackAssertionError` constructor only forwards `cause` into `ErrorOptions`, so storing a caught error under an `error` property captures it as ordinary metadata instead of preserving the error cause chain. + +## Q: How does the local QEMU emulator expose host-side control channels? +A: `docker/local-emulator/qemu/run-emulator.sh` daemonizes QEMU with a QMP monitor socket at `$EMULATOR_RUN_DIR/vm/monitor.sock`, a QEMU guest agent socket at `$EMULATOR_RUN_DIR/vm/qga.sock`, and serial output redirected to `$EMULATOR_RUN_DIR/vm/serial.log`. The default user networking forwards only Stack-facing service ports, not SSH. + +## Q: Where should remote development environment local state live? +A: Use `~/.stack/dev-envs.json` on macOS/Linux and `%LOCALAPPDATA%\Stack Auth\dev-envs.json` on Windows for local remote-development-environment state. The CLI and local dashboard both read this file; it stores the local dashboard bearer secret, anonymous refresh token, and config-path-to-project credential mappings with owner-only permissions. + +## Q: How should `stack dev` run the local dashboard in a published CLI? +A: The CLI cannot depend on `apps/dashboard` source being present or run `next dev`. Package a Next.js standalone dashboard build into `packages/stack-cli/dist/dashboard`, copy it to a writable runtime directory next to the RDE state file, replace dashboard `STACK_ENV_VAR_SENTINEL_*` values for that launch, and run the standalone `apps/dashboard/server.js` with `node`, `HOSTNAME=127.0.0.1`, `PORT=26700`, and `NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT=true`. + +## Q: Where should the RDE local dashboard self-shutdown lifecycle start? +A: Start the RDE lifecycle from the dashboard server startup path (`apps/dashboard/src/instrumentation.ts`) when `NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT=true`, not lazily after session registration. Keep the shutdown timer idempotent and use a short initial empty-session grace period so failed session registration still exits instead of leaving an orphaned standalone dashboard process. Once a CLI session has been explicitly closed, the dashboard can skip the startup grace and exit on the next shutdown tick if no sessions or operations remain. + +## Q: How should the dashboard Stack app be split for the local remote development environment? +A: Keep `apps/dashboard/src/stack/client.tsx` as the root `StackProvider` app and handler app so the local RDE dashboard can boot without `STACK_SECRET_SERVER_KEY`. Put `StackServerApp` in `apps/dashboard/src/stack/server.tsx`, inherit from the client app, and only import it from server-only routes that are not needed in local RDE. + +## Q: How should `stack dev` handle a local RDE dashboard outage while the child command is still running? +A: Keep it in the existing heartbeat path. If the heartbeat cannot reach the local dashboard and the dashboard session has passed the 5-second stability window, restart the standalone dashboard and re-register the RDE session; otherwise throw to avoid restart loops. + +## Q: How should the local RDE dashboard authenticate in the browser without exposing refresh tokens? +A: The browser should fetch only a short-lived access token from the local RDE auth endpoint, install it into the memory token store with an empty refresh token, and refresh it by calling the local endpoint before expiry. Shared session logic must allow access-token-only sessions to read a still-valid access token; otherwise the SDK treats the session as absent and may redirect or create a separate anonymous user. + +## Q: Why can `stack dev` fail to register an RDE session with `ECONNREFUSED` against `localhost`? +A: The RDE dashboard does server-side SDK calls from Node. If the backend is configured as `http://localhost:`, Node may resolve or probe loopback differently than the browser; normalize exact `localhost` API base URLs to `127.0.0.1` in the CLI. If the backend process is actually down, the dashboard log will still show `ECONNREFUSED 127.0.0.1:` and the dev server needs to be restarted. diff --git a/AGENTS.md b/AGENTS.md index 67c5d11d57..a4ba74391c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -113,6 +113,7 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub - When you made frontend (or docs, dashboard, demo, etc.) changes, and you have a browser MCP in your list of MCP tools, make sure to test the changes in the browser MCP. - If you're using the browser to test the dashboard and need to sign in, use GitHub OAuth to sign in (by default it should redirect you to the mock OAuth provider page, where you can sign in with admin@example.com). - NEVER INSTALL A NEW PACKAGE (or anything else) WITHOUT EXPLICIT APPROVAL FROM THE USER. +- A "development environment" is either an RDE (remote development environment; = local dashboard + prod backend) or a local emulator (local dashboard + local backend). When communicating to the user, we always say "development environment" instead of RDE or local emulator (the distinction to the user is minor, even though the implementation is quite different). ### Code-related - Use ES6 maps instead of records wherever you can. diff --git a/apps/backend/prisma/migrations/20260513000000_add_project_development_environment/migration.sql b/apps/backend/prisma/migrations/20260513000000_add_project_development_environment/migration.sql new file mode 100644 index 0000000000..d2ba1c6501 --- /dev/null +++ b/apps/backend/prisma/migrations/20260513000000_add_project_development_environment/migration.sql @@ -0,0 +1,9 @@ +ALTER TABLE "Project" +ADD COLUMN "isDevelopmentEnvironment" BOOLEAN NOT NULL DEFAULT false; + +UPDATE "Project" +SET "isDevelopmentEnvironment" = true +WHERE "id" IN ( + SELECT "projectId" + FROM "LocalEmulatorProject" +); diff --git a/apps/backend/prisma/migrations/20260513000000_add_project_development_environment/tests/default-and-updates.ts b/apps/backend/prisma/migrations/20260513000000_add_project_development_environment/tests/default-and-updates.ts new file mode 100644 index 0000000000..b8f8d641b0 --- /dev/null +++ b/apps/backend/prisma/migrations/20260513000000_add_project_development_environment/tests/default-and-updates.ts @@ -0,0 +1,39 @@ +import { randomUUID } from "crypto"; +import type { Sql } from "postgres"; +import { expect } from "vitest"; + +export const preMigration = async (sql: Sql) => { + const projectId = `test-${randomUUID()}`; + const localEmulatorProjectId = `test-${randomUUID()}`; + await sql` + INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") + VALUES (${projectId}, NOW(), NOW(), 'Development Environment Flag Project', '', false) + `; + await sql` + INSERT INTO "Project" ("id", "createdAt", "updatedAt", "displayName", "description", "isProductionMode") + VALUES (${localEmulatorProjectId}, NOW(), NOW(), 'Existing Local Emulator Project', '', false) + `; + await sql` + INSERT INTO "LocalEmulatorProject" ("absoluteFilePath", "projectId", "createdAt", "updatedAt") + VALUES (${`/tmp/${randomUUID()}/stack.config.ts`}, ${localEmulatorProjectId}, NOW(), NOW()) + `; + return { projectId, localEmulatorProjectId }; +}; + +export const postMigration = async (sql: Sql, ctx: Awaited>) => { + const rows = await sql` + SELECT "isDevelopmentEnvironment" + FROM "Project" + WHERE "id" = ${ctx.projectId} + `; + expect(rows).toHaveLength(1); + expect(rows[0].isDevelopmentEnvironment).toBe(false); + + const localEmulatorRows = await sql` + SELECT "isDevelopmentEnvironment" + FROM "Project" + WHERE "id" = ${ctx.localEmulatorProjectId} + `; + expect(localEmulatorRows).toHaveLength(1); + expect(localEmulatorRows[0].isDevelopmentEnvironment).toBe(true); +}; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 0b32726af2..87093753e6 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -25,6 +25,7 @@ model Project { displayName String description String @default("") isProductionMode Boolean + isDevelopmentEnvironment Boolean @default(false) ownerTeamId String? @db.Uuid onboardingStatus String @default("completed") onboardingState Json? diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index a005102d0c..f7ab263753 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -15,6 +15,8 @@ import { seedDummyProject } from '@/lib/seed-dummy-data'; import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from '@/lib/tenancies'; import { getPrismaClientForTenancy, globalPrismaClient } from '@/prisma-client'; import { ALL_APPS } from '@stackframe/stack-shared/dist/apps/apps-config'; +import { DEFAULT_EMAIL_THEME_ID } from '@stackframe/stack-shared/dist/helpers/emails'; +import { AdminUserProjectsCrud } from '@stackframe/stack-shared/dist/interface/crud/projects'; import { ITEM_IDS, PLAN_LIMITS } from '@stackframe/stack-shared/dist/plans'; import { DayInterval } from '@stackframe/stack-shared/dist/utils/dates'; import { throwErr } from '@stackframe/stack-shared/dist/utils/errors'; @@ -23,6 +25,7 @@ import { typedEntries, typedFromEntries } from '@stackframe/stack-shared/dist/ut const MONTHLY_REPEAT: DayInterval = [1, "month"]; const DUMMY_PROJECT_ID = '6fbbf22e-f4b2-4c6e-95a1-beab6fa41063'; +const DEVELOPMENT_ENVIRONMENT_PROJECT_ID = '5f2a45c8-9096-4f0b-b987-7640a47f7a79'; let didEnableSeedLogTimestamps = false; @@ -405,6 +408,49 @@ export async function seed() { }); } + const developmentEnvironmentProjectData = { + display_name: 'Development Environment Project', + description: 'Seeded project for debugging development-environment dashboard behavior.', + is_production_mode: false, + is_development_environment: true, + owner_team_id: internalTeamId, + config: { + allow_localhost: true, + sign_up_enabled: true, + credential_enabled: true, + magic_link_enabled: true, + passkey_enabled: true, + client_team_creation_enabled: true, + client_user_deletion_enabled: true, + allow_user_api_keys: true, + allow_team_api_keys: true, + create_team_on_sign_up: false, + email_theme: DEFAULT_EMAIL_THEME_ID, + email_config: { + type: 'shared', + }, + oauth_providers: oauthProviderIds.map((id) => ({ + id: id as any, + type: 'shared', + })), + domains: [], + }, + } satisfies AdminUserProjectsCrud["Admin"]["Create"]; + if (await getProject(DEVELOPMENT_ENVIRONMENT_PROJECT_ID)) { + await createOrUpdateProjectWithLegacyConfig({ + type: 'update', + projectId: DEVELOPMENT_ENVIRONMENT_PROJECT_ID, + branchId: DEFAULT_BRANCH_ID, + data: developmentEnvironmentProjectData, + }); + } else { + await createOrUpdateProjectWithLegacyConfig({ + type: 'create', + projectId: DEVELOPMENT_ENVIRONMENT_PROJECT_ID, + data: developmentEnvironmentProjectData, + }); + } + // Create optional default admin user if credentials are provided. // This user will be able to login to the dashboard with both email/password and magic link. diff --git a/apps/backend/src/app/api/latest/internal/config/override/[level]/reset-keys/route.tsx b/apps/backend/src/app/api/latest/internal/config/override/[level]/reset-keys/route.tsx index 2b04d82e40..22cb650ab5 100644 --- a/apps/backend/src/app/api/latest/internal/config/override/[level]/reset-keys/route.tsx +++ b/apps/backend/src/app/api/latest/internal/config/override/[level]/reset-keys/route.tsx @@ -1,8 +1,7 @@ import { resetBranchConfigOverrideKeys, resetEnvironmentConfigOverrideKeys } from "@/lib/config"; -import { LOCAL_EMULATOR_ENV_CONFIG_BLOCKED_MESSAGE, isLocalEmulatorProject } from "@/lib/local-emulator"; +import { assertConfigOverrideWriteAllowed } from "@/lib/development-environment"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { adaptSchema, adminAuthTypeSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; -import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; const levelSchema = yupString().oneOf(["branch", "environment"]).defined(); @@ -41,9 +40,7 @@ export const POST = createSmartRouteHandler({ bodyType: yupString().oneOf(["success"]).defined(), }), handler: async (req) => { - if (req.params.level === "environment" && await isLocalEmulatorProject(req.auth.tenancy.project.id)) { - throw new StatusError(StatusError.BadRequest, LOCAL_EMULATOR_ENV_CONFIG_BLOCKED_MESSAGE); - } + await assertConfigOverrideWriteAllowed(req.params.level, req.auth.tenancy.project.id); const levelConfig = levelConfigs[req.params.level]; diff --git a/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx b/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx index 85de6a3cce..e06108e775 100644 --- a/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx +++ b/apps/backend/src/app/api/latest/internal/config/override/[level]/route.tsx @@ -12,8 +12,8 @@ import { validateBranchConfigOverride, validateEnvironmentConfigOverride, } from "@/lib/config"; +import { assertConfigOverrideWriteAllowed } from "@/lib/development-environment"; import { enqueueExternalDbSync } from "@/lib/external-db-sync-queue"; -import { LOCAL_EMULATOR_ENV_CONFIG_BLOCKED_MESSAGE, isLocalEmulatorProject } from "@/lib/local-emulator"; import { globalPrismaClient, rawQuery } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { branchConfigSchema, environmentConfigSchema, getConfigOverrideErrors, migrateConfigOverride, projectConfigSchema } from "@stackframe/stack-shared/dist/config/schema"; @@ -230,10 +230,7 @@ export const PUT = createSmartRouteHandler({ response: writeResponseSchema, handler: async (req) => { assertServerAccessAllowed(req.auth.type, req.params.level); - - if (req.params.level === "environment" && await isLocalEmulatorProject(req.auth.tenancy.project.id)) { - throw new StatusError(StatusError.BadRequest, LOCAL_EMULATOR_ENV_CONFIG_BLOCKED_MESSAGE); - } + await assertConfigOverrideWriteAllowed(req.params.level, req.auth.tenancy.project.id); const levelConfig = levelConfigs[req.params.level]; const parsedConfig = await parseAndValidateConfig(req.body.config_string, levelConfig); @@ -289,10 +286,7 @@ export const PATCH = createSmartRouteHandler({ response: writeResponseSchema, handler: async (req) => { assertServerAccessAllowed(req.auth.type, req.params.level); - - if (req.params.level === "environment" && await isLocalEmulatorProject(req.auth.tenancy.project.id)) { - throw new StatusError(StatusError.BadRequest, LOCAL_EMULATOR_ENV_CONFIG_BLOCKED_MESSAGE); - } + await assertConfigOverrideWriteAllowed(req.params.level, req.auth.tenancy.project.id); const levelConfig = levelConfigs[req.params.level]; const parsedConfig = await parseAndValidateConfig(req.body.config_override_string, levelConfig); 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 77b2afa719..b9bbff8e45 100644 --- a/apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx +++ b/apps/backend/src/app/api/latest/internal/local-emulator/project/route.tsx @@ -1,5 +1,4 @@ import { Prisma } from "@/generated/prisma/client"; -import { overrideEnvironmentConfigOverride } from "@/lib/config"; import { LOCAL_EMULATOR_ADMIN_USER_ID, LOCAL_EMULATOR_ONLY_ENDPOINT_MESSAGE, @@ -106,6 +105,11 @@ async function getOrCreateLocalEmulatorProjectId(absoluteFilePath: string): Prom ownerTeamId: LOCAL_EMULATOR_OWNER_TEAM_ID, }, }); + await globalPrismaClient.$executeRaw(Prisma.sql` + UPDATE "Project" + SET "isDevelopmentEnvironment" = TRUE + WHERE "id" = ${projectId} + `); await globalPrismaClient.tenancy.upsert({ where: { @@ -124,25 +128,6 @@ async function getOrCreateLocalEmulatorProjectId(absoluteFilePath: string): Prom }, }); - const created = existingRow === undefined; - - // Seed environment-level defaults BEFORE registering as a LocalEmulatorProject: - // once registered, setEnvironmentConfigOverride refuses to write. - // - domains.allowLocalhost: fresh emulator projects allow localhost redirects - // so developers don't hit "Redirect URL not whitelisted" before configuring - // trustedDomains. - // - payments.testMode: emulator payments always go through stripe-mock. - if (created) { - await overrideEnvironmentConfigOverride({ - projectId, - branchId: DEFAULT_BRANCH_ID, - environmentConfigOverrideOverride: { - "domains.allowLocalhost": true, - "payments.testMode": true, - }, - }); - } - await globalPrismaClient.$executeRaw(Prisma.sql` INSERT INTO "LocalEmulatorProject" ("absoluteFilePath", "projectId", "createdAt", "updatedAt") VALUES (${absoluteFilePath}, ${projectId}, NOW(), NOW()) @@ -152,7 +137,7 @@ async function getOrCreateLocalEmulatorProjectId(absoluteFilePath: string): Prom "updatedAt" = NOW() `); - return { projectId, created }; + return { projectId, created: existingRow === undefined }; } async function getOrCreateCredentials(projectId: string) { diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index 078dd9be83..d9f7f40ff5 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -11,7 +11,8 @@ import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { deindent, stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; import * as yup from "yup"; import { RawQuery, globalPrismaClient, rawQuery } from "../prisma-client"; -import { LOCAL_EMULATOR_ENV_CONFIG_BLOCKED_MESSAGE, getLocalEmulatorFilePath, isLocalEmulatorEnabled, isLocalEmulatorProject, readConfigFromFile, writeConfigToFile } from "./local-emulator"; +import { DEVELOPMENT_ENVIRONMENT_ENV_CONFIG_BLOCKED_MESSAGE, getEnvironmentConfigWriteBlockReason } from "./development-environment"; +import { getLocalEmulatorFilePath, isLocalEmulatorEnabled, isLocalEmulatorProject, readConfigFromFile, writeConfigToFile } from "./local-emulator"; import { listPermissionDefinitionsFromConfig } from "./permissions"; type BranchConfigSourceApi = yup.InferType; @@ -21,6 +22,11 @@ type BranchOptions = ProjectOptions & { branchId: string }; type EnvironmentOptions = BranchOptions; type OrganizationOptions = EnvironmentOptions & ({ organizationId: string | null } | { forUserId: string }); +const DEVELOPMENT_ENVIRONMENT_CONFIG_OVERRIDE = migrateConfigOverride("environment", { + "domains.allowLocalhost": true, + "payments.testMode": true, +}); + // --------------------------------------------------------------------------------------------------------------------- // getRendered<$$$>Config // --------------------------------------------------------------------------------------------------------------------- @@ -183,20 +189,26 @@ export function getBranchConfigOverrideQuery(options: BranchOptions): RawQuery

> { - // fetch environment config from DB (either our own, or the source of truth one) return { supportedPrismaClients: ["global"], readOnlyQuery: true, sql: Prisma.sql` - SELECT "EnvironmentConfigOverride".* - FROM "EnvironmentConfigOverride" - WHERE "EnvironmentConfigOverride"."branchId" = ${options.branchId} - AND "EnvironmentConfigOverride"."projectId" = ${options.projectId} + SELECT + "EnvironmentConfigOverride"."config", + "Project"."isDevelopmentEnvironment" + FROM "Project" + LEFT JOIN "EnvironmentConfigOverride" + ON "EnvironmentConfigOverride"."projectId" = "Project"."id" + AND "EnvironmentConfigOverride"."branchId" = ${options.branchId} + WHERE "Project"."id" = ${options.projectId} `, postProcess: async (queryResult) => { if (queryResult.length > 1) { throw new StackAssertionError(`Expected 0 or 1 environment config overrides for project ${options.projectId} and branch ${options.branchId}, got ${queryResult.length}`, { queryResult }); } + if (queryResult[0]?.isDevelopmentEnvironment === true) { + return DEVELOPMENT_ENVIRONMENT_CONFIG_OVERRIDE; + } return migrateConfigOverride("environment", queryResult[0]?.config ?? {}); }, }; @@ -379,12 +391,9 @@ export async function setEnvironmentConfigOverride(options: { branchId: string, environmentConfigOverride: EnvironmentConfigOverride, }): Promise { - if ( - isLocalEmulatorEnabled() && - getEnvVariable("STACK_SEED_MODE", "false") !== "true" && - await isLocalEmulatorProject(options.projectId) - ) { - throw new StackAssertionError(LOCAL_EMULATOR_ENV_CONFIG_BLOCKED_MESSAGE, { + const blockReason = await getEnvironmentConfigWriteBlockReason(options.projectId); + if (blockReason != null) { + throw new StackAssertionError(blockReason, { projectId: options.projectId, branchId: options.branchId, }); @@ -1073,34 +1082,24 @@ import.meta.vitest?.test('_validateConfigOverrideSchemaImpl(...)', async ({ expe `); }); -import.meta.vitest?.test('setEnvironmentConfigOverride blocks writes in local emulator mode', async ({ expect }) => { +import.meta.vitest?.test('setEnvironmentConfigOverride blocks writes for development environment projects', async ({ expect }) => { const vi = import.meta.vitest?.vi; if (!vi) { throw new StackAssertionError("Vitest context is required for in-source tests."); } - const envUtils = await import("@stackframe/stack-shared/dist/utils/env"); - const localEmulator = await import("./local-emulator"); + const developmentEnvironment = await import("./development-environment"); - const getEnvVariableSpy = vi.spyOn(envUtils, "getEnvVariable").mockImplementation((name: string, defaultValue?: string) => { - if (name === "STACK_SEED_MODE") { - return "false"; - } - return defaultValue ?? "test-value"; - }); - const isLocalEmulatorEnabledSpy = vi.spyOn(localEmulator, "isLocalEmulatorEnabled").mockReturnValue(true); - const isLocalEmulatorProjectSpy = vi.spyOn(localEmulator, "isLocalEmulatorProject").mockResolvedValue(true); + const isDevelopmentEnvironmentProjectSpy = vi.spyOn(developmentEnvironment, "isDevelopmentEnvironmentProject").mockResolvedValue(true); try { await expect(setEnvironmentConfigOverride({ projectId: "project-id", branchId: "main", environmentConfigOverride: {}, - })).rejects.toThrow(LOCAL_EMULATOR_ENV_CONFIG_BLOCKED_MESSAGE); + })).rejects.toThrow(DEVELOPMENT_ENVIRONMENT_ENV_CONFIG_BLOCKED_MESSAGE); } finally { - isLocalEmulatorProjectSpy.mockRestore(); - isLocalEmulatorEnabledSpy.mockRestore(); - getEnvVariableSpy.mockRestore(); + isDevelopmentEnvironmentProjectSpy.mockRestore(); } }); diff --git a/apps/backend/src/lib/development-environment.ts b/apps/backend/src/lib/development-environment.ts new file mode 100644 index 0000000000..3b0c3a964a --- /dev/null +++ b/apps/backend/src/lib/development-environment.ts @@ -0,0 +1,38 @@ +import { Prisma } from "@/generated/prisma/client"; +import { globalPrismaClient } from "@/prisma-client"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; + +export const DEVELOPMENT_ENVIRONMENT_ENV_CONFIG_BLOCKED_MESSAGE = + "Environment configuration overrides cannot be changed in a development environment. Update this in your production deployment instead."; + +export type ConfigOverrideWriteLevel = "project" | "branch" | "environment"; + +export async function isDevelopmentEnvironmentProject(projectId: string): Promise { + const rows = await globalPrismaClient.$queryRaw>(Prisma.sql` + SELECT "isDevelopmentEnvironment" + FROM "Project" + WHERE "id" = ${projectId} + LIMIT 1 + `); + return rows[0]?.isDevelopmentEnvironment === true; +} + +export async function getEnvironmentConfigWriteBlockReason(projectId: string): Promise { + return await isDevelopmentEnvironmentProject(projectId) + ? DEVELOPMENT_ENVIRONMENT_ENV_CONFIG_BLOCKED_MESSAGE + : null; +} + +export async function getConfigOverrideWriteBlockReason(level: ConfigOverrideWriteLevel, projectId: string): Promise { + if (level !== "environment") { + return null; + } + return await getEnvironmentConfigWriteBlockReason(projectId); +} + +export async function assertConfigOverrideWriteAllowed(level: ConfigOverrideWriteLevel, projectId: string): Promise { + const blockReason = await getConfigOverrideWriteBlockReason(level, projectId); + if (blockReason != null) { + throw new StatusError(StatusError.BadRequest, blockReason); + } +} diff --git a/apps/backend/src/lib/local-emulator.test.ts b/apps/backend/src/lib/local-emulator.test.ts index cbb3fffdfd..7bd90797af 100644 --- a/apps/backend/src/lib/local-emulator.test.ts +++ b/apps/backend/src/lib/local-emulator.test.ts @@ -53,7 +53,7 @@ describe("local emulator config", () => { 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 or \"show-onboarding\"." + "Invalid config in /irrelevant/path/stack.config.ts. The file must export a plain `config` object or \"show-onboarding\"." ); }); diff --git a/apps/backend/src/lib/local-emulator.ts b/apps/backend/src/lib/local-emulator.ts index 841045afa0..ca22ae4a29 100644 --- a/apps/backend/src/lib/local-emulator.ts +++ b/apps/backend/src/lib/local-emulator.ts @@ -1,10 +1,10 @@ import { globalPrismaClient } from "@/prisma-client"; +import { showOnboardingStackConfigValue } from "@stackframe/stack-shared/dist/config-authoring"; import { detectImportPackageFromDir, renderConfigFileContent } from "@stackframe/stack-shared/dist/config-rendering"; -import { isValidConfig } from "@stackframe/stack-shared/dist/config/format"; +import { parseStackConfigFileContent } from "@stackframe/stack-shared/dist/stack-config-file"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; 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"; @@ -12,12 +12,10 @@ export const LOCAL_EMULATOR_OWNER_TEAM_ID = "5a0c858b-d9e9-49d4-9943-8ce385d8642 export const LOCAL_EMULATOR_ADMIN_EMAIL = "local-emulator@stack-auth.com"; export const LOCAL_EMULATOR_ADMIN_PASSWORD = "LocalEmulatorPassword"; -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 const LOCAL_EMULATOR_SHOW_ONBOARDING_VALUE = "show-onboarding" as const; +export const LOCAL_EMULATOR_SHOW_ONBOARDING_VALUE = showOnboardingStackConfigValue; type LocalEmulatorConfigValue = Record | typeof LOCAL_EMULATOR_SHOW_ONBOARDING_VALUE; @@ -76,27 +74,12 @@ async function readConfigContent(filePath: string): Promise { async function readConfigValueFromFile(filePath: string): Promise { const content = await readConfigContent(filePath); - if (content.trim() === "") { - return {}; - } - - const evalFilename = /\.[cm]?tsx?$/.test(filePath) ? filePath : `${filePath}.ts`; - const jiti = createJiti(import.meta.url, { cache: false }); - let mod: Record; try { - mod = jiti.evalModule(content, { filename: evalFilename }) as Record; + return parseStackConfigFileContent(content, filePath); } catch (e) { const message = e instanceof Error ? e.message : String(e); - throw new StatusError(StatusError.BadRequest, `Error evaluating config in ${filePath}: ${message}`); + throw new StatusError(StatusError.BadRequest, message); } - const config = mod.config; - if (config === LOCAL_EMULATOR_SHOW_ONBOARDING_VALUE) { - return config; - } - if (!isValidConfig(config)) { - throw new StatusError(StatusError.BadRequest, `Invalid config in ${filePath}. The file must export a 'config' object or "show-onboarding".`); - } - return config; } export async function isLocalEmulatorOnboardingEnabledInConfig(filePath: string): Promise { diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index a29c67a7f0..795517560e 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -72,6 +72,7 @@ export function getProjectQuery(projectId: string): RawQuery 0) { + const isCreatingDevelopmentEnvironment = options.type === "create" && options.data.is_development_environment === true; + if (!isCreatingDevelopmentEnvironment && (options.type === "create" || Object.keys(configOverrideOverride).length > 0)) { await overrideEnvironmentConfigOverride({ projectId: projectId, branchId: branchId, diff --git a/apps/backend/src/route-handlers/smart-request.tsx b/apps/backend/src/route-handlers/smart-request.tsx index e25b71648f..7a80f8228b 100644 --- a/apps/backend/src/route-handlers/smart-request.tsx +++ b/apps/backend/src/route-handlers/smart-request.tsx @@ -206,8 +206,11 @@ const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextReque }; }; - const extractUserFromAdminAccessToken = async (options: { token: string, projectId: string }) => { - const result = await decodeAccessToken(options.token, { allowAnonymous: false, allowRestricted: false }); + const extractUserFromAdminAccessToken = async (options: { token: string, projectId: string, allowAnonymous: boolean }) => { + const result = await decodeAccessToken(options.token, { + allowAnonymous: options.allowAnonymous, + allowRestricted: options.allowAnonymous, + }); if (result.status === "error") { if (KnownErrors.AccessTokenExpired.isInstance(result.error)) { throw new KnownErrors.AdminAccessTokenExpired(result.error.constructorArgs[0]); @@ -219,6 +222,12 @@ const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextReque if (result.data.projectId !== "internal") { throw new KnownErrors.AdminAccessTokenIsNotAdmin(); } + if (result.data.restrictedReason != null && !result.data.isAnonymous) { + throw new KnownErrors.AdminAccessTokenIsNotAdmin(); + } + if (result.data.isAnonymous && !options.allowAnonymous) { + throw new KnownErrors.AdminAccessTokenIsNotAdmin(); + } const user = await getUser({ projectId: 'internal', branchId: DEFAULT_BRANCH_ID, userId: result.data.userId }); if (!user) { @@ -272,7 +281,11 @@ const parseAuth = withTraceSpan('smart request parseAuth', async (req: NextReque if (result.status === "error") throw new StatusError(401, "Invalid development key override"); } else if (adminAccessToken) { // TODO put this into the bundled queries above (not so important because this path is quite rare) - await extractUserFromAdminAccessToken({ token: adminAccessToken, projectId }); // assert that the admin token is valid + await extractUserFromAdminAccessToken({ + token: adminAccessToken, + projectId, + allowAnonymous: project.is_development_environment, + }); // assert that the admin token is valid } else { switch (requestType) { case "client": { diff --git a/apps/dashboard/next.config.mjs b/apps/dashboard/next.config.mjs index 7698286e34..03dda09c71 100644 --- a/apps/dashboard/next.config.mjs +++ b/apps/dashboard/next.config.mjs @@ -56,6 +56,10 @@ const nextConfig = { poweredByHeader: false, + typescript: { + ignoreBuildErrors: process.env.NEXT_CONFIG_DISABLE_TYPESCRIPT === "true", + }, + images: { remotePatterns: [ { diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 9537992295..eeae1fd5c9 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -12,6 +12,7 @@ "bundle-type-definitions": "tsx scripts/bundle-type-definitions.ts", "bundle-type-definitions:watch": "tsx watch --clear-screen=false scripts/bundle-type-definitions.ts", "build": "pnpm run bundle-type-definitions && next build", + "build:rde-standalone": "NEXT_CONFIG_OUTPUT=standalone NEXT_CONFIG_DISABLE_TYPESCRIPT=true pnpm run build", "docker-build": "pnpm run bundle-type-definitions && next build --experimental-build-mode compile", "analyze-bundle": "next experimental-analyze", "start": "next start --port ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01", diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/content.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/content.tsx index 99a454bd92..5957d3b55d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/content.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/new-project/page-client-parts/content.tsx @@ -21,9 +21,10 @@ import { Spinner, Typography, } from "@/components/ui"; +import { useDashboardInternalUser } from "@/lib/dashboard-user"; import { getPublicEnvVar } from "@/lib/env"; import { PlusCircleIcon } from "@phosphor-icons/react"; -import { AdminOwnedProject, useStackApp, useUser } from "@stackframe/stack"; +import { AdminOwnedProject, useStackApp } from "@stackframe/stack"; import { runAsynchronouslyWithAlert, wait } from "@stackframe/stack-shared/dist/utils/promises"; import { useSearchParams } from "next/navigation"; import { Suspense, useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react"; @@ -56,12 +57,14 @@ export default function PageClient() { function PageClientInner() { const app = useStackApp(); const appInternals = useMemo(() => getStackAppInternals(app), [app]); - const user = useUser({ or: "redirect", projectIdMustMatch: "internal" }); + const user = useDashboardInternalUser(); const teams = user.useTeams(); const projects = user.useOwnedProjects(); const router = useRouter(); const searchParams = useSearchParams(); const isLocalEmulator = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true"; + const isRemoteDevelopmentEnvironment = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true"; + const isDevelopmentEnvironment = isLocalEmulator || isRemoteDevelopmentEnvironment; const selectedProjectId = searchParams.get("project_id"); const displayNameFromSearch = searchParams.get("display_name"); @@ -251,13 +254,14 @@ function PageClientInner() { }); }; - if (isLocalEmulator && selectedProjectId == null) { + if (isDevelopmentEnvironment && selectedProjectId == null) { + const developmentEnvironmentName = isRemoteDevelopmentEnvironment ? "remote development environment" : "local emulator"; return (

- Project creation is disabled in local emulator mode + Project creation is disabled in development environment mode - Use the Open config file action on the Projects page to open or create projects from a local config file path. + Use the Projects page to open the project created for this {developmentEnvironmentName}.
+ {!isRemoteDevelopmentEnvironment && ( + + )}
diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page.tsx b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page.tsx index 062e303f26..dd409589c3 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/page.tsx @@ -1,51 +1,10 @@ -import { getPublicEnvVar } from "@/lib/env"; -import { stackServerApp } from "@/stack"; -import { redirect } from "next/navigation"; -import Footer from "./footer"; +import type { Metadata } from "next"; import PageClient from "./page-client"; -import PreviewProjectRedirect from "./preview-project-redirect"; -export const metadata = { +export const metadata: Metadata = { title: "Projects", }; -export default async function Page() { - const isPreview = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_PREVIEW") === "true"; - - if (isPreview) { - // In preview mode, don't use { or: "redirect" } — the client layout handles - // credential sign-up, and we can't redirect before that completes. - const user = await stackServerApp.getUser(); - if (user) { - const projects = await user.listOwnedProjects(); - if (projects.length > 0) { - redirect(`/projects/${encodeURIComponent(projects[0].id)}`); - } - } - return ; - } - - const user = await stackServerApp.getUser({ or: "redirect" }); - const projects = await user.listOwnedProjects(); - const isLocalEmulator = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true"; - if (projects.length === 0 && !isLocalEmulator) { - redirect("/new-project"); - } - - return ( - <> - {/* Dotted background */} -
- -
- - ); +export default function Page() { + return ; } diff --git a/apps/dashboard/src/app/(main)/(protected)/layout-client.tsx b/apps/dashboard/src/app/(main)/(protected)/layout-client.tsx index f86c3b50bd..2b0eef0c12 100644 --- a/apps/dashboard/src/app/(main)/(protected)/layout-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/layout-client.tsx @@ -12,11 +12,19 @@ import { useEffect } from "react"; export default function LayoutClient({ children }: { children: React.ReactNode }) { const app = useStackApp(); const isLocalEmulator = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true"; + const isRemoteDevelopmentEnvironment = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true"; const isPreview = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_PREVIEW") === "true"; - const user = useUser(); + const user = useUser( + isRemoteDevelopmentEnvironment + ? { + or: "anonymous-if-exists[deprecated]", + } + : undefined + ); useEffect(() => { const autoLogin = async () => { + if (isRemoteDevelopmentEnvironment) return; if (user) return; if (isLocalEmulator) { await app.signInWithCredential({ @@ -34,9 +42,9 @@ export default function LayoutClient({ children }: { children: React.ReactNode } } }; runAsynchronouslyWithAlert(autoLogin()); - }, [user, app, isLocalEmulator, isPreview]); + }, [user, app, isLocalEmulator, isRemoteDevelopmentEnvironment, isPreview]); - if ((isLocalEmulator || isPreview) && !user) { + if ((isLocalEmulator || isRemoteDevelopmentEnvironment || isPreview) && !user) { return ; } else { return ( diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/-selector-/[...path]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/-selector-/[...path]/page-client.tsx index efed91bf6a..fb8f4f16d5 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/-selector-/[...path]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/-selector-/[...path]/page-client.tsx @@ -15,13 +15,13 @@ import { SelectTrigger, SelectValue } from "@/components/ui"; +import { useDashboardInternalUser } from "@/lib/dashboard-user"; import { PlusIcon } from "@phosphor-icons/react"; -import { useUser } from "@stackframe/stack"; import { useEffect, useState } from "react"; export function ProjectSelectorPageClient(props: { deepPath: string }) { const router = useRouter(); - const user = useUser({ or: 'redirect', projectIdMustMatch: "internal" }); + const user = useDashboardInternalUser(); const projects = user.useOwnedProjects(); const [selectedProject, setSelectedProject] = useState(""); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx index 2b5324364c..655c11e0c0 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/(overview)/globe.tsx @@ -1,8 +1,9 @@ import { useWaitForIdle } from '@/hooks/use-wait-for-idle'; +import { useDashboardUser } from '@/lib/dashboard-user'; import { useThemeWatcher } from '@/lib/theme'; import { cn } from '@/lib/utils'; import useResizeObserver from '@react-hook/resize-observer'; -import { UserAvatar, useUser } from '@stackframe/stack'; +import { UserAvatar } from '@stackframe/stack'; import type { MetricsRecentUser } from '@stackframe/stack-shared/dist/interface/admin-metrics'; import { throwErr } from '@stackframe/stack-shared/dist/utils/errors'; import { use } from '@stackframe/stack-shared/dist/utils/react'; @@ -652,7 +653,7 @@ function GlobeSectionInner({ countryData, totalUsers, activeUsersByCountry, sate } }; - const user = useUser({ or: "redirect" }); + const user = useDashboardUser(); const displayName = user.displayName ?? user.primaryEmail; const { theme, mounted } = useThemeWatcher(); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx index 2e44c34b1d..8e9d359939 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/analytics/shared.tsx @@ -15,7 +15,7 @@ import { WarningCircleIcon } from "@phosphor-icons/react"; import { Alert, AlertDescription, Button } from "@/components/ui"; -import { useUser } from "@stackframe/stack"; +import { useDashboardInternalUser } from "@/lib/dashboard-user"; import { PLAN_LIMITS, resolvePlanId } from "@stackframe/stack-shared/dist/plans"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { useVirtualizer } from "@tanstack/react-virtual"; @@ -328,7 +328,7 @@ export function ErrorDisplay({ error, onRetry }: { error: unknown, onRetry: () = export function AnalyticsEventLimitBanner() { const adminApp = useAdminApp(); const project = adminApp.useProject(); - const user = useUser({ or: "redirect", projectIdMustMatch: "internal" }); + const user = useDashboardInternalUser(); const teams = user.useTeams(); const ownerTeam = useMemo( @@ -350,7 +350,7 @@ export function AnalyticsEventLimitBanner() { export function SessionReplayLimitBanner() { const adminApp = useAdminApp(); const project = adminApp.useProject(); - const user = useUser({ or: "redirect", projectIdMustMatch: "internal" }); + const user = useDashboardInternalUser(); const teams = user.useTeams(); const ownerTeam = useMemo( diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/[dashboardId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/[dashboardId]/page-client.tsx index dfcc3f76b8..52d74cc77d 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/[dashboardId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/dashboards/[dashboardId]/page-client.tsx @@ -16,6 +16,7 @@ import { } from "@/components/vibe-coding"; import { ToolCallContent } from "@/components/vibe-coding/chat-adapters"; import { useUpdateConfig } from "@/lib/config-update"; +import { useDashboardUser } from "@/lib/dashboard-user"; import { cn } from "@/lib/utils"; import { ChatCircleIcon, @@ -30,7 +31,6 @@ import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import type { AppId } from "@/lib/apps-frontend"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { getPublicEnvVar } from "@/lib/env"; -import { useUser } from "@stackframe/stack"; import { usePathname } from "next/navigation"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { PageLayout } from "../../page-layout"; @@ -47,7 +47,7 @@ export default function PageClient() { const adminApp = useAdminApp(); const project = adminApp.useProject(); const projectId = useProjectId(); - const currentUser = useUser({ or: "redirect" }); + const currentUser = useDashboardUser(); const backendBaseUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_STACK_API_URL is not set"); const config = project.useConfig(); const updateConfig = useUpdateConfig(); @@ -118,7 +118,7 @@ function DashboardDetailContent({ adminApp: ReturnType, updateConfig: ReturnType, router: ReturnType, - currentUser: NonNullable>, + currentUser: ReturnType, backendBaseUrl: string, enabledAppIds: AppId[], }) { diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx index 8d9eadaf7d..9e152312a4 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-drafts/[draftId]/page-client.tsx @@ -10,7 +10,7 @@ import { ActionDialog, Alert, AlertDescription, AlertTitle, Badge, Button, Input import { AssistantChat, CodeEditor, VibeCodeLayout, type ViewportMode, type WysiwygDebugInfo } from "@/components/vibe-coding"; import { ToolCallContent, applyWysiwygEdit, createChatAdapter, createHistoryAdapter } from "@/components/vibe-coding/chat-adapters"; import { EmailDraftUI } from "@/components/vibe-coding/draft-tool-components"; -import { useUser } from "@stackframe/stack"; +import { useDashboardUser } from "@/lib/dashboard-user"; import { getPublicEnvVar } from "@/lib/env"; import { PauseIcon, PlayIcon, XCircleIcon } from "@phosphor-icons/react"; import { AdminEmailOutbox, AdminEmailOutboxStatus } from "@stackframe/stack"; @@ -45,7 +45,7 @@ function isValidStage(stage: string | null): stage is DraftStage { export default function PageClient({ draftId }: { draftId: string }) { const stackAdminApp = useAdminApp(); - const currentUser = useUser({ or: "redirect" }); + const currentUser = useDashboardUser(); const backendBaseUrl = getPublicEnvVar("NEXT_PUBLIC_SERVER_STACK_API_URL") ?? getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_SERVER_STACK_API_URL is not set"); const router = useRouter(); const searchParams = useSearchParams(); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx index 66f6e3a3c0..582c328ab8 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-templates/[templateId]/page-client.tsx @@ -15,8 +15,8 @@ import { type WysiwygDebugInfo, } from "@/components/vibe-coding"; import { applyWysiwygEdit, ToolCallContent } from "@/components/vibe-coding/chat-adapters"; +import { useDashboardUser } from "@/lib/dashboard-user"; import { getPublicEnvVar } from "@/lib/env"; -import { useUser } from "@stackframe/stack"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; @@ -38,7 +38,7 @@ import { useAdminApp } from "../../use-admin-app"; export default function PageClient(props: { templateId: string }) { const stackAdminApp = useAdminApp(); - const currentUser = useUser({ or: "redirect" }); + const currentUser = useDashboardUser(); const backendBaseUrl = getPublicEnvVar("NEXT_PUBLIC_SERVER_STACK_API_URL") ?? getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_SERVER_STACK_API_URL is not set"); const templates = stackAdminApp.useEmailTemplates(); const { setNeedConfirm } = useRouterConfirm(); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/[themeId]/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/[themeId]/page-client.tsx index bb76ddbb49..1f9f073fdc 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/[themeId]/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/email-themes/[themeId]/page-client.tsx @@ -10,11 +10,11 @@ import { createHistoryAdapter, ToolCallContent } from "@/components/vibe-coding/chat-adapters"; +import { useDashboardUser } from "@/lib/dashboard-user"; import { getPublicEnvVar } from "@/lib/env"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { previewTemplateSource } from "@stackframe/stack-shared/dist/helpers/emails"; import { KnownErrors } from "@stackframe/stack-shared/dist/known-errors"; -import { useUser } from "@stackframe/stack"; import { useCallback, useEffect, useState } from "react"; const BUILDER_STATUS_MESSAGES = [ @@ -32,7 +32,7 @@ import { useAdminApp } from "../../use-admin-app"; export default function PageClient({ themeId }: { themeId: string }) { const stackAdminApp = useAdminApp(); - const currentUser = useUser({ or: "redirect" }); + const currentUser = useDashboardUser(); const backendBaseUrl = getPublicEnvVar("NEXT_PUBLIC_SERVER_STACK_API_URL") ?? getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_SERVER_STACK_API_URL is not set"); const theme = stackAdminApp.useEmailTheme(themeId); const { setNeedConfirm } = useRouterConfirm(); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx index 846456e82d..d4283482cc 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/emails/page-client.tsx @@ -89,7 +89,7 @@ export default function PageClient() { {isLocalEmulator && } {/* Email Server Card */} - + {/* Email Log Card */} @@ -137,8 +137,7 @@ function EmulatorModeCard() { ); } -function EmailServerCard({ emailConfig }: { emailConfig: CompleteConfig['emails']['server'] }) { - const isLocalEmulator = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true"; +function EmailServerCard({ emailConfig, isDevelopmentEnvironment }: { emailConfig: CompleteConfig['emails']['server'], isDevelopmentEnvironment: boolean }) { const serverType = emailConfig.isShared ? 'Shared' : emailConfig.provider === 'managed' @@ -158,13 +157,13 @@ function EmailServerCard({ emailConfig }: { emailConfig: CompleteConfig['emails'
- {isLocalEmulator - ? "Email server settings are read-only in the local emulator" + {isDevelopmentEnvironment + ? "Email server settings are read-only in development environments" : "Configure the email server and sender address for outgoing emails"}
- {!emailConfig.isShared && !isLocalEmulator && ( + {!emailConfig.isShared && !isDevelopmentEnvironment && ( @@ -174,7 +173,7 @@ function EmailServerCard({ emailConfig }: { emailConfig: CompleteConfig['emails' } /> )} - {!isLocalEmulator ? ( + {!isDevelopmentEnvironment ? ( <>
- {isLocalEmulator && ( + {isDevelopmentEnvironment && ( - Email server settings cannot be changed in the local emulator. Update these settings in your production deployment. + Email server settings cannot be changed in development environments. Update these settings in your production deployment. )} diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/layout.tsx index da39540969..933e54656f 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/payments/layout.tsx @@ -61,9 +61,7 @@ function PaymentsLayoutInner({ children }: { children: React.ReactNode }) { }); }; - const isLocalEmulator = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true"; - - if (!stripeAccountInfo && !isLocalEmulator) { + if (!stripeAccountInfo && !project.isDevelopmentEnvironment) { return (
@@ -238,7 +236,7 @@ function PaymentsLayoutInner({ children }: { children: React.ReactNode }) {
)} - {getPublicEnvVar("NEXT_PUBLIC_STACK_IS_PREVIEW") !== "true" && getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") !== "true" && ( + {getPublicEnvVar("NEXT_PUBLIC_STACK_IS_PREVIEW") !== "true" && !project.isDevelopmentEnvironment && (
- {isPreview || isLocalEmulator ? ( + {isPreview || project.isDevelopmentEnvironment ? ( - Payouts are unavailable in {isLocalEmulator ? "the local emulator" : "preview mode"}. + Payouts are unavailable in {project.isDevelopmentEnvironment ? "development environments" : "preview mode"}. ) : ( diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/page-client.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/page-client.tsx index 9bba7ea6e7..394cbb7f41 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/page-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/project-settings/page-client.tsx @@ -11,9 +11,10 @@ import { type DesignEditableGridItem, } from "@/components/design-components"; import { ActionDialog, Avatar, AvatarFallback, AvatarImage, SimpleTooltip, Switch, useToast } from "@/components/ui"; +import { useDashboardInternalUser } from "@/lib/dashboard-user"; import { getPublicEnvVar } from "@/lib/env"; import type { PushedConfigSource } from "@stackframe/stack"; -import { TeamSwitcher, useUser } from "@stackframe/stack"; +import { TeamSwitcher } from "@stackframe/stack"; import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; import { ArrowsLeftRightIcon, BuildingsIcon, GearIcon, GlobeHemisphereWestIcon, ImageIcon, WarningIcon } from "@phosphor-icons/react"; @@ -55,7 +56,7 @@ export default function PageClient() { const stackAdminApp = useAdminApp(); const project = stackAdminApp.useProject(); const productionModeErrors = project.useProductionModeErrors(); - const user = useUser({ or: 'redirect', projectIdMustMatch: "internal" }); + const user = useDashboardInternalUser(); const teams = user.useTeams(); const [selectedTeamId, setSelectedTeamId] = useState(null); const [isTransferring, setIsTransferring] = useState(false); diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx index 5469e3f4ff..e78b89c855 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/use-admin-app.tsx @@ -1,6 +1,7 @@ "use client"; -import { StackAdminApp, useUser } from "@stackframe/stack"; +import { useDashboardInternalUser } from "@/lib/dashboard-user"; +import { StackAdminApp } from "@stackframe/stack"; import { StackAssertionError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { notFound, usePathname } from "next/navigation"; import React from "react"; @@ -27,7 +28,7 @@ export function useAdminAppIfExists() { } export function useAdminApp(projectId?: string) { - const user = useUser({ or: "redirect", projectIdMustMatch: "internal" }); + const user = useDashboardInternalUser(); const projects = user.useOwnedProjects(); const providedApp = useAdminAppIfExists(); diff --git a/apps/dashboard/src/app/(main)/handler/[...stack]/page.tsx b/apps/dashboard/src/app/(main)/handler/[...stack]/page.tsx index 14c3244e15..69d5acbe82 100644 --- a/apps/dashboard/src/app/(main)/handler/[...stack]/page.tsx +++ b/apps/dashboard/src/app/(main)/handler/[...stack]/page.tsx @@ -1,5 +1,4 @@ import { StyledLink } from "@/components/link"; -import { stackServerApp } from "@/stack"; import { StackHandler } from "@stackframe/stack"; export default function Handler(props: unknown) { @@ -18,8 +17,6 @@ export default function Handler(props: unknown) {
diff --git a/apps/dashboard/src/app/(main)/integrations/featurebase/sso/page.tsx b/apps/dashboard/src/app/(main)/integrations/featurebase/sso/page.tsx index c2ada64266..84463e98cb 100644 --- a/apps/dashboard/src/app/(main)/integrations/featurebase/sso/page.tsx +++ b/apps/dashboard/src/app/(main)/integrations/featurebase/sso/page.tsx @@ -1,4 +1,4 @@ -import { stackServerApp } from "@/stack"; +import { stackServerApp } from "@/stack/server"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { getOrCreateFeaturebaseUser } from "@stackframe/stack-shared/dist/utils/featurebase"; import { urlString } from "@stackframe/stack-shared/dist/utils/urls"; diff --git a/apps/dashboard/src/app/(main)/integrations/oauth-confirm-card.tsx b/apps/dashboard/src/app/(main)/integrations/oauth-confirm-card.tsx index e20f0d9f8e..bdaf7632b9 100644 --- a/apps/dashboard/src/app/(main)/integrations/oauth-confirm-card.tsx +++ b/apps/dashboard/src/app/(main)/integrations/oauth-confirm-card.tsx @@ -1,7 +1,8 @@ "use client"; import { Logo } from "@/components/logo"; -import { AdminProject, useUser } from "@stackframe/stack"; +import { useDashboardInternalUser } from "@/lib/dashboard-user"; +import { AdminProject } from "@stackframe/stack"; import { Button, Card, CardContent, CardFooter, CardHeader, Input, Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue, Typography } from "@/components/ui"; import Image from "next/image"; import { useSearchParams } from "next/navigation"; @@ -12,7 +13,7 @@ export default function ConfirmCard(props: { onContinue: (options: { projectId: string, projectName?: string }) => Promise<{ error: string } | undefined>, type: "neon" | "custom", }) { - const user = useUser({ or: "redirect", projectIdMustMatch: "internal" }); + const user = useDashboardInternalUser(); const projects = user.useOwnedProjects(); const searchParams = useSearchParams(); diff --git a/apps/dashboard/src/app/(main)/integrations/oauth-confirm-page.tsx b/apps/dashboard/src/app/(main)/integrations/oauth-confirm-page.tsx index e8cd448e9c..dc53deee6f 100644 --- a/apps/dashboard/src/app/(main)/integrations/oauth-confirm-page.tsx +++ b/apps/dashboard/src/app/(main)/integrations/oauth-confirm-page.tsx @@ -1,4 +1,4 @@ -import { stackServerApp } from "@/stack"; +import { stackServerApp } from "@/stack/server"; import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env"; import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; import { redirect } from "next/navigation"; diff --git a/apps/dashboard/src/app/api/development-environment/health/route.ts b/apps/dashboard/src/app/api/development-environment/health/route.ts new file mode 100644 index 0000000000..16445a16bc --- /dev/null +++ b/apps/dashboard/src/app/api/development-environment/health/route.ts @@ -0,0 +1,83 @@ +import { getPublicEnvVar } from "@/lib/env"; +import { NextRequest, NextResponse } from "next/server"; +import { isLocalhost } from "@stackframe/stack-shared/dist/utils/urls"; + +export const runtime = "nodejs"; + +type HealthResponse = { + ok: boolean, + restart_command: string, +}; + +function requestHostIsLoopback(req: NextRequest): boolean { + const host = req.headers.get("host"); + if (host == null) return false; + return isLocalhost(`http://${host}`); +} + +function originIsAllowed(req: NextRequest): boolean { + const origin = req.headers.get("origin"); + if (origin == null) return true; + return isLocalhost(origin); +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function devRestartCommand(configFilePath: string | undefined): string { + if (configFilePath == null) { + return "stack dev --config-file -- "; + } + return `stack dev --config-file ${shellQuote(configFilePath)} -- `; +} + +function healthResponse(body: HealthResponse, status: number): NextResponse { + return NextResponse.json(body, { status }); +} + +async function localEmulatorIsHealthy(): Promise { + const apiBaseUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL"); + if (apiBaseUrl == null) return false; + + try { + const response = await fetch(`${apiBaseUrl}/api/v1/projects/current`, { + cache: "no-store", + headers: { + "X-Stack-Access-Type": "client", + "X-Stack-Project-Id": "internal", + "X-Stack-Publishable-Client-Key": getPublicEnvVar("NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY") ?? "", + }, + }); + return response.ok; + } catch { + return false; + } +} + +export async function GET(req: NextRequest) { + if (!requestHostIsLoopback(req) || !originIsAllowed(req)) { + return NextResponse.json({ error: "Development environment health checks only accept loopback requests." }, { status: 403 }); + } + + const isRemoteDevelopmentEnvironment = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true"; + if (isRemoteDevelopmentEnvironment) { + const { getRemoteDevelopmentEnvironmentHealth } = await import("@/lib/remote-development-environment/manager"); + const health = getRemoteDevelopmentEnvironmentHealth(); + return healthResponse({ + ok: health.healthy, + restart_command: devRestartCommand(health.configFilePath), + }, health.healthy ? 200 : 503); + } + + const isLocalEmulator = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true"; + if (isLocalEmulator) { + const healthy = await localEmulatorIsHealthy(); + return healthResponse({ + ok: healthy, + restart_command: devRestartCommand(undefined), + }, healthy ? 200 : 503); + } + + return NextResponse.json({ error: "Development environment health checks are disabled." }, { status: 404 }); +} diff --git a/apps/dashboard/src/app/api/remote-development-environment/auth/route.ts b/apps/dashboard/src/app/api/remote-development-environment/auth/route.ts new file mode 100644 index 0000000000..343a414c00 --- /dev/null +++ b/apps/dashboard/src/app/api/remote-development-environment/auth/route.ts @@ -0,0 +1,70 @@ +import { NextRequest, NextResponse } from "next/server"; +import { isRemoteDevelopmentEnvironmentEnabled } from "@/lib/remote-development-environment/env"; +import { isLocalhost } from "@stackframe/stack-shared/dist/utils/urls"; + +export const runtime = "nodejs"; + +const INTERNAL_PROJECT_ID = "internal"; + +function requestHostIsLoopback(req: NextRequest): boolean { + const host = req.headers.get("host"); + if (host == null) return false; + return isLocalhost(`http://${host}`); +} + +function originIsAllowed(req: NextRequest): boolean { + const origin = req.headers.get("origin"); + if (origin == null) return true; + return isLocalhost(origin); +} + +function assertRemoteDevelopmentEnvironmentBrowserRequest(req: NextRequest): NextResponse | null { + if (!isRemoteDevelopmentEnvironmentEnabled()) { + return NextResponse.json({ error: "Remote development environment endpoints are disabled." }, { status: 404 }); + } + + if (!requestHostIsLoopback(req) || !originIsAllowed(req)) { + return NextResponse.json({ error: "Remote development environment endpoints only accept loopback requests." }, { status: 403 }); + } + + const fetchSite = req.headers.get("sec-fetch-site"); + if (fetchSite != null && fetchSite !== "same-origin" && fetchSite !== "none") { + return NextResponse.json({ error: "Remote development environment browser auth only accepts same-origin navigation." }, { status: 403 }); + } + + return null; +} + +function isInternalProjectRefreshCookieName(name: string): boolean { + return ( + name === "stack-refresh" || + name === `stack-refresh-${INTERNAL_PROJECT_ID}` || + name.startsWith(`stack-refresh-${INTERNAL_PROJECT_ID}--`) || + name.startsWith(`__Host-stack-refresh-${INTERNAL_PROJECT_ID}--`) + ); +} + +function deleteInternalProjectAuthCookies(req: NextRequest, response: NextResponse): void { + response.cookies.delete("stack-access"); + for (const cookie of req.cookies.getAll()) { + if (isInternalProjectRefreshCookieName(cookie.name)) { + response.cookies.delete(cookie.name); + } + } +} + +export async function GET(req: NextRequest) { + const securityResponse = assertRemoteDevelopmentEnvironmentBrowserRequest(req); + if (securityResponse != null) return securityResponse; + + const { getRemoteDevelopmentEnvironmentAccessToken } = await import("@/lib/remote-development-environment/manager"); + const token = await getRemoteDevelopmentEnvironmentAccessToken(); + const response = NextResponse.json({ + access_token: token.accessToken, + expires_at_millis: token.expiresAtMillis, + issued_at_millis: token.issuedAtMillis, + user_id: token.userId, + }); + deleteInternalProjectAuthCookies(req, response); + return response; +} diff --git a/apps/dashboard/src/app/api/remote-development-environment/config/apply-update/route.ts b/apps/dashboard/src/app/api/remote-development-environment/config/apply-update/route.ts new file mode 100644 index 0000000000..f8dd985ff9 --- /dev/null +++ b/apps/dashboard/src/app/api/remote-development-environment/config/apply-update/route.ts @@ -0,0 +1,28 @@ +import { NextRequest, NextResponse } from "next/server"; +import { applyRemoteDevelopmentEnvironmentConfigUpdate } from "@/lib/remote-development-environment/manager"; +import { assertRemoteDevelopmentEnvironmentRequest } from "@/lib/remote-development-environment/security"; +import { isValidConfig } from "@stackframe/stack-shared/dist/config/format"; + +export const runtime = "nodejs"; + +export async function POST(req: NextRequest) { + const securityResponse = assertRemoteDevelopmentEnvironmentRequest(req); + if (securityResponse != null) return securityResponse; + + const body = await req.json() as { + session_id?: unknown, + config?: unknown, + }; + if (typeof body.session_id !== "string" || body.config == null || typeof body.config !== "object" || Array.isArray(body.config)) { + return NextResponse.json({ error: "session_id and config object are required." }, { status: 400 }); + } + if (!isValidConfig(body.config)) { + return NextResponse.json({ error: "config must be a valid Stack Auth config object." }, { status: 400 }); + } + + await applyRemoteDevelopmentEnvironmentConfigUpdate({ + sessionId: body.session_id, + config: body.config, + }); + return NextResponse.json({ ok: true }); +} diff --git a/apps/dashboard/src/app/api/remote-development-environment/sessions/[sessionId]/heartbeat/route.ts b/apps/dashboard/src/app/api/remote-development-environment/sessions/[sessionId]/heartbeat/route.ts new file mode 100644 index 0000000000..94dfc7b79f --- /dev/null +++ b/apps/dashboard/src/app/api/remote-development-environment/sessions/[sessionId]/heartbeat/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from "next/server"; +import { heartbeatRemoteDevelopmentEnvironmentSession } from "@/lib/remote-development-environment/manager"; +import { assertRemoteDevelopmentEnvironmentRequest } from "@/lib/remote-development-environment/security"; + +export const runtime = "nodejs"; + +export async function POST(req: NextRequest, { params }: { params: Promise<{ sessionId: string }> }) { + const securityResponse = assertRemoteDevelopmentEnvironmentRequest(req); + if (securityResponse != null) return securityResponse; + + const { sessionId } = await params; + if (!heartbeatRemoteDevelopmentEnvironmentSession(sessionId)) { + return NextResponse.json({ error: "Unknown remote development environment session." }, { status: 404 }); + } + return NextResponse.json({ ok: true }); +} diff --git a/apps/dashboard/src/app/api/remote-development-environment/sessions/[sessionId]/route.ts b/apps/dashboard/src/app/api/remote-development-environment/sessions/[sessionId]/route.ts new file mode 100644 index 0000000000..6f001e7065 --- /dev/null +++ b/apps/dashboard/src/app/api/remote-development-environment/sessions/[sessionId]/route.ts @@ -0,0 +1,14 @@ +import { NextRequest, NextResponse } from "next/server"; +import { closeRemoteDevelopmentEnvironmentSession } from "@/lib/remote-development-environment/manager"; +import { assertRemoteDevelopmentEnvironmentRequest } from "@/lib/remote-development-environment/security"; + +export const runtime = "nodejs"; + +export async function DELETE(req: NextRequest, { params }: { params: Promise<{ sessionId: string }> }) { + const securityResponse = assertRemoteDevelopmentEnvironmentRequest(req); + if (securityResponse != null) return securityResponse; + + const { sessionId } = await params; + closeRemoteDevelopmentEnvironmentSession(sessionId); + return NextResponse.json({ ok: true }); +} diff --git a/apps/dashboard/src/app/api/remote-development-environment/sessions/route.ts b/apps/dashboard/src/app/api/remote-development-environment/sessions/route.ts new file mode 100644 index 0000000000..38342c5d1d --- /dev/null +++ b/apps/dashboard/src/app/api/remote-development-environment/sessions/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from "next/server"; +import { registerRemoteDevelopmentEnvironmentSession } from "@/lib/remote-development-environment/manager"; +import { assertRemoteDevelopmentEnvironmentRequest } from "@/lib/remote-development-environment/security"; +import { createUrlIfValid, isLocalhost } from "@stackframe/stack-shared/dist/utils/urls"; + +export const runtime = "nodejs"; + +function isAllowedApiBaseUrl(value: string): boolean { + const url = createUrlIfValid(value); + if (url == null || (url.protocol !== "http:" && url.protocol !== "https:")) return false; + return isLocalhost(url) || url.hostname === "api.stack-auth.com" || url.hostname.endsWith(".stack-auth.com"); +} + +export async function POST(req: NextRequest) { + const securityResponse = assertRemoteDevelopmentEnvironmentRequest(req); + if (securityResponse != null) return securityResponse; + + const body = await req.json() as { + api_base_url?: unknown, + config_path?: unknown, + }; + if (typeof body.api_base_url !== "string" || typeof body.config_path !== "string") { + return NextResponse.json({ error: "api_base_url and config_path are required." }, { status: 400 }); + } + if (!isAllowedApiBaseUrl(body.api_base_url)) { + return NextResponse.json({ error: "api_base_url is not allowed for remote development environments." }, { status: 400 }); + } + + const result = await registerRemoteDevelopmentEnvironmentSession({ + apiBaseUrl: body.api_base_url, + configPath: body.config_path, + }); + return NextResponse.json({ + session_id: result.sessionId, + env: result.env, + project_id: result.projectId, + onboarding_outstanding: result.onboardingOutstanding, + }); +} diff --git a/apps/dashboard/src/app/layout-client.tsx b/apps/dashboard/src/app/layout-client.tsx new file mode 100644 index 0000000000..7725094752 --- /dev/null +++ b/apps/dashboard/src/app/layout-client.tsx @@ -0,0 +1,179 @@ +"use client"; + +import { DevErrorNotifier } from "@/components/dev-error-notifier"; +import { RouterProvider } from "@/components/router"; +import { SiteLoadingIndicatorDisplay } from "@/components/site-loading-indicator"; +import { Toaster } from "@/components/ui"; +import { VersionAlerter } from "@/components/version-alerter"; +import { getPublicEnvVar } from "@/lib/env"; +import { stackClientApp } from "@/stack/client"; +import { StackProvider, StackTheme } from "@stackframe/stack"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import React, { useSyncExternalStore } from "react"; +import { BackgroundShine } from "./background-shine"; +import { ClientPolyfill } from "./client-polyfill"; +import { DevelopmentPortDisplay } from "./development-port-display"; +import Loading from "./loading"; +import { UserIdentity } from "./providers"; +import { RemoteDevelopmentEnvironmentAuthGate } from "./remote-development-environment-auth-gate"; + +const DEV_ENVIRONMENT_HEALTHCHECK_INTERVAL_MS = 2_000; + +type DevEnvironmentHealthSnapshot = + | { status: "checking" | "healthy" } + | { status: "unhealthy", restartCommand: string }; + +function isDevEnvironmentHealthResponse(value: unknown): value is { ok: boolean, restart_command: string } { + return ( + value != null && + typeof value === "object" && + "ok" in value && + typeof value.ok === "boolean" && + "restart_command" in value && + typeof value.restart_command === "string" + ); +} + +let devEnvironmentHealthSnapshot: DevEnvironmentHealthSnapshot = { status: "checking" }; +const devEnvironmentHealthSubscribers = new Set<() => void>(); +let devEnvironmentHealthTimer: ReturnType | undefined; + +function setDevEnvironmentHealthSnapshot(snapshot: DevEnvironmentHealthSnapshot) { + devEnvironmentHealthSnapshot = snapshot; + for (const subscriber of devEnvironmentHealthSubscribers) { + subscriber(); + } +} + +async function refreshDevEnvironmentHealth() { + try { + const response = await fetch("/api/development-environment/health", { + cache: "no-store", + headers: { + Accept: "application/json", + }, + }); + const body: unknown = await response.json(); + if (!isDevEnvironmentHealthResponse(body)) { + throw new Error("Development environment health endpoint returned an invalid response."); + } + + setDevEnvironmentHealthSnapshot(body.ok && response.ok + ? { status: "healthy" } + : { status: "unhealthy", restartCommand: body.restart_command }); + } catch { + setDevEnvironmentHealthSnapshot({ + status: "unhealthy", + restartCommand: "stack dev --config-file -- ", + }); + } +} + +function subscribeDevEnvironmentHealth(callback: () => void) { + devEnvironmentHealthSubscribers.add(callback); + if (devEnvironmentHealthSubscribers.size === 1) { + setDevEnvironmentHealthSnapshot({ status: "checking" }); + runAsynchronouslyWithAlert(refreshDevEnvironmentHealth()); + devEnvironmentHealthTimer = setInterval(() => { + runAsynchronouslyWithAlert(refreshDevEnvironmentHealth()); + }, DEV_ENVIRONMENT_HEALTHCHECK_INTERVAL_MS); + } + + return () => { + devEnvironmentHealthSubscribers.delete(callback); + if (devEnvironmentHealthSubscribers.size === 0 && devEnvironmentHealthTimer !== undefined) { + clearInterval(devEnvironmentHealthTimer); + devEnvironmentHealthTimer = undefined; + } + }; +} + +function getDevEnvironmentHealthSnapshot() { + return devEnvironmentHealthSnapshot; +} + +function getServerDevEnvironmentHealthSnapshot(): DevEnvironmentHealthSnapshot { + return { status: "checking" }; +} + +function subscribeHealthyDevEnvironment(_callback: () => void) { + return () => {}; +} + +function getHealthyDevEnvironmentSnapshot(): DevEnvironmentHealthSnapshot { + return { status: "healthy" }; +} + +function DevEnvironmentStoppedScreen(props: { restartCommand: string }) { + return ( +
+
+
+ Development environment paused +
+

The dev environment is not currently running

+

+ Your Stack Auth changes have been saved. The local Stack Auth development environment just is not active right now, so the dashboard has paused instead of showing stale project data. +

+

+ Restart it from your terminal with: +

+
{props.restartCommand}
+
+
+ ); +} + +function DevEnvironmentHealthGate(props: { children: React.ReactNode }) { + const isLocalEmulator = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true"; + const isRemoteDevelopmentEnvironment = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true"; + const shouldCheckHealth = isLocalEmulator || isRemoteDevelopmentEnvironment; + const health = useSyncExternalStore( + shouldCheckHealth ? subscribeDevEnvironmentHealth : subscribeHealthyDevEnvironment, + shouldCheckHealth ? getDevEnvironmentHealthSnapshot : getHealthyDevEnvironmentSnapshot, + shouldCheckHealth ? getServerDevEnvironmentHealthSnapshot : getHealthyDevEnvironmentSnapshot, + ); + + if (!shouldCheckHealth) { + return props.children; + } + + if (health.status === "unhealthy") { + return ; + } + + if (health.status === "checking") { + return ; + } + + return props.children; +} + +export function LayoutClient(props: { + children: React.ReactNode, + translationLocale?: string, +}) { + return ( + <> + ["lang"]}> + + + + + + + + + {props.children} + + + + + + + + + + + ); +} diff --git a/apps/dashboard/src/app/layout.tsx b/apps/dashboard/src/app/layout.tsx index 9ed724a4fc..81a4e53c74 100644 --- a/apps/dashboard/src/app/layout.tsx +++ b/apps/dashboard/src/app/layout.tsx @@ -1,11 +1,6 @@ -import { DevErrorNotifier } from '@/components/dev-error-notifier'; -import { RouterProvider } from '@/components/router'; -import { SiteLoadingIndicatorDisplay } from '@/components/site-loading-indicator'; import { StyleLink } from '@/components/style-link'; -import { Toaster, cn } from '@/components/ui'; +import { cn } from '@/components/ui'; import { getPublicEnvVar } from '@/lib/env'; -import { stackServerApp } from '@/stack'; -import { StackProvider, StackTheme } from '@stackframe/stack'; import { getEnvVariable, getNodeEnvironment } from '@stackframe/stack-shared/dist/utils/env'; import { Analytics } from "@vercel/analytics/react"; import { SpeedInsights } from "@vercel/speed-insights/next"; @@ -14,14 +9,9 @@ import { GeistSans } from 'geist/font/sans'; import type { Metadata } from 'next'; import { Inter as FontSans } from "next/font/google"; import React from 'react'; -// import { VersionAlerter } from '../components/version-alerter'; -import { VersionAlerter } from '@/components/version-alerter'; import '../polyfills'; -import { BackgroundShine } from './background-shine'; -import { ClientPolyfill } from './client-polyfill'; -import { DevelopmentPortDisplay } from './development-port-display'; import './globals.css'; -import { UserIdentity } from './providers'; +import { LayoutClient } from './layout-client'; export const metadata: Metadata = { metadataBase: new URL(getPublicEnvVar('NEXT_PUBLIC_STACK_API_URL') || ''), @@ -103,21 +93,9 @@ export default function RootLayout({ > - - - - - - - - {children} - - - - - - - + + {children} + ); diff --git a/apps/dashboard/src/app/remote-development-environment-auth-gate.tsx b/apps/dashboard/src/app/remote-development-environment-auth-gate.tsx new file mode 100644 index 0000000000..2caa56a5a2 --- /dev/null +++ b/apps/dashboard/src/app/remote-development-environment-auth-gate.tsx @@ -0,0 +1,191 @@ +"use client"; + +import Loading from "@/app/loading"; +import { getPublicEnvVar } from "@/lib/env"; +import { stackAppInternalsSymbol } from "@/lib/stack-app-internals"; +import { useStackApp } from "@stackframe/stack"; +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { useEffect, useState } from "react"; + +const RDE_ACCESS_TOKEN_MIN_EXPIRATION_MS = 30_000; +const RDE_ACCESS_TOKEN_MAX_AGE_MS = 60_000; +const RDE_ACCESS_TOKEN_MIN_REFRESH_MS = 1_000; + +type StackAppTokenInternals = { + signInWithTokens: (tokens: { accessToken: string, refreshToken: string }) => Promise, +}; + +type RemoteDevelopmentEnvironmentAccessTokenResponse = { + accessToken: string, + expiresAtMillis: number, + issuedAtMillis: number, + userId: string, +}; + +function isStackAppTokenInternals(value: unknown): value is StackAppTokenInternals { + return ( + value != null && + typeof value === "object" && + "signInWithTokens" in value && + typeof value.signInWithTokens === "function" + ); +} + +function getStackAppTokenInternals(appValue: unknown): StackAppTokenInternals { + if (appValue == null || typeof appValue !== "object") { + throw new Error("The Stack app instance is unavailable."); + } + + const internals = Reflect.get(appValue, stackAppInternalsSymbol); + if (!isStackAppTokenInternals(internals)) { + throw new Error("The Stack client app cannot install remote development environment tokens."); + } + + return internals; +} + +function parseRemoteDevelopmentEnvironmentAccessTokenResponse(value: unknown): RemoteDevelopmentEnvironmentAccessTokenResponse { + if ( + value == null || + typeof value !== "object" || + !("access_token" in value) || + typeof value.access_token !== "string" || + !("expires_at_millis" in value) || + typeof value.expires_at_millis !== "number" || + !("issued_at_millis" in value) || + typeof value.issued_at_millis !== "number" || + !("user_id" in value) || + typeof value.user_id !== "string" + ) { + throw new Error("Remote development environment auth endpoint returned an invalid response."); + } + + return { + accessToken: value.access_token, + expiresAtMillis: value.expires_at_millis, + issuedAtMillis: value.issued_at_millis, + userId: value.user_id, + }; +} + +function getRefreshInMillis(token: RemoteDevelopmentEnvironmentAccessTokenResponse): number { + const now = Date.now(); + const refreshBeforeExpirationInMillis = token.expiresAtMillis - RDE_ACCESS_TOKEN_MIN_EXPIRATION_MS - now; + const refreshBeforeMaxAgeInMillis = token.issuedAtMillis + RDE_ACCESS_TOKEN_MAX_AGE_MS - now; + return Math.max( + RDE_ACCESS_TOKEN_MIN_REFRESH_MS, + Math.min(refreshBeforeExpirationInMillis, refreshBeforeMaxAgeInMillis), + ); +} + +function shouldRefreshAccessToken(token: RemoteDevelopmentEnvironmentAccessTokenResponse | undefined): boolean { + if (token === undefined) return true; + const now = Date.now(); + return ( + token.expiresAtMillis - now < RDE_ACCESS_TOKEN_MIN_EXPIRATION_MS || + now - token.issuedAtMillis > RDE_ACCESS_TOKEN_MAX_AGE_MS + ); +} + +async function getRemoteDevelopmentEnvironmentAccessToken(): Promise { + const response = await fetch("/api/remote-development-environment/auth", { + headers: { + Accept: "application/json", + }, + }); + if (!response.ok) { + throw new Error(`Failed to authenticate local remote development environment dashboard (${response.status}): ${await response.text()}`); + } + + return parseRemoteDevelopmentEnvironmentAccessTokenResponse(await response.json()); +} + +async function installRemoteDevelopmentEnvironmentAccessToken(app: unknown): Promise { + const token = await getRemoteDevelopmentEnvironmentAccessToken(); + await getStackAppTokenInternals(app).signInWithTokens({ + accessToken: token.accessToken, + refreshToken: "", + }); + return token; +} + +function RemoteDevelopmentEnvironmentAuthGateInner(props: { children: React.ReactNode }) { + const app = useStackApp(); + const [accessTokenInstalled, setAccessTokenInstalled] = useState(false); + + useEffect(() => { + let cancelled = false; + let refreshTimeout: ReturnType | undefined; + let refreshPromise: Promise | undefined; + let currentToken: RemoteDevelopmentEnvironmentAccessTokenResponse | undefined; + + const refreshAccessToken = async (): Promise => { + const token = await installRemoteDevelopmentEnvironmentAccessToken(app); + const currentUser = await app.getUser({ + or: "anonymous-if-exists[deprecated]", + }); + if (currentUser?.id !== token.userId) { + throw new Error("Installed remote development environment token did not match the expected anonymous user."); + } + if (cancelled) return; + currentToken = token; + setAccessTokenInstalled(true); + + refreshTimeout = setTimeout(() => { + refreshPromise = undefined; + requestRefresh(); + }, getRefreshInMillis(token)); + }; + + const requestRefresh = (options?: { force?: boolean }) => { + if (options?.force !== true && !shouldRefreshAccessToken(currentToken)) { + return; + } + if (refreshTimeout !== undefined) { + clearTimeout(refreshTimeout); + refreshTimeout = undefined; + } + refreshPromise ??= refreshAccessToken().finally(() => { + refreshPromise = undefined; + }); + runAsynchronouslyWithAlert(refreshPromise); + }; + + const refreshOnWake = () => { + if (document.visibilityState === "hidden") return; + requestRefresh(); + }; + + requestRefresh({ force: true }); + window.addEventListener("focus", refreshOnWake); + document.addEventListener("visibilitychange", refreshOnWake); + + return () => { + cancelled = true; + window.removeEventListener("focus", refreshOnWake); + document.removeEventListener("visibilitychange", refreshOnWake); + if (refreshTimeout !== undefined) { + clearTimeout(refreshTimeout); + } + }; + }, [app]); + + if (!accessTokenInstalled) { + return ; + } + + return props.children; +} + +export function RemoteDevelopmentEnvironmentAuthGate(props: { children: React.ReactNode }) { + const isRemoteDevelopmentEnvironment = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true"; + if (!isRemoteDevelopmentEnvironment) { + return props.children; + } + + return ( + + {props.children} + + ); +} diff --git a/apps/dashboard/src/components/commands/create-dashboard/create-dashboard-preview.tsx b/apps/dashboard/src/components/commands/create-dashboard/create-dashboard-preview.tsx index aa75b67d12..f7c084584a 100644 --- a/apps/dashboard/src/components/commands/create-dashboard/create-dashboard-preview.tsx +++ b/apps/dashboard/src/components/commands/create-dashboard/create-dashboard-preview.tsx @@ -8,10 +8,10 @@ import { buildDashboardMessages } from "@/lib/ai-dashboard/shared-prompt"; import type { AppId } from "@/lib/apps-frontend"; import { buildStackAuthHeaders } from "@/lib/api-headers"; import { useUpdateConfig } from "@/lib/config-update"; +import { useDashboardUser } from "@/lib/dashboard-user"; import { getPublicEnvVar } from "@/lib/env"; import { cn } from "@/lib/utils"; import { FloppyDiskIcon } from "@phosphor-icons/react"; -import { useUser } from "@stackframe/stack"; import { ALL_APPS } from "@stackframe/stack-shared/dist/apps/apps-config"; import { captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors"; import { typedEntries } from "@stackframe/stack-shared/dist/utils/objects"; @@ -89,7 +89,7 @@ const CreateDashboardPreviewInner = memo(function CreateDashboardPreviewInner({ const adminApp = useAdminApp(projectId); const project = adminApp.useProject(); const config = project.useConfig(); - const currentUser = useUser({ or: "redirect" }); + const currentUser = useDashboardUser(); const backendBaseUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_STACK_API_URL is not set"); const browserBaseUrl = getPublicEnvVar("NEXT_PUBLIC_BROWSER_STACK_API_URL") ?? backendBaseUrl; const updateConfig = useUpdateConfig(); diff --git a/apps/dashboard/src/components/commands/create-dashboard/dashboard-sandbox-host.tsx b/apps/dashboard/src/components/commands/create-dashboard/dashboard-sandbox-host.tsx index f0b9cee996..ff3a4dc3e3 100644 --- a/apps/dashboard/src/components/commands/create-dashboard/dashboard-sandbox-host.tsx +++ b/apps/dashboard/src/components/commands/create-dashboard/dashboard-sandbox-host.tsx @@ -1,9 +1,9 @@ "use client"; import { DashboardRuntimeCodegen } from "@/lib/ai-dashboard/contracts"; +import { useDashboardUser } from "@/lib/dashboard-user"; import { getPublicEnvVar } from "@/lib/env"; import { useTheme } from "@/lib/theme"; -import { useUser } from "@stackframe/stack"; import { captureError } from "@stackframe/stack-shared/dist/utils/errors"; import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; import { memo, useEffect, useMemo, useRef } from "react"; @@ -723,7 +723,7 @@ export const DashboardSandboxHost = memo(function DashboardSandboxHost({ onRuntimeErrorRef.current = onRuntimeError; const onWidgetSelectedRef = useRef(onWidgetSelected); onWidgetSelectedRef.current = onWidgetSelected; - const user = useUser({ or: "redirect" }); + const user = useDashboardUser(); const { resolvedTheme } = useTheme(); const baseUrl = useMemo(() => { diff --git a/apps/dashboard/src/components/navbar.tsx b/apps/dashboard/src/components/navbar.tsx index 8ba27d4160..60d91a04e7 100644 --- a/apps/dashboard/src/components/navbar.tsx +++ b/apps/dashboard/src/components/navbar.tsx @@ -1,6 +1,7 @@ 'use client'; import { Typography } from "@/components/ui"; +import { getPublicEnvVar } from "@/lib/env"; import { UserButton } from "@stackframe/stack"; import { Link } from "./link"; @@ -8,6 +9,8 @@ import { Logo } from "./logo"; import ThemeToggle from "./theme-toggle"; export function Navbar({ ...props }) { + const isRemoteDevelopmentEnvironment = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true"; + return (
- + {!isRemoteDevelopmentEnvironment && }
); diff --git a/apps/dashboard/src/components/payments/stripe-connect-provider.tsx b/apps/dashboard/src/components/payments/stripe-connect-provider.tsx index 6d57a3f3da..5cf5470861 100644 --- a/apps/dashboard/src/components/payments/stripe-connect-provider.tsx +++ b/apps/dashboard/src/components/payments/stripe-connect-provider.tsx @@ -13,7 +13,6 @@ import { useEffect } from "react"; import { appearanceVariablesForTheme } from "./stripe-theme-variables"; const isPreview = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_PREVIEW") === "true"; -const isLocalEmulator = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true"; type StripeConnectProviderProps = { children: React.ReactNode, @@ -35,9 +34,10 @@ export function getStripeConnectInstance(adminApp: StackAdminApp) { export function StripeConnectProvider({ children }: StripeConnectProviderProps) { const adminApp = useAdminApp(); + const project = adminApp.useProject(); const { resolvedTheme } = useTheme(); - const stripeConnectInstance = isPreview || isLocalEmulator ? null : getStripeConnectInstance(adminApp); + const stripeConnectInstance = isPreview || project.isDevelopmentEnvironment ? null : getStripeConnectInstance(adminApp); useEffect(() => { if (!stripeConnectInstance) return; @@ -48,7 +48,7 @@ export function StripeConnectProvider({ children }: StripeConnectProviderProps) }); }, [resolvedTheme, stripeConnectInstance]); - // In preview/emulator mode, skip Stripe Connect initialization entirely + // Preview and development-environment projects do not initialize Stripe Connect. if (!stripeConnectInstance) { return <>{children}; } diff --git a/apps/dashboard/src/components/project-switcher.tsx b/apps/dashboard/src/components/project-switcher.tsx index 1ce6b22ca0..4bc6516ce1 100644 --- a/apps/dashboard/src/components/project-switcher.tsx +++ b/apps/dashboard/src/components/project-switcher.tsx @@ -1,8 +1,8 @@ "use client"; import { useRouter } from "@/components/router"; import { Button, Select, SelectContent, SelectItem, SelectTrigger } from "@/components/ui"; +import { useDashboardInternalUser } from "@/lib/dashboard-user"; import { PlusIcon } from "@phosphor-icons/react"; -import { useUser } from "@stackframe/stack"; import { useMemo } from "react"; export function ProjectAvatar(props: { displayName: string }) { @@ -17,7 +17,7 @@ export function ProjectAvatar(props: { displayName: string }) { export function ProjectSwitcher(props: { currentProjectId: string }) { const router = useRouter(); - const user = useUser({ or: 'redirect', projectIdMustMatch: "internal" }); + const user = useDashboardInternalUser(); const rawProjects = user.useOwnedProjects(); const { currentProject, projects } = useMemo(() => { const currentProject = rawProjects.find((project) => project.id === props.currentProjectId); diff --git a/apps/dashboard/src/instrumentation.ts b/apps/dashboard/src/instrumentation.ts index faf6c80213..5962696e9a 100644 --- a/apps/dashboard/src/instrumentation.ts +++ b/apps/dashboard/src/instrumentation.ts @@ -1,12 +1,26 @@ import * as Sentry from "@sentry/nextjs"; -import { getEnvVariable, getNextRuntime, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; +import { getEnvBoolean, getEnvVariable, getNextRuntime, getNodeEnvironment } from "@stackframe/stack-shared/dist/utils/env"; import { sentryBaseConfig } from "@stackframe/stack-shared/dist/utils/sentry"; import { nicify } from "@stackframe/stack-shared/dist/utils/strings"; import "./polyfills"; -export function register() { +async function startRemoteDevelopmentEnvironmentLifecycleIfNeeded(): Promise { + if (getNextRuntime() !== "nodejs" || getEnvVariable("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT", "") !== "true") { + return; + } + + const { startRemoteDevelopmentEnvironmentLifecycle } = await import("./lib/remote-development-environment/manager"); + startRemoteDevelopmentEnvironmentLifecycle(); +} + +export async function register() { if (getNextRuntime() === "nodejs") { - globalThis.process.title = `stack-dashboard:${getEnvVariable("NEXT_PUBLIC_STACK_PORT_PREFIX", "81")} (node/nextjs)`; + if (getEnvBoolean("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT")) { + globalThis.process.title = `Stack Auth — Development Server (port ${getEnvVariable("PORT", "?")})`; + } else { + globalThis.process.title = `stack-dashboard:${getEnvVariable("NEXT_PUBLIC_STACK_PORT_PREFIX", "81")} (node/nextjs)`; + } + await startRemoteDevelopmentEnvironmentLifecycleIfNeeded(); } if (getNextRuntime() === "nodejs" || getNextRuntime() === "edge") { diff --git a/apps/dashboard/src/lib/config-update.tsx b/apps/dashboard/src/lib/config-update.tsx index 6d39b7257a..5490f06222 100644 --- a/apps/dashboard/src/lib/config-update.tsx +++ b/apps/dashboard/src/lib/config-update.tsx @@ -2,7 +2,6 @@ import { Link } from "@/components/link"; import { ActionDialog } from "@/components/ui/action-dialog"; -import { getPublicEnvVar } from "@/lib/env"; import type { PushedConfigSource, StackAdminApp } from "@stackframe/stack"; import type { EnvironmentConfigOverrideOverride } from "@stackframe/stack-shared/dist/config/schema"; import React, { createContext, useCallback, useContext, useState } from "react"; @@ -44,7 +43,6 @@ export function ConfigUpdateDialogProvider({ children }: { children: React.React // Fetch the source first const project = await adminApp.getProject(); const source = await project.getPushedConfigSource(); - const isLocalEmulator = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true"; let shouldUpdate = true; if (source.type !== "unlinked") { @@ -65,7 +63,7 @@ export function ConfigUpdateDialogProvider({ children }: { children: React.React if (shouldUpdate) { await project.updatePushedConfig(configUpdate); - if (!isLocalEmulator) { + if (!project.isDevelopmentEnvironment) { await project.resetConfigOverrideKeys("environment", Object.keys(configUpdate)); } return true; @@ -263,7 +261,6 @@ export type UpdateConfigOptions = { */ export function useUpdateConfig() { const { showPushableDialog } = useConfigUpdateDialog(); - const isLocalEmulator = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR") === "true"; return useCallback(async (options: UpdateConfigOptions): Promise => { const { adminApp, configUpdate, pushable } = options; @@ -272,17 +269,17 @@ export function useUpdateConfig() { // Show dialog (or save directly if unlinked) based on source type return await showPushableDialog(adminApp, configUpdate); } else { - if (isLocalEmulator) { - alert("These settings are read-only in the local emulator. Update them in your production deployment instead."); - return false; - } // Update environment config directly const project = await adminApp.getProject(); + if (project.isDevelopmentEnvironment) { + alert("These settings are read-only in a development environment. Update them in your production deployment instead."); + return false; + } // eslint-disable-next-line no-restricted-syntax -- this is the hook implementation itself await project.updateConfig(configUpdate); return true; } - }, [isLocalEmulator, showPushableDialog]); + }, [showPushableDialog]); } /** diff --git a/apps/dashboard/src/lib/dashboard-user.ts b/apps/dashboard/src/lib/dashboard-user.ts new file mode 100644 index 0000000000..ccb98848fd --- /dev/null +++ b/apps/dashboard/src/lib/dashboard-user.ts @@ -0,0 +1,26 @@ +"use client"; + +import { getPublicEnvVar } from "@/lib/env"; +import { useUser } from "@stackframe/stack"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; + +function isRemoteDevelopmentEnvironment(): boolean { + return getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true"; +} + +export function useDashboardUser() { + const user = useUser({ + or: isRemoteDevelopmentEnvironment() ? "anonymous-if-exists[deprecated]" : "redirect", + }); + + return user ?? throwErr("Dashboard expected a signed-in user because the protected dashboard auth gate should have installed or redirected the user."); +} + +export function useDashboardInternalUser() { + const user = useUser({ + or: isRemoteDevelopmentEnvironment() ? "anonymous-if-exists[deprecated]" : "redirect", + projectIdMustMatch: "internal", + }); + + return user ?? throwErr("Dashboard expected an internal user because the protected dashboard auth gate should have installed or redirected the user."); +} diff --git a/apps/dashboard/src/lib/env.tsx b/apps/dashboard/src/lib/env.tsx index 3c64cd8393..fc9301f248 100644 --- a/apps/dashboard/src/lib/env.tsx +++ b/apps/dashboard/src/lib/env.tsx @@ -12,6 +12,7 @@ const _inlineEnvVars = { NEXT_PUBLIC_STACK_DASHBOARD_URL: process.env.NEXT_PUBLIC_STACK_DASHBOARD_URL, NEXT_PUBLIC_STACK_SVIX_SERVER_URL: process.env.NEXT_PUBLIC_STACK_SVIX_SERVER_URL, NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR: process.env.NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR, + NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT: process.env.NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT, NEXT_PUBLIC_STACK_IS_PREVIEW: process.env.NEXT_PUBLIC_STACK_IS_PREVIEW, NEXT_PUBLIC_STACK_PROJECT_ID: process.env.NEXT_PUBLIC_STACK_PROJECT_ID, NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY: process.env.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY, @@ -53,6 +54,7 @@ const _postBuildEnvVars = { NEXT_PUBLIC_SENTRY_DSN: "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_SENTRY_DSN", NEXT_PUBLIC_VERSION_ALERTER_SEVERE_ONLY: "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_VERSION_ALERTER_SEVERE_ONLY", NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR: "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR", + NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT: "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT", NEXT_PUBLIC_STACK_IS_PREVIEW: "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_STACK_IS_PREVIEW", NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY: "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY", NEXT_PUBLIC_STACK_URL: "STACK_ENV_VAR_SENTINEL_NEXT_PUBLIC_STACK_URL", diff --git a/apps/dashboard/src/lib/prefetch/url-prefetcher.tsx b/apps/dashboard/src/lib/prefetch/url-prefetcher.tsx index 4371e964b4..7714ce1beb 100644 --- a/apps/dashboard/src/lib/prefetch/url-prefetcher.tsx +++ b/apps/dashboard/src/lib/prefetch/url-prefetcher.tsx @@ -1,7 +1,8 @@ "use client"; import { useAdminApp } from "@/app/(main)/(protected)/projects/[projectId]/use-admin-app"; -import { stackAppInternalsSymbol, useUser } from "@stackframe/stack"; +import { useDashboardInternalUser } from "@/lib/dashboard-user"; +import { stackAppInternalsSymbol } from "@stackframe/stack"; import { previewTemplateSource } from "@stackframe/stack-shared/dist/helpers/emails"; import { createCachedRegex } from "@stackframe/stack-shared/dist/utils/regex"; import { memo, useEffect, useMemo, useState } from "react"; @@ -210,11 +211,11 @@ const urlPrefetchers: Record { - useUser({ or: "redirect", projectIdMustMatch: "internal" }); + useDashboardInternalUser(); }, ([_, projectId]) => { const project = useAdminApp(projectId).useProject(); - const teams = useUser({ or: "redirect", projectIdMustMatch: "internal" }).useTeams(); + const teams = useDashboardInternalUser().useTeams(); const ownerTeam = teams.find((team) => team.id === project.ownerTeamId); if (ownerTeam) { return [() => { diff --git a/apps/dashboard/src/lib/remote-development-environment/config-file.ts b/apps/dashboard/src/lib/remote-development-environment/config-file.ts new file mode 100644 index 0000000000..3c067cf00c --- /dev/null +++ b/apps/dashboard/src/lib/remote-development-environment/config-file.ts @@ -0,0 +1,54 @@ +import "server-only"; + +import { showOnboardingStackConfigValue } from "@stackframe/stack-shared/dist/config-authoring"; +import { detectImportPackageFromDir, renderConfigFileContent } from "@stackframe/stack-shared/dist/config-rendering"; +import { parseStackConfigFileContent } from "@stackframe/stack-shared/dist/stack-config-file"; +import { createHash } from "crypto"; +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs"; +import path from "path"; + +export function sha256String(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +export function resolveConfigFilePath(inputPath: string): string { + const resolved = path.resolve(inputPath); + const looksLikeConfigFile = /\.(ts|js|mjs|cjs)$/i.test(resolved); + return looksLikeConfigFile ? resolved : path.join(resolved, "stack.config.ts"); +} + +export function ensureConfigFileExists(configFilePath: string): void { + if (existsSync(configFilePath)) return; + mkdirSync(path.dirname(configFilePath), { recursive: true }); + writeConfigObject(configFilePath, {}); +} + +export function readConfigObject(configFilePath: string): Record { + return readConfigFile(configFilePath).config; +} + +export function readConfigFile(configFilePath: string): { config: Record, showOnboarding: boolean } { + ensureConfigFileExists(configFilePath); + const content = readFileSync(configFilePath, "utf-8"); + const config = parseStackConfigFileContent(content, configFilePath); + if (config === showOnboardingStackConfigValue) { + return { + config: {}, + showOnboarding: true, + }; + } + return { + config, + showOnboarding: false, + }; +} + +export function writeConfigObject(configFilePath: string, config: Record): void { + const dir = path.dirname(configFilePath); + mkdirSync(dir, { recursive: true }); + const importPackage = detectImportPackageFromDir(dir); + const content = renderConfigFileContent(config, importPackage); + const tempPath = path.join(dir, `.stack.config.${process.pid}.${Date.now()}.tmp`); + writeFileSync(tempPath, content, "utf-8"); + renameSync(tempPath, configFilePath); +} diff --git a/apps/dashboard/src/lib/remote-development-environment/env.ts b/apps/dashboard/src/lib/remote-development-environment/env.ts new file mode 100644 index 0000000000..527855c478 --- /dev/null +++ b/apps/dashboard/src/lib/remote-development-environment/env.ts @@ -0,0 +1,15 @@ +import "server-only"; + +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; + +export const REMOTE_DEVELOPMENT_ENVIRONMENT_ENABLED_ENV = "NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT"; + +export function isRemoteDevelopmentEnvironmentEnabled(): boolean { + return process.env[REMOTE_DEVELOPMENT_ENVIRONMENT_ENABLED_ENV] === "true"; +} + +export function assertRemoteDevelopmentEnvironmentEnabled(): void { + if (!isRemoteDevelopmentEnvironmentEnabled()) { + throw new StackAssertionError(`${REMOTE_DEVELOPMENT_ENVIRONMENT_ENABLED_ENV}=true is required to use remote development environment internals.`); + } +} diff --git a/apps/dashboard/src/lib/remote-development-environment/manager.ts b/apps/dashboard/src/lib/remote-development-environment/manager.ts new file mode 100644 index 0000000000..3dbc6ce848 --- /dev/null +++ b/apps/dashboard/src/lib/remote-development-environment/manager.ts @@ -0,0 +1,609 @@ +import "server-only"; + +import { getPublicEnvVar } from "@/lib/env"; +import { stackAppInternalsSymbol } from "@/lib/stack-app-internals"; +import { AdminOwnedProject, StackClientApp } from "@stackframe/stack"; +import { DEFAULT_EMAIL_THEME_ID } from "@stackframe/stack-shared/dist/helpers/emails"; +import { ProjectOnboardingStatus } from "@stackframe/stack-shared/dist/schema-fields"; +import { AccessToken } from "@stackframe/stack-shared/dist/sessions"; +import { errorToNiceString } from "@stackframe/stack-shared/dist/utils/errors"; +import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises"; +import { randomUUID } from "crypto"; +import { watch, type FSWatcher } from "fs"; +import { basename, dirname } from "path"; +import { + ensureConfigFileExists, + readConfigFile, + resolveConfigFilePath, + sha256String, + writeConfigObject, +} from "./config-file"; +import { assertRemoteDevelopmentEnvironmentEnabled } from "./env"; +import { + RemoteDevelopmentEnvironmentProject, + readRemoteDevelopmentEnvironmentState, + updateRemoteDevelopmentEnvironmentState, +} from "./state"; + +const SESSION_TTL_MS = 25_000; +const STARTUP_EMPTY_SESSION_GRACE_MS = 20_000; +const SYNC_DEBOUNCE_MS = 500; +const CONFIG_SYNC_FORMAT_VERSION = 2; +const LOG_PREFIX = "[Stack RDE]"; + +type ActiveSession = { + configFilePath: string, + lastHeartbeatMs: number, +}; + +type RemoteDevelopmentEnvironmentGlobals = { + sessions: Map, + watchers: Map, + syncTimers: Map, + syncErrors: Map, + shutdownTimerStarted: boolean, + startedAtMs: number, + activeOperations: number, + hasClosedSession: boolean, +}; + +type StackAppRequestInternals = { + sendRequest: (path: string, requestOptions: RequestInit, requestType?: "client" | "server" | "admin") => Promise, +}; + +const globals = globalThis as typeof globalThis & { + __stackRemoteDevelopmentEnvironment?: RemoteDevelopmentEnvironmentGlobals, +}; + +function getGlobals(): RemoteDevelopmentEnvironmentGlobals { + assertRemoteDevelopmentEnvironmentEnabled(); + globals.__stackRemoteDevelopmentEnvironment ??= { + sessions: new Map(), + watchers: new Map(), + syncTimers: new Map(), + syncErrors: new Map(), + shutdownTimerStarted: false, + startedAtMs: performance.now(), + activeOperations: 0, + hasClosedSession: false, + }; + return globals.__stackRemoteDevelopmentEnvironment; +} + +function logRemoteDevelopmentEnvironment(message: string, details?: Record): void { + if (details == null) { + console.log(`${LOG_PREFIX} ${message}`); + return; + } + console.log(`${LOG_PREFIX} ${message}`, details); +} + +function warnRemoteDevelopmentEnvironment(message: string, details?: Record): void { + if (details == null) { + console.warn(`${LOG_PREFIX} ${message}`); + return; + } + console.warn(`${LOG_PREFIX} ${message}`, details); +} + +function isStackAppRequestInternals(value: unknown): value is StackAppRequestInternals { + return ( + value != null && + typeof value === "object" && + "sendRequest" in value && + typeof value.sendRequest === "function" + ); +} + +function getStackAppRequestInternals(appValue: unknown): StackAppRequestInternals { + if (appValue == null || typeof appValue !== "object") { + throw new Error("The Stack app instance is unavailable."); + } + + const internals = Reflect.get(appValue, stackAppInternalsSymbol); + if (!isStackAppRequestInternals(internals)) { + throw new Error("The Stack app cannot send remote development environment onboarding updates."); + } + + return internals; +} + +function beginRemoteDevelopmentEnvironmentOperation(name: string, details?: Record): () => void { + const state = getGlobals(); + state.activeOperations += 1; + logRemoteDevelopmentEnvironment(`Started ${name}`, { + ...details, + activeOperations: state.activeOperations, + }); + + let ended = false; + return () => { + if (ended) return; + ended = true; + state.activeOperations -= 1; + logRemoteDevelopmentEnvironment(`Finished ${name}`, { + ...details, + activeOperations: state.activeOperations, + }); + }; +} + +function internalPublishableClientKey(): string { + const key = process.env.STACK_CLI_PUBLISHABLE_CLIENT_KEY ?? getPublicEnvVar("NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY"); + if (key == null || key.length === 0) { + throw new Error("Missing internal publishable client key for remote development environment dashboard."); + } + return key; +} + +function createInternalApp(apiBaseUrl: string, anonymousRefreshToken?: string) { + return new StackClientApp({ + projectId: "internal", + publishableClientKey: internalPublishableClientKey(), + baseUrl: apiBaseUrl, + tokenStore: anonymousRefreshToken == null ? "memory" : { refreshToken: anonymousRefreshToken, accessToken: "" }, + noAutomaticPrefetch: true, + }); +} + +function envVarsForProject(project: RemoteDevelopmentEnvironmentProject): Record { + return { + STACK_PROJECT_ID: project.projectId, + NEXT_PUBLIC_STACK_PROJECT_ID: project.projectId, + VITE_STACK_PROJECT_ID: project.projectId, + EXPO_PUBLIC_STACK_PROJECT_ID: project.projectId, + STACK_PUBLISHABLE_CLIENT_KEY: project.publishableClientKey, + NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY: project.publishableClientKey, + VITE_STACK_PUBLISHABLE_CLIENT_KEY: project.publishableClientKey, + EXPO_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY: project.publishableClientKey, + STACK_SECRET_SERVER_KEY: project.secretServerKey, + STACK_API_URL: project.apiBaseUrl, + NEXT_PUBLIC_STACK_API_URL: project.apiBaseUrl, + VITE_STACK_API_URL: project.apiBaseUrl, + EXPO_PUBLIC_STACK_API_URL: project.apiBaseUrl, + }; +} + +async function getOrCreateProject(options: { + apiBaseUrl: string, + configFilePath: string, + anonymousRefreshToken?: string, +}): Promise<{ anonymousRefreshToken: string, project: RemoteDevelopmentEnvironmentProject }> { + logRemoteDevelopmentEnvironment("Ensuring development-environment project exists", { + apiBaseUrl: options.apiBaseUrl, + configFilePath: options.configFilePath, + hasExistingAnonymousSession: options.anonymousRefreshToken != null, + }); + const app = createInternalApp(options.apiBaseUrl, options.anonymousRefreshToken); + const user = await app.getUser({ or: "anonymous" }); + const authJson = await user.getAuthJson(); + const anonymousRefreshToken = authJson.refreshToken ?? (() => { + throw new Error("Anonymous session did not return a refresh token."); + })(); + + const state = readRemoteDevelopmentEnvironmentState(); + const storedProject = state.projectsByConfigPath[options.configFilePath]; + const ownedProjects = await user.listOwnedProjects(); + const existingProject = storedProject == null + ? undefined + : ownedProjects.find((project) => project.id === storedProject.projectId); + if (storedProject != null && existingProject != null) { + const updatedProject = { + ...storedProject, + apiBaseUrl: options.apiBaseUrl, + updatedAtMillis: Date.now(), + }; + updateRemoteDevelopmentEnvironmentState((current) => ({ + ...current, + anonymousRefreshToken, + anonymousApiBaseUrl: options.apiBaseUrl, + projectsByConfigPath: { + ...current.projectsByConfigPath, + [options.configFilePath]: updatedProject, + }, + })); + logRemoteDevelopmentEnvironment("Reusing stored development-environment project", { + projectId: updatedProject.projectId, + teamId: updatedProject.teamId, + configFilePath: options.configFilePath, + }); + return { anonymousRefreshToken, project: updatedProject }; + } + + const label = basename(dirname(options.configFilePath)) || "Project"; + logRemoteDevelopmentEnvironment("Creating new development-environment team and project", { + label, + configFilePath: options.configFilePath, + }); + const team = await user.createTeam({ + displayName: `Development Environment: ${label}`, + }); + const project = await user.createProject({ + displayName: "Development Environment Project", + description: `Development environment for ${label}`, + teamId: team.id, + isProductionMode: false, + isDevelopmentEnvironment: true, + config: { + allowLocalhost: true, + signUpEnabled: true, + credentialEnabled: true, + magicLinkEnabled: true, + passkeyEnabled: true, + clientTeamCreationEnabled: true, + clientUserDeletionEnabled: true, + allowUserApiKeys: true, + allowTeamApiKeys: true, + createTeamOnSignUp: false, + emailTheme: DEFAULT_EMAIL_THEME_ID, + emailConfig: { type: "shared" }, + domains: [], + oauthProviders: [], + }, + }); + const key = await project.app.createInternalApiKey({ + description: `Development environment key for ${label}`, + expiresAt: new Date("2099-12-31T23:59:59Z"), + hasPublishableClientKey: true, + hasSecretServerKey: true, + hasSuperSecretAdminKey: false, + }); + if (key.publishableClientKey == null || key.secretServerKey == null) { + throw new Error("Development environment API key response did not include the expected keys."); + } + + const mappedProject: RemoteDevelopmentEnvironmentProject = { + projectId: project.id, + teamId: team.id, + publishableClientKey: key.publishableClientKey, + secretServerKey: key.secretServerKey, + apiBaseUrl: options.apiBaseUrl, + updatedAtMillis: Date.now(), + }; + logRemoteDevelopmentEnvironment("Created development-environment project", { + projectId: mappedProject.projectId, + teamId: mappedProject.teamId, + configFilePath: options.configFilePath, + }); + updateRemoteDevelopmentEnvironmentState((current) => ({ + ...current, + anonymousRefreshToken, + anonymousApiBaseUrl: options.apiBaseUrl, + projectsByConfigPath: { + ...current.projectsByConfigPath, + [options.configFilePath]: mappedProject, + }, + })); + return { anonymousRefreshToken, project: mappedProject }; +} + +export async function getRemoteDevelopmentEnvironmentAccessToken(): Promise<{ accessToken: string, expiresAtMillis: number, issuedAtMillis: number, userId: string }> { + const state = readRemoteDevelopmentEnvironmentState(); + if (state.anonymousRefreshToken == null) { + throw new Error("Remote development environment has no anonymous session yet."); + } + + const apiBaseUrl = state.anonymousApiBaseUrl ?? Object.values(state.projectsByConfigPath)[0]?.apiBaseUrl; + if (apiBaseUrl == null) { + throw new Error("Remote development environment has no API base URL yet."); + } + + const app = createInternalApp(apiBaseUrl, state.anonymousRefreshToken); + const user = await app.getUser({ or: "anonymous" }); + const accessToken = (await user.getAuthJson()).accessToken ?? (() => { + throw new Error("Remote development environment anonymous session did not return an access token."); + })(); + const parsedAccessToken = AccessToken.createIfValid(accessToken) ?? (() => { + throw new Error("Remote development environment anonymous session returned an invalid access token."); + })(); + + return { + accessToken, + expiresAtMillis: parsedAccessToken.expiresAt.getTime(), + issuedAtMillis: parsedAccessToken.issuedAt.getTime(), + userId: user.id, + }; +} + +async function syncRemoteDevelopmentEnvironmentOnboardingStatus( + project: AdminOwnedProject, + showOnboarding: boolean, +): Promise { + const onboardingStatus = showOnboarding && project.onboardingStatus === "completed" + ? "config_choice" + : showOnboarding + ? project.onboardingStatus + : "completed"; + + const body = showOnboarding + ? { onboarding_status: onboardingStatus } + : { onboarding_status: onboardingStatus, onboarding_state: null }; + const response = await getStackAppRequestInternals(project.app).sendRequest( + "/internal/projects/current", + { + method: "PATCH", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }, + "admin", + ); + if (!response.ok) { + throw new Error(`Failed to sync development-environment project onboarding status (${response.status}): ${await response.text()}`); + } + + return onboardingStatus; +} + +async function syncConfigToRemote(configFilePath: string): Promise { + const state = readRemoteDevelopmentEnvironmentState(); + const project = state.projectsByConfigPath[configFilePath]; + if (project == null || state.anonymousRefreshToken == null) { + warnRemoteDevelopmentEnvironment("Skipping config sync because local state is incomplete", { + configFilePath, + hasProject: project != null, + hasAnonymousRefreshToken: state.anonymousRefreshToken != null, + }); + return undefined; + } + + const { config, showOnboarding } = readConfigFile(configFilePath); + const configHash = sha256String(JSON.stringify({ config, showOnboarding, syncFormatVersion: CONFIG_SYNC_FORMAT_VERSION })); + const app = createInternalApp(project.apiBaseUrl, state.anonymousRefreshToken); + const user = await app.getUser({ or: "anonymous" }); + const ownedProject = (await user.listOwnedProjects()).find((p) => p.id === project.projectId); + if (ownedProject == null) { + warnRemoteDevelopmentEnvironment("Skipping config sync because the project is not owned by the anonymous user", { + projectId: project.projectId, + configFilePath, + }); + return undefined; + } + if (project.lastSyncedConfigHash === configHash) { + return ownedProject.onboardingStatus; + } + + logRemoteDevelopmentEnvironment("Syncing config to development-environment project", { + projectId: project.projectId, + configFilePath, + showOnboarding, + }); + await ownedProject.replaceConfigOverride("branch", config); + const onboardingStatus = await syncRemoteDevelopmentEnvironmentOnboardingStatus(ownedProject, showOnboarding); + + updateRemoteDevelopmentEnvironmentState((current) => ({ + ...current, + projectsByConfigPath: { + ...current.projectsByConfigPath, + [configFilePath]: { + ...project, + lastSyncedConfigHash: configHash, + updatedAtMillis: Date.now(), + }, + }, + })); + logRemoteDevelopmentEnvironment("Synced config to development-environment project", { + projectId: project.projectId, + configFilePath, + showOnboarding, + onboardingStatus, + }); + return onboardingStatus; +} + +function scheduleSync(configFilePath: string): void { + const state = getGlobals(); + const existing = state.syncTimers.get(configFilePath); + if (existing != null) clearTimeout(existing); + logRemoteDevelopmentEnvironment("Scheduling config sync after local file change", { + configFilePath, + debounceMs: SYNC_DEBOUNCE_MS, + }); + const timer = setTimeout(() => { + state.syncTimers.delete(configFilePath); + runAsynchronously( + async () => { + await syncConfigToRemote(configFilePath); + state.syncErrors.delete(configFilePath); + }, + { + onError: (error) => { + warnRemoteDevelopmentEnvironment("Config sync failed", { + configFilePath, + error: errorToNiceString(error), + }); + state.syncErrors.set(configFilePath, error); + }, + }, + ); + }, SYNC_DEBOUNCE_MS); + timer.unref(); + state.syncTimers.set(configFilePath, timer); +} + +function ensureWatcher(configFilePath: string): void { + const state = getGlobals(); + if (state.watchers.has(configFilePath)) return; + const watcher = watch(configFilePath, { persistent: false }, () => { + scheduleSync(configFilePath); + }); + state.watchers.set(configFilePath, watcher); + logRemoteDevelopmentEnvironment("Started watching config file", { + configFilePath, + watchedConfigFiles: state.watchers.size, + }); +} + +function ensureShutdownTimer(): void { + const state = getGlobals(); + if (state.shutdownTimerStarted) return; + state.shutdownTimerStarted = true; + logRemoteDevelopmentEnvironment("Started shutdown timer", { + sessionTtlMs: SESSION_TTL_MS, + startupEmptySessionGraceMs: STARTUP_EMPTY_SESSION_GRACE_MS, + }); + const timer = setInterval(() => { + const now = performance.now(); + for (const [id, session] of state.sessions.entries()) { + if (now - session.lastHeartbeatMs > SESSION_TTL_MS) { + warnRemoteDevelopmentEnvironment("Expiring stale session", { + sessionId: id, + ageMs: Math.round(now - session.lastHeartbeatMs), + activeSessionsBeforeExpire: state.sessions.size, + }); + state.sessions.delete(id); + } + } + if (state.sessions.size === 0 && state.activeOperations === 0 && (state.hasClosedSession || now - state.startedAtMs > STARTUP_EMPTY_SESSION_GRACE_MS)) { + logRemoteDevelopmentEnvironment("No active sessions remain; shutting down local dashboard", { + uptimeMs: Math.round(now - state.startedAtMs), + watchedConfigFiles: state.watchers.size, + pendingSyncs: state.syncTimers.size, + syncErrors: state.syncErrors.size, + activeOperations: state.activeOperations, + hasClosedSession: state.hasClosedSession, + }); + for (const watcher of state.watchers.values()) watcher.close(); + process.exit(0); + } + }, 5_000); + timer.unref(); +} + +export function startRemoteDevelopmentEnvironmentLifecycle(): void { + assertRemoteDevelopmentEnvironmentEnabled(); + logRemoteDevelopmentEnvironment("Starting local dashboard lifecycle"); + ensureShutdownTimer(); +} + +export async function registerRemoteDevelopmentEnvironmentSession(options: { + apiBaseUrl: string, + configPath: string, +}): Promise<{ sessionId: string, env: Record, projectId: string, onboardingOutstanding: boolean }> { + assertRemoteDevelopmentEnvironmentEnabled(); + const configFilePath = resolveConfigFilePath(options.configPath); + const endOperation = beginRemoteDevelopmentEnvironmentOperation("session registration", { + apiBaseUrl: options.apiBaseUrl, + configFilePath, + }); + try { + logRemoteDevelopmentEnvironment("Registering CLI session", { + apiBaseUrl: options.apiBaseUrl, + configFilePath, + }); + ensureConfigFileExists(configFilePath); + const state = readRemoteDevelopmentEnvironmentState(); + const { project } = await getOrCreateProject({ + apiBaseUrl: options.apiBaseUrl, + configFilePath, + anonymousRefreshToken: state.anonymousRefreshToken, + }); + const sessionId = randomUUID(); + getGlobals().sessions.set(sessionId, { + configFilePath, + lastHeartbeatMs: performance.now(), + }); + logRemoteDevelopmentEnvironment("Registered CLI session", { + sessionId, + projectId: project.projectId, + activeSessions: getGlobals().sessions.size, + configFilePath, + }); + ensureWatcher(configFilePath); + const onboardingStatus = await syncConfigToRemote(configFilePath); + return { + sessionId, + env: envVarsForProject(project), + projectId: project.projectId, + onboardingOutstanding: onboardingStatus != null && onboardingStatus !== "completed", + }; + } finally { + endOperation(); + } +} + +export function heartbeatRemoteDevelopmentEnvironmentSession(sessionId: string): boolean { + assertRemoteDevelopmentEnvironmentEnabled(); + const session = getGlobals().sessions.get(sessionId); + if (session == null) { + warnRemoteDevelopmentEnvironment("Received heartbeat for unknown session", { + sessionId, + }); + return false; + } + session.lastHeartbeatMs = performance.now(); + return true; +} + +export function closeRemoteDevelopmentEnvironmentSession(sessionId: string): void { + assertRemoteDevelopmentEnvironmentEnabled(); + const state = getGlobals(); + const existed = state.sessions.delete(sessionId); + if (existed) { + state.hasClosedSession = true; + } + logRemoteDevelopmentEnvironment("Closed CLI session", { + sessionId, + existed, + activeSessions: state.sessions.size, + }); +} + +export function getRemoteDevelopmentEnvironmentHealth(): { + healthy: boolean, + configFilePath?: string, +} { + assertRemoteDevelopmentEnvironmentEnabled(); + const globals = getGlobals(); + const activeSession = globals.sessions.values().next().value as ActiveSession | undefined; + if (activeSession != null) { + return { + healthy: true, + configFilePath: activeSession.configFilePath, + }; + } + + const state = readRemoteDevelopmentEnvironmentState(); + let configFilePath: string | undefined; + let latestUpdatedAtMillis = -Infinity; + for (const [projectConfigFilePath, project] of Object.entries(state.projectsByConfigPath)) { + if (project == null || project.updatedAtMillis <= latestUpdatedAtMillis) continue; + configFilePath = projectConfigFilePath; + latestUpdatedAtMillis = project.updatedAtMillis; + } + + return { + healthy: false, + configFilePath, + }; +} + +export async function applyRemoteDevelopmentEnvironmentConfigUpdate(options: { + sessionId: string, + config: Record, +}): Promise { + assertRemoteDevelopmentEnvironmentEnabled(); + const endOperation = beginRemoteDevelopmentEnvironmentOperation("config update", { + sessionId: options.sessionId, + }); + try { + const session = getGlobals().sessions.get(options.sessionId); + if (session == null) { + throw new Error("Remote development environment session is not active."); + } + const configFilePath = session.configFilePath; + logRemoteDevelopmentEnvironment("Applying config update from local dashboard", { + sessionId: options.sessionId, + configFilePath, + }); + writeConfigObject(configFilePath, options.config); + await syncConfigToRemote(configFilePath); + logRemoteDevelopmentEnvironment("Applied config update from local dashboard", { + sessionId: options.sessionId, + configFilePath, + }); + } finally { + endOperation(); + } +} diff --git a/apps/dashboard/src/lib/remote-development-environment/security.test.ts b/apps/dashboard/src/lib/remote-development-environment/security.test.ts new file mode 100644 index 0000000000..6070fb1f6f --- /dev/null +++ b/apps/dashboard/src/lib/remote-development-environment/security.test.ts @@ -0,0 +1,125 @@ +import { chmodSync, mkdtempSync, rmSync, statSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("server-only", () => ({})); + +let tempDir: string | undefined; +const remoteDevelopmentEnvironmentEnabledEnv = "NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT"; + +function useTempStateFile(secret = "secret") { + tempDir = mkdtempSync(join(tmpdir(), "stack-rde-security-")); + process.env[remoteDevelopmentEnvironmentEnabledEnv] = "true"; + process.env.STACK_DEV_ENVS_PATH = join(tempDir, "dev-envs.json"); + writeFileSync(process.env.STACK_DEV_ENVS_PATH, JSON.stringify({ + version: 1, + localDashboard: { + port: 26700, + secret, + pid: 123, + startedAtMillis: Date.now(), + }, + projectsByConfigPath: {}, + })); + chmodSync(process.env.STACK_DEV_ENVS_PATH, 0o600); +} + +function request(headers: Record) { + return new Request("http://127.0.0.1:26700/api/remote-development-environment/sessions", { headers }) as any; +} + +afterEach(() => { + delete process.env[remoteDevelopmentEnvironmentEnabledEnv]; + delete process.env.STACK_DEV_ENVS_PATH; + if (tempDir != null) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = undefined; + } +}); + +describe("remote development environment security", () => { + it("is inactive unless explicitly enabled", async () => { + useTempStateFile(); + delete process.env[remoteDevelopmentEnvironmentEnabledEnv]; + const { assertRemoteDevelopmentEnvironmentRequest } = await import("./security"); + const response = assertRemoteDevelopmentEnvironmentRequest(request({ + host: "127.0.0.1:26700", + authorization: "Bearer secret", + })); + expect(response?.status).toBe(404); + }); + + it("rejects missing or wrong bearer token", async () => { + useTempStateFile(); + const { assertRemoteDevelopmentEnvironmentRequest } = await import("./security"); + const response = assertRemoteDevelopmentEnvironmentRequest(request({ + host: "127.0.0.1:26700", + authorization: "Bearer wrong", + })); + expect(response?.status).toBe(401); + }); + + it("rejects non-loopback host and origin", async () => { + useTempStateFile(); + const { assertRemoteDevelopmentEnvironmentRequest } = await import("./security"); + const badHost = assertRemoteDevelopmentEnvironmentRequest(request({ + host: "example.com", + authorization: "Bearer secret", + })); + expect(badHost?.status).toBe(403); + + const badOrigin = assertRemoteDevelopmentEnvironmentRequest(request({ + host: "127.0.0.1:26700", + origin: "https://example.com", + authorization: "Bearer secret", + })); + expect(badOrigin?.status).toBe(403); + }); + + it("allows same-origin browser auth without exposing the CLI bearer token", async () => { + useTempStateFile(); + const { assertRemoteDevelopmentEnvironmentBrowserRequest } = await import("./security"); + const response = assertRemoteDevelopmentEnvironmentBrowserRequest(request({ + host: "127.0.0.1:26700", + "sec-fetch-site": "same-origin", + })); + expect(response).toBeNull(); + }); + + it("rejects cross-site browser auth navigation", async () => { + useTempStateFile(); + const { assertRemoteDevelopmentEnvironmentBrowserRequest } = await import("./security"); + const response = assertRemoteDevelopmentEnvironmentBrowserRequest(request({ + host: "127.0.0.1:26700", + "sec-fetch-site": "cross-site", + })); + expect(response?.status).toBe(403); + }); + + it("rejects config writes without an active session", async () => { + useTempStateFile(); + const { applyRemoteDevelopmentEnvironmentConfigUpdate } = await import("./manager"); + await expect(applyRemoteDevelopmentEnvironmentConfigUpdate({ + sessionId: "missing", + config: {}, + })).rejects.toThrow(/session is not active/); + }); + + it("repairs broad state file permissions before checking requests", async () => { + useTempStateFile(); + const statePath = process.env.STACK_DEV_ENVS_PATH; + if (statePath == null) { + throw new Error("STACK_DEV_ENVS_PATH should be set by useTempStateFile()."); + } + chmodSync(statePath, 0o644); + + const { assertRemoteDevelopmentEnvironmentRequest } = await import("./security"); + const response = assertRemoteDevelopmentEnvironmentRequest(request({ + host: "127.0.0.1:26700", + authorization: "Bearer secret", + })); + expect(response).toBeNull(); + expect(statSync(statePath).mode & 0o777).toBe(0o600); + }); +}); diff --git a/apps/dashboard/src/lib/remote-development-environment/security.ts b/apps/dashboard/src/lib/remote-development-environment/security.ts new file mode 100644 index 0000000000..922a58e668 --- /dev/null +++ b/apps/dashboard/src/lib/remote-development-environment/security.ts @@ -0,0 +1,58 @@ +import "server-only"; + +import { NextRequest, NextResponse } from "next/server"; +import { isLocalhost } from "@stackframe/stack-shared/dist/utils/urls"; +import { isRemoteDevelopmentEnvironmentEnabled } from "./env"; +import { readRemoteDevelopmentEnvironmentState } from "./state"; + +function requestHostIsLoopback(req: NextRequest): boolean { + const host = req.headers.get("host"); + if (host == null) return false; + return isLocalhost(`http://${host}`); +} + +function originIsAllowed(req: NextRequest): boolean { + const origin = req.headers.get("origin"); + if (origin == null) return true; + return isLocalhost(origin); +} + +export function assertRemoteDevelopmentEnvironmentRequest(req: NextRequest): NextResponse | null { + if (!isRemoteDevelopmentEnvironmentEnabled()) { + return NextResponse.json({ error: "Remote development environment endpoints are disabled." }, { status: 404 }); + } + + const state = readRemoteDevelopmentEnvironmentState(); + const expectedSecret = state.localDashboard?.secret; + if (expectedSecret == null || expectedSecret.length === 0) { + return NextResponse.json({ error: "Remote development environment is not active." }, { status: 404 }); + } + + if (!requestHostIsLoopback(req) || !originIsAllowed(req)) { + return NextResponse.json({ error: "Remote development environment endpoints only accept loopback requests." }, { status: 403 }); + } + + const authorization = req.headers.get("authorization"); + if (authorization !== `Bearer ${expectedSecret}`) { + return NextResponse.json({ error: "Unauthorized." }, { status: 401 }); + } + + return null; +} + +export function assertRemoteDevelopmentEnvironmentBrowserRequest(req: NextRequest): NextResponse | null { + if (!isRemoteDevelopmentEnvironmentEnabled()) { + return NextResponse.json({ error: "Remote development environment endpoints are disabled." }, { status: 404 }); + } + + if (!requestHostIsLoopback(req) || !originIsAllowed(req)) { + return NextResponse.json({ error: "Remote development environment endpoints only accept loopback requests." }, { status: 403 }); + } + + const fetchSite = req.headers.get("sec-fetch-site"); + if (fetchSite != null && fetchSite !== "same-origin" && fetchSite !== "none") { + return NextResponse.json({ error: "Remote development environment browser auth only accepts same-origin navigation." }, { status: 403 }); + } + + return null; +} diff --git a/apps/dashboard/src/lib/remote-development-environment/state.ts b/apps/dashboard/src/lib/remote-development-environment/state.ts new file mode 100644 index 0000000000..c0d4a5995f --- /dev/null +++ b/apps/dashboard/src/lib/remote-development-environment/state.ts @@ -0,0 +1,79 @@ +import "server-only"; + +import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs"; +import { dirname } from "path"; +import { stackDevEnvStatePath } from "@stackframe/stack-shared/dist/utils/dev-env-state-path"; +import { assertRemoteDevelopmentEnvironmentEnabled } from "./env"; + +export type RemoteDevelopmentEnvironmentProject = { + projectId: string, + teamId: string, + publishableClientKey: string, + secretServerKey: string, + apiBaseUrl: string, + lastSyncedConfigHash?: string, + updatedAtMillis: number, +}; + +export type RemoteDevelopmentEnvironmentState = { + version: 1, + anonymousRefreshToken?: string, + localDashboard?: { + port: number, + secret: string, + pid: number, + startedAtMillis: number, + logPath?: string, + }, + anonymousApiBaseUrl?: string, + projectsByConfigPath: Partial>, +}; + +export function devEnvsStatePath(): string { + return stackDevEnvStatePath(); +} + +export function emptyRemoteDevelopmentEnvironmentState(): RemoteDevelopmentEnvironmentState { + return { + version: 1, + projectsByConfigPath: {}, + }; +} + +export function readRemoteDevelopmentEnvironmentState(): RemoteDevelopmentEnvironmentState { + assertRemoteDevelopmentEnvironmentEnabled(); + const path = devEnvsStatePath(); + if (!existsSync(path)) { + return emptyRemoteDevelopmentEnvironmentState(); + } + if ((statSync(path).mode & 0o077) !== 0) { + chmodSync(path, 0o600); + if ((statSync(path).mode & 0o077) !== 0) { + throw new Error(`${path} must not be readable or writable by group/others. Run: chmod 600 ${path}`); + } + } + const parsed = JSON.parse(readFileSync(path, "utf-8")) as Partial; + return { + version: 1, + anonymousRefreshToken: typeof parsed.anonymousRefreshToken === "string" ? parsed.anonymousRefreshToken : undefined, + anonymousApiBaseUrl: typeof parsed.anonymousApiBaseUrl === "string" ? parsed.anonymousApiBaseUrl : undefined, + localDashboard: parsed.localDashboard, + projectsByConfigPath: parsed.projectsByConfigPath ?? {}, + }; +} + +export function writeRemoteDevelopmentEnvironmentState(state: RemoteDevelopmentEnvironmentState): void { + assertRemoteDevelopmentEnvironmentEnabled(); + const path = devEnvsStatePath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 }); + chmodSync(path, 0o600); +} + +export function updateRemoteDevelopmentEnvironmentState( + updater: (state: RemoteDevelopmentEnvironmentState) => RemoteDevelopmentEnvironmentState, +): RemoteDevelopmentEnvironmentState { + const next = updater(readRemoteDevelopmentEnvironmentState()); + writeRemoteDevelopmentEnvironmentState(next); + return next; +} diff --git a/apps/dashboard/src/stack.tsx b/apps/dashboard/src/stack/client.tsx similarity index 65% rename from apps/dashboard/src/stack.tsx rename to apps/dashboard/src/stack/client.tsx index 723d73e841..99a80b4fe4 100644 --- a/apps/dashboard/src/stack.tsx +++ b/apps/dashboard/src/stack/client.tsx @@ -1,31 +1,33 @@ import { getPublicEnvVar } from "@/lib/env"; -import { StackServerApp } from '@stackframe/stack'; -import { throwErr } from '@stackframe/stack-shared/dist/utils/errors'; -import './polyfills'; +import { StackClientApp } from "@stackframe/stack"; +import { throwErr } from "@stackframe/stack-shared/dist/utils/errors"; +import "../polyfills"; if (getPublicEnvVar("NEXT_PUBLIC_STACK_PROJECT_ID") !== "internal") { throw new Error("This project is not configured correctly. stack-dashboard must always use the internal project."); } const isPreview = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_PREVIEW") === "true"; +const isRemoteDevelopmentEnvironment = getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true"; -export const stackServerApp = new StackServerApp({ +export const stackClientApp = new StackClientApp({ baseUrl: { browser: getPublicEnvVar("NEXT_PUBLIC_BROWSER_STACK_API_URL") ?? getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_BROWSER_STACK_API_URL is not set"), server: getPublicEnvVar("NEXT_PUBLIC_SERVER_STACK_API_URL") ?? getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_SERVER_STACK_API_URL is not set"), }, projectId: "internal", publishableClientKey: getPublicEnvVar("NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY"), - tokenStore: isPreview ? "memory" : "nextjs-cookie", + tokenStore: isPreview || isRemoteDevelopmentEnvironment ? "memory" : "nextjs-cookie", urls: { afterSignIn: "/projects", afterSignUp: "/new-project", afterSignOut: "/", }, analytics: { + enabled: !isRemoteDevelopmentEnvironment, replays: { maskAllInputs: false, - enabled: !isPreview, + enabled: !isPreview && !isRemoteDevelopmentEnvironment, }, }, }); diff --git a/apps/dashboard/src/stack/server.tsx b/apps/dashboard/src/stack/server.tsx new file mode 100644 index 0000000000..0e67eeaf64 --- /dev/null +++ b/apps/dashboard/src/stack/server.tsx @@ -0,0 +1,14 @@ +import "server-only"; + +import { isRemoteDevelopmentEnvironmentEnabled } from "@/lib/remote-development-environment/env"; +import { StackServerApp } from "@stackframe/stack"; +import { StackAssertionError } from "@stackframe/stack-shared/dist/utils/errors"; +import { stackClientApp } from "./client"; + +if (isRemoteDevelopmentEnvironmentEnabled()) { + throw new StackAssertionError("stackServerApp is not available in the local remote development environment dashboard."); +} + +export const stackServerApp = new StackServerApp({ + inheritsFrom: stackClientApp, +}); diff --git a/package.json b/package.json index b69a22492e..f8e101e521 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,8 @@ "typecheck": "pnpm pre && turbo typecheck --", "build:dev": "pnpm pre && NODE_ENV=development pnpm run build", "build": "pnpm pre && turbo build", - "cli": "pnpm pre && pnpm run --filter=@stackframe/stack-cli build && node packages/stack-cli/dist/index.js", + "cli": "pnpm pre && pnpm run --filter=@stackframe/stack-cli build && pnpm run cli:no-build", + "cli:no-build": "pnpm pre && echo && echo && echo '[CLI output starts below]' && echo && echo && STACK_API_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}02 STACK_DASHBOARD_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01 STACK_CLI_PUBLISHABLE_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only node packages/stack-cli/dist/index.js", "build:backend": "pnpm pre && turbo run build --filter=@stackframe/backend...", "build:dashboard": "pnpm pre && turbo run build --filter=@stackframe/dashboard...", "build:demo": "pnpm pre && turbo run build --filter=demo-app...", diff --git a/packages/stack-cli/package.json b/packages/stack-cli/package.json index 8270568719..a0be5309c5 100644 --- a/packages/stack-cli/package.json +++ b/packages/stack-cli/package.json @@ -10,7 +10,7 @@ }, "scripts": { "clean": "rimraf node_modules && rimraf dist", - "build": "tsdown && node scripts/copy-emulator-assets.mjs", + "build": "tsdown && turbo run build:rde-standalone --filter=@stackframe/dashboard && node scripts/copy-runtime-assets.mjs", "dev": "tsdown --watch", "lint": "eslint --ext .tsx,.ts .", "typecheck": "tsc --noEmit", diff --git a/packages/stack-cli/scripts/copy-emulator-assets.mjs b/packages/stack-cli/scripts/copy-emulator-assets.mjs deleted file mode 100644 index 8ae3dfa17d..0000000000 --- a/packages/stack-cli/scripts/copy-emulator-assets.mjs +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env node -import { execFileSync } from "child_process"; -import { chmodSync, cpSync, mkdirSync } from "fs"; -import { dirname, join, resolve } from "path"; -import { fileURLToPath } from "url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const packageRoot = resolve(__dirname, ".."); -const qemuSrc = resolve(packageRoot, "../../docker/local-emulator/qemu"); -const envGenScript = resolve(packageRoot, "../../docker/local-emulator/generate-env-development.mjs"); -const envSrc = resolve(packageRoot, "../../docker/local-emulator/.env.development"); -const distDir = join(packageRoot, "dist"); -const emulatorDist = join(distDir, "emulator"); - -execFileSync(process.execPath, [envGenScript], { stdio: "inherit" }); - -mkdirSync(emulatorDist, { recursive: true }); - -for (const name of ["run-emulator.sh", "common.sh", "cloud-init"]) { - cpSync(join(qemuSrc, name), join(emulatorDist, name), { recursive: true }); -} - -chmodSync(join(emulatorDist, "run-emulator.sh"), 0o755); - -cpSync(envSrc, join(distDir, ".env.development")); - -console.log(`Copied emulator assets into ${emulatorDist} (+ .env.development into ${distDir}).`); diff --git a/packages/stack-cli/scripts/copy-runtime-assets.mjs b/packages/stack-cli/scripts/copy-runtime-assets.mjs new file mode 100644 index 0000000000..61d2fec56c --- /dev/null +++ b/packages/stack-cli/scripts/copy-runtime-assets.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +import { execFileSync } from "child_process"; +import { chmodSync, cpSync, existsSync, mkdirSync, rmSync } from "fs"; +import { dirname, join, resolve } from "path"; +import { fileURLToPath } from "url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const packageRoot = resolve(__dirname, ".."); +const repoRoot = resolve(packageRoot, "../.."); +const qemuSrc = resolve(repoRoot, "docker/local-emulator/qemu"); +const envGenScript = resolve(repoRoot, "docker/local-emulator/generate-env-development.mjs"); +const envSrc = resolve(repoRoot, "docker/local-emulator/.env.development"); +const dashboardRoot = resolve(repoRoot, "apps/dashboard"); +const dashboardStandaloneSrc = join(dashboardRoot, ".next/standalone"); +const dashboardStaticSrc = join(dashboardRoot, ".next/static"); +const dashboardPublicSrc = join(dashboardRoot, "public"); +const distDir = join(packageRoot, "dist"); +const emulatorDist = join(distDir, "emulator"); +const dashboardDist = join(distDir, "dashboard"); + +function assertExists(path, message) { + if (!existsSync(path)) { + throw new Error(message); + } +} + +function copyEmulatorAssets() { + execFileSync(process.execPath, [envGenScript], { stdio: "inherit" }); + + mkdirSync(emulatorDist, { recursive: true }); + + for (const name of ["run-emulator.sh", "common.sh", "cloud-init"]) { + cpSync(join(qemuSrc, name), join(emulatorDist, name), { recursive: true }); + } + + chmodSync(join(emulatorDist, "run-emulator.sh"), 0o755); + cpSync(envSrc, join(distDir, ".env.development")); + + console.log(`Copied emulator assets into ${emulatorDist} (+ .env.development into ${distDir}).`); +} + +function copyDashboardAssets() { + assertExists( + join(dashboardStandaloneSrc, "apps/dashboard/server.js"), + "Dashboard standalone build is missing. Run `pnpm exec turbo run build:rde-standalone --filter=@stackframe/dashboard` before building @stackframe/stack-cli.", + ); + assertExists( + dashboardStaticSrc, + "Dashboard static assets are missing. Run `pnpm exec turbo run build:rde-standalone --filter=@stackframe/dashboard` before building @stackframe/stack-cli.", + ); + + rmSync(dashboardDist, { recursive: true, force: true }); + cpSync(dashboardStandaloneSrc, dashboardDist, { recursive: true }); + cpSync(dashboardStaticSrc, join(dashboardDist, "apps/dashboard/.next/static"), { recursive: true }); + if (existsSync(dashboardPublicSrc)) { + cpSync(dashboardPublicSrc, join(dashboardDist, "apps/dashboard/public"), { recursive: true }); + } + + console.log(`Copied dashboard standalone runtime into ${dashboardDist}.`); +} + +copyEmulatorAssets(); +copyDashboardAssets(); diff --git a/packages/stack-cli/src/commands/dev.ts b/packages/stack-cli/src/commands/dev.ts new file mode 100644 index 0000000000..964df631cc --- /dev/null +++ b/packages/stack-cli/src/commands/dev.ts @@ -0,0 +1,482 @@ +import { execFileSync, spawn } from "child_process"; +import { Command } from "commander"; +import { chmodSync, closeSync, cpSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync, writeSync } from "fs"; +import { dirname, join, resolve } from "path"; +import { fileURLToPath } from "url"; +import { DEFAULT_API_URL, DEFAULT_PUBLISHABLE_CLIENT_KEY, resolveLoginConfig } from "../lib/auth.js"; +import { devEnvStatePath, ensureLocalDashboardSecret, recordLocalDashboardProcess } from "../lib/dev-env-state.js"; +import { CliError } from "../lib/errors.js"; + +type ChildCommand = { + command: string, + args: string[], +}; + +type DevOptions = { + configFile?: string, +}; + +type SessionResponse = { + session_id: string, + env: Record, + project_id: string, + onboarding_outstanding: boolean, +}; + +const HEARTBEAT_INTERVAL_MS = 5_000; +const DASHBOARD_RESTART_MIN_UPTIME_MS = 5_000; +const DASHBOARD_PORT = 26700; +const DASHBOARD_START_TIMEOUT_MS = 60_000; +const BUNDLED_DASHBOARD_DIR_NAME = "dashboard"; +const BUNDLED_DASHBOARD_SERVER_PATH = join("apps", "dashboard", "server.js"); +const DASHBOARD_RUNTIME_DIR_NAME = "rde-dashboard-runtime"; +const SENTINEL_PREFIX = "STACK_ENV_VAR_SENTINEL_"; +const USE_INLINE_ENV_VARS_SENTINEL = "STACK_ENV_VAR_SENTINEL_USE_INLINE_ENV_VARS"; +const SENTINEL_REGEX = /STACK_ENV_VAR_SENTINEL(?:_[A-Z0-9_]+)?/g; +const LOG_PREFIX = "[Stack Auth] "; + +type ProgressLogger = { + stop: (finalMessage?: string) => void, +}; + +type DashboardSessionState = { + session: SessionResponse, + dashboardReachableSinceMs: number, +}; + +function wait(ms: number): Promise { + return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function splitDevCommandArgs(commandArgs: string[]): ChildCommand { + if (commandArgs.length === 0) { + throw new CliError("Missing command. Usage: stack dev --config-file -- [args...]"); + } + const command = commandArgs[0]; + return { command, args: commandArgs.slice(1) }; +} + +function dashboardUrl(): string { + return `http://127.0.0.1:${DASHBOARD_PORT}`; +} + +function normalizeApiBaseUrl(apiBaseUrl: string): string { + const url = new URL(apiBaseUrl); + if (url.hostname === "localhost") { + url.hostname = "127.0.0.1"; + } + return url.toString().replace(/\/$/, ""); +} + +function logDev(message: string): void { + console.warn(`${LOG_PREFIX}${message}`); +} + +function openUrlInBrowser(url: string): boolean { + try { + if (process.platform === "darwin") { + execFileSync("open", [url], { stdio: "ignore" }); + return true; + } + if (process.platform === "win32") { + execFileSync("cmd", ["/c", "start", "", url], { stdio: "ignore" }); + return true; + } + execFileSync("xdg-open", [url], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +function maybeOpenOnboardingPage(session: SessionResponse): void { + if (!session.onboarding_outstanding) { + return; + } + const url = `${dashboardUrl()}/new-project?project_id=${encodeURIComponent(session.project_id)}`; + const opened = openUrlInBrowser(url); + if (opened) { + logDev(`Onboarding is still pending for project ${session.project_id}. Opened: ${url}`); + } else { + logDev(`Onboarding is still pending for project ${session.project_id}. Open this URL manually: ${url}`); + } +} + +function startProgressLog(message: string): ProgressLogger { + if (!process.stderr.isTTY) { + logDev(`${message}...`); + return { + stop() { + logDev(`${message}... done!`); + }, + }; + } + + let dotCount = 0; + let stopped = false; + const render = () => { + process.stderr.write(`\r\x1b[2K${LOG_PREFIX}${message}${".".repeat(dotCount)}`); + dotCount = (dotCount + 1) % 4; + }; + render(); + const timer = setInterval(render, 400); + timer.unref(); + + return { + stop() { + if (stopped) return; + stopped = true; + clearInterval(timer); + process.stderr.write("\r\x1b[2K"); + logDev(`${message}... done!`); + }, + }; +} + +function bundledDashboardRoot(): string { + return join(dirname(fileURLToPath(import.meta.url)), BUNDLED_DASHBOARD_DIR_NAME); +} + +function assertBundledDashboardExists(): void { + const serverPath = join(bundledDashboardRoot(), BUNDLED_DASHBOARD_SERVER_PATH); + if (!existsSync(serverPath)) { + throw new CliError([ + "This stack-cli build does not include the bundled development-environment dashboard.", + "Build the CLI package with the dashboard standalone assets before running `stack dev`.", + ].join(" ")); + } +} + +function dashboardRuntimeRoot(): string { + return join(dirname(devEnvStatePath()), DASHBOARD_RUNTIME_DIR_NAME); +} + +function dashboardLogPath(): string { + return join(dirname(devEnvStatePath()), "rde-dashboard.log"); +} + +function replaceSentinels(content: string, env: NodeJS.ProcessEnv): string { + return content.replace(SENTINEL_REGEX, (sentinel) => { + if (sentinel === USE_INLINE_ENV_VARS_SENTINEL) { + return "true"; + } + if (!sentinel.startsWith(SENTINEL_PREFIX)) { + return sentinel; + } + return env[sentinel.slice(SENTINEL_PREFIX.length)] ?? sentinel; + }); +} + +function replaceDashboardRuntimeSentinels(root: string, env: NodeJS.ProcessEnv): void { + for (const entry of readdirSync(root, { withFileTypes: true })) { + const path = join(root, entry.name); + if (entry.isDirectory()) { + replaceDashboardRuntimeSentinels(path, env); + continue; + } + if (!entry.isFile()) { + continue; + } + + const buffer = readFileSync(path); + if (!buffer.includes("STACK_ENV_VAR_SENTINEL")) { + continue; + } + writeFileSync(path, replaceSentinels(buffer.toString("utf-8"), env)); + } +} + +function prepareDashboardRuntime(env: NodeJS.ProcessEnv): string { + assertBundledDashboardExists(); + const runtimeRoot = dashboardRuntimeRoot(); + mkdirSync(dirname(runtimeRoot), { recursive: true }); + rmSync(runtimeRoot, { recursive: true, force: true }); + cpSync(bundledDashboardRoot(), runtimeRoot, { recursive: true }); + replaceDashboardRuntimeSentinels(runtimeRoot, env); + + const runtimeServerPath = join(runtimeRoot, BUNDLED_DASHBOARD_SERVER_PATH); + if (!existsSync(runtimeServerPath)) { + throw new CliError("The bundled development-environment dashboard is missing its server entrypoint."); + } + return runtimeServerPath; +} + +function resolveConfigFilePath(configFile: string): string { + const resolved = resolve(configFile); + if (existsSync(resolved) && statSync(resolved).isDirectory()) { + return join(resolved, "stack.config.ts"); + } + if (!/\.(ts|js|mjs|cjs)$/i.test(resolved)) { + return join(resolved, "stack.config.ts"); + } + return resolved; +} + +async function isDashboardReachable(url: string): Promise { + try { + const response = await fetch(`${url}/health`); + return response.ok; + } catch { + return false; + } +} + +async function startDashboardIfNeeded(options: { apiBaseUrl: string, secret: string }): Promise { + const url = dashboardUrl(); + if (await isDashboardReachable(url)) { + logDev(`Using existing Stack Auth dashboard on ${url}.`); + return; + } + + const progress = startProgressLog(`Stack Auth dashboard not found on port ${DASHBOARD_PORT}. Starting now`); + const dashboardEnv = { + ...process.env, + NODE_ENV: "production", + PORT: String(DASHBOARD_PORT), + HOSTNAME: "127.0.0.1", + STACK_API_URL: options.apiBaseUrl, + NEXT_PUBLIC_STACK_API_URL: options.apiBaseUrl, + NEXT_PUBLIC_BROWSER_STACK_API_URL: options.apiBaseUrl, + NEXT_PUBLIC_SERVER_STACK_API_URL: options.apiBaseUrl, + NEXT_PUBLIC_STACK_DASHBOARD_URL: url, + NEXT_PUBLIC_BROWSER_STACK_DASHBOARD_URL: url, + NEXT_PUBLIC_SERVER_STACK_DASHBOARD_URL: url, + NEXT_PUBLIC_STACK_PROJECT_ID: "internal", + NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY: DEFAULT_PUBLISHABLE_CLIENT_KEY, + NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR: "false", + NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT: "true", + NEXT_PUBLIC_STACK_IS_PREVIEW: "false", + }; + try { + const dashboardServerPath = prepareDashboardRuntime(dashboardEnv); + const logPath = dashboardLogPath(); + mkdirSync(dirname(logPath), { recursive: true }); + const logFd = openSync(logPath, "a", 0o600); + chmodSync(logPath, 0o600); + writeSync(logFd, `\n[${new Date().toISOString()}] Starting Stack Auth development-environment dashboard on ${url}\n`); + const child = (() => { + try { + return spawn(process.execPath, [dashboardServerPath], { + cwd: resolve(dirname(dashboardServerPath), "../.."), + detached: true, + stdio: ["ignore", logFd, logFd], + env: dashboardEnv, + }); + } finally { + closeSync(logFd); + } + })(); + if (child.pid == null) { + throw new CliError(`Failed to start the development environment dashboard process. Dashboard logs: ${logPath}`); + } + recordLocalDashboardProcess(DASHBOARD_PORT, options.secret, child.pid, logPath); + child.unref(); + + const startedAt = performance.now(); + while (performance.now() - startedAt < DASHBOARD_START_TIMEOUT_MS) { + if (await isDashboardReachable(url)) { + progress.stop(`Started Stack Auth dashboard`); + return; + } + await wait(500); + } + + throw new CliError(`Timed out waiting for the development environment dashboard to start at ${url}. Dashboard logs: ${logPath}`); + } catch (error) { + progress.stop(); + throw error; + } +} + +async function dashboardRequest(path: string, options: RequestInit, secret: string): Promise { + const url = `${dashboardUrl()}${path}`; + try { + return await fetch(url, { + ...options, + headers: { + Authorization: `Bearer ${secret}`, + ...options.headers, + }, + }); + } catch (error) { + throw new CliError(`Failed to reach local Stack Auth dashboard at ${url}: ${errorMessage(error)}`); + } +} + +async function createRemoteDevelopmentEnvironmentSession(options: { + apiBaseUrl: string, + configFilePath: string, + secret: string, +}): Promise { + const response = await dashboardRequest("/api/remote-development-environment/sessions", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + api_base_url: options.apiBaseUrl, + config_path: options.configFilePath, + }), + }, options.secret); + if (!response.ok) { + throw new CliError(`Failed to register development environment session (${response.status}): ${await response.text()}`); + } + const body = await response.json() as SessionResponse; + if ( + typeof body.session_id !== "string" || + typeof body.project_id !== "string" || + typeof body.onboarding_outstanding !== "boolean" || + typeof body.env !== "object" + ) { + throw new CliError("Local dashboard returned an invalid development environment session response."); + } + return body; +} + +function runChildProcess(command: ChildCommand, env: NodeJS.ProcessEnv): Promise { + return new Promise((resolvePromise, reject) => { + const child = spawn(command.command, command.args, { stdio: "inherit", env }); + const forward = (signal: NodeJS.Signals) => () => child.kill(signal); + const onSigint = forward("SIGINT"); + const onSigterm = forward("SIGTERM"); + const cleanup = () => { + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); + }; + process.on("SIGINT", onSigint); + process.on("SIGTERM", onSigterm); + child.on("close", (code) => { + cleanup(); + resolvePromise(code ?? 1); + }); + child.on("error", (err) => { + cleanup(); + reject(new CliError(`Failed to run ${command.command}: ${err.message}`)); + }); + }); +} + +async function restartDashboardForHeartbeat(options: { + apiBaseUrl: string, + configFilePath: string, + dashboardReachableSinceMs: number, + secret: string, +}): Promise { + const dashboardUptimeMs = performance.now() - options.dashboardReachableSinceMs; + if (dashboardUptimeMs < DASHBOARD_RESTART_MIN_UPTIME_MS) { + throw new CliError(`Local Stack Auth dashboard stopped before it had been running for ${DASHBOARD_RESTART_MIN_UPTIME_MS / 1000} seconds. Not restarting to avoid a restart loop.`); + } + + logDev("Local Stack Auth dashboard stopped. Restarting..."); + await startDashboardIfNeeded({ apiBaseUrl: options.apiBaseUrl, secret: options.secret }); + return await createRemoteDevelopmentEnvironmentSession({ + apiBaseUrl: options.apiBaseUrl, + configFilePath: options.configFilePath, + secret: options.secret, + }); +} + +async function heartbeatUntilStopped(sessionState: DashboardSessionState, options: { + apiBaseUrl: string, + configFilePath: string, + secret: string, + shouldStop: () => boolean, +}): Promise { + while (!options.shouldStop()) { + await wait(HEARTBEAT_INTERVAL_MS); + if (options.shouldStop()) return; + + let response: Response; + try { + response = await dashboardRequest(`/api/remote-development-environment/sessions/${encodeURIComponent(sessionState.session.session_id)}/heartbeat`, { + method: "POST", + }, options.secret); + } catch { + sessionState.session = await restartDashboardForHeartbeat({ + apiBaseUrl: options.apiBaseUrl, + configFilePath: options.configFilePath, + dashboardReachableSinceMs: sessionState.dashboardReachableSinceMs, + secret: options.secret, + }); + sessionState.dashboardReachableSinceMs = performance.now(); + logDev(`Stack Auth dashboard running at ${dashboardUrl()}`); + continue; + } + + if (!response.ok) { + logDev(`Development environment heartbeat failed (${response.status}): ${await response.text()}`); + return; + } + } +} + +async function closeSession(sessionId: string, secret: string): Promise { + let response: Response; + try { + response = await dashboardRequest(`/api/remote-development-environment/sessions/${encodeURIComponent(sessionId)}`, { + method: "DELETE", + }, secret); + } catch (error) { + logDev(`Failed to close development environment session: ${errorMessage(error)}`); + return; + } + if (!response.ok) { + logDev(`Failed to close development environment session (${response.status}): ${await response.text()}`); + } +} + +export function registerDevCommand(program: Command) { + program + .command("dev") + .usage("--config-file -- [args...]") + .description("Run a command with Stack Auth development-environment credentials") + .requiredOption("--config-file ", "Path to stack.config.ts or a project directory") + .argument("", "Command and arguments to run after --") + .action(async (commandArgs: string[], opts: DevOptions) => { + if (opts.configFile == null) { + throw new CliError("--config-file is required."); + } + + const childCommand = splitDevCommandArgs(commandArgs); + const localDashboardUrl = dashboardUrl(); + const secret = ensureLocalDashboardSecret(DASHBOARD_PORT); + const config = resolveLoginConfig(program.opts()); + const apiBaseUrl = normalizeApiBaseUrl(config.apiUrl || DEFAULT_API_URL); + const configFilePath = resolveConfigFilePath(opts.configFile); + await startDashboardIfNeeded({ apiBaseUrl, secret }); + const sessionState: DashboardSessionState = { + session: await createRemoteDevelopmentEnvironmentSession({ + apiBaseUrl, + configFilePath, + secret, + }), + dashboardReachableSinceMs: performance.now(), + }; + logDev(`Stack Auth dashboard running at ${localDashboardUrl}`); + maybeOpenOnboardingPage(sessionState.session); + + let stopped = false; + const heartbeat = heartbeatUntilStopped(sessionState, { + apiBaseUrl, + configFilePath, + secret, + shouldStop: () => stopped, + }); + let exitCode = 1; + try { + exitCode = await runChildProcess(childCommand, { + ...process.env, + ...sessionState.session.env, + }); + } finally { + stopped = true; + await heartbeat; + await closeSession(sessionState.session.session_id, secret); + } + process.exit(exitCode); + }); +} diff --git a/packages/stack-cli/src/commands/emulator.test.ts b/packages/stack-cli/src/commands/emulator.test.ts index 9cbe9caa16..7c2b6f0708 100644 --- a/packages/stack-cli/src/commands/emulator.test.ts +++ b/packages/stack-cli/src/commands/emulator.test.ts @@ -6,6 +6,7 @@ import { platformInstallHint, renderProgressLine, resolveArch, + splitEmulatorCommandArgs, } from "./emulator.js"; describe("formatBytes", () => { @@ -149,6 +150,19 @@ describe("resolveArch", () => { }); }); +describe("splitEmulatorCommandArgs", () => { + it("splits the command from its arguments", () => { + expect(splitEmulatorCommandArgs(["pnpm", "dev", "--host", "127.0.0.1"])).toEqual({ + command: "pnpm", + args: ["dev", "--host", "127.0.0.1"], + }); + }); + + it("requires a command", () => { + expect(() => splitEmulatorCommandArgs([])).toThrow(/stack emulator run -- /); + }); +}); + describe("platformInstallHint", () => { it("uses brew on darwin and apt on linux", () => { const spy = vi.spyOn(process, "platform", "get"); diff --git a/packages/stack-cli/src/commands/emulator.ts b/packages/stack-cli/src/commands/emulator.ts index ddeeec8c5a..c407355ef0 100644 --- a/packages/stack-cli/src/commands/emulator.ts +++ b/packages/stack-cli/src/commands/emulator.ts @@ -85,6 +85,16 @@ type EmulatorCredentials = { onboarding_outstanding: boolean, }; +type EmulatorChildOptions = { + arch?: string, + configFile?: string, +}; + +export type EmulatorChildCommand = { + command: string, + args: string[], +}; + async function fetchEmulatorCredentials(pck: string, backendPort: number, configFile: string): Promise { const url = `http://127.0.0.1:${backendPort}/api/v1/internal/local-emulator/project`; const res = await fetch(url, { @@ -163,6 +173,14 @@ function maybeOpenOnboardingPage(credentials: EmulatorCredentials): void { } } +export function splitEmulatorCommandArgs(commandArgs: string[], commandName = "run"): EmulatorChildCommand { + if (commandArgs.length === 0) { + throw new CliError(`Missing command. Usage: stack emulator ${commandName} -- [args...]`); + } + const command = commandArgs[0]; + return { command, args: commandArgs.slice(1) }; +} + // Resolve a GitHub auth token. We try GITHUB_TOKEN first so users can pin a // PAT, then fall back to `gh auth token` if the gh CLI is installed and // signed in. If neither works we return undefined — public release downloads @@ -312,6 +330,111 @@ async function startEmulator(arch: "arm64" | "amd64"): Promise { await runEmulator("start", { EMULATOR_ARCH: arch, STACK_EMULATOR_CLI_WROTE_ISO: "1" }); } +function resolveEmulatorConfigFile(configFile: string | undefined): string | undefined { + if (configFile === undefined) { + return undefined; + } + const resolvedConfigFile = resolve(configFile); + if (!existsSync(resolvedConfigFile)) { + throw new CliError(`Config file not found: ${resolvedConfigFile}`); + } + return resolvedConfigFile; +} + +async function buildEmulatorChildEnv(resolvedConfigFile: string | undefined): Promise { + const childEnv: NodeJS.ProcessEnv = { ...process.env }; + if (resolvedConfigFile === undefined) { + return childEnv; + } + + const pck = await readInternalPck(); + const backendPort = emulatorBackendPort(); + const creds = await fetchEmulatorCredentials(pck, backendPort, resolvedConfigFile); + maybeOpenOnboardingPage(creds); + const apiUrl = `http://127.0.0.1:${backendPort}`; + childEnv.STACK_PROJECT_ID = creds.project_id; + childEnv.NEXT_PUBLIC_STACK_PROJECT_ID = creds.project_id; + childEnv.VITE_STACK_PROJECT_ID = creds.project_id; + childEnv.EXPO_PUBLIC_STACK_PROJECT_ID = creds.project_id; + childEnv.STACK_PUBLISHABLE_CLIENT_KEY = creds.publishable_client_key; + childEnv.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY = creds.publishable_client_key; + childEnv.VITE_STACK_PUBLISHABLE_CLIENT_KEY = creds.publishable_client_key; + childEnv.EXPO_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY = creds.publishable_client_key; + childEnv.STACK_SECRET_SERVER_KEY = creds.secret_server_key; + childEnv.STACK_API_URL = apiUrl; + childEnv.NEXT_PUBLIC_STACK_API_URL = apiUrl; + childEnv.VITE_STACK_API_URL = apiUrl; + childEnv.EXPO_PUBLIC_STACK_API_URL = apiUrl; + return childEnv; +} + +function runChildProcess(command: string, args: string[], env: NodeJS.ProcessEnv): Promise { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { stdio: "inherit", env }); + + const forward = (signal: NodeJS.Signals) => () => child.kill(signal); + const onSigint = forward("SIGINT"); + const onSigterm = forward("SIGTERM"); + const cleanup = () => { + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); + }; + + process.on("SIGINT", onSigint); + process.on("SIGTERM", onSigterm); + + child.on("close", (code) => { + cleanup(); + resolvePromise(code ?? 1); + }); + child.on("error", (err) => { + cleanup(); + reject(new CliError(`Failed to run ${command}: ${err.message}`)); + }); + }); +} + +async function stopEmulatorAfterChild(): Promise { + console.log("\nStopping emulator..."); + try { + await runEmulator("stop"); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + process.stderr.write(`Failed to stop emulator cleanly: ${msg}\n`); + } +} + +async function runWithLocalEmulator( + commandName: string, + opts: EmulatorChildOptions, + runChild: (env: NodeJS.ProcessEnv) => Promise, +): Promise { + const arch = resolveArch(opts.arch); + preflightForVmStart(commandName, arch); + const resolvedConfigFile = resolveEmulatorConfigFile(opts.configFile); + + let startedByThisCommand = false; + const exitCode = await (async () => { + try { + if (isEmulatorRunning()) { + console.log("Emulator already running, reusing existing instance."); + } else { + await startEmulator(arch); + startedByThisCommand = true; + } + + const childEnv = await buildEmulatorChildEnv(resolvedConfigFile); + return await runChild(childEnv); + } finally { + if (startedByThisCommand) { + await stopEmulatorAfterChild(); + } + } + })(); + + process.exit(exitCode); +} + function printEmulatorWelcome(): void { const dashboardPort = envPort("EMULATOR_DASHBOARD_PORT", DEFAULT_EMULATOR_DASHBOARD_PORT); const backendPort = envPort("EMULATOR_BACKEND_PORT", DEFAULT_EMULATOR_BACKEND_PORT); @@ -331,7 +454,7 @@ function printEmulatorWelcome(): void { console.log(" stack emulator status Check service health"); console.log(" stack emulator stop Stop the VM (keeps data)"); console.log(" stack emulator reset Wipe all state and start fresh"); - console.log(" stack emulator run Start the emulator, run , stop on exit"); + console.log(" stack emulator run -- Start the emulator, run , stop on exit"); console.log(""); } @@ -803,76 +926,14 @@ export function registerEmulatorCommand(program: Command) { emulator .command("run") + .usage("[options] -- [args...]") .description("Start the emulator, run a command, and stop the emulator when the command exits") - .argument("", "Command to run (e.g. \"npm run dev\")") + .argument("", "Command and arguments to run after -- (e.g. -- npm run dev)") .option("--arch ", "Target architecture") .option("--config-file ", "Path to a config file; fetches credentials and injects STACK_PROJECT_ID / STACK_PUBLISHABLE_CLIENT_KEY / STACK_SECRET_SERVER_KEY into the child") - .action(async (cmd: string, opts: { arch?: string, configFile?: string }) => { - const arch = resolveArch(opts.arch); - preflightForVmStart("run", arch); - - let resolvedConfigFile: string | undefined; - if (opts.configFile) { - resolvedConfigFile = resolve(opts.configFile); - if (!existsSync(resolvedConfigFile)) { - throw new CliError(`Config file not found: ${resolvedConfigFile}`); - } - } - - const alreadyRunning = isEmulatorRunning(); - if (alreadyRunning) { - console.log("Emulator already running, reusing existing instance."); - } else { - await startEmulator(arch); - } - - const childEnv: Record = { ...process.env as Record }; - if (resolvedConfigFile) { - const pck = await readInternalPck(); - const backendPort = emulatorBackendPort(); - const creds = await fetchEmulatorCredentials(pck, backendPort, resolvedConfigFile); - maybeOpenOnboardingPage(creds); - const apiUrl = `http://127.0.0.1:${backendPort}`; - childEnv.STACK_PROJECT_ID = creds.project_id; - childEnv.NEXT_PUBLIC_STACK_PROJECT_ID = creds.project_id; - childEnv.VITE_STACK_PROJECT_ID = creds.project_id; - childEnv.EXPO_PUBLIC_STACK_PROJECT_ID = creds.project_id; - childEnv.STACK_PUBLISHABLE_CLIENT_KEY = creds.publishable_client_key; - childEnv.NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY = creds.publishable_client_key; - childEnv.VITE_STACK_PUBLISHABLE_CLIENT_KEY = creds.publishable_client_key; - childEnv.EXPO_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY = creds.publishable_client_key; - childEnv.STACK_SECRET_SERVER_KEY = creds.secret_server_key; - childEnv.STACK_API_URL = apiUrl; - childEnv.NEXT_PUBLIC_STACK_API_URL = apiUrl; - childEnv.VITE_STACK_API_URL = apiUrl; - childEnv.EXPO_PUBLIC_STACK_API_URL = apiUrl; - } - - const child = spawn(cmd, { shell: true, stdio: "inherit", env: childEnv }); - - const forward = (signal: NodeJS.Signals) => () => child.kill(signal); - const onSigint = forward("SIGINT"); - const onSigterm = forward("SIGTERM"); - process.on("SIGINT", onSigint); - process.on("SIGTERM", onSigterm); - - child.on("close", (code) => { - process.off("SIGINT", onSigint); - process.off("SIGTERM", onSigterm); - const exitCode = code ?? 1; - if (alreadyRunning) { - process.exit(exitCode); - } else { - console.log("\nStopping emulator..."); - const warnStopFailed = (e: unknown) => { - const msg = e instanceof Error ? e.message : String(e); - process.stderr.write(`Failed to stop emulator cleanly: ${msg}\n`); - }; - runEmulator("stop") - .catch(warnStopFailed) - .finally(() => process.exit(exitCode)); - } - }); + .action(async (commandArgs: string[], opts: EmulatorChildOptions) => { + const childCommand = splitEmulatorCommandArgs(commandArgs); + await runWithLocalEmulator("run", opts, (env) => runChildProcess(childCommand.command, childCommand.args, env)); }); emulator diff --git a/packages/stack-cli/src/commands/whoami.ts b/packages/stack-cli/src/commands/whoami.ts new file mode 100644 index 0000000000..df4daee943 --- /dev/null +++ b/packages/stack-cli/src/commands/whoami.ts @@ -0,0 +1,44 @@ +import { Command } from "commander"; +import { getInternalUser } from "../lib/app.js"; +import { resolveSessionAuth } from "../lib/auth.js"; + +export function registerWhoamiCommand(program: Command) { + program + .command("whoami") + .description("Show the currently logged-in Stack Auth CLI user") + .action(async () => { + const flags = program.opts(); + const auth = resolveSessionAuth(flags); + const user = await getInternalUser(auth); + const teams = await user.listTeams(); + + const result = { + id: user.id, + displayName: user.displayName, + primaryEmail: user.primaryEmail, + primaryEmailVerified: user.primaryEmailVerified, + isAnonymous: user.isAnonymous, + isRestricted: user.isRestricted, + teams: teams.map((team) => ({ + id: team.id, + displayName: team.displayName, + })), + apiUrl: auth.apiUrl, + dashboardUrl: auth.dashboardUrl, + }; + + if (flags.json) { + console.log(JSON.stringify(result, null, 2)); + return; + } + + console.log(`User ID: ${result.id}`); + console.log(`Display name: ${result.displayName ?? "(none)"}`); + console.log(`Primary email: ${result.primaryEmail ?? "(none)"}${result.primaryEmailVerified ? " (verified)" : ""}`); + console.log(`Anonymous: ${result.isAnonymous ? "yes" : "no"}`); + console.log(`Restricted: ${result.isRestricted ? "yes" : "no"}`); + console.log(`Teams: ${result.teams.length}`); + console.log(`API URL: ${result.apiUrl}`); + console.log(`Dashboard URL: ${result.dashboardUrl}`); + }); +} diff --git a/packages/stack-cli/src/index.ts b/packages/stack-cli/src/index.ts index b3b34179f1..3cf17a7a4f 100644 --- a/packages/stack-cli/src/index.ts +++ b/packages/stack-cli/src/index.ts @@ -15,8 +15,10 @@ 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"; +import { registerDevCommand } from "./commands/dev.js"; import { registerFixCommand } from "./commands/fix.js"; import { registerDoctorCommand } from "./commands/doctor.js"; +import { registerWhoamiCommand } from "./commands/whoami.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -38,12 +40,17 @@ registerConfigCommand(program); registerInitCommand(program); registerProjectCommand(program); registerEmulatorCommand(program); +registerDevCommand(program); +registerWhoamiCommand(program); registerFixCommand(program); registerDoctorCommand(program); async function main() { try { - await program.parseAsync(process.argv); + const argv = process.argv[2] === "--" + ? [process.argv[0], process.argv[1], ...process.argv.slice(3)] + : process.argv; + await program.parseAsync(argv); } catch (err) { if (err instanceof AuthError) { console.error(`Auth error: ${err.message}`); diff --git a/packages/stack-cli/src/lib/dev-env-state.test.ts b/packages/stack-cli/src/lib/dev-env-state.test.ts new file mode 100644 index 0000000000..07bd03ebc7 --- /dev/null +++ b/packages/stack-cli/src/lib/dev-env-state.test.ts @@ -0,0 +1,101 @@ +import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, describe, expect, it } from "vitest"; +import { devEnvStatePath, ensureLocalDashboardSecret, readDevEnvState, recordLocalDashboardProcess, writeDevEnvState } from "./dev-env-state"; + +let tempDir: string | undefined; + +function useTempStateFile() { + tempDir = mkdtempSync(join(tmpdir(), "stack-dev-env-state-")); + process.env.STACK_DEV_ENVS_PATH = join(tempDir, "dev-envs.json"); +} + +afterEach(() => { + delete process.env.STACK_DEV_ENVS_PATH; + delete process.env.LOCALAPPDATA; + if (tempDir != null) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = undefined; + } +}); + +describe("dev env state", () => { + it("uses the Windows local app data directory by default on Windows", () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + Object.defineProperty(process, "platform", { value: "win32" }); + process.env.LOCALAPPDATA = "C:\\Users\\Test\\AppData\\Local"; + try { + expect(devEnvStatePath()).toBe(join("C:\\Users\\Test\\AppData\\Local", "Stack Auth", "dev-envs.json")); + } finally { + Object.defineProperty(process, "platform", platformDescriptor ?? { value: process.platform }); + } + }); + + it("returns an empty v1 state when no file exists", () => { + useTempStateFile(); + expect(readDevEnvState()).toEqual({ + version: 1, + projectsByConfigPath: {}, + }); + }); + + it("persists the dashboard secret without replacing it", () => { + useTempStateFile(); + const first = ensureLocalDashboardSecret(9101); + const second = ensureLocalDashboardSecret(9101); + expect(second).toBe(first); + expect(readDevEnvState().localDashboard).toMatchObject({ + port: 9101, + secret: first, + }); + }); + + it("records the dashboard process without rotating the secret", () => { + useTempStateFile(); + const secret = ensureLocalDashboardSecret(26700); + recordLocalDashboardProcess(26700, secret, 12345, "/tmp/stack-rde-dashboard.log"); + + expect(readDevEnvState().localDashboard).toMatchObject({ + port: 26700, + secret, + pid: 12345, + logPath: "/tmp/stack-rde-dashboard.log", + }); + }); + + it("writes state as owner-readable JSON", () => { + useTempStateFile(); + writeDevEnvState({ + version: 1, + anonymousRefreshToken: "rt", + projectsByConfigPath: {}, + }); + const statePath = process.env.STACK_DEV_ENVS_PATH; + if (statePath == null) { + throw new Error("STACK_DEV_ENVS_PATH should be set by useTempStateFile()."); + } + const content = readFileSync(statePath, "utf-8"); + expect(statSync(statePath).mode & 0o777).toBe(0o600); + expect(JSON.parse(content)).toMatchObject({ + version: 1, + anonymousRefreshToken: "rt", + }); + }); + + it("repairs state file permissions before reading", () => { + useTempStateFile(); + const statePath = process.env.STACK_DEV_ENVS_PATH; + if (statePath == null) { + throw new Error("STACK_DEV_ENVS_PATH should be set by useTempStateFile()."); + } + writeFileSync(statePath, JSON.stringify({ version: 1, projectsByConfigPath: {} })); + chmodSync(statePath, 0o644); + + expect(readDevEnvState()).toEqual({ + version: 1, + projectsByConfigPath: {}, + }); + expect(statSync(statePath).mode & 0o777).toBe(0o600); + }); +}); diff --git a/packages/stack-cli/src/lib/dev-env-state.ts b/packages/stack-cli/src/lib/dev-env-state.ts new file mode 100644 index 0000000000..98675fba2d --- /dev/null +++ b/packages/stack-cli/src/lib/dev-env-state.ts @@ -0,0 +1,88 @@ +import { randomBytes } from "crypto"; +import { chmodSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs"; +import { dirname } from "path"; +import { stackDevEnvStatePath } from "@stackframe/stack-shared/dist/utils/dev-env-state-path"; + +export type DevEnvState = { + version: 1, + anonymousRefreshToken?: string, + localDashboard?: { + port: number, + secret: string, + pid: number, + startedAtMillis: number, + logPath?: string, + }, + anonymousApiBaseUrl?: string, + projectsByConfigPath: Partial>, +}; + +export function devEnvStatePath(): string { + return stackDevEnvStatePath(); +} + +export function readDevEnvState(): DevEnvState { + const path = devEnvStatePath(); + if (!existsSync(path)) { + return { version: 1, projectsByConfigPath: {} }; + } + if ((statSync(path).mode & 0o077) !== 0) { + chmodSync(path, 0o600); + if ((statSync(path).mode & 0o077) !== 0) { + throw new Error(`${path} must not be readable or writable by group/others. Run: chmod 600 ${path}`); + } + } + const parsed = JSON.parse(readFileSync(path, "utf-8")) as Partial; + return { + version: 1, + anonymousRefreshToken: typeof parsed.anonymousRefreshToken === "string" ? parsed.anonymousRefreshToken : undefined, + anonymousApiBaseUrl: typeof parsed.anonymousApiBaseUrl === "string" ? parsed.anonymousApiBaseUrl : undefined, + localDashboard: parsed.localDashboard, + projectsByConfigPath: parsed.projectsByConfigPath ?? {}, + }; +} + +export function writeDevEnvState(state: DevEnvState): void { + const path = devEnvStatePath(); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, JSON.stringify(state, null, 2) + "\n", { mode: 0o600 }); + chmodSync(path, 0o600); +} + +export function ensureLocalDashboardSecret(port: number): string { + const state = readDevEnvState(); + const existing = state.localDashboard?.secret; + const secret = existing ?? randomBytes(32).toString("hex"); + writeDevEnvState({ + ...state, + localDashboard: { + port, + secret, + pid: state.localDashboard?.pid ?? 0, + startedAtMillis: state.localDashboard?.startedAtMillis ?? Date.now(), + logPath: state.localDashboard?.logPath, + }, + }); + return secret; +} + +export function recordLocalDashboardProcess(port: number, secret: string, pid: number, logPath: string): void { + writeDevEnvState({ + ...readDevEnvState(), + localDashboard: { + port, + secret, + pid, + startedAtMillis: Date.now(), + logPath, + }, + }); +} diff --git a/packages/stack-shared/src/config-rendering.ts b/packages/stack-shared/src/config-rendering.ts index 8fe5566a13..fa4cdda354 100644 --- a/packages/stack-shared/src/config-rendering.ts +++ b/packages/stack-shared/src/config-rendering.ts @@ -1,6 +1,8 @@ import { existsSync, readFileSync } from "fs"; import path from "path"; import { isValidConfig, normalize } from "./config/format"; +import { parseStackConfigFileContent } from "./stack-config-file"; +export { parseStackConfigFileContent }; /** * Packages that export the `StackConfig` type, in priority order. @@ -91,6 +93,33 @@ import.meta.vitest?.test("renderConfigFileContent normalizes config exports", ({ };`); }); +import.meta.vitest?.test("parseStackConfigFileContent parses static config exports", ({ expect }) => { + expect(parseStackConfigFileContent(` + import type { StackConfig } from "@stackframe/js"; + export const config: StackConfig = { + auth: { allowSignUp: true }, + payments: { testMode: false }, + }; + `, "stack.config.ts")).toMatchInlineSnapshot(` + { + "auth": { + "allowSignUp": true, + }, + "payments": { + "testMode": false, + }, + } + `); +}); + +import.meta.vitest?.test("parseStackConfigFileContent parses show-onboarding", ({ expect }) => { + expect(parseStackConfigFileContent('export const config = "show-onboarding";', "stack.config.ts")).toBe("show-onboarding"); +}); + +import.meta.vitest?.test("parseStackConfigFileContent rejects dynamic config exports", ({ expect }) => { + expect(() => parseStackConfigFileContent("export const config = makeConfig();", "stack.config.ts")).toThrow(/Unsupported config expression/); +}); + import.meta.vitest?.test("renderConfigFileContent rejects conflicting dotted keys", ({ expect }) => { expect(() => renderConfigFileContent({ "a.b": 1, diff --git a/packages/stack-shared/src/interface/crud/projects.ts b/packages/stack-shared/src/interface/crud/projects.ts index d90d15546c..098b9273eb 100644 --- a/packages/stack-shared/src/interface/crud/projects.ts +++ b/packages/stack-shared/src/interface/crud/projects.ts @@ -85,6 +85,7 @@ export const projectsCrudAdminReadSchema = yupObject({ logo_full_dark_mode_url: schemaFields.projectLogoFullDarkModeUrlSchema.nullable().optional(), created_at_millis: schemaFields.projectCreatedAtMillisSchema.defined(), is_production_mode: schemaFields.projectIsProductionModeSchema.defined(), + is_development_environment: schemaFields.yupBoolean().defined(), owner_team_id: schemaFields.yupString().nullable().defined(), onboarding_status: schemaFields.projectOnboardingStatusSchema.defined(), onboarding_state: projectOnboardingStateSchema.nullable().optional(), @@ -167,6 +168,7 @@ export const projectsCrudAdminUpdateSchema = yupObject({ export const projectsCrudAdminCreateSchema = projectsCrudAdminUpdateSchema.concat(yupObject({ display_name: schemaFields.projectDisplayNameSchema.defined(), + is_development_environment: schemaFields.yupBoolean().optional(), owner_team_id: schemaFields.yupString().uuid().defined(), }).defined()); diff --git a/packages/stack-shared/src/sessions.ts b/packages/stack-shared/src/sessions.ts index 0793d684fa..d53c43de8d 100644 --- a/packages/stack-shared/src/sessions.ts +++ b/packages/stack-shared/src/sessions.ts @@ -271,7 +271,6 @@ export class InternalSession { * @returns An access token, which may be expired or expire soon, or null if it is known to be invalid. */ private _getPotentiallyInvalidAccessTokenIfAvailable(): AccessToken | null { - if (!this._refreshToken) return null; if (this.isKnownToBeInvalid()) return null; const accessToken = this._accessToken.get(); diff --git a/packages/stack-shared/src/stack-config-file.ts b/packages/stack-shared/src/stack-config-file.ts new file mode 100644 index 0000000000..96aa7430d7 --- /dev/null +++ b/packages/stack-shared/src/stack-config-file.ts @@ -0,0 +1,91 @@ +import * as parser from "@babel/parser"; +import * as t from "@babel/types"; + +export const showOnboardingStackConfigValue = "show-onboarding"; + +type ParsedStackConfig = Record | typeof showOnboardingStackConfigValue; + +function unwrapStaticConfigExpression(expression: t.Expression): t.Expression { + if ( + t.isTSAsExpression(expression) + || t.isTSSatisfiesExpression(expression) + || t.isTSTypeAssertion(expression) + || t.isTSNonNullExpression(expression) + ) { + return unwrapStaticConfigExpression(expression.expression); + } + return expression; +} + +function evaluateStaticConfigExpression(expression: t.Expression): unknown { + const unwrapped = unwrapStaticConfigExpression(expression); + if (t.isStringLiteral(unwrapped)) return unwrapped.value; + if (t.isBooleanLiteral(unwrapped)) return unwrapped.value; + if (t.isNumericLiteral(unwrapped)) return unwrapped.value; + if (t.isNullLiteral(unwrapped)) return null; + if (t.isIdentifier(unwrapped) && unwrapped.name === "undefined") return undefined; + if (t.isUnaryExpression(unwrapped) && unwrapped.operator === "-" && t.isNumericLiteral(unwrapped.argument)) { + return -unwrapped.argument.value; + } + if (t.isArrayExpression(unwrapped)) { + return unwrapped.elements.map((element) => { + if (element == null || t.isSpreadElement(element)) { + throw new Error("Config arrays cannot contain holes or spreads."); + } + return evaluateStaticConfigExpression(element); + }); + } + if (t.isObjectExpression(unwrapped)) { + const result: Record = {}; + for (const property of unwrapped.properties) { + if (t.isSpreadElement(property)) { + throw new Error("Config objects cannot contain spreads."); + } + if (property.computed) { + throw new Error("Config object keys cannot be computed."); + } + const key = t.isIdentifier(property.key) + ? property.key.name + : t.isStringLiteral(property.key) || t.isNumericLiteral(property.key) + ? String(property.key.value) + : null; + if (key == null) { + throw new Error("Unsupported config object key."); + } + if (t.isObjectMethod(property)) { + throw new Error("Config objects cannot contain methods."); + } + if (!t.isExpression(property.value)) { + throw new Error("Unsupported config object value."); + } + result[key] = evaluateStaticConfigExpression(property.value); + } + return result; + } + throw new Error(`Unsupported config expression: ${unwrapped.type}`); +} + +export function parseStackConfigFileContent(content: string, filePath: string): ParsedStackConfig { + if (content.trim() === "") return {}; + const ast = parser.parse(content, { + sourceType: "module", + plugins: ["typescript"], + }); + + for (const statement of ast.program.body) { + if (!t.isExportNamedDeclaration(statement) || !t.isVariableDeclaration(statement.declaration)) { + continue; + } + for (const declaration of statement.declaration.declarations) { + if (!t.isIdentifier(declaration.id) || declaration.id.name !== "config") { + continue; + } + if (declaration.init == null || !t.isExpression(declaration.init)) { + throw new Error(`Config export in ${filePath} must have an initializer.`); + } + return evaluateStaticConfigExpression(declaration.init) as ParsedStackConfig; + } + } + + throw new Error(`Invalid config in ${filePath}. The file must export a plain \`config\` object or "show-onboarding".`); +} diff --git a/packages/stack-shared/src/utils/dev-env-state-path.ts b/packages/stack-shared/src/utils/dev-env-state-path.ts new file mode 100644 index 0000000000..b6083b55b1 --- /dev/null +++ b/packages/stack-shared/src/utils/dev-env-state-path.ts @@ -0,0 +1,14 @@ +import { homedir } from "os"; +import { join } from "path"; + +export function defaultStackDevEnvStatePath(): string { + if (process.platform === "win32") { + const localAppData = process.env.LOCALAPPDATA ?? join(homedir(), "AppData", "Local"); + return join(localAppData, "Stack Auth", "dev-envs.json"); + } + return join(homedir(), ".stack", "dev-envs.json"); +} + +export function stackDevEnvStatePath(): string { + return process.env.STACK_DEV_ENVS_PATH ?? defaultStackDevEnvStatePath(); +} diff --git a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts index 4f84b215d1..dc594b1e8c 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/admin-app-impl.ts @@ -183,6 +183,7 @@ export class _StackAdminAppImplIncomplete { @@ -665,7 +667,7 @@ export class _StackClientAppImplIncomplete { diff --git a/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts b/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts index be3cf89d8a..d8f2762a79 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts @@ -32,6 +32,12 @@ export type AnalyticsReplayOptions = { }; export type AnalyticsOptions = { + /** + * Whether SDK-managed analytics capture is enabled. + * + * @default true + */ + enabled?: boolean, /** * Options for session replay recording. Replays are disabled by default; * set `enabled: true` to opt in. diff --git a/packages/template/src/lib/stack-app/projects/index.ts b/packages/template/src/lib/stack-app/projects/index.ts index 1888adf17e..950a8c8e9a 100644 --- a/packages/template/src/lib/stack-app/projects/index.ts +++ b/packages/template/src/lib/stack-app/projects/index.ts @@ -35,6 +35,7 @@ export type AdminProject = { readonly description: string | null, readonly createdAt: Date, readonly isProductionMode: boolean, + readonly isDevelopmentEnvironment: boolean, readonly ownerTeamId: string | null, readonly onboardingStatus: ProjectOnboardingStatus, readonly logoUrl: string | null | undefined, @@ -221,11 +222,13 @@ export function adminProjectUpdateOptionsToCrud(options: AdminProjectUpdateOptio export type AdminProjectCreateOptions = Omit & { displayName: string, teamId: string, + isDevelopmentEnvironment?: boolean, }; export function adminProjectCreateOptionsToCrud(options: AdminProjectCreateOptions): AdminUserProjectsCrud["Server"]["Create"] { return { ...adminProjectUpdateOptionsToCrud(options), display_name: options.displayName, + is_development_environment: options.isDevelopmentEnvironment, owner_team_id: options.teamId, }; } diff --git a/turbo.json b/turbo.json index 5a392d98cc..317b767214 100644 --- a/turbo.json +++ b/turbo.json @@ -43,6 +43,25 @@ ], "outputLogs": "new-only" }, + "build:rde-standalone": { + "inputs": [ + "$TURBO_DEFAULT$", + ".env", + ".env.local", + ".env.development", + ".env.development.local", + ".env.production", + ".env.production.local" + ], + "dependsOn": [ + "^build" + ], + "outputs": [ + ".next/**", + "src/generated/bundled-type-definitions.ts" + ], + "outputLogs": "new-only" + }, "docker-build": { "inputs": [ "$TURBO_DEFAULT$", From 8f87d9a703a7f01587b82b117f96caac836cb29f Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 15 May 2026 14:38:41 -0700 Subject: [PATCH 2/7] Fixes --- .../config/apply-update/route.ts | 30 +++++--- apps/dashboard/src/lib/config-update.tsx | 30 ++++++++ .../config-file.ts | 10 ++- .../remote-development-environment/manager.ts | 71 ++++++++++++++++--- .../security.test.ts | 2 +- 5 files changed, 123 insertions(+), 20 deletions(-) diff --git a/apps/dashboard/src/app/api/remote-development-environment/config/apply-update/route.ts b/apps/dashboard/src/app/api/remote-development-environment/config/apply-update/route.ts index f8dd985ff9..168e9158e8 100644 --- a/apps/dashboard/src/app/api/remote-development-environment/config/apply-update/route.ts +++ b/apps/dashboard/src/app/api/remote-development-environment/config/apply-update/route.ts @@ -1,28 +1,42 @@ import { NextRequest, NextResponse } from "next/server"; import { applyRemoteDevelopmentEnvironmentConfigUpdate } from "@/lib/remote-development-environment/manager"; -import { assertRemoteDevelopmentEnvironmentRequest } from "@/lib/remote-development-environment/security"; +import { assertRemoteDevelopmentEnvironmentBrowserRequest, assertRemoteDevelopmentEnvironmentRequest } from "@/lib/remote-development-environment/security"; import { isValidConfig } from "@stackframe/stack-shared/dist/config/format"; export const runtime = "nodejs"; export async function POST(req: NextRequest) { - const securityResponse = assertRemoteDevelopmentEnvironmentRequest(req); + const securityResponse = req.headers.has("authorization") + ? assertRemoteDevelopmentEnvironmentRequest(req) + : assertRemoteDevelopmentEnvironmentBrowserRequest(req); if (securityResponse != null) return securityResponse; const body = await req.json() as { session_id?: unknown, - config?: unknown, + project_id?: unknown, + config_update?: unknown, + wait_for_sync?: unknown, }; - if (typeof body.session_id !== "string" || body.config == null || typeof body.config !== "object" || Array.isArray(body.config)) { - return NextResponse.json({ error: "session_id and config object are required." }, { status: 400 }); + if ( + (body.session_id !== undefined && typeof body.session_id !== "string") || + (body.project_id !== undefined && typeof body.project_id !== "string") || + (body.wait_for_sync !== undefined && typeof body.wait_for_sync !== "boolean") || + (body.session_id === undefined && body.project_id === undefined) || + body.config_update == null || + typeof body.config_update !== "object" || + Array.isArray(body.config_update) + ) { + return NextResponse.json({ error: "session_id or project_id, and config_update object are required." }, { status: 400 }); } - if (!isValidConfig(body.config)) { - return NextResponse.json({ error: "config must be a valid Stack Auth config object." }, { status: 400 }); + if (!isValidConfig(body.config_update)) { + return NextResponse.json({ error: "config_update must be a valid Stack Auth config object." }, { status: 400 }); } await applyRemoteDevelopmentEnvironmentConfigUpdate({ sessionId: body.session_id, - config: body.config, + projectId: body.project_id, + configUpdate: body.config_update, + waitForSync: body.wait_for_sync ?? true, }); return NextResponse.json({ ok: true }); } diff --git a/apps/dashboard/src/lib/config-update.tsx b/apps/dashboard/src/lib/config-update.tsx index 5490f06222..fcb884175d 100644 --- a/apps/dashboard/src/lib/config-update.tsx +++ b/apps/dashboard/src/lib/config-update.tsx @@ -2,6 +2,7 @@ import { Link } from "@/components/link"; import { ActionDialog } from "@/components/ui/action-dialog"; +import { getPublicEnvVar } from "@/lib/env"; import type { PushedConfigSource, StackAdminApp } from "@stackframe/stack"; import type { EnvironmentConfigOverrideOverride } from "@stackframe/stack-shared/dist/config/schema"; import React, { createContext, useCallback, useContext, useState } from "react"; @@ -210,6 +211,27 @@ function useConfigUpdateDialog() { return context; } +async function updateRemoteDevelopmentEnvironmentConfigFile( + adminApp: StackAdminApp, + configUpdate: EnvironmentConfigOverrideOverride, +): Promise { + const response = await fetch("/api/remote-development-environment/config/apply-update", { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + project_id: adminApp.projectId, + config_update: configUpdate, + wait_for_sync: false, + }), + }); + if (!response.ok) { + throw new Error(`Failed to update local development environment config (${response.status}): ${await response.text()}`); + } +} + /** * Options for the updateConfig utility function. */ @@ -265,6 +287,14 @@ export function useUpdateConfig() { return useCallback(async (options: UpdateConfigOptions): Promise => { const { adminApp, configUpdate, pushable } = options; + if (getPublicEnvVar("NEXT_PUBLIC_STACK_IS_REMOTE_DEVELOPMENT_ENVIRONMENT") === "true") { + const project = await adminApp.getProject(); + await updateRemoteDevelopmentEnvironmentConfigFile(adminApp, configUpdate); + // Update the remote project immediately so the dashboard reads the new value before the file sync lands. + await project.updatePushedConfig(configUpdate); + return true; + } + if (pushable) { // Show dialog (or save directly if unlinked) based on source type return await showPushableDialog(adminApp, configUpdate); diff --git a/apps/dashboard/src/lib/remote-development-environment/config-file.ts b/apps/dashboard/src/lib/remote-development-environment/config-file.ts index 3c067cf00c..e073ff2f40 100644 --- a/apps/dashboard/src/lib/remote-development-environment/config-file.ts +++ b/apps/dashboard/src/lib/remote-development-environment/config-file.ts @@ -1,6 +1,7 @@ import "server-only"; import { showOnboardingStackConfigValue } from "@stackframe/stack-shared/dist/config-authoring"; +import { Config, isValidConfig } from "@stackframe/stack-shared/dist/config/format"; import { detectImportPackageFromDir, renderConfigFileContent } from "@stackframe/stack-shared/dist/config-rendering"; import { parseStackConfigFileContent } from "@stackframe/stack-shared/dist/stack-config-file"; import { createHash } from "crypto"; @@ -23,11 +24,11 @@ export function ensureConfigFileExists(configFilePath: string): void { writeConfigObject(configFilePath, {}); } -export function readConfigObject(configFilePath: string): Record { +export function readConfigObject(configFilePath: string): Config { return readConfigFile(configFilePath).config; } -export function readConfigFile(configFilePath: string): { config: Record, showOnboarding: boolean } { +export function readConfigFile(configFilePath: string): { config: Config, showOnboarding: boolean } { ensureConfigFileExists(configFilePath); const content = readFileSync(configFilePath, "utf-8"); const config = parseStackConfigFileContent(content, configFilePath); @@ -37,13 +38,16 @@ export function readConfigFile(configFilePath: string): { config: Record): void { +export function writeConfigObject(configFilePath: string, config: Config): void { const dir = path.dirname(configFilePath); mkdirSync(dir, { recursive: true }); const importPackage = detectImportPackageFromDir(dir); diff --git a/apps/dashboard/src/lib/remote-development-environment/manager.ts b/apps/dashboard/src/lib/remote-development-environment/manager.ts index 3dbc6ce848..c8b0f2a3b2 100644 --- a/apps/dashboard/src/lib/remote-development-environment/manager.ts +++ b/apps/dashboard/src/lib/remote-development-environment/manager.ts @@ -3,6 +3,7 @@ import "server-only"; import { getPublicEnvVar } from "@/lib/env"; import { stackAppInternalsSymbol } from "@/lib/stack-app-internals"; import { AdminOwnedProject, StackClientApp } from "@stackframe/stack"; +import { Config, override } from "@stackframe/stack-shared/dist/config/format"; import { DEFAULT_EMAIL_THEME_ID } from "@stackframe/stack-shared/dist/helpers/emails"; import { ProjectOnboardingStatus } from "@stackframe/stack-shared/dist/schema-fields"; import { AccessToken } from "@stackframe/stack-shared/dist/sessions"; @@ -41,6 +42,7 @@ type RemoteDevelopmentEnvironmentGlobals = { watchers: Map, syncTimers: Map, syncErrors: Map, + synchronouslyUpdatingConfigFiles: Set, shutdownTimerStarted: boolean, startedAtMs: number, activeOperations: number, @@ -62,6 +64,7 @@ function getGlobals(): RemoteDevelopmentEnvironmentGlobals { watchers: new Map(), syncTimers: new Map(), syncErrors: new Map(), + synchronouslyUpdatingConfigFiles: new Set(), shutdownTimerStarted: false, startedAtMs: performance.now(), activeOperations: 0, @@ -360,8 +363,9 @@ async function syncConfigToRemote(configFilePath: string): Promise ({ ...current, @@ -394,6 +397,12 @@ async function syncConfigToRemote(configFilePath: string): Promise { + const state = getGlobals(); + const pendingTimer = state.syncTimers.get(configFilePath); + if (pendingTimer != null) { + clearTimeout(pendingTimer); + state.syncTimers.delete(configFilePath); + } + const onboardingStatus = await syncConfigToRemote(configFilePath); + state.syncErrors.delete(configFilePath); + return onboardingStatus; +} + function ensureWatcher(configFilePath: string): void { const state = getGlobals(); if (state.watchers.has(configFilePath)) return; @@ -511,7 +532,7 @@ export async function registerRemoteDevelopmentEnvironmentSession(options: { configFilePath, }); ensureWatcher(configFilePath); - const onboardingStatus = await syncConfigToRemote(configFilePath); + const onboardingStatus = await syncConfigToRemoteNow(configFilePath); return { sessionId, env: envVarsForProject(project), @@ -580,28 +601,62 @@ export function getRemoteDevelopmentEnvironmentHealth(): { } export async function applyRemoteDevelopmentEnvironmentConfigUpdate(options: { - sessionId: string, - config: Record, + sessionId?: string, + projectId?: string, + configUpdate: Config, + waitForSync?: boolean, }): Promise { assertRemoteDevelopmentEnvironmentEnabled(); const endOperation = beginRemoteDevelopmentEnvironmentOperation("config update", { sessionId: options.sessionId, + projectId: options.projectId, }); try { - const session = getGlobals().sessions.get(options.sessionId); + const state = getGlobals(); + const session = (() => { + if (options.sessionId != null) { + return state.sessions.get(options.sessionId); + } + if (options.projectId == null) { + throw new Error("Remote development environment config update requires a session ID or project ID."); + } + for (const activeSession of state.sessions.values()) { + const stateProject = readRemoteDevelopmentEnvironmentState().projectsByConfigPath[activeSession.configFilePath]; + if (stateProject?.projectId === options.projectId) { + return activeSession; + } + } + return undefined; + })(); if (session == null) { throw new Error("Remote development environment session is not active."); } const configFilePath = session.configFilePath; logRemoteDevelopmentEnvironment("Applying config update from local dashboard", { sessionId: options.sessionId, + projectId: options.projectId, configFilePath, }); - writeConfigObject(configFilePath, options.config); - await syncConfigToRemote(configFilePath); + const currentConfig = readConfigFile(configFilePath).config; + if (options.waitForSync === false) { + writeConfigObject(configFilePath, override(currentConfig, options.configUpdate)); + scheduleSync(configFilePath); + } else { + state.synchronouslyUpdatingConfigFiles.add(configFilePath); + try { + writeConfigObject(configFilePath, override(currentConfig, options.configUpdate)); + } finally { + setTimeout(() => { + state.synchronouslyUpdatingConfigFiles.delete(configFilePath); + }, SYNC_DEBOUNCE_MS).unref(); + } + await syncConfigToRemoteNow(configFilePath); + } logRemoteDevelopmentEnvironment("Applied config update from local dashboard", { sessionId: options.sessionId, + projectId: options.projectId, configFilePath, + waitForSync: options.waitForSync ?? true, }); } finally { endOperation(); diff --git a/apps/dashboard/src/lib/remote-development-environment/security.test.ts b/apps/dashboard/src/lib/remote-development-environment/security.test.ts index 6070fb1f6f..bd7739b78f 100644 --- a/apps/dashboard/src/lib/remote-development-environment/security.test.ts +++ b/apps/dashboard/src/lib/remote-development-environment/security.test.ts @@ -102,7 +102,7 @@ describe("remote development environment security", () => { const { applyRemoteDevelopmentEnvironmentConfigUpdate } = await import("./manager"); await expect(applyRemoteDevelopmentEnvironmentConfigUpdate({ sessionId: "missing", - config: {}, + configUpdate: {}, })).rejects.toThrow(/session is not active/); }); From e801e531db1a1e209f8db023b26c0c549bf5fb65 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 15 May 2026 15:05:43 -0700 Subject: [PATCH 3/7] Fix CI/CD --- turbo.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/turbo.json b/turbo.json index 3a595a74fb..109b1210b7 100644 --- a/turbo.json +++ b/turbo.json @@ -54,7 +54,7 @@ ".env.production.local" ], "dependsOn": [ - "^build" + "build" ], "outputs": [ ".next/**", From c40cc4fd5ed29d4b1142caf8628263589c27745c Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 15 May 2026 18:23:25 -0700 Subject: [PATCH 4/7] PR comments --- .claude/CLAUDE-KNOWLEDGE.md | 3 + apps/backend/src/lib/config.tsx | 5 +- apps/backend/src/lib/local-emulator.test.ts | 11 ++- apps/backend/src/lib/local-emulator.ts | 2 +- .../src/route-handlers/smart-request.tsx | 1 + apps/dashboard/next.config.mjs | 2 +- apps/dashboard/package.json | 2 +- .../(outside-dashboard)/projects/actions.ts | 12 ++- .../development-environment/health/route.ts | 7 ++ .../auth/route.ts | 33 +-------- .../config/apply-update/route.ts | 16 +++- .../sessions/route.ts | 16 +++- apps/dashboard/src/app/layout-client.tsx | 12 ++- packages/stack-cli/src/commands/dev.ts | 74 ++++++++++++++++--- .../stack-cli/src/lib/dev-env-state.test.ts | 35 ++++++--- packages/stack-cli/src/lib/dev-env-state.ts | 2 +- .../implementations/session-replay.test.ts | 29 ++++++++ .../apps/implementations/session-replay.ts | 2 + 18 files changed, 201 insertions(+), 63 deletions(-) create mode 100644 packages/template/src/lib/stack-app/apps/implementations/session-replay.test.ts diff --git a/.claude/CLAUDE-KNOWLEDGE.md b/.claude/CLAUDE-KNOWLEDGE.md index e1c1ec4224..0e40cd783b 100644 --- a/.claude/CLAUDE-KNOWLEDGE.md +++ b/.claude/CLAUDE-KNOWLEDGE.md @@ -484,3 +484,6 @@ A: The RDE dashboard does server-side SDK calls from Node. If the backend is con ## Q: How should Stack CLI `--config-file` options interpret paths? A: `--config-file` should point directly to a regular config file. Do not treat an existing directory as a shortcut for `stack.config.ts` inside it; reject directories with a clear error instead. `stack config pull` may default to `./stack.config.ts` when the flag is omitted, but an explicitly provided directory is still invalid. + +## Q: How should RDE PR-review fixes handle the local dashboard and CLI lifecycle? +A: Use the shared RDE browser security helper for browser-local endpoints, mark bearer-token responses `Cache-Control: private, no-store`, and return 400 for malformed local endpoint JSON. `stack dev` should fail loudly if a bundled dashboard sentinel has no environment value, validate session response shapes at runtime before using `env`, recover from HTTP heartbeat failures the same way as network heartbeat failures, and make heartbeat shutdown interruptible so child-process exit is not delayed by the full heartbeat interval. diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index d9f7f40ff5..f6585f95d5 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -206,10 +206,11 @@ export function getEnvironmentConfigOverrideQuery(options: EnvironmentOptions): if (queryResult.length > 1) { throw new StackAssertionError(`Expected 0 or 1 environment config overrides for project ${options.projectId} and branch ${options.branchId}, got ${queryResult.length}`, { queryResult }); } + const storedConfigOverride = migrateConfigOverride("environment", queryResult[0]?.config ?? {}); if (queryResult[0]?.isDevelopmentEnvironment === true) { - return DEVELOPMENT_ENVIRONMENT_CONFIG_OVERRIDE; + return override(storedConfigOverride, DEVELOPMENT_ENVIRONMENT_CONFIG_OVERRIDE); } - return migrateConfigOverride("environment", queryResult[0]?.config ?? {}); + return storedConfigOverride; }, }; } diff --git a/apps/backend/src/lib/local-emulator.test.ts b/apps/backend/src/lib/local-emulator.test.ts index 7bd90797af..0d07429c7b 100644 --- a/apps/backend/src/lib/local-emulator.test.ts +++ b/apps/backend/src/lib/local-emulator.test.ts @@ -53,7 +53,16 @@ describe("local emulator config", () => { 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 plain `config` object or \"show-onboarding\"." + "Error evaluating config in /irrelevant/path/stack.config.ts: Invalid config in /irrelevant/path/stack.config.ts. The file must export a plain `config` object or \"show-onboarding\"." + ); + }); + + it("includes the config file path when static config parsing fails", async () => { + const content = `export const config = makeConfig();\n`; + vi.stubEnv("STACK_LOCAL_EMULATOR_CONFIG_CONTENT", Buffer.from(content).toString("base64")); + + await expect(readConfigFromFile("/irrelevant/path/stack.config.ts")).rejects.toThrow( + "Error evaluating config in /irrelevant/path/stack.config.ts: Unsupported config expression: CallExpression" ); }); diff --git a/apps/backend/src/lib/local-emulator.ts b/apps/backend/src/lib/local-emulator.ts index eeda04a0cd..a9ea9e2538 100644 --- a/apps/backend/src/lib/local-emulator.ts +++ b/apps/backend/src/lib/local-emulator.ts @@ -79,7 +79,7 @@ async function readConfigValueFromFile(filePath: string): Promise { const result = await decodeAccessToken(options.token, { allowAnonymous: options.allowAnonymous, + // Anonymous dev-environment tokens may be restricted; non-anonymous restricted tokens are rejected below after decoding. allowRestricted: options.allowAnonymous, }); if (result.status === "error") { diff --git a/apps/dashboard/next.config.mjs b/apps/dashboard/next.config.mjs index 03dda09c71..9349d7837b 100644 --- a/apps/dashboard/next.config.mjs +++ b/apps/dashboard/next.config.mjs @@ -57,7 +57,7 @@ const nextConfig = { poweredByHeader: false, typescript: { - ignoreBuildErrors: process.env.NEXT_CONFIG_DISABLE_TYPESCRIPT === "true", + ignoreBuildErrors: process.env.STACK_NEXT_CONFIG_DISABLE_TYPESCRIPT === "true", }, images: { diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index eeae1fd5c9..cb1784dd92 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -12,7 +12,7 @@ "bundle-type-definitions": "tsx scripts/bundle-type-definitions.ts", "bundle-type-definitions:watch": "tsx watch --clear-screen=false scripts/bundle-type-definitions.ts", "build": "pnpm run bundle-type-definitions && next build", - "build:rde-standalone": "NEXT_CONFIG_OUTPUT=standalone NEXT_CONFIG_DISABLE_TYPESCRIPT=true pnpm run build", + "build:rde-standalone": "NEXT_CONFIG_OUTPUT=standalone STACK_NEXT_CONFIG_DISABLE_TYPESCRIPT=true pnpm run build", "docker-build": "pnpm run bundle-type-definitions && next build --experimental-build-mode compile", "analyze-bundle": "next experimental-analyze", "start": "next start --port ${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}01", diff --git a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts index 8c5aa38bdc..7e5ef6acfd 100644 --- a/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts +++ b/apps/dashboard/src/app/(main)/(protected)/(outside-dashboard)/projects/actions.ts @@ -1,8 +1,16 @@ "use server"; -import { stackServerApp } from "@/stack/server"; +import { isRemoteDevelopmentEnvironmentEnabled } from "@/lib/remote-development-environment/env"; + +async function getStackServerApp() { + if (isRemoteDevelopmentEnvironmentEnabled()) { + throw new Error("Team invitation management is not available in the remote development environment dashboard."); + } + return (await import("@/stack/server")).stackServerApp; +} export async function revokeInvitation(teamId: string, invitationId: string) { "use server"; + const stackServerApp = await getStackServerApp(); const user = await stackServerApp.getUser(); const team = await user?.getTeam(teamId); if (!team) { @@ -16,6 +24,7 @@ export async function revokeInvitation(teamId: string, invitationId: string) { } export async function listInvitations(teamId: string) { + const stackServerApp = await getStackServerApp(); const user = await stackServerApp.getUser(); const team = await user?.getTeam(teamId); if (!team) { @@ -30,6 +39,7 @@ export async function listInvitations(teamId: string) { } export async function inviteUser(teamId: string, email: string, origin: string) { + const stackServerApp = await getStackServerApp(); const callbackUrl = new URL(stackServerApp.urls.teamInvitation, origin).toString(); const user = await stackServerApp.getUser(); const team = await user?.getTeam(teamId); diff --git a/apps/dashboard/src/app/api/development-environment/health/route.ts b/apps/dashboard/src/app/api/development-environment/health/route.ts index 16445a16bc..1cb6f3e824 100644 --- a/apps/dashboard/src/app/api/development-environment/health/route.ts +++ b/apps/dashboard/src/app/api/development-environment/health/route.ts @@ -4,6 +4,8 @@ import { isLocalhost } from "@stackframe/stack-shared/dist/utils/urls"; export const runtime = "nodejs"; +const LOCAL_EMULATOR_HEALTH_TIMEOUT_MS = 2_000; + type HealthResponse = { ok: boolean, restart_command: string, @@ -40,9 +42,12 @@ async function localEmulatorIsHealthy(): Promise { const apiBaseUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL"); if (apiBaseUrl == null) return false; + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), LOCAL_EMULATOR_HEALTH_TIMEOUT_MS); try { const response = await fetch(`${apiBaseUrl}/api/v1/projects/current`, { cache: "no-store", + signal: controller.signal, headers: { "X-Stack-Access-Type": "client", "X-Stack-Project-Id": "internal", @@ -52,6 +57,8 @@ async function localEmulatorIsHealthy(): Promise { return response.ok; } catch { return false; + } finally { + clearTimeout(timeout); } } diff --git a/apps/dashboard/src/app/api/remote-development-environment/auth/route.ts b/apps/dashboard/src/app/api/remote-development-environment/auth/route.ts index 343a414c00..3eb60bc049 100644 --- a/apps/dashboard/src/app/api/remote-development-environment/auth/route.ts +++ b/apps/dashboard/src/app/api/remote-development-environment/auth/route.ts @@ -1,40 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; -import { isRemoteDevelopmentEnvironmentEnabled } from "@/lib/remote-development-environment/env"; -import { isLocalhost } from "@stackframe/stack-shared/dist/utils/urls"; +import { assertRemoteDevelopmentEnvironmentBrowserRequest } from "@/lib/remote-development-environment/security"; export const runtime = "nodejs"; const INTERNAL_PROJECT_ID = "internal"; -function requestHostIsLoopback(req: NextRequest): boolean { - const host = req.headers.get("host"); - if (host == null) return false; - return isLocalhost(`http://${host}`); -} - -function originIsAllowed(req: NextRequest): boolean { - const origin = req.headers.get("origin"); - if (origin == null) return true; - return isLocalhost(origin); -} - -function assertRemoteDevelopmentEnvironmentBrowserRequest(req: NextRequest): NextResponse | null { - if (!isRemoteDevelopmentEnvironmentEnabled()) { - return NextResponse.json({ error: "Remote development environment endpoints are disabled." }, { status: 404 }); - } - - if (!requestHostIsLoopback(req) || !originIsAllowed(req)) { - return NextResponse.json({ error: "Remote development environment endpoints only accept loopback requests." }, { status: 403 }); - } - - const fetchSite = req.headers.get("sec-fetch-site"); - if (fetchSite != null && fetchSite !== "same-origin" && fetchSite !== "none") { - return NextResponse.json({ error: "Remote development environment browser auth only accepts same-origin navigation." }, { status: 403 }); - } - - return null; -} - function isInternalProjectRefreshCookieName(name: string): boolean { return ( name === "stack-refresh" || @@ -65,6 +35,7 @@ export async function GET(req: NextRequest) { issued_at_millis: token.issuedAtMillis, user_id: token.userId, }); + response.headers.set("Cache-Control", "private, no-store"); deleteInternalProjectAuthCookies(req, response); return response; } diff --git a/apps/dashboard/src/app/api/remote-development-environment/config/apply-update/route.ts b/apps/dashboard/src/app/api/remote-development-environment/config/apply-update/route.ts index 168e9158e8..3b1dd4549c 100644 --- a/apps/dashboard/src/app/api/remote-development-environment/config/apply-update/route.ts +++ b/apps/dashboard/src/app/api/remote-development-environment/config/apply-update/route.ts @@ -5,13 +5,27 @@ import { isValidConfig } from "@stackframe/stack-shared/dist/config/format"; export const runtime = "nodejs"; +async function readJsonBody(req: NextRequest): Promise { + try { + return await req.json(); + } catch (error) { + if (error instanceof SyntaxError) { + return NextResponse.json({ error: "Malformed JSON request body." }, { status: 400 }); + } + throw error; + } +} + export async function POST(req: NextRequest) { const securityResponse = req.headers.has("authorization") ? assertRemoteDevelopmentEnvironmentRequest(req) : assertRemoteDevelopmentEnvironmentBrowserRequest(req); if (securityResponse != null) return securityResponse; - const body = await req.json() as { + const parsedBody = await readJsonBody(req); + if (parsedBody instanceof NextResponse) return parsedBody; + + const body = parsedBody as { session_id?: unknown, project_id?: unknown, config_update?: unknown, diff --git a/apps/dashboard/src/app/api/remote-development-environment/sessions/route.ts b/apps/dashboard/src/app/api/remote-development-environment/sessions/route.ts index 38342c5d1d..bcddd41e28 100644 --- a/apps/dashboard/src/app/api/remote-development-environment/sessions/route.ts +++ b/apps/dashboard/src/app/api/remote-development-environment/sessions/route.ts @@ -5,6 +5,17 @@ import { createUrlIfValid, isLocalhost } from "@stackframe/stack-shared/dist/uti export const runtime = "nodejs"; +async function readJsonBody(req: NextRequest): Promise { + try { + return await req.json(); + } catch (error) { + if (error instanceof SyntaxError) { + return NextResponse.json({ error: "Malformed JSON request body." }, { status: 400 }); + } + throw error; + } +} + function isAllowedApiBaseUrl(value: string): boolean { const url = createUrlIfValid(value); if (url == null || (url.protocol !== "http:" && url.protocol !== "https:")) return false; @@ -15,7 +26,10 @@ export async function POST(req: NextRequest) { const securityResponse = assertRemoteDevelopmentEnvironmentRequest(req); if (securityResponse != null) return securityResponse; - const body = await req.json() as { + const parsedBody = await readJsonBody(req); + if (parsedBody instanceof NextResponse) return parsedBody; + + const body = parsedBody as { api_base_url?: unknown, config_path?: unknown, }; diff --git a/apps/dashboard/src/app/layout-client.tsx b/apps/dashboard/src/app/layout-client.tsx index 7725094752..ba3b3207bd 100644 --- a/apps/dashboard/src/app/layout-client.tsx +++ b/apps/dashboard/src/app/layout-client.tsx @@ -37,6 +37,7 @@ function isDevEnvironmentHealthResponse(value: unknown): value is { ok: boolean, let devEnvironmentHealthSnapshot: DevEnvironmentHealthSnapshot = { status: "checking" }; const devEnvironmentHealthSubscribers = new Set<() => void>(); let devEnvironmentHealthTimer: ReturnType | undefined; +let devEnvironmentHealthRequestSequence = 0; function setDevEnvironmentHealthSnapshot(snapshot: DevEnvironmentHealthSnapshot) { devEnvironmentHealthSnapshot = snapshot; @@ -46,6 +47,13 @@ function setDevEnvironmentHealthSnapshot(snapshot: DevEnvironmentHealthSnapshot) } async function refreshDevEnvironmentHealth() { + const requestSequence = ++devEnvironmentHealthRequestSequence; + const setSnapshotIfCurrent = (snapshot: DevEnvironmentHealthSnapshot) => { + if (requestSequence === devEnvironmentHealthRequestSequence) { + setDevEnvironmentHealthSnapshot(snapshot); + } + }; + try { const response = await fetch("/api/development-environment/health", { cache: "no-store", @@ -58,11 +66,11 @@ async function refreshDevEnvironmentHealth() { throw new Error("Development environment health endpoint returned an invalid response."); } - setDevEnvironmentHealthSnapshot(body.ok && response.ok + setSnapshotIfCurrent(body.ok && response.ok ? { status: "healthy" } : { status: "unhealthy", restartCommand: body.restart_command }); } catch { - setDevEnvironmentHealthSnapshot({ + setSnapshotIfCurrent({ status: "unhealthy", restartCommand: "stack dev --config-file -- ", }); diff --git a/packages/stack-cli/src/commands/dev.ts b/packages/stack-cli/src/commands/dev.ts index 1e44f3687b..7945d6c28f 100644 --- a/packages/stack-cli/src/commands/dev.ts +++ b/packages/stack-cli/src/commands/dev.ts @@ -25,6 +25,7 @@ type SessionResponse = { }; const HEARTBEAT_INTERVAL_MS = 5_000; +const HEARTBEAT_STOP_POLL_MS = 100; const DASHBOARD_RESTART_MIN_UPTIME_MS = 5_000; const DASHBOARD_PORT = 26700; const DASHBOARD_START_TIMEOUT_MS = 60_000; @@ -168,7 +169,12 @@ function replaceSentinels(content: string, env: NodeJS.ProcessEnv): string { if (!sentinel.startsWith(SENTINEL_PREFIX)) { return sentinel; } - return env[sentinel.slice(SENTINEL_PREFIX.length)] ?? sentinel; + const envVarName = sentinel.slice(SENTINEL_PREFIX.length); + const value = env[envVarName]; + if (value == null) { + throw new CliError(`Missing environment variable ${envVarName} while preparing the bundled dashboard runtime.`); + } + return value; }); } @@ -297,6 +303,31 @@ async function dashboardRequest(path: string, options: RequestInit, secret: stri } } +function isStringRecord(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.values(value).every((entry) => typeof entry === "string") + ); +} + +function isSessionResponse(value: unknown): value is SessionResponse { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + "session_id" in value && + typeof value.session_id === "string" && + "project_id" in value && + typeof value.project_id === "string" && + "onboarding_outstanding" in value && + typeof value.onboarding_outstanding === "boolean" && + "env" in value && + isStringRecord(value.env) + ); +} + async function createRemoteDevelopmentEnvironmentSession(options: { apiBaseUrl: string, configFilePath: string, @@ -315,13 +346,8 @@ async function createRemoteDevelopmentEnvironmentSession(options: { if (!response.ok) { throw new CliError(`Failed to register development environment session (${response.status}): ${await response.text()}`); } - const body = await response.json() as SessionResponse; - if ( - typeof body.session_id !== "string" || - typeof body.project_id !== "string" || - typeof body.onboarding_outstanding !== "boolean" || - typeof body.env !== "object" - ) { + const body: unknown = await response.json(); + if (!isSessionResponse(body)) { throw new CliError("Local dashboard returned an invalid development environment session response."); } return body; @@ -370,6 +396,16 @@ async function restartDashboardForHeartbeat(options: { }); } +async function waitForHeartbeatIntervalOrStop(shouldStop: () => boolean): Promise { + const startedAtMs = performance.now(); + while (!shouldStop()) { + const remainingMs = HEARTBEAT_INTERVAL_MS - (performance.now() - startedAtMs); + if (remainingMs <= 0) return false; + await wait(Math.min(remainingMs, HEARTBEAT_STOP_POLL_MS)); + } + return true; +} + async function heartbeatUntilStopped(sessionState: DashboardSessionState, options: { apiBaseUrl: string, configFilePath: string, @@ -377,15 +413,22 @@ async function heartbeatUntilStopped(sessionState: DashboardSessionState, option shouldStop: () => boolean, }): Promise { while (!options.shouldStop()) { - await wait(HEARTBEAT_INTERVAL_MS); - if (options.shouldStop()) return; + if (await waitForHeartbeatIntervalOrStop(options.shouldStop)) return; let response: Response; + const controller = new AbortController(); + const abortOnStop = setInterval(() => { + if (options.shouldStop()) { + controller.abort(); + } + }, HEARTBEAT_STOP_POLL_MS); try { response = await dashboardRequest(`/api/remote-development-environment/sessions/${encodeURIComponent(sessionState.session.session_id)}/heartbeat`, { method: "POST", + signal: controller.signal, }, options.secret); } catch { + if (options.shouldStop()) return; sessionState.session = await restartDashboardForHeartbeat({ apiBaseUrl: options.apiBaseUrl, configFilePath: options.configFilePath, @@ -395,11 +438,20 @@ async function heartbeatUntilStopped(sessionState: DashboardSessionState, option sessionState.dashboardReachableSinceMs = performance.now(); logDev(`Stack Auth dashboard running at ${dashboardUrl()}`); continue; + } finally { + clearInterval(abortOnStop); } if (!response.ok) { logDev(`Development environment heartbeat failed (${response.status}): ${await response.text()}`); - return; + sessionState.session = await restartDashboardForHeartbeat({ + apiBaseUrl: options.apiBaseUrl, + configFilePath: options.configFilePath, + dashboardReachableSinceMs: sessionState.dashboardReachableSinceMs, + secret: options.secret, + }); + sessionState.dashboardReachableSinceMs = performance.now(); + logDev(`Stack Auth dashboard running at ${dashboardUrl()}`); } } } diff --git a/packages/stack-cli/src/lib/dev-env-state.test.ts b/packages/stack-cli/src/lib/dev-env-state.test.ts index 07bd03ebc7..2500ae0121 100644 --- a/packages/stack-cli/src/lib/dev-env-state.test.ts +++ b/packages/stack-cli/src/lib/dev-env-state.test.ts @@ -1,7 +1,7 @@ import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { join } from "path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { devEnvStatePath, ensureLocalDashboardSecret, readDevEnvState, recordLocalDashboardProcess, writeDevEnvState } from "./dev-env-state"; let tempDir: string | undefined; @@ -14,6 +14,7 @@ function useTempStateFile() { afterEach(() => { delete process.env.STACK_DEV_ENVS_PATH; delete process.env.LOCALAPPDATA; + vi.restoreAllMocks(); if (tempDir != null) { rmSync(tempDir, { recursive: true, force: true }); tempDir = undefined; @@ -22,14 +23,9 @@ afterEach(() => { describe("dev env state", () => { it("uses the Windows local app data directory by default on Windows", () => { - const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); - Object.defineProperty(process, "platform", { value: "win32" }); + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); process.env.LOCALAPPDATA = "C:\\Users\\Test\\AppData\\Local"; - try { - expect(devEnvStatePath()).toBe(join("C:\\Users\\Test\\AppData\\Local", "Stack Auth", "dev-envs.json")); - } finally { - Object.defineProperty(process, "platform", platformDescriptor ?? { value: process.platform }); - } + expect(devEnvStatePath()).toBe(join("C:\\Users\\Test\\AppData\\Local", "Stack Auth", "dev-envs.json")); }); it("returns an empty v1 state when no file exists", () => { @@ -76,7 +72,9 @@ describe("dev env state", () => { throw new Error("STACK_DEV_ENVS_PATH should be set by useTempStateFile()."); } const content = readFileSync(statePath, "utf-8"); - expect(statSync(statePath).mode & 0o777).toBe(0o600); + if (process.platform !== "win32") { + expect(statSync(statePath).mode & 0o777).toBe(0o600); + } expect(JSON.parse(content)).toMatchObject({ version: 1, anonymousRefreshToken: "rt", @@ -84,6 +82,9 @@ describe("dev env state", () => { }); it("repairs state file permissions before reading", () => { + if (process.platform === "win32") { + return; + } useTempStateFile(); const statePath = process.env.STACK_DEV_ENVS_PATH; if (statePath == null) { @@ -98,4 +99,20 @@ describe("dev env state", () => { }); expect(statSync(statePath).mode & 0o777).toBe(0o600); }); + + it("does not enforce POSIX state file permissions on Windows", () => { + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + useTempStateFile(); + const statePath = process.env.STACK_DEV_ENVS_PATH; + if (statePath == null) { + throw new Error("STACK_DEV_ENVS_PATH should be set by useTempStateFile()."); + } + writeFileSync(statePath, JSON.stringify({ version: 1, projectsByConfigPath: {} })); + chmodSync(statePath, 0o644); + + expect(readDevEnvState()).toEqual({ + version: 1, + projectsByConfigPath: {}, + }); + }); }); diff --git a/packages/stack-cli/src/lib/dev-env-state.ts b/packages/stack-cli/src/lib/dev-env-state.ts index 98675fba2d..0e9d032483 100644 --- a/packages/stack-cli/src/lib/dev-env-state.ts +++ b/packages/stack-cli/src/lib/dev-env-state.ts @@ -34,7 +34,7 @@ export function readDevEnvState(): DevEnvState { if (!existsSync(path)) { return { version: 1, projectsByConfigPath: {} }; } - if ((statSync(path).mode & 0o077) !== 0) { + if (process.platform !== "win32" && (statSync(path).mode & 0o077) !== 0) { chmodSync(path, 0o600); if ((statSync(path).mode & 0o077) !== 0) { throw new Error(`${path} must not be readable or writable by group/others. Run: chmod 600 ${path}`); diff --git a/packages/template/src/lib/stack-app/apps/implementations/session-replay.test.ts b/packages/template/src/lib/stack-app/apps/implementations/session-replay.test.ts new file mode 100644 index 0000000000..02c0af02aa --- /dev/null +++ b/packages/template/src/lib/stack-app/apps/implementations/session-replay.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { analyticsOptionsFromJson, analyticsOptionsToJson } from "./session-replay"; + +describe("analytics option JSON conversion", () => { + it("preserves top-level analytics options when serializing replay block classes", () => { + const json = analyticsOptionsToJson({ + enabled: false, + replays: { + enabled: true, + blockClass: /stack-sensitive/u, + }, + }); + + expect(json?.enabled).toBe(false); + expect(json?.replays?.enabled).toBe(true); + }); + + it("preserves top-level analytics options when deserializing replay block classes", () => { + const roundTripped = analyticsOptionsFromJson(analyticsOptionsToJson({ + enabled: false, + replays: { + blockClass: /stack-sensitive/u, + }, + })); + + expect(roundTripped?.enabled).toBe(false); + expect(roundTripped?.replays?.blockClass).toEqual(/stack-sensitive/u); + }); +}); diff --git a/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts b/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts index d8f2762a79..8744522829 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/session-replay.ts @@ -56,6 +56,7 @@ export function analyticsOptionsToJson(options: AnalyticsOptions | undefined): A const { blockClass, ...rest } = options.replays; if (!(blockClass instanceof RegExp)) return options; return { + ...options, replays: { ...rest, // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment @@ -74,6 +75,7 @@ export function analyticsOptionsFromJson(json: AnalyticsOptions | undefined): An if (typeof blockClass === 'object' && '__regexp' in blockClass) { const bc = blockClass as unknown as { __regexp: string, __flags: string }; return { + ...json, replays: { ...rest, blockClass: new RegExp(bc.__regexp, bc.__flags), From 70880e50dd34159e2d28dd6164a47247db8b0532 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 15 May 2026 18:41:40 -0700 Subject: [PATCH 5/7] More fixes --- .claude/CLAUDE-KNOWLEDGE.md | 3 ++ .../auth/route.ts | 4 +- .../sessions/route.ts | 10 +---- .../api-base-url.test.ts | 40 +++++++++++++++++++ .../api-base-url.ts | 32 +++++++++++++++ .../security.test.ts | 30 ++++++++++---- .../security.ts | 37 ++++++++++++++--- 7 files changed, 135 insertions(+), 21 deletions(-) create mode 100644 apps/dashboard/src/lib/remote-development-environment/api-base-url.test.ts create mode 100644 apps/dashboard/src/lib/remote-development-environment/api-base-url.ts diff --git a/.claude/CLAUDE-KNOWLEDGE.md b/.claude/CLAUDE-KNOWLEDGE.md index 0e40cd783b..5d7ca42504 100644 --- a/.claude/CLAUDE-KNOWLEDGE.md +++ b/.claude/CLAUDE-KNOWLEDGE.md @@ -487,3 +487,6 @@ A: `--config-file` should point directly to a regular config file. Do not treat ## Q: How should RDE PR-review fixes handle the local dashboard and CLI lifecycle? A: Use the shared RDE browser security helper for browser-local endpoints, mark bearer-token responses `Cache-Control: private, no-store`, and return 400 for malformed local endpoint JSON. `stack dev` should fail loudly if a bundled dashboard sentinel has no environment value, validate session response shapes at runtime before using `env`, recover from HTTP heartbeat failures the same way as network heartbeat failures, and make heartbeat shutdown interruptible so child-process exit is not delayed by the full heartbeat interval. + +## Q: How should local RDE endpoints trust browser origins and API base URLs? +A: Browser-only RDE endpoints should accept only the exact local dashboard origin derived from the dashboard env/state, such as `http://127.0.0.1:26700`, and reject arbitrary localhost subdomains like `evil.localhost`. CLI bearer endpoints should require the bearer secret and a loopback host but should not use broad localhost origins as trust signals. RDE session registration should accept only `https://api.stack-auth.com`, the exact API base URL passed into the local dashboard by the CLI, or exact custom URLs from a `STACK_`-prefixed allowlist. diff --git a/apps/dashboard/src/app/api/remote-development-environment/auth/route.ts b/apps/dashboard/src/app/api/remote-development-environment/auth/route.ts index 3eb60bc049..2d9d6635b8 100644 --- a/apps/dashboard/src/app/api/remote-development-environment/auth/route.ts +++ b/apps/dashboard/src/app/api/remote-development-environment/auth/route.ts @@ -35,7 +35,9 @@ export async function GET(req: NextRequest) { issued_at_millis: token.issuedAtMillis, user_id: token.userId, }); - response.headers.set("Cache-Control", "private, no-store"); + response.headers.set("Cache-Control", "no-store, no-cache"); + response.headers.set("Pragma", "no-cache"); + response.headers.set("Expires", "0"); deleteInternalProjectAuthCookies(req, response); return response; } diff --git a/apps/dashboard/src/app/api/remote-development-environment/sessions/route.ts b/apps/dashboard/src/app/api/remote-development-environment/sessions/route.ts index bcddd41e28..09c28e65a1 100644 --- a/apps/dashboard/src/app/api/remote-development-environment/sessions/route.ts +++ b/apps/dashboard/src/app/api/remote-development-environment/sessions/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; +import { isAllowedRemoteDevelopmentEnvironmentApiBaseUrl } from "@/lib/remote-development-environment/api-base-url"; import { registerRemoteDevelopmentEnvironmentSession } from "@/lib/remote-development-environment/manager"; import { assertRemoteDevelopmentEnvironmentRequest } from "@/lib/remote-development-environment/security"; -import { createUrlIfValid, isLocalhost } from "@stackframe/stack-shared/dist/utils/urls"; export const runtime = "nodejs"; @@ -16,12 +16,6 @@ async function readJsonBody(req: NextRequest): Promise { } } -function isAllowedApiBaseUrl(value: string): boolean { - const url = createUrlIfValid(value); - if (url == null || (url.protocol !== "http:" && url.protocol !== "https:")) return false; - return isLocalhost(url) || url.hostname === "api.stack-auth.com" || url.hostname.endsWith(".stack-auth.com"); -} - export async function POST(req: NextRequest) { const securityResponse = assertRemoteDevelopmentEnvironmentRequest(req); if (securityResponse != null) return securityResponse; @@ -36,7 +30,7 @@ export async function POST(req: NextRequest) { if (typeof body.api_base_url !== "string" || typeof body.config_path !== "string") { return NextResponse.json({ error: "api_base_url and config_path are required." }, { status: 400 }); } - if (!isAllowedApiBaseUrl(body.api_base_url)) { + if (!isAllowedRemoteDevelopmentEnvironmentApiBaseUrl(body.api_base_url)) { return NextResponse.json({ error: "api_base_url is not allowed for remote development environments." }, { status: 400 }); } diff --git a/apps/dashboard/src/lib/remote-development-environment/api-base-url.test.ts b/apps/dashboard/src/lib/remote-development-environment/api-base-url.test.ts new file mode 100644 index 0000000000..f5e74c1bfb --- /dev/null +++ b/apps/dashboard/src/lib/remote-development-environment/api-base-url.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); +}); + +async function isAllowedApiBaseUrl(value: string): Promise { + const { isAllowedRemoteDevelopmentEnvironmentApiBaseUrl } = await import("./api-base-url"); + return isAllowedRemoteDevelopmentEnvironmentApiBaseUrl(value); +} + +describe("remote development environment API base URL allowlist", () => { + it("accepts the production Stack API host", async () => { + await expect(isAllowedApiBaseUrl("https://api.stack-auth.com")).resolves.toBe(true); + await expect(isAllowedApiBaseUrl("https://api.stack-auth.com/")).resolves.toBe(true); + }); + + it("accepts the exact local API base URL passed to the dashboard", async () => { + vi.stubEnv("NEXT_PUBLIC_STACK_API_URL", "http://127.0.0.1:8102"); + + await expect(isAllowedApiBaseUrl("http://127.0.0.1:8102")).resolves.toBe(true); + }); + + it("rejects arbitrary loopback hosts", async () => { + vi.stubEnv("NEXT_PUBLIC_STACK_API_URL", "http://127.0.0.1:8102"); + + await expect(isAllowedApiBaseUrl("http://127.1.2.3:8102")).resolves.toBe(false); + }); + + it("rejects arbitrary stack-auth subdomains", async () => { + await expect(isAllowedApiBaseUrl("https://evil.stack-auth.com")).resolves.toBe(false); + }); + + it("accepts explicit custom hosts from the STACK-prefixed allowlist", async () => { + vi.stubEnv("STACK_RDE_API_BASE_URL_ALLOWLIST", "https://api.staging.stack-auth.com"); + + await expect(isAllowedApiBaseUrl("https://api.staging.stack-auth.com")).resolves.toBe(true); + }); +}); diff --git a/apps/dashboard/src/lib/remote-development-environment/api-base-url.ts b/apps/dashboard/src/lib/remote-development-environment/api-base-url.ts new file mode 100644 index 0000000000..d4f91d709e --- /dev/null +++ b/apps/dashboard/src/lib/remote-development-environment/api-base-url.ts @@ -0,0 +1,32 @@ +import { getPublicEnvVar } from "@/lib/env"; +import { createUrlIfValid } from "@stackframe/stack-shared/dist/utils/urls"; + +const DEFAULT_REMOTE_DEVELOPMENT_ENVIRONMENT_API_BASE_URLS = [ + "https://api.stack-auth.com", +] as const; + +function canonicalApiBaseUrl(value: string | undefined): string | null { + if (value == null || value.trim().length === 0) return null; + const url = createUrlIfValid(value.trim()); + if (url == null || (url.protocol !== "http:" && url.protocol !== "https:")) return null; + if (url.username !== "" || url.password !== "" || url.search !== "" || url.hash !== "") return null; + if (url.pathname !== "/" && url.pathname !== "") return null; + return url.origin; +} + +function apiBaseUrlAllowlistEntries(): string[] { + return [ + ...DEFAULT_REMOTE_DEVELOPMENT_ENVIRONMENT_API_BASE_URLS, + process.env.STACK_API_URL, + getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL"), + getPublicEnvVar("NEXT_PUBLIC_BROWSER_STACK_API_URL"), + getPublicEnvVar("NEXT_PUBLIC_SERVER_STACK_API_URL"), + ...(process.env.STACK_RDE_API_BASE_URL_ALLOWLIST ?? "").split(","), + ].map(canonicalApiBaseUrl).filter((url): url is string => url != null); +} + +export function isAllowedRemoteDevelopmentEnvironmentApiBaseUrl(value: string): boolean { + const canonicalUrl = canonicalApiBaseUrl(value); + if (canonicalUrl == null) return false; + return new Set(apiBaseUrlAllowlistEntries()).has(canonicalUrl); +} diff --git a/apps/dashboard/src/lib/remote-development-environment/security.test.ts b/apps/dashboard/src/lib/remote-development-environment/security.test.ts index bd7739b78f..a0d74bcc37 100644 --- a/apps/dashboard/src/lib/remote-development-environment/security.test.ts +++ b/apps/dashboard/src/lib/remote-development-environment/security.test.ts @@ -60,7 +60,7 @@ describe("remote development environment security", () => { expect(response?.status).toBe(401); }); - it("rejects non-loopback host and origin", async () => { + it("rejects non-loopback hosts for bearer requests", async () => { useTempStateFile(); const { assertRemoteDevelopmentEnvironmentRequest } = await import("./security"); const badHost = assertRemoteDevelopmentEnvironmentRequest(request({ @@ -68,23 +68,28 @@ describe("remote development environment security", () => { authorization: "Bearer secret", })); expect(badHost?.status).toBe(403); + }); - const badOrigin = assertRemoteDevelopmentEnvironmentRequest(request({ + it("allows same-origin browser auth without exposing the CLI bearer token", async () => { + useTempStateFile(); + const { assertRemoteDevelopmentEnvironmentBrowserRequest } = await import("./security"); + const response = assertRemoteDevelopmentEnvironmentBrowserRequest(request({ host: "127.0.0.1:26700", - origin: "https://example.com", - authorization: "Bearer secret", + origin: "http://127.0.0.1:26700", + "sec-fetch-site": "same-origin", })); - expect(badOrigin?.status).toBe(403); + expect(response).toBeNull(); }); - it("allows same-origin browser auth without exposing the CLI bearer token", async () => { + it("rejects browser auth from arbitrary localhost origins", async () => { useTempStateFile(); const { assertRemoteDevelopmentEnvironmentBrowserRequest } = await import("./security"); const response = assertRemoteDevelopmentEnvironmentBrowserRequest(request({ host: "127.0.0.1:26700", + origin: "http://evil.localhost:26700", "sec-fetch-site": "same-origin", })); - expect(response).toBeNull(); + expect(response?.status).toBe(403); }); it("rejects cross-site browser auth navigation", async () => { @@ -97,6 +102,17 @@ describe("remote development environment security", () => { expect(response?.status).toBe(403); }); + it("accepts CLI bearer requests from loopback without trusting arbitrary origins", async () => { + useTempStateFile(); + const { assertRemoteDevelopmentEnvironmentRequest } = await import("./security"); + const response = assertRemoteDevelopmentEnvironmentRequest(request({ + host: "127.0.0.1:26700", + origin: "http://evil.localhost:26700", + authorization: "Bearer secret", + })); + expect(response).toBeNull(); + }); + it("rejects config writes without an active session", async () => { useTempStateFile(); const { applyRemoteDevelopmentEnvironmentConfigUpdate } = await import("./manager"); diff --git a/apps/dashboard/src/lib/remote-development-environment/security.ts b/apps/dashboard/src/lib/remote-development-environment/security.ts index 922a58e668..53e2d65ac3 100644 --- a/apps/dashboard/src/lib/remote-development-environment/security.ts +++ b/apps/dashboard/src/lib/remote-development-environment/security.ts @@ -1,20 +1,47 @@ import "server-only"; +import { getPublicEnvVar } from "@/lib/env"; import { NextRequest, NextResponse } from "next/server"; -import { isLocalhost } from "@stackframe/stack-shared/dist/utils/urls"; +import { createUrlIfValid, isLocalhost } from "@stackframe/stack-shared/dist/utils/urls"; import { isRemoteDevelopmentEnvironmentEnabled } from "./env"; import { readRemoteDevelopmentEnvironmentState } from "./state"; +function urlOrigin(value: string | undefined): string | null { + if (value == null || value.length === 0) return null; + return createUrlIfValid(value)?.origin ?? null; +} + function requestHostIsLoopback(req: NextRequest): boolean { const host = req.headers.get("host"); if (host == null) return false; return isLocalhost(`http://${host}`); } -function originIsAllowed(req: NextRequest): boolean { +function requestHostOrigin(req: NextRequest): string | null { + const host = req.headers.get("host"); + if (host == null) return null; + return urlOrigin(`http://${host}`); +} + +function expectedDashboardOrigins(): Set { + const state = readRemoteDevelopmentEnvironmentState(); + return new Set([ + urlOrigin(getPublicEnvVar("NEXT_PUBLIC_STACK_DASHBOARD_URL")), + urlOrigin(getPublicEnvVar("NEXT_PUBLIC_BROWSER_STACK_DASHBOARD_URL")), + urlOrigin(getPublicEnvVar("NEXT_PUBLIC_SERVER_STACK_DASHBOARD_URL")), + state.localDashboard?.port == null ? null : `http://127.0.0.1:${state.localDashboard.port}`, + ].filter((origin): origin is string => origin != null)); +} + +function browserRequestOriginIsAllowed(req: NextRequest): boolean { + const allowedOrigins = expectedDashboardOrigins(); + const requestOrigin = requestHostOrigin(req); + if (requestOrigin == null || !allowedOrigins.has(requestOrigin)) return false; + const origin = req.headers.get("origin"); if (origin == null) return true; - return isLocalhost(origin); + const parsedOrigin = urlOrigin(origin); + return parsedOrigin != null && allowedOrigins.has(parsedOrigin); } export function assertRemoteDevelopmentEnvironmentRequest(req: NextRequest): NextResponse | null { @@ -28,7 +55,7 @@ export function assertRemoteDevelopmentEnvironmentRequest(req: NextRequest): Nex return NextResponse.json({ error: "Remote development environment is not active." }, { status: 404 }); } - if (!requestHostIsLoopback(req) || !originIsAllowed(req)) { + if (!requestHostIsLoopback(req)) { return NextResponse.json({ error: "Remote development environment endpoints only accept loopback requests." }, { status: 403 }); } @@ -45,7 +72,7 @@ export function assertRemoteDevelopmentEnvironmentBrowserRequest(req: NextReques return NextResponse.json({ error: "Remote development environment endpoints are disabled." }, { status: 404 }); } - if (!requestHostIsLoopback(req) || !originIsAllowed(req)) { + if (!requestHostIsLoopback(req) || !browserRequestOriginIsAllowed(req)) { return NextResponse.json({ error: "Remote development environment endpoints only accept loopback requests." }, { status: 403 }); } From 858712df971db0efe9ad1566e3cd884867e4c61b Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 15 May 2026 18:46:46 -0700 Subject: [PATCH 6/7] PR comment --- .../config/apply-update/route.ts | 14 ++------------ .../sessions/route.ts | 14 ++------------ .../remote-development-environment/route-json.ts | 12 ++++++++++++ 3 files changed, 16 insertions(+), 24 deletions(-) create mode 100644 apps/dashboard/src/lib/remote-development-environment/route-json.ts diff --git a/apps/dashboard/src/app/api/remote-development-environment/config/apply-update/route.ts b/apps/dashboard/src/app/api/remote-development-environment/config/apply-update/route.ts index 3b1dd4549c..aeb5a9a527 100644 --- a/apps/dashboard/src/app/api/remote-development-environment/config/apply-update/route.ts +++ b/apps/dashboard/src/app/api/remote-development-environment/config/apply-update/route.ts @@ -1,28 +1,18 @@ import { NextRequest, NextResponse } from "next/server"; import { applyRemoteDevelopmentEnvironmentConfigUpdate } from "@/lib/remote-development-environment/manager"; +import { readRemoteDevelopmentEnvironmentJsonBody } from "@/lib/remote-development-environment/route-json"; import { assertRemoteDevelopmentEnvironmentBrowserRequest, assertRemoteDevelopmentEnvironmentRequest } from "@/lib/remote-development-environment/security"; import { isValidConfig } from "@stackframe/stack-shared/dist/config/format"; export const runtime = "nodejs"; -async function readJsonBody(req: NextRequest): Promise { - try { - return await req.json(); - } catch (error) { - if (error instanceof SyntaxError) { - return NextResponse.json({ error: "Malformed JSON request body." }, { status: 400 }); - } - throw error; - } -} - export async function POST(req: NextRequest) { const securityResponse = req.headers.has("authorization") ? assertRemoteDevelopmentEnvironmentRequest(req) : assertRemoteDevelopmentEnvironmentBrowserRequest(req); if (securityResponse != null) return securityResponse; - const parsedBody = await readJsonBody(req); + const parsedBody = await readRemoteDevelopmentEnvironmentJsonBody(req); if (parsedBody instanceof NextResponse) return parsedBody; const body = parsedBody as { diff --git a/apps/dashboard/src/app/api/remote-development-environment/sessions/route.ts b/apps/dashboard/src/app/api/remote-development-environment/sessions/route.ts index 09c28e65a1..d910d7a301 100644 --- a/apps/dashboard/src/app/api/remote-development-environment/sessions/route.ts +++ b/apps/dashboard/src/app/api/remote-development-environment/sessions/route.ts @@ -1,26 +1,16 @@ import { NextRequest, NextResponse } from "next/server"; import { isAllowedRemoteDevelopmentEnvironmentApiBaseUrl } from "@/lib/remote-development-environment/api-base-url"; import { registerRemoteDevelopmentEnvironmentSession } from "@/lib/remote-development-environment/manager"; +import { readRemoteDevelopmentEnvironmentJsonBody } from "@/lib/remote-development-environment/route-json"; import { assertRemoteDevelopmentEnvironmentRequest } from "@/lib/remote-development-environment/security"; export const runtime = "nodejs"; -async function readJsonBody(req: NextRequest): Promise { - try { - return await req.json(); - } catch (error) { - if (error instanceof SyntaxError) { - return NextResponse.json({ error: "Malformed JSON request body." }, { status: 400 }); - } - throw error; - } -} - export async function POST(req: NextRequest) { const securityResponse = assertRemoteDevelopmentEnvironmentRequest(req); if (securityResponse != null) return securityResponse; - const parsedBody = await readJsonBody(req); + const parsedBody = await readRemoteDevelopmentEnvironmentJsonBody(req); if (parsedBody instanceof NextResponse) return parsedBody; const body = parsedBody as { diff --git a/apps/dashboard/src/lib/remote-development-environment/route-json.ts b/apps/dashboard/src/lib/remote-development-environment/route-json.ts new file mode 100644 index 0000000000..14ced5197e --- /dev/null +++ b/apps/dashboard/src/lib/remote-development-environment/route-json.ts @@ -0,0 +1,12 @@ +import { NextRequest, NextResponse } from "next/server"; + +export async function readRemoteDevelopmentEnvironmentJsonBody(req: NextRequest): Promise { + try { + return await req.json(); + } catch (error) { + if (error instanceof SyntaxError) { + return NextResponse.json({ error: "Malformed JSON request body." }, { status: 400 }); + } + throw error; + } +} From d2fcc22e3a3f3e790469368e85236ad58a16a2ae Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Fri, 15 May 2026 19:02:46 -0700 Subject: [PATCH 7/7] fix cicd --- .claude/CLAUDE-KNOWLEDGE.md | 3 +++ packages/stack-cli/package.json | 2 +- turbo.json | 9 +++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.claude/CLAUDE-KNOWLEDGE.md b/.claude/CLAUDE-KNOWLEDGE.md index 5d7ca42504..877d25ebeb 100644 --- a/.claude/CLAUDE-KNOWLEDGE.md +++ b/.claude/CLAUDE-KNOWLEDGE.md @@ -490,3 +490,6 @@ A: Use the shared RDE browser security helper for browser-local endpoints, mark ## Q: How should local RDE endpoints trust browser origins and API base URLs? A: Browser-only RDE endpoints should accept only the exact local dashboard origin derived from the dashboard env/state, such as `http://127.0.0.1:26700`, and reject arbitrary localhost subdomains like `evil.localhost`. CLI bearer endpoints should require the bearer secret and a loopback host but should not use broad localhost origins as trust signals. RDE session registration should accept only `https://api.stack-auth.com`, the exact API base URL passed into the local dashboard by the CLI, or exact custom URLs from a `STACK_`-prefixed allowlist. + +## Q: How should the Stack CLI depend on the dashboard RDE standalone build in CI? +A: Do not invoke a nested `turbo run build:rde-standalone` from `packages/stack-cli`'s `build` script. When the outer CI is already running `turbo run build`, that nested Turbo process can start `apps/dashboard`'s Next build while the outer graph is also building it, causing `.next/lock` failures. Model the dependency in `turbo.json` instead with `@stackframe/stack-cli#build` depending on `@stackframe/dashboard#build:rde-standalone`, and let the CLI script only run `tsdown` plus runtime asset copying. diff --git a/packages/stack-cli/package.json b/packages/stack-cli/package.json index a0be5309c5..dd428e5d16 100644 --- a/packages/stack-cli/package.json +++ b/packages/stack-cli/package.json @@ -10,7 +10,7 @@ }, "scripts": { "clean": "rimraf node_modules && rimraf dist", - "build": "tsdown && turbo run build:rde-standalone --filter=@stackframe/dashboard && node scripts/copy-runtime-assets.mjs", + "build": "tsdown && node scripts/copy-runtime-assets.mjs", "dev": "tsdown --watch", "lint": "eslint --ext .tsx,.ts .", "typecheck": "tsc --noEmit", diff --git a/turbo.json b/turbo.json index 109b1210b7..fbcf0f68ee 100644 --- a/turbo.json +++ b/turbo.json @@ -90,6 +90,15 @@ "codegen" ] }, + "@stackframe/stack-cli#build": { + "dependsOn": [ + "^build", + "@stackframe/dashboard#build:rde-standalone" + ], + "outputs": [ + "dist/**" + ] + }, "clean": { "cache": false },