diff --git a/.claude/CLAUDE-KNOWLEDGE.md b/.claude/CLAUDE-KNOWLEDGE.md index 3e7e84f03d..877d25ebeb 100644 --- a/.claude/CLAUDE-KNOWLEDGE.md +++ b/.claude/CLAUDE-KNOWLEDGE.md @@ -458,5 +458,38 @@ 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. + ## 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. + +## 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/AGENTS.md b/AGENTS.md index 439af284d4..90ac51ab1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -114,6 +114,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 1491f41ec7..20326d0507 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 c00c6646ca..796ef30d45 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; @@ -404,6 +407,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 6207681d7a..8e33ff4313 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"; @@ -269,10 +269,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); @@ -328,10 +325,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 1e81ff9c71..7c85618c0b 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..f6585f95d5 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,21 +189,28 @@ 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 }); } - return migrateConfigOverride("environment", queryResult[0]?.config ?? {}); + const storedConfigOverride = migrateConfigOverride("environment", queryResult[0]?.config ?? {}); + if (queryResult[0]?.isDevelopmentEnvironment === true) { + return override(storedConfigOverride, DEVELOPMENT_ENVIRONMENT_CONFIG_OVERRIDE); + } + return storedConfigOverride; }, }; } @@ -379,12 +392,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 +1083,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..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 '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 fa3ba464cd..a9ea9e2538 100644 --- a/apps/backend/src/lib/local-emulator.ts +++ b/apps/backend/src/lib/local-emulator.ts @@ -1,23 +1,22 @@ 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 { parseStackConfigFileContent } from "@stackframe/stack-shared/dist/stack-config-file"; import { isValidConfig } from "@stackframe/stack-shared/dist/config/format"; import { LOCAL_EMULATOR_ADMIN_EMAIL, LOCAL_EMULATOR_ADMIN_PASSWORD } from "@stackframe/stack-shared/dist/local-emulator"; 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"; export const LOCAL_EMULATOR_OWNER_TEAM_ID = "5a0c858b-d9e9-49d4-9943-8ce385d86428"; export { LOCAL_EMULATOR_ADMIN_EMAIL, LOCAL_EMULATOR_ADMIN_PASSWORD }; -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 +75,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}`); } - 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..2562c599dc 100644 --- a/apps/backend/src/route-handlers/smart-request.tsx +++ b/apps/backend/src/route-handlers/smart-request.tsx @@ -206,8 +206,12 @@ 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, + // Anonymous dev-environment tokens may be restricted; non-anonymous restricted tokens are rejected below after decoding. + allowRestricted: options.allowAnonymous, + }); if (result.status === "error") { if (KnownErrors.AccessTokenExpired.isInstance(result.error)) { throw new KnownErrors.AdminAccessTokenExpired(result.error.constructorArgs[0]); @@ -219,6 +223,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 +282,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..9349d7837b 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.STACK_NEXT_CONFIG_DISABLE_TYPESCRIPT === "true", + }, + images: { remotePatterns: [ { diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 9537992295..cb1784dd92 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 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)/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 005bee0738..9c492958a0 100644 --- a/apps/dashboard/src/app/(main)/(protected)/layout-client.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/layout-client.tsx @@ -13,11 +13,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({ @@ -35,9 +43,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 018afca780..e451743727 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 c48e92d11a..871bcc5d28 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 047fd52f00..17fad99ca4 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 b3944f86b6..2bb87317ce 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 @@ -88,7 +88,7 @@ export default function PageClient() { {isLocalEmulator && } {/* Email Server Card */} - + {/* Email Log Card */} @@ -136,8 +136,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' @@ -157,13 +156,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 && ( @@ -173,7 +172,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..1cb6f3e824 --- /dev/null +++ b/apps/dashboard/src/app/api/development-environment/health/route.ts @@ -0,0 +1,90 @@ +import { getPublicEnvVar } from "@/lib/env"; +import { NextRequest, NextResponse } from "next/server"; +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, +}; + +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; + + 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", + "X-Stack-Publishable-Client-Key": getPublicEnvVar("NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY") ?? "", + }, + }); + return response.ok; + } catch { + return false; + } finally { + clearTimeout(timeout); + } +} + +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..2d9d6635b8 --- /dev/null +++ b/apps/dashboard/src/app/api/remote-development-environment/auth/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { assertRemoteDevelopmentEnvironmentBrowserRequest } from "@/lib/remote-development-environment/security"; + +export const runtime = "nodejs"; + +const INTERNAL_PROJECT_ID = "internal"; + +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, + }); + 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/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..aeb5a9a527 --- /dev/null +++ b/apps/dashboard/src/app/api/remote-development-environment/config/apply-update/route.ts @@ -0,0 +1,46 @@ +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"; + +export async function POST(req: NextRequest) { + const securityResponse = req.headers.has("authorization") + ? assertRemoteDevelopmentEnvironmentRequest(req) + : assertRemoteDevelopmentEnvironmentBrowserRequest(req); + if (securityResponse != null) return securityResponse; + + const parsedBody = await readRemoteDevelopmentEnvironmentJsonBody(req); + if (parsedBody instanceof NextResponse) return parsedBody; + + const body = parsedBody as { + session_id?: unknown, + project_id?: unknown, + config_update?: unknown, + wait_for_sync?: unknown, + }; + 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_update)) { + return NextResponse.json({ error: "config_update must be a valid Stack Auth config object." }, { status: 400 }); + } + + await applyRemoteDevelopmentEnvironmentConfigUpdate({ + sessionId: body.session_id, + 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/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..d910d7a301 --- /dev/null +++ b/apps/dashboard/src/app/api/remote-development-environment/sessions/route.ts @@ -0,0 +1,37 @@ +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"; + +export async function POST(req: NextRequest) { + const securityResponse = assertRemoteDevelopmentEnvironmentRequest(req); + if (securityResponse != null) return securityResponse; + + const parsedBody = await readRemoteDevelopmentEnvironmentJsonBody(req); + if (parsedBody instanceof NextResponse) return parsedBody; + + const body = parsedBody 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 (!isAllowedRemoteDevelopmentEnvironmentApiBaseUrl(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..ba3b3207bd --- /dev/null +++ b/apps/dashboard/src/app/layout-client.tsx @@ -0,0 +1,187 @@ +"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; +let devEnvironmentHealthRequestSequence = 0; + +function setDevEnvironmentHealthSnapshot(snapshot: DevEnvironmentHealthSnapshot) { + devEnvironmentHealthSnapshot = snapshot; + for (const subscriber of devEnvironmentHealthSubscribers) { + subscriber(); + } +} + +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", + headers: { + Accept: "application/json", + }, + }); + const body: unknown = await response.json(); + if (!isDevEnvironmentHealthResponse(body)) { + throw new Error("Development environment health endpoint returned an invalid response."); + } + + setSnapshotIfCurrent(body.ok && response.ok + ? { status: "healthy" } + : { status: "unhealthy", restartCommand: body.restart_command }); + } catch { + setSnapshotIfCurrent({ + 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 d3fdf9a386..6fb2109bdc 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 { createUnifiedAiTransport } from "@/components/assistant-ui/chat-stream" import { buildDashboardMessages } from "@/lib/ai-dashboard/shared-prompt"; import type { AppId } from "@/lib/apps-frontend"; 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"; @@ -88,7 +88,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..fcb884175d 100644 --- a/apps/dashboard/src/lib/config-update.tsx +++ b/apps/dashboard/src/lib/config-update.tsx @@ -44,7 +44,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 +64,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; @@ -212,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. */ @@ -263,26 +283,33 @@ 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; + 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); } 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/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/config-file.ts b/apps/dashboard/src/lib/remote-development-environment/config-file.ts new file mode 100644 index 0000000000..e073ff2f40 --- /dev/null +++ b/apps/dashboard/src/lib/remote-development-environment/config-file.ts @@ -0,0 +1,58 @@ +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"; +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): Config { + return readConfigFile(configFilePath).config; +} + +export function readConfigFile(configFilePath: string): { config: Config, showOnboarding: boolean } { + ensureConfigFileExists(configFilePath); + const content = readFileSync(configFilePath, "utf-8"); + const config = parseStackConfigFileContent(content, configFilePath); + if (config === showOnboardingStackConfigValue) { + return { + config: {}, + showOnboarding: true, + }; + } + if (!isValidConfig(config)) { + throw new Error(`Invalid config in ${configFilePath}.`); + } + return { + config, + showOnboarding: false, + }; +} + +export function writeConfigObject(configFilePath: string, config: Config): 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..c8b0f2a3b2 --- /dev/null +++ b/apps/dashboard/src/lib/remote-development-environment/manager.ts @@ -0,0 +1,664 @@ +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"; +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, + synchronouslyUpdatingConfigFiles: Set, + 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(), + synchronouslyUpdatingConfigFiles: new Set(), + 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; + } + const onboardingStatus = await syncRemoteDevelopmentEnvironmentOnboardingStatus(ownedProject, showOnboarding); + if (project.lastSyncedConfigHash === configHash) { + return onboardingStatus; + } + + logRemoteDevelopmentEnvironment("Syncing config to development-environment project", { + projectId: project.projectId, + configFilePath, + showOnboarding, + }); + await ownedProject.replaceConfigOverride("branch", config); + + 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(); + if (state.synchronouslyUpdatingConfigFiles.has(configFilePath)) { + logRemoteDevelopmentEnvironment("Skipping async config sync during synchronous dashboard update", { + configFilePath, + }); + return; + } + 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); +} + +async function syncConfigToRemoteNow(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; + 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 syncConfigToRemoteNow(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, + projectId?: string, + configUpdate: Config, + waitForSync?: boolean, +}): Promise { + assertRemoteDevelopmentEnvironmentEnabled(); + const endOperation = beginRemoteDevelopmentEnvironmentOperation("config update", { + sessionId: options.sessionId, + projectId: options.projectId, + }); + try { + 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, + }); + 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/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; + } +} 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..a0d74bcc37 --- /dev/null +++ b/apps/dashboard/src/lib/remote-development-environment/security.test.ts @@ -0,0 +1,141 @@ +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 hosts for bearer requests", async () => { + useTempStateFile(); + const { assertRemoteDevelopmentEnvironmentRequest } = await import("./security"); + const badHost = assertRemoteDevelopmentEnvironmentRequest(request({ + host: "example.com", + authorization: "Bearer secret", + })); + expect(badHost?.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", + origin: "http://127.0.0.1:26700", + "sec-fetch-site": "same-origin", + })); + expect(response).toBeNull(); + }); + + 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?.status).toBe(403); + }); + + 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("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"); + await expect(applyRemoteDevelopmentEnvironmentConfigUpdate({ + sessionId: "missing", + configUpdate: {}, + })).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..53e2d65ac3 --- /dev/null +++ b/apps/dashboard/src/lib/remote-development-environment/security.ts @@ -0,0 +1,85 @@ +import "server-only"; + +import { getPublicEnvVar } from "@/lib/env"; +import { NextRequest, NextResponse } from "next/server"; +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 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; + const parsedOrigin = urlOrigin(origin); + return parsedOrigin != null && allowedOrigins.has(parsedOrigin); +} + +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)) { + 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) || !browserRequestOriginIsAllowed(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 390ed7c6d3..27a87774df 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..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 && node scripts/copy-emulator-assets.mjs", + "build": "tsdown && 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..7945d6c28f --- /dev/null +++ b/packages/stack-cli/src/commands/dev.ts @@ -0,0 +1,524 @@ +import { execFileSync, spawn } from "child_process"; +import { Command } from "commander"; +import { chmodSync, closeSync, cpSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, 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 { resolveConfigFilePathOption } from "../lib/config-file-path.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 HEARTBEAT_STOP_POLL_MS = 100; +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; + } + 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; + }); +} + +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; +} + +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)}`); + } +} + +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, + 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: unknown = await response.json(); + if (!isSessionResponse(body)) { + 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 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, + secret: string, + shouldStop: () => boolean, +}): Promise { + while (!options.shouldStop()) { + 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, + dashboardReachableSinceMs: sessionState.dashboardReachableSinceMs, + secret: options.secret, + }); + 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()}`); + 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()}`); + } + } +} + +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") + .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(); + const apiBaseUrl = normalizeApiBaseUrl(config.apiUrl || DEFAULT_API_URL); + const configFilePath = resolveConfigFilePathOption(opts.configFile, { mustExist: false }); + 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 623dcb2b50..f5a6c82ab9 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", () => { @@ -195,6 +196,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 177391cf97..c8cfef0479 100644 --- a/packages/stack-cli/src/commands/emulator.ts +++ b/packages/stack-cli/src/commands/emulator.ts @@ -48,6 +48,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, { @@ -126,6 +136,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 @@ -282,6 +300,107 @@ 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; + } + return resolveConfigFilePathOption(configFile, { mustExist: true }); +} + +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 = emulatorDashboardPort(); const backendPort = emulatorBackendPort(); @@ -301,7 +420,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(""); } @@ -770,73 +889,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 = resolveConfigFilePathOption(opts.configFile, { mustExist: true }); - } - - 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 6c73f91562..9ed277c39d 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); @@ -37,12 +39,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..2500ae0121 --- /dev/null +++ b/packages/stack-cli/src/lib/dev-env-state.test.ts @@ -0,0 +1,118 @@ +import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { afterEach, describe, expect, it, vi } 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; + vi.restoreAllMocks(); + 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", () => { + vi.spyOn(process, "platform", "get").mockReturnValue("win32"); + process.env.LOCALAPPDATA = "C:\\Users\\Test\\AppData\\Local"; + expect(devEnvStatePath()).toBe(join("C:\\Users\\Test\\AppData\\Local", "Stack Auth", "dev-envs.json")); + }); + + 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"); + if (process.platform !== "win32") { + expect(statSync(statePath).mode & 0o777).toBe(0o600); + } + expect(JSON.parse(content)).toMatchObject({ + version: 1, + anonymousRefreshToken: "rt", + }); + }); + + it("repairs state file permissions before reading", () => { + if (process.platform === "win32") { + return; + } + 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); + }); + + 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 new file mode 100644 index 0000000000..0e9d032483 --- /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 (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}`); + } + } + 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 63ab5b1c28..c6e1a5d92f 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.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 be3cf89d8a..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 @@ -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. @@ -50,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 @@ -68,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), 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 2ccee669c1..fbcf0f68ee 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$", @@ -71,6 +90,15 @@ "codegen" ] }, + "@stackframe/stack-cli#build": { + "dependsOn": [ + "^build", + "@stackframe/dashboard#build:rde-standalone" + ], + "outputs": [ + "dist/**" + ] + }, "clean": { "cache": false },