mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Remove source-of-truth logic
This commit is contained in:
parent
884f8dfcea
commit
f6ef49a3dc
@ -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<string, string> = {};
|
||||
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,
|
||||
|
||||
@ -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<string, string> = {};
|
||||
const uuidConnectionStrings: Record<string, string> = {};
|
||||
|
||||
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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<PrismaClient, "$on"> // $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<Parameters<PrismaClient['$transaction']>[0]>[0];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
const prismaClientsStore = (globalVar.__stack_prisma_clients as undefined) || {
|
||||
neon: new Map<string, PrismaClient>(),
|
||||
postgres: new Map<string, {
|
||||
client: PrismaClient,
|
||||
schema: string | null,
|
||||
}>(),
|
||||
};
|
||||
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<string> {
|
||||
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<string>("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;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -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": "<stripped UUID>" },
|
||||
"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": "<stripped UUID>" },
|
||||
"type": "neon",
|
||||
"type": "hosted",
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
@ -12,11 +12,7 @@ type FuzzerConfig<T> = ReadonlyArray<T extends object ? ([T] extends [any[]] ? {
|
||||
|
||||
const projectSchemaFuzzerConfig = [{
|
||||
sourceOfTruth: [{
|
||||
type: ["hosted", "neon", "postgres"],
|
||||
connectionString: ["", "postgres://user:password@host:port/database", "THIS IS A STRING LOLOL"],
|
||||
connectionStrings: [{
|
||||
"123-some-branch-id": ["", "THIS IS A CONNECTION STRING OR SO"],
|
||||
}],
|
||||
type: ["hosted"],
|
||||
}],
|
||||
project: [{
|
||||
requirePublishableClientKey: [true, false],
|
||||
|
||||
@ -46,17 +46,6 @@ export const projectConfigSchema = yupObject({
|
||||
yupObject({
|
||||
type: yupString().oneOf(['hosted']).defined(),
|
||||
}),
|
||||
yupObject({
|
||||
type: yupString().oneOf(['neon']).defined(),
|
||||
connectionStrings: yupRecord(
|
||||
userSpecifiedIdSchema("connectionStringId").defined(),
|
||||
yupString().defined(),
|
||||
).defined(),
|
||||
}),
|
||||
yupObject({
|
||||
type: yupString().oneOf(['postgres']).defined(),
|
||||
connectionString: yupString().defined()
|
||||
}),
|
||||
),
|
||||
project: yupObject({
|
||||
requirePublishableClientKey: yupBoolean(),
|
||||
@ -561,10 +550,33 @@ export function migrateConfigOverride(type: "project" | "branch" | "environment"
|
||||
}
|
||||
// END
|
||||
|
||||
// BEGIN 2026-05-23: external source-of-truth config was never used and is no longer supported.
|
||||
// Drop the override so legacy Neon/Postgres configs continue to render as the hosted default.
|
||||
if (type === "project") {
|
||||
res = removeProperty(res, p => 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<string, any>, 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<T extends ProjectRenderedConfigBeforeSanitization>(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<string, string> }
|
||||
} 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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user