From 8f55c1e7101a385fcd43d1fd1cf00077c36b95ed Mon Sep 17 00:00:00 2001 From: Shridhar Deshmukh Date: Thu, 31 Jul 2025 22:15:57 +0200 Subject: [PATCH 1/4] fix: update super-admin-key name for neon auth (#809) Deleting super-secrete-admin-key breaks the integration with Neon Auth. This Pull request adds warning to key name to not delete it. --- .../app/api/latest/integrations/neon/internal/confirm/route.tsx | 2 +- .../api/latest/integrations/neon/projects/provision/route.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/app/api/latest/integrations/neon/internal/confirm/route.tsx b/apps/backend/src/app/api/latest/integrations/neon/internal/confirm/route.tsx index 261948a3a..61492f0d1 100644 --- a/apps/backend/src/app/api/latest/integrations/neon/internal/confirm/route.tsx +++ b/apps/backend/src/app/api/latest/integrations/neon/internal/confirm/route.tsx @@ -33,7 +33,7 @@ export const POST = createSmartRouteHandler({ const set = await globalPrismaClient.apiKeySet.create({ data: { projectId: req.body.project_id, - description: `Auto-generated for ${req.body.external_project_name ? `"${req.body.external_project_name}"` : "an external project"}`, + description: `Auto-generated for ${req.body.external_project_name ? `"${req.body.external_project_name}"` : "an external project"} (DO NOT DELETE)`, expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 100), superSecretAdminKey: `sak_${generateSecureRandomString()}`, }, 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 fc285f4a8..c29cff49c 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 @@ -69,7 +69,7 @@ export const POST = createSmartRouteHandler({ const set = await createApiKeySet({ projectId: createdProject.id, - description: `Auto-generated for Neon (${req.body.display_name})`, + description: `Auto-generated for Neon Auth (DO NOT DELETE)`, expires_at_millis: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 100).getTime(), has_publishable_client_key: false, has_secret_server_key: false, From 7fe0dbf742fc8c82af7b07411decb3172f57ade7 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Thu, 31 Jul 2025 15:25:25 -0700 Subject: [PATCH 2/4] Fix tests --- .../backend/endpoints/api/v1/integrations/custom/oauth.test.ts | 2 +- .../backend/endpoints/api/v1/integrations/neon/oauth.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/integrations/custom/oauth.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/integrations/custom/oauth.test.ts index 5d931f686..05d59bdd0 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/integrations/custom/oauth.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/integrations/custom/oauth.test.ts @@ -233,7 +233,7 @@ it(`should exchange the authorization code for an admin API key that works`, asy "items": [ { "created_at_millis": , - "description": "Auto-generated for an external project", + "description": "Auto-generated for an external project (DO NOT DELETE)", "expires_at_millis": , "id": "", "super_secret_admin_key": { "last_four": }, diff --git a/apps/e2e/tests/backend/endpoints/api/v1/integrations/neon/oauth.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/integrations/neon/oauth.test.ts index 69ac14e31..9d7c6f9af 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/integrations/neon/oauth.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/integrations/neon/oauth.test.ts @@ -233,7 +233,7 @@ it(`should exchange the authorization code for an admin API key that works`, asy "items": [ { "created_at_millis": , - "description": "Auto-generated for an external project", + "description": "Auto-generated for an external project (DO NOT DELETE)", "expires_at_millis": , "id": "", "super_secret_admin_key": { "last_four": }, From 9399b84f97f4c5f001d2e16740b5fbafa5ea41aa Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Thu, 31 Jul 2025 15:43:48 -0700 Subject: [PATCH 3/4] Use retryTransaction instead of $transaction --- apps/backend/src/app/api/latest/(api-keys)/handlers.tsx | 4 ++-- .../integrations/credential-scanning/revoke/route.tsx | 6 +++--- apps/backend/src/auto-migrations/auto-migration.tests.ts | 2 ++ apps/backend/src/auto-migrations/index.tsx | 2 ++ apps/backend/src/lib/projects.tsx | 2 +- apps/backend/src/prisma-client.tsx | 1 + configs/eslint/defaults.js | 4 ++++ 7 files changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/backend/src/app/api/latest/(api-keys)/handlers.tsx b/apps/backend/src/app/api/latest/(api-keys)/handlers.tsx index 1ef687641..26f9ed20e 100644 --- a/apps/backend/src/app/api/latest/(api-keys)/handlers.tsx +++ b/apps/backend/src/app/api/latest/(api-keys)/handlers.tsx @@ -1,6 +1,6 @@ import { listPermissions } from "@/lib/permissions"; import { Tenancy } from "@/lib/tenancies"; -import { getPrismaClientForTenancy } from "@/prisma-client"; +import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client"; import { createCrudHandlers } from "@/route-handlers/crud-handler"; import { SmartRequestAuth } from "@/route-handlers/smart-request"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; @@ -57,7 +57,7 @@ async function ensureUserCanManageApiKeys( // Check team API key permissions if (options.teamId !== undefined) { const userId = auth.user.id; - const hasManageApiKeysPermission = await prisma.$transaction(async (tx) => { + const hasManageApiKeysPermission = await retryTransaction(prisma, async (tx) => { const permissions = await listPermissions(tx, { scope: 'team', tenancy: auth.tenancy, diff --git a/apps/backend/src/app/api/latest/integrations/credential-scanning/revoke/route.tsx b/apps/backend/src/app/api/latest/integrations/credential-scanning/revoke/route.tsx index 9eb976115..16bf5c56a 100644 --- a/apps/backend/src/app/api/latest/integrations/credential-scanning/revoke/route.tsx +++ b/apps/backend/src/app/api/latest/integrations/credential-scanning/revoke/route.tsx @@ -1,7 +1,7 @@ import { getSharedEmailConfig, sendEmail } from "@/lib/emails"; import { listPermissions } from "@/lib/permissions"; import { getTenancy } from "@/lib/tenancies"; -import { getPrismaClientForTenancy, globalPrismaClient } from "@/prisma-client"; +import { getPrismaClientForTenancy, globalPrismaClient, retryTransaction } from "@/prisma-client"; import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; import { KnownErrors } from "@stackframe/stack-shared"; import { yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; @@ -27,7 +27,7 @@ export const POST = createSmartRouteHandler({ async handler({ body }) { // Get the API key and revoke it. We use a transaction to ensure we do not send emails multiple times. // We don't support revoking API keys in tenancies with non-global source of truth atm. - const updatedApiKey = await globalPrismaClient.$transaction(async (tx) => { + const updatedApiKey = await retryTransaction(globalPrismaClient, async (tx) => { // Find the API key in the database const apiKey = await tx.projectApiKey.findUnique({ where: { @@ -116,7 +116,7 @@ export const POST = createSmartRouteHandler({ const prisma = await getPrismaClientForTenancy(tenancy); - const userIdsWithManageApiKeysPermission = await prisma.$transaction(async (tx) => { + const userIdsWithManageApiKeysPermission = await retryTransaction(prisma, async (tx) => { if (!updatedApiKey.teamId) { throw new StackAssertionError("Team ID not specified in team API key"); } diff --git a/apps/backend/src/auto-migrations/auto-migration.tests.ts b/apps/backend/src/auto-migrations/auto-migration.tests.ts index ee248b30a..fa7f67921 100644 --- a/apps/backend/src/auto-migrations/auto-migration.tests.ts +++ b/apps/backend/src/auto-migrations/auto-migration.tests.ts @@ -285,6 +285,7 @@ import.meta.vitest?.test("applies migration while running concurrent queries", r })); import.meta.vitest?.test("applies migration while running an interactive transaction", runTest(async ({ expect, prismaClient, dbURL }) => { + // eslint-disable-next-line no-restricted-syntax return await prismaClient.$transaction(async (tx, ...args) => { await runMigrationNeeded({ prismaClient, @@ -304,6 +305,7 @@ import.meta.vitest?.test("applies migration while running an interactive transac import.meta.vitest?.test("applies migration while running concurrent interactive transactions", runTest(async ({ expect, prismaClient, dbURL }) => { const runTransactionWithMigration = async (testValue: string) => { + // eslint-disable-next-line no-restricted-syntax return await prismaClient.$transaction(async (tx) => { await runMigrationNeeded({ prismaClient, diff --git a/apps/backend/src/auto-migrations/index.tsx b/apps/backend/src/auto-migrations/index.tsx index 07dfdb2d3..bd9c3c808 100644 --- a/apps/backend/src/auto-migrations/index.tsx +++ b/apps/backend/src/auto-migrations/index.tsx @@ -39,6 +39,7 @@ async function getAppliedMigrations(options: { prismaClient: PrismaClient, schema: string, }) { + // eslint-disable-next-line no-restricted-syntax const [_1, _2, _3, appliedMigrations] = await options.prismaClient.$transaction([ options.prismaClient.$executeRaw`SELECT pg_advisory_xact_lock(${MIGRATION_LOCK_ID})`, options.prismaClient.$executeRaw(Prisma.sql` @@ -143,6 +144,7 @@ export async function applyMigrations(options: { VALUES (${migration.migrationName}, clock_timestamp()) `); try { + // eslint-disable-next-line no-restricted-syntax await options.prismaClient.$transaction(transaction); } catch (e) { const error = getMigrationError(e); diff --git a/apps/backend/src/lib/projects.tsx b/apps/backend/src/lib/projects.tsx index 84a9d5f91..ae13aa461 100644 --- a/apps/backend/src/lib/projects.tsx +++ b/apps/backend/src/lib/projects.tsx @@ -234,7 +234,7 @@ export async function createOrUpdateProject( // Update owner metadata const internalEnvironmentConfig = await rawQuery(globalPrismaClient, getRenderedEnvironmentConfigQuery({ projectId: "internal", branchId: DEFAULT_BRANCH_ID })); const prisma = await getPrismaClientForSourceOfTruth(internalEnvironmentConfig.sourceOfTruth, DEFAULT_BRANCH_ID); - await prisma.$transaction(async (tx) => { + await retryTransaction(prisma, async (tx) => { for (const userId of options.ownerIds ?? []) { const projectUserTx = await tx.projectUser.findUnique({ where: { diff --git a/apps/backend/src/prisma-client.tsx b/apps/backend/src/prisma-client.tsx index 7637ab582..ec84923b0 100644 --- a/apps/backend/src/prisma-client.tsx +++ b/apps/backend/src/prisma-client.tsx @@ -129,6 +129,7 @@ export async function retryTransaction(client: PrismaClient, fn: (tx: PrismaC return await traceSpan(`transaction attempt #${attemptIndex}`, async (attemptSpan) => { const attemptRes = await (async () => { try { + // eslint-disable-next-line no-restricted-syntax return Result.ok(await client.$transaction(async (tx, ...args) => { let res; try { diff --git a/configs/eslint/defaults.js b/configs/eslint/defaults.js index 7fac0255a..fb257a9ff 100644 --- a/configs/eslint/defaults.js +++ b/configs/eslint/defaults.js @@ -103,6 +103,10 @@ module.exports = { "Identifier[name='localeCompare']", message: "Use stringCompare() from utils/strings.tsx instead of String.prototype.localeCompare.", }, + { + selector: "CallExpression > MemberExpression[property.name='$transaction']", + message: "Calling .$transaction is disallowed. Use retryTransaction() instead.", + }, ], "@typescript-eslint/no-misused-promises": [ "error", From 7c14504098eb06308e8d8b24fec5c7d1c7824362 Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Thu, 31 Jul 2025 15:50:57 -0700 Subject: [PATCH 4/4] Do-not-delete warning on external projects --- .../api/latest/integrations/custom/internal/confirm/route.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/app/api/latest/integrations/custom/internal/confirm/route.tsx b/apps/backend/src/app/api/latest/integrations/custom/internal/confirm/route.tsx index 7bcd9b749..93f549104 100644 --- a/apps/backend/src/app/api/latest/integrations/custom/internal/confirm/route.tsx +++ b/apps/backend/src/app/api/latest/integrations/custom/internal/confirm/route.tsx @@ -33,7 +33,7 @@ export const POST = createSmartRouteHandler({ const set = await globalPrismaClient.apiKeySet.create({ data: { projectId: req.body.project_id, - description: `Auto-generated for ${req.body.external_project_name ? `"${req.body.external_project_name}"` : "an external project"}`, + description: `Auto-generated for ${req.body.external_project_name ? `"${req.body.external_project_name}"` : "an external project"} (DO NOT DELETE)`, expiresAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 365 * 100), superSecretAdminKey: `sak_${generateSecureRandomString()}`, },