diff --git a/apps/backend/src/app/api/latest/integrations/neon/projects/connection/route.tsx b/apps/backend/src/app/api/latest/integrations/neon/projects/connection/route.tsx index 026c8a345..f96db4333 100644 --- a/apps/backend/src/app/api/latest/integrations/neon/projects/connection/route.tsx +++ b/apps/backend/src/app/api/latest/integrations/neon/projects/connection/route.tsx @@ -1,11 +1,8 @@ -import { overrideProjectConfigOverride } from "@/lib/config"; -import { getPrismaClientForSourceOfTruth, globalPrismaClient } from "@/prisma-client"; +import { globalPrismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { getStackServerApp } from "@/stack"; import { KnownErrors } from "@stackframe/stack-shared"; import { neonAuthorizationHeaderSchema, yupArray, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields"; import { decodeBasicAuthorizationHeader } from "@stackframe/stack-shared/dist/utils/http"; -import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; export const POST = createSmartRouteHandler({ metadata: { @@ -44,31 +41,8 @@ export const POST = createSmartRouteHandler({ throw new KnownErrors.ProjectNotFound(req.query.project_id); } - const uuidConnectionStrings: Record = {}; - const store = await getStackServerApp().getDataVaultStore('neon-connection-strings'); - const secret = "no client side encryption"; - for (const c of req.body.connection_strings) { - const uuid = generateUuid(); - await store.setValue(uuid, c.connection_string, { secret }); - uuidConnectionStrings[c.branch_id] = uuid; - } - - const sourceOfTruthPersisted = { - type: 'neon' as const, - connectionStrings: uuidConnectionStrings, - }; - await overrideProjectConfigOverride({ - projectId: provisionedProject.projectId, - projectConfigOverrideOverride: { - sourceOfTruth: sourceOfTruthPersisted, - }, - }); - - await Promise.all(req.body.connection_strings.map(({ branch_id, connection_string }) => getPrismaClientForSourceOfTruth({ - type: 'neon', - connectionString: undefined, - connectionStrings: { [branch_id]: connection_string }, - } as const, branch_id))); + // Connection strings used to configure Neon as source-of-truth. That mode no + // longer exists, but keep accepting this webhook so old integrations do not fail. return { statusCode: 200, 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 5ca91fcdb..1e099007a 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,11 +1,9 @@ import { createApiKeySet } from "@/lib/internal-api-keys"; import { createOrUpdateProjectWithLegacyConfig } from "@/lib/projects"; -import { getPrismaClientForSourceOfTruth, globalPrismaClient } from "@/prisma-client"; +import { globalPrismaClient } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; -import { getStackServerApp } from "@/stack"; import { neonAuthorizationHeaderSchema, projectDisplayNameSchema, yupArray, yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields"; import { decodeBasicAuthorizationHeader } from "@stackframe/stack-shared/dist/utils/http"; -import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; export const POST = createSmartRouteHandler({ metadata: { @@ -33,31 +31,11 @@ export const POST = createSmartRouteHandler({ }), handler: async (req) => { const [clientId] = decodeBasicAuthorizationHeader(req.headers.authorization[0])!; - - const hasNeonConnections = req.body.connection_strings && req.body.connection_strings.length > 0; - const realConnectionStrings: Record = {}; - const uuidConnectionStrings: Record = {}; - - if (hasNeonConnections) { - const store = await getStackServerApp().getDataVaultStore('neon-connection-strings'); - const secret = "no client side encryption"; - - for (const c of req.body.connection_strings!) { - const uuid = generateUuid(); - await store.setValue(uuid, c.connection_string, { secret }); - realConnectionStrings[c.branch_id] = c.connection_string; - uuidConnectionStrings[c.branch_id] = uuid; - } - } - - const sourceOfTruthPersisted = hasNeonConnections ? { - type: 'neon' as const, - connectionString: undefined, - connectionStrings: uuidConnectionStrings, - } : { type: 'hosted' as const, connectionString: undefined, connectionStrings: undefined }; + // connection_strings used to configure Neon as source-of-truth. That mode no + // longer exists, but keep accepting the field so old integrations do not fail. const createdProject = await createOrUpdateProjectWithLegacyConfig({ - sourceOfTruth: sourceOfTruthPersisted, + sourceOfTruth: { type: 'hosted' }, type: 'create', data: { display_name: req.body.display_name, @@ -80,18 +58,6 @@ export const POST = createSmartRouteHandler({ } }); - - if (hasNeonConnections) { - // Run migrations using the real connection strings (do not persist them) - const branchIds = Object.keys(realConnectionStrings); - await Promise.all(branchIds.map((branchId) => getPrismaClientForSourceOfTruth({ - type: 'neon', - connectionString: undefined, - connectionStrings: realConnectionStrings, - } as const, branchId))); - } - - await globalPrismaClient.provisionedProject.create({ data: { projectId: createdProject.id, diff --git a/apps/backend/src/lib/config.tsx b/apps/backend/src/lib/config.tsx index 9c535d095..bf5be93c1 100644 --- a/apps/backend/src/lib/config.tsx +++ b/apps/backend/src/lib/config.tsx @@ -775,7 +775,11 @@ import.meta.vitest?.test('_validateConfigOverrideSchemaImpl(...)', async ({ expe type: 'postgres', connectionString: 'postgres://user:pass@host:port/db', }, - })).toEqual(Result.ok(null)); + })).toEqual(Result.error(deindent` + [WARNING] sourceOfTruth is not matched by any of the provided schemas: + Schema 0: + sourceOfTruth.type must be one of the following values: hosted + `)); expect(await validateConfigOverrideSchema(projectConfigSchema, projectSchemaBase, { sourceOfTruth: { type: 'postgres', @@ -784,10 +788,6 @@ import.meta.vitest?.test('_validateConfigOverrideSchemaImpl(...)', async ({ expe [WARNING] sourceOfTruth is not matched by any of the provided schemas: Schema 0: sourceOfTruth.type must be one of the following values: hosted - Schema 1: - sourceOfTruth.connectionStrings must be defined - Schema 2: - sourceOfTruth.connectionString must be defined `)); // Dot-notation keys that dot into nothing — detected by simulating the rendering pipeline diff --git a/apps/backend/src/prisma-client.tsx b/apps/backend/src/prisma-client.tsx index 94cba5609..a9f7f91ed 100644 --- a/apps/backend/src/prisma-client.tsx +++ b/apps/backend/src/prisma-client.tsx @@ -1,5 +1,4 @@ import { Prisma, PrismaClient } from "@/generated/prisma/client"; -import { PrismaNeon } from "@prisma/adapter-neon"; import { PrismaPg } from '@prisma/adapter-pg'; import { readReplicas } from '@prisma/extension-read-replicas'; import { CompleteConfig } from "@stackframe/stack-shared/dist/config/schema"; @@ -12,11 +11,9 @@ import { concatStacktracesIfRejected, ignoreUnhandledRejection, runAsynchronousl import { throwingProxy } from "@stackframe/stack-shared/dist/utils/proxies"; import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { traceSpan } from "@stackframe/stack-shared/dist/utils/telemetry"; -import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import net from "node:net"; import { Pool } from "pg"; import { isPromise } from "util/types"; -import { runMigrationNeeded } from "./auto-migrations"; import { registerPgPool } from "./lib/dev-perf-stats"; import { Tenancy } from "./lib/tenancies"; import { ensurePolyfilled } from "./polyfills"; @@ -29,45 +26,10 @@ export type PrismaClientTransaction = | Omit // $on is not available on extended Prisma clients, so we don't require it here. see: https://www.prisma.io/docs/orm/reference/prisma-client-reference#on | Parameters[0]>[0]; -// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -const prismaClientsStore = (globalVar.__stack_prisma_clients as undefined) || { - neon: new Map(), - postgres: new Map(), -}; -if (getNodeEnvironment().includes('development')) { - globalVar.__stack_prisma_clients = prismaClientsStore; // store globally so fast refresh doesn't recreate too many Prisma clients -} - -function getNeonPrismaClient(connectionString: string) { - let neonPrismaClient = prismaClientsStore.neon.get(connectionString); - if (!neonPrismaClient) { - const schema = getSchemaFromConnectionString(connectionString); - const adapter = new PrismaNeon({ connectionString }, { schema }); - neonPrismaClient = new PrismaClient({ adapter }); - prismaClientsStore.neon.set(connectionString, neonPrismaClient); - } - return neonPrismaClient; -} - function getSchemaFromConnectionString(connectionString: string) { return (new URL(connectionString)).searchParams.get('schema') ?? "public"; } -async function resolveNeonConnectionString(entry: string): Promise { - if (!isUuid(entry)) { - return entry; - } - const { getStackServerApp } = await import("@/stack"); - const store = await getStackServerApp().getDataVaultStore('neon-connection-strings'); - const secret = "no client side encryption"; - const value = await store.getValue(entry, { secret }); - if (!value) throw new Error('No Neon connection string found for UUID'); - return value; -} - export async function getPrismaClientForTenancy(tenancy: Tenancy) { return await getPrismaClientForSourceOfTruth(tenancy.config.sourceOfTruth, tenancy.branchId); } @@ -110,9 +72,6 @@ if (!getEnvVariable("VERCEL", "") && !globalVar.__stack_prisma_sigterm_registere for (const [, entry] of postgresPrismaClientsStore) { await entry.client.$disconnect(); } - for (const [, client] of prismaClientsStore.neon) { - await client.$disconnect(); - } } finally { clearTimeout(keepAlive); } @@ -417,49 +376,12 @@ export const { client: globalPrismaClient, schema: globalPrismaSchema }: { schema: throwingProxy("STACK_DATABASE_CONNECTION_STRING environment variable is not set. Please set it to a valid PostgreSQL connection string, or use a mock Prisma client for testing."), }; -export async function getPrismaClientForSourceOfTruth(sourceOfTruth: CompleteConfig["sourceOfTruth"], branchId: string) { - switch (sourceOfTruth.type) { - case 'neon': { - if (!(branchId in sourceOfTruth.connectionStrings)) { - throw new Error(`No connection string provided for Neon source of truth for branch ${branchId}`); - } - const entry = sourceOfTruth.connectionStrings[branchId]; - const connectionString = await resolveNeonConnectionString(entry); - const neonPrismaClient = getNeonPrismaClient(connectionString); - await runMigrationNeeded({ prismaClient: neonPrismaClient, schema: getSchemaFromConnectionString(connectionString), logging: true }); - return extendWithFakeReadReplica(neonPrismaClient); - } - case 'postgres': { - const postgresPrismaClient = getPostgresPrismaClient(sourceOfTruth.connectionString); - await runMigrationNeeded({ prismaClient: postgresPrismaClient.client, schema: getSchemaFromConnectionString(sourceOfTruth.connectionString), logging: true }); - return extendWithFakeReadReplica(postgresPrismaClient.client); - } - case 'hosted': { - return globalPrismaClient; - } - } +export async function getPrismaClientForSourceOfTruth(_sourceOfTruth: CompleteConfig["sourceOfTruth"], _branchId: string) { + return globalPrismaClient; } -export async function getPrismaSchemaForSourceOfTruth(sourceOfTruth: CompleteConfig["sourceOfTruth"], branchId: string) { - switch (sourceOfTruth.type) { - case 'postgres': { - return getSchemaFromConnectionString(sourceOfTruth.connectionString); - } - case 'neon': { - if (!(branchId in sourceOfTruth.connectionStrings)) { - throw new Error(`No connection string provided for Neon source of truth for branch ${branchId}`); - } - const entry = sourceOfTruth.connectionStrings[branchId]; - if (isUuid(entry)) { - const connectionString = await resolveNeonConnectionString(entry); - return getSchemaFromConnectionString(connectionString); - } - return getSchemaFromConnectionString(entry); - } - case 'hosted': { - return globalPrismaSchema; - } - } +export async function getPrismaSchemaForSourceOfTruth(_sourceOfTruth: CompleteConfig["sourceOfTruth"], _branchId: string) { + return globalPrismaSchema; } diff --git a/apps/e2e/tests/backend/endpoints/api/v1/integrations/neon/projects/provision.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/integrations/neon/projects/provision.test.ts index 5dc5e132d..bb8c98748 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/integrations/neon/projects/provision.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/integrations/neon/projects/provision.test.ts @@ -1,6 +1,5 @@ -import { decryptValue, hashKey } from "@stackframe/stack-shared/dist/helpers/vault/client-side"; import { it } from "../../../../../../../helpers"; -import { Auth, InternalApiKey, InternalProjectKeys, backendContext, niceBackendFetch } from "../../../../../../backend-helpers"; +import { Auth, InternalApiKey, backendContext, niceBackendFetch } from "../../../../../../backend-helpers"; import { provisionProject } from "./provision-helpers"; it("should be able to provision a new project if neon client details are correct", async ({ expect }) => { @@ -224,13 +223,7 @@ it("should validate connection_strings item shape", async ({ expect }) => { expect(response.headers.get("x-stack-known-error")).toBe("SCHEMA_ERROR"); }); -it("can provision with a Neon connection string when provided via env (optional)", async ({ expect }) => { - // this test only runs with a neon connection string set - const neonConnectionString = process.env.STACK_TEST_NEON_CONNECTION_STRING; - if (!neonConnectionString) { - return; - } - +it("ignores connection_strings while provisioning with hosted source-of-truth", async ({ expect }) => { const response = await niceBackendFetch("/api/v1/integrations/neon/projects/provision", { method: "POST", body: { @@ -238,7 +231,7 @@ it("can provision with a Neon connection string when provided via env (optional) connection_strings: [ { branch_id: "main", - connection_string: neonConnectionString, + connection_string: "postgres://user:password@neon.example.com/database", }, ], }, @@ -263,42 +256,18 @@ it("can provision with a Neon connection string when provided via env (optional) const sourceOfTruth = JSON.parse(configResponse.body.config_string).sourceOfTruth; expect(sourceOfTruth).toMatchInlineSnapshot(` { - "connectionStrings": { "main": "" }, - "type": "neon", + "type": "hosted", } `); - backendContext.set({ - projectKeys: InternalProjectKeys, - }); - - const getConnectionResponse = await niceBackendFetch(`/api/latest/data-vault/stores/neon-connection-strings/get`, { - method: "POST", - accessType: "server", - body: { - hashed_key: await hashKey("no client side encryption", sourceOfTruth.connectionStrings.main), - }, - }); - expect(getConnectionResponse.status).toBe(200); - const connectionString = await decryptValue( - "no client side encryption", - sourceOfTruth.connectionStrings.main, - getConnectionResponse.body.encrypted_value - ); - expect(connectionString).toBe(neonConnectionString); }); -it("can update the connection_strings for an existing provisioned project", async ({ expect }) => { - // this test only runs with a neon connection string set - const neonConnectionString = process.env.STACK_TEST_NEON_CONNECTION_STRING; - if (!neonConnectionString) { - return; - } +it("accepts connection_strings updates without changing hosted source-of-truth", async ({ expect }) => { const provisionResponse = await provisionProject(); const response = await niceBackendFetch(`/api/v1/integrations/neon/projects/connection?project_id=${provisionResponse.body.project_id}`, { method: "POST", body: { connection_strings: [ - { branch_id: "branch1", connection_string: neonConnectionString }, + { branch_id: "branch1", connection_string: "postgres://user:password@neon.example.com/database" }, ], }, headers: { @@ -327,8 +296,7 @@ it("can update the connection_strings for an existing provisioned project", asyn const sourceOfTruth = JSON.parse(configResponse.body.config_string).sourceOfTruth; expect(sourceOfTruth).toMatchInlineSnapshot(` { - "connectionStrings": { "branch1": "" }, - "type": "neon", + "type": "hosted", } `); }); diff --git a/packages/stack-shared/src/config/schema-fuzzer.test.ts b/packages/stack-shared/src/config/schema-fuzzer.test.ts index ec6bbaf85..56bae5044 100644 --- a/packages/stack-shared/src/config/schema-fuzzer.test.ts +++ b/packages/stack-shared/src/config/schema-fuzzer.test.ts @@ -12,11 +12,7 @@ type FuzzerConfig = ReadonlyArray p[0] === "sourceOfTruth"); + } + // END + // return the result return res; }; +import.meta.vitest?.test("migrateConfigOverride removes legacy sourceOfTruth overrides", ({ expect }) => { + expect(migrateConfigOverride("project", { + sourceOfTruth: { + type: "neon", + connectionStrings: { + main: "postgres://user:password@neon.example.com/database", + }, + }, + })).toEqual({}); + + expect(migrateConfigOverride("project", { + "sourceOfTruth.type": "postgres", + "sourceOfTruth.connectionString": "postgres://user:password@host:5432/database", + })).toEqual({}); +}); + function removeProperty(obj: Record, pathCond: (path: (string | symbol)[]) => boolean): any { return mapProperty(obj, pathCond, () => undefined); } @@ -670,8 +682,6 @@ import.meta.vitest?.test("renameProperty", ({ expect }) => { const projectConfigDefaults = { sourceOfTruth: { type: 'hosted', - connectionStrings: undefined, - connectionString: undefined, }, project: { requirePublishableClientKey: false, @@ -1027,23 +1037,12 @@ export function applyOrganizationDefaults(config: OrganizationRenderedConfigBefo export async function sanitizeProjectConfig(config: T) { assertNormalized(config); - const oldSourceOfTruth = config.sourceOfTruth; - const sourceOfTruth = - oldSourceOfTruth.type === 'neon' && typeof oldSourceOfTruth.connectionStrings === 'object' ? { - type: 'neon', - connectionStrings: { ...filterUndefined(oldSourceOfTruth.connectionStrings) as Record } - } as const - : oldSourceOfTruth.type === 'postgres' && typeof oldSourceOfTruth.connectionString === 'string' ? { - type: 'postgres', - connectionString: oldSourceOfTruth.connectionString, - } as const - : { - type: 'hosted', - } as const; return { ...config, - sourceOfTruth, + sourceOfTruth: { + type: 'hosted', + } as const, }; }