From 2b5eebcd226de44927feb707c089b3062fd0af73 Mon Sep 17 00:00:00 2001 From: Zai Shi Date: Fri, 1 Aug 2025 18:28:27 +0200 Subject: [PATCH] Config override CRUD (#803) ---- > [!IMPORTANT] > Add admin-only API endpoints and UI support for project configuration overrides, with comprehensive tests and documentation updates. > > - **New Features**: > - Added admin-only API endpoints for reading and updating project configuration overrides in `config/crud.tsx` and `config/override/crud.tsx`. > - Admin app supports fetching, caching, and updating configuration overrides with new React hooks in `admin-app-impl.ts`. > - **Bug Fixes**: > - Validation and error handling for OAuth providers, duplicate IDs, and invalid config fields in `oauth-providers/crud.tsx`. > - **Tests**: > - Added end-to-end tests for configuration management and validation errors in `config.test.ts` and `js/config.test.ts`. > - **Documentation**: > - Updated API documentation for new config override endpoints in `config.ts`. > > This description was created by [Ellipsis](https://www.ellipsis.dev?ref=stack-auth%2Fstack-auth&utm_source=github&utm_medium=referral) for 3d20abc0920052edf758a4201918c654d372f3f1. You can [customize](https://app.ellipsis.dev/stack-auth/settings/summaries) this summary. It will automatically update as commits are pushed. ---- ## Summary by CodeRabbit * **New Features** * Added the ability for admins to view and update project configuration overrides through new internal API endpoints. * Extended the admin app to support fetching, updating, and caching configuration overrides, including React hook support for real-time config usage. * Introduced new admin interface methods for retrieving and updating configuration. * **Bug Fixes** * Improved validation and error handling for configuration updates, including checks for duplicate or invalid OAuth provider entries and non-existent configuration fields. * **Tests** * Added comprehensive end-to-end tests covering configuration retrieval, updates, access control, OAuth provider management, and domain management. * **Documentation** * Enhanced API documentation for configuration management endpoints and operations. --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Konsti Wohlwend --- apps/backend/prisma/seed.ts | 8 +- .../custom/projects/provision/route.tsx | 4 +- .../neon/oauth-providers/crud.tsx | 8 +- .../neon/projects/provision/route.tsx | 4 +- .../app/api/latest/internal/config/crud.tsx | 13 + .../latest/internal/config/override/crud.tsx | 49 ++ .../latest/internal/config/override/route.tsx | 3 + .../app/api/latest/internal/config/route.tsx | 3 + .../app/api/latest/internal/projects/crud.tsx | 4 +- .../latest/internal/projects/current/crud.tsx | 4 +- apps/backend/src/lib/projects.tsx | 2 +- .../endpoints/api/v1/internal/config.test.ts | 533 ++++++++++++++++++ apps/e2e/tests/js/config.test.ts | 64 +++ .../src/interface/admin-interface.ts | 25 +- .../stack-shared/src/interface/crud/config.ts | 38 ++ .../apps/implementations/admin-app-impl.ts | 27 +- .../src/lib/stack-app/projects/index.ts | 7 + 17 files changed, 776 insertions(+), 20 deletions(-) create mode 100644 apps/backend/src/app/api/latest/internal/config/crud.tsx create mode 100644 apps/backend/src/app/api/latest/internal/config/override/crud.tsx create mode 100644 apps/backend/src/app/api/latest/internal/config/override/route.tsx create mode 100644 apps/backend/src/app/api/latest/internal/config/route.tsx create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/internal/config.test.ts create mode 100644 apps/e2e/tests/js/config.test.ts create mode 100644 packages/stack-shared/src/interface/crud/config.ts diff --git a/apps/backend/prisma/seed.ts b/apps/backend/prisma/seed.ts index 98e78d1df..930734de4 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -1,6 +1,6 @@ /* eslint-disable no-restricted-syntax */ import { usersCrudHandlers } from '@/app/api/latest/users/crud'; -import { createOrUpdateProject, getProject } from '@/lib/projects'; +import { createOrUpdateProjectWithLegacyConfig, getProject } from '@/lib/projects'; import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from '@/lib/tenancies'; import { getPrismaClientForTenancy } from '@/prisma-client'; import { PrismaClient } from '@prisma/client'; @@ -34,7 +34,7 @@ async function seed() { let internalProject = await getProject('internal'); if (!internalProject) { - internalProject = await createOrUpdateProject({ + internalProject = await createOrUpdateProjectWithLegacyConfig({ type: 'create', projectId: 'internal', data: { @@ -60,7 +60,7 @@ async function seed() { const internalTenancy = await getSoleTenancyFromProjectBranch("internal", DEFAULT_BRANCH_ID); const internalPrisma = await getPrismaClientForTenancy(internalTenancy); - internalProject = await createOrUpdateProject({ + internalProject = await createOrUpdateProjectWithLegacyConfig({ projectId: 'internal', branchId: DEFAULT_BRANCH_ID, type: 'update', @@ -235,7 +235,7 @@ async function seed() { if (existingProject) { console.log('Emulator project already exists, skipping creation'); } else { - await createOrUpdateProject({ + await createOrUpdateProjectWithLegacyConfig({ projectId: emulatorProjectId, type: 'create', data: { diff --git a/apps/backend/src/app/api/latest/integrations/custom/projects/provision/route.tsx b/apps/backend/src/app/api/latest/integrations/custom/projects/provision/route.tsx index a27ea921a..fe6d71d23 100644 --- a/apps/backend/src/app/api/latest/integrations/custom/projects/provision/route.tsx +++ b/apps/backend/src/app/api/latest/integrations/custom/projects/provision/route.tsx @@ -1,5 +1,5 @@ import { createApiKeySet } from "@/lib/internal-api-keys"; -import { createOrUpdateProject } from "@/lib/projects"; +import { createOrUpdateProjectWithLegacyConfig } from "@/lib/projects"; import { globalPrismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { neonAuthorizationHeaderSchema, projectDisplayNameSchema, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields"; @@ -28,7 +28,7 @@ export const POST = createSmartRouteHandler({ handler: async (req) => { const [clientId] = decodeBasicAuthorizationHeader(req.headers.authorization[0])!; - const createdProject = await createOrUpdateProject({ + const createdProject = await createOrUpdateProjectWithLegacyConfig({ ownerIds: [], type: 'create', data: { diff --git a/apps/backend/src/app/api/latest/integrations/neon/oauth-providers/crud.tsx b/apps/backend/src/app/api/latest/integrations/neon/oauth-providers/crud.tsx index ddb294e3a..858018bc5 100644 --- a/apps/backend/src/app/api/latest/integrations/neon/oauth-providers/crud.tsx +++ b/apps/backend/src/app/api/latest/integrations/neon/oauth-providers/crud.tsx @@ -1,4 +1,4 @@ -import { createOrUpdateProject } from "@/lib/projects"; +import { createOrUpdateProjectWithLegacyConfig } from "@/lib/projects"; import { Tenancy, getTenancy } from "@/lib/tenancies"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { createCrud } from "@stackframe/stack-shared/dist/crud"; @@ -97,7 +97,7 @@ export const oauthProvidersCrudHandlers = createLazyProxy(() => createCrudHandle throw new StatusError(StatusError.BadRequest, 'OAuth provider already exists'); } - await createOrUpdateProject({ + await createOrUpdateProjectWithLegacyConfig({ type: 'update', projectId: auth.project.id, branchId: auth.branchId, @@ -124,7 +124,7 @@ export const oauthProvidersCrudHandlers = createLazyProxy(() => createCrudHandle throw new StatusError(StatusError.NotFound, 'OAuth provider not found'); } - await createOrUpdateProject({ + await createOrUpdateProjectWithLegacyConfig({ type: 'update', projectId: auth.project.id, branchId: auth.branchId, @@ -153,7 +153,7 @@ export const oauthProvidersCrudHandlers = createLazyProxy(() => createCrudHandle throw new StatusError(StatusError.NotFound, 'OAuth provider not found'); } - await createOrUpdateProject({ + await createOrUpdateProjectWithLegacyConfig({ type: 'update', projectId: auth.project.id, branchId: auth.branchId, diff --git a/apps/backend/src/app/api/latest/integrations/neon/projects/provision/route.tsx b/apps/backend/src/app/api/latest/integrations/neon/projects/provision/route.tsx index c29cff49c..08429661e 100644 --- a/apps/backend/src/app/api/latest/integrations/neon/projects/provision/route.tsx +++ b/apps/backend/src/app/api/latest/integrations/neon/projects/provision/route.tsx @@ -1,5 +1,5 @@ import { createApiKeySet } from "@/lib/internal-api-keys"; -import { createOrUpdateProject } from "@/lib/projects"; +import { createOrUpdateProjectWithLegacyConfig } from "@/lib/projects"; import { globalPrismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { neonAuthorizationHeaderSchema, projectDisplayNameSchema, yupArray, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields"; @@ -32,7 +32,7 @@ export const POST = createSmartRouteHandler({ handler: async (req) => { const [clientId] = decodeBasicAuthorizationHeader(req.headers.authorization[0])!; - const createdProject = await createOrUpdateProject({ + const createdProject = await createOrUpdateProjectWithLegacyConfig({ ownerIds: [], sourceOfTruth: req.body.connection_strings ? { type: 'neon', diff --git a/apps/backend/src/app/api/latest/internal/config/crud.tsx b/apps/backend/src/app/api/latest/internal/config/crud.tsx new file mode 100644 index 000000000..03ed2092e --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/config/crud.tsx @@ -0,0 +1,13 @@ +import { createCrudHandlers } from "@/route-handlers/crud-handler"; +import { configCrud } from "@stackframe/stack-shared/dist/interface/crud/config"; +import { yupObject } from "@stackframe/stack-shared/dist/schema-fields"; +import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; + +export const configCrudHandlers = createLazyProxy(() => createCrudHandlers(configCrud, { + paramsSchema: yupObject({}), + onRead: async ({ auth }) => { + return { + config_string: JSON.stringify(auth.tenancy.config), + }; + }, +})); diff --git a/apps/backend/src/app/api/latest/internal/config/override/crud.tsx b/apps/backend/src/app/api/latest/internal/config/override/crud.tsx new file mode 100644 index 000000000..c16e35228 --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/config/override/crud.tsx @@ -0,0 +1,49 @@ +import { getRenderedEnvironmentConfigQuery, overrideEnvironmentConfigOverride, validateEnvironmentConfigOverride } from "@/lib/config"; +import { globalPrismaClient, rawQuery } from "@/prisma-client"; +import { createCrudHandlers } from "@/route-handlers/crud-handler"; +import { configOverrideCrud } from "@stackframe/stack-shared/dist/interface/crud/config"; +import { yupObject } from "@stackframe/stack-shared/dist/schema-fields"; +import { StatusError } from "@stackframe/stack-shared/dist/utils/errors"; +import { createLazyProxy } from "@stackframe/stack-shared/dist/utils/proxies"; + +export const configOverridesCrudHandlers = createLazyProxy(() => createCrudHandlers(configOverrideCrud, { + paramsSchema: yupObject({}), + onUpdate: async ({ auth, data }) => { + if (data.config_override_string) { + let parsedConfig; + try { + parsedConfig = JSON.parse(data.config_override_string); + } catch (e) { + if (e instanceof SyntaxError) { + throw new StatusError(StatusError.BadRequest, 'Invalid config JSON'); + } + throw e; + } + + const validationResult = await validateEnvironmentConfigOverride({ + environmentConfigOverride: parsedConfig, + branchId: auth.tenancy.branchId, + projectId: auth.tenancy.project.id, + }); + + if (validationResult.status === "error") { + throw new StatusError(StatusError.BadRequest, validationResult.error); + } + + await overrideEnvironmentConfigOverride({ + projectId: auth.tenancy.project.id, + branchId: auth.tenancy.branchId, + environmentConfigOverrideOverride: parsedConfig, + }); + } + + const updatedConfig = await rawQuery(globalPrismaClient, getRenderedEnvironmentConfigQuery({ + projectId: auth.tenancy.project.id, + branchId: auth.tenancy.branchId, + })); + + return { + config_override_string: JSON.stringify(updatedConfig), + }; + }, +})); diff --git a/apps/backend/src/app/api/latest/internal/config/override/route.tsx b/apps/backend/src/app/api/latest/internal/config/override/route.tsx new file mode 100644 index 000000000..9fc6faa6f --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/config/override/route.tsx @@ -0,0 +1,3 @@ +import { configOverridesCrudHandlers } from "./crud"; + +export const PATCH = configOverridesCrudHandlers.updateHandler; diff --git a/apps/backend/src/app/api/latest/internal/config/route.tsx b/apps/backend/src/app/api/latest/internal/config/route.tsx new file mode 100644 index 000000000..014d6a7ba --- /dev/null +++ b/apps/backend/src/app/api/latest/internal/config/route.tsx @@ -0,0 +1,3 @@ +import { configCrudHandlers } from "./crud"; + +export const GET = configCrudHandlers.readHandler; diff --git a/apps/backend/src/app/api/latest/internal/projects/crud.tsx b/apps/backend/src/app/api/latest/internal/projects/crud.tsx index 83c4a14bc..d945f4d56 100644 --- a/apps/backend/src/app/api/latest/internal/projects/crud.tsx +++ b/apps/backend/src/app/api/latest/internal/projects/crud.tsx @@ -1,5 +1,5 @@ import { renderedOrganizationConfigToProjectCrud } from "@/lib/config"; -import { createOrUpdateProject, getProjectQuery, listManagedProjectIds } from "@/lib/projects"; +import { createOrUpdateProjectWithLegacyConfig, getProjectQuery, listManagedProjectIds } from "@/lib/projects"; import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies"; import { globalPrismaClient, rawQueryAll } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; @@ -30,7 +30,7 @@ export const adminUserProjectsCrudHandlers = createLazyProxy(() => createCrudHan const ownerPack = ownerPacks.find(p => p.has(user.id)); const userIds = ownerPack ? [...ownerPack] : [user.id]; - const project = await createOrUpdateProject({ + const project = await createOrUpdateProjectWithLegacyConfig({ ownerIds: userIds, type: 'create', data: { diff --git a/apps/backend/src/app/api/latest/internal/projects/current/crud.tsx b/apps/backend/src/app/api/latest/internal/projects/current/crud.tsx index b929d8fae..e1c4e48a6 100644 --- a/apps/backend/src/app/api/latest/internal/projects/current/crud.tsx +++ b/apps/backend/src/app/api/latest/internal/projects/current/crud.tsx @@ -1,5 +1,5 @@ import { renderedOrganizationConfigToProjectCrud } from "@/lib/config"; -import { createOrUpdateProject } from "@/lib/projects"; +import { createOrUpdateProjectWithLegacyConfig } from "@/lib/projects"; import { getTenancy } from "@/lib/tenancies"; import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; @@ -17,7 +17,7 @@ export const projectsCrudHandlers = createLazyProxy(() => createCrudHandlers(pro ) { throw new StatusError(400, "Invalid email theme"); } - const project = await createOrUpdateProject({ + const project = await createOrUpdateProjectWithLegacyConfig({ type: "update", projectId: auth.project.id, branchId: auth.branchId, diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index ae13aa461..dc62a4d7f 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -60,7 +60,7 @@ export async function getProject(projectId: string): Promise { + await Project.createAndSwitch(); + + // Test client access + const clientResponse = await niceBackendFetch("/api/v1/internal/config", { + accessType: "client" + }); + expect(clientResponse).toMatchInlineSnapshot(` + NiceResponse { + "status": 401, + "body": { + "code": "INSUFFICIENT_ACCESS_TYPE", + "details": { + "actual_access_type": "client", + "allowed_access_types": ["admin"], + }, + "error": "The x-stack-access-type header must be 'admin', but was 'client'.", + }, + "headers": Headers { + "x-stack-known-error": "INSUFFICIENT_ACCESS_TYPE", +