From 47558efcce9ea8572f48cc615df6bc6a2f9b3ae7 Mon Sep 17 00:00:00 2001 From: Mayank Saini Date: Fri, 14 Aug 2026 21:50:49 +0530 Subject: [PATCH 01/13] feat(prisma): add Prisma plugin with 22 Management API + Postgres operations --- packages/corsair/core/constants.ts | 3 + packages/prisma/api.test.ts | 307 ++++++++++++++++++ packages/prisma/client.ts | 79 +++++ packages/prisma/endpoints/backups.ts | 45 +++ packages/prisma/endpoints/connections.ts | 74 +++++ packages/prisma/endpoints/databases.ts | 86 +++++ packages/prisma/endpoints/factory.ts | 319 +++++++++++++++++++ packages/prisma/endpoints/index.ts | 57 ++++ packages/prisma/endpoints/integrations.ts | 41 +++ packages/prisma/endpoints/operation-types.ts | 21 ++ packages/prisma/endpoints/operations.ts | 25 ++ packages/prisma/endpoints/projects.ts | 86 +++++ packages/prisma/endpoints/regions.ts | 51 +++ packages/prisma/endpoints/sql-helpers.ts | 10 + packages/prisma/endpoints/sql.ts | 108 +++++++ packages/prisma/endpoints/types.ts | 130 ++++++++ packages/prisma/endpoints/workspaces.ts | 33 ++ packages/prisma/error-handlers.ts | 31 ++ packages/prisma/index.ts | 128 ++++++++ packages/prisma/jest.config.cjs | 55 ++++ packages/prisma/operations/backups.ts | 25 ++ packages/prisma/operations/connections.ts | 34 ++ packages/prisma/operations/databases.ts | 67 ++++ packages/prisma/operations/integrations.ts | 15 + packages/prisma/operations/projects.ts | 53 +++ packages/prisma/operations/regions.ts | 23 ++ packages/prisma/operations/sql.ts | 28 ++ packages/prisma/operations/workspaces.ts | 13 + packages/prisma/package.json | 48 +++ packages/prisma/pg-client.ts | 199 ++++++++++++ packages/prisma/plugin-docs.yaml | 11 + packages/prisma/schema.test.ts | 20 ++ packages/prisma/schema/database.ts | 78 +++++ packages/prisma/schema/index.ts | 22 ++ packages/prisma/tsconfig.json | 20 ++ packages/prisma/tsup.config.ts | 15 + pnpm-lock.yaml | 33 +- 37 files changed, 2392 insertions(+), 1 deletion(-) create mode 100644 packages/prisma/api.test.ts create mode 100644 packages/prisma/client.ts create mode 100644 packages/prisma/endpoints/backups.ts create mode 100644 packages/prisma/endpoints/connections.ts create mode 100644 packages/prisma/endpoints/databases.ts create mode 100644 packages/prisma/endpoints/factory.ts create mode 100644 packages/prisma/endpoints/index.ts create mode 100644 packages/prisma/endpoints/integrations.ts create mode 100644 packages/prisma/endpoints/operation-types.ts create mode 100644 packages/prisma/endpoints/operations.ts create mode 100644 packages/prisma/endpoints/projects.ts create mode 100644 packages/prisma/endpoints/regions.ts create mode 100644 packages/prisma/endpoints/sql-helpers.ts create mode 100644 packages/prisma/endpoints/sql.ts create mode 100644 packages/prisma/endpoints/types.ts create mode 100644 packages/prisma/endpoints/workspaces.ts create mode 100644 packages/prisma/error-handlers.ts create mode 100644 packages/prisma/index.ts create mode 100644 packages/prisma/jest.config.cjs create mode 100644 packages/prisma/operations/backups.ts create mode 100644 packages/prisma/operations/connections.ts create mode 100644 packages/prisma/operations/databases.ts create mode 100644 packages/prisma/operations/integrations.ts create mode 100644 packages/prisma/operations/projects.ts create mode 100644 packages/prisma/operations/regions.ts create mode 100644 packages/prisma/operations/sql.ts create mode 100644 packages/prisma/operations/workspaces.ts create mode 100644 packages/prisma/package.json create mode 100644 packages/prisma/pg-client.ts create mode 100644 packages/prisma/plugin-docs.yaml create mode 100644 packages/prisma/schema.test.ts create mode 100644 packages/prisma/schema/database.ts create mode 100644 packages/prisma/schema/index.ts create mode 100644 packages/prisma/tsconfig.json create mode 100644 packages/prisma/tsup.config.ts diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index 7f168fd0c..256e1c277 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -102,6 +102,7 @@ export const BaseProviders = [ 'pagerduty', 'perplexityai', 'posthog', + 'prisma', 'razorpay', 'reddit', 'resend', @@ -227,6 +228,7 @@ export const ProviderDisplayNames = { pagerduty: 'PagerDuty', perplexityai: 'Perplexity AI', posthog: 'PostHog', + prisma: 'Prisma', razorpay: 'Razorpay', reddit: 'Reddit', resend: 'Resend', @@ -359,6 +361,7 @@ export type AllProviders = | 'pagerduty' | 'perplexityai' | 'posthog' + | 'prisma' | 'razorpay' | 'reddit' | 'resend' diff --git a/packages/prisma/api.test.ts b/packages/prisma/api.test.ts new file mode 100644 index 000000000..5613cc58b --- /dev/null +++ b/packages/prisma/api.test.ts @@ -0,0 +1,307 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { request } from 'corsair/http'; +import { makePrismaRequest, PRISMA_API_BASE } from './client'; +import { prismaOperations } from './endpoints'; +import type { PrismaContext } from './index'; +import { prisma, prismaEndpointSchemas } from './index'; +import { executePostgresQuery } from './pg-client'; + +jest.mock('corsair/http', () => { + const original = jest.requireActual('corsair/http'); + return { + ...original, + request: jest.fn(), + }; +}); + +const mockRequest = request as jest.Mock; + +jest.mock('./pg-client', () => ({ + executePostgresQuery: jest.fn(), + inspectPostgresSchema: jest.fn(), +})); + +const mockExecutePostgresQuery = executePostgresQuery as jest.Mock; + +function countLeaves(tree: Record): number { + return Object.values(tree).reduce((count, value) => { + if (typeof value === 'function') return count + 1; + if (value && typeof value === 'object') { + return count + countLeaves(value as Record); + } + return count; + }, 0); +} + +function endpointPaths(tree: Record, prefix = ''): string[] { + return Object.entries(tree).flatMap(([key, value]) => { + const path = prefix ? `${prefix}.${key}` : key; + if (typeof value === 'function') return [path]; + if (value && typeof value === 'object') { + return endpointPaths(value as Record, path); + } + return []; + }); +} + +const mockCtx = { + key: 'test-token', + $getAccountId: () => 'test-account-id', + options: {}, + logEvent: jest.fn(), + db: {}, +} as unknown as PrismaContext; + +describe('Prisma plugin shape', () => { + it('keeps endpoint domain files explicit', () => { + const projectsSource = readFileSync( + join(__dirname, 'endpoints/projects.ts'), + 'utf8', + ); + const databasesSource = readFileSync( + join(__dirname, 'endpoints/databases.ts'), + 'utf8', + ); + const sqlSource = readFileSync(join(__dirname, 'endpoints/sql.ts'), 'utf8'); + + expect(projectsSource).toContain('export const createProject'); + expect(projectsSource).toContain('export const transferProject'); + expect(databasesSource).toContain('export const DatabasesEndpoints'); + expect(databasesSource).toContain('inspectSchema: inspectDatabaseSchema'); + expect(sqlSource).toContain('export const queryDatabase'); + expect(sqlSource).toContain('export const executeDatabaseCommand'); + }); + + it('exposes every listed operation with schemas and no webhooks', () => { + const plugin = prisma(); + const endpoints = plugin.endpoints as Record; + const paths = endpointPaths(endpoints).sort(); + + expect(countLeaves(endpoints)).toBe(22); + expect(Object.keys(plugin.endpointMeta ?? {})).toHaveLength(22); + expect(Object.keys(prismaEndpointSchemas)).toHaveLength(22); + expect(Object.keys(plugin.endpointMeta ?? {}).sort()).toEqual(paths); + expect(Object.keys(prismaEndpointSchemas).sort()).toEqual(paths); + expect(Object.keys(plugin.schema?.entities ?? {})).toEqual([ + 'workspaces', + 'projects', + 'databases', + 'connections', + 'backups', + 'regions', + 'integrations', + ]); + expect(plugin.webhooks).toEqual({}); + expect(plugin.pluginWebhookMatcher?.({ headers: {}, body: '' })).toBe( + false, + ); + }); + + it('marks destructive operations as irreversible', () => { + const meta = prisma().endpointMeta as Record< + string, + { riskLevel: string; irreversible?: boolean } + >; + for (const key of [ + 'projects.delete', + 'databases.delete', + 'connections.delete', + ]) { + expect(meta[key]!.riskLevel).toBe('destructive'); + expect(meta[key]!.irreversible).toBe(true); + } + expect(meta['backups.restore']!.riskLevel).toBe('destructive'); + }); + + it('uses api key auth by default and supports oauth', () => { + const plugin = prisma(); + expect(plugin.options?.authType).toBe('api_key'); + expect(plugin.authConfig).toEqual({ + api_key: { account: ['tenant_external_id'] }, + oauth_2: { account: ['tenant_external_id'] }, + }); + }); +}); + +describe('Prisma request client', () => { + beforeEach(() => { + mockRequest.mockReset(); + mockRequest.mockResolvedValue({ ok: true }); + }); + + it('sends bearer auth and JSON bodies to the Prisma Management API', async () => { + await makePrismaRequest('/projects', 'test-token', { + method: 'POST', + body: { name: 'demo', region: 'aws-us-east-1' }, + }); + + expect(mockRequest).toHaveBeenCalledTimes(1); + const [config, requestOptions] = mockRequest.mock.calls[0]; + expect(config!.BASE).toBe(PRISMA_API_BASE); + expect(config!.TOKEN).toBe('test-token'); + expect((requestOptions as { body: unknown } | undefined)?.body).toEqual({ + name: 'demo', + region: 'aws-us-east-1', + }); + }); +}); +describe('Prisma REST endpoints', () => { + beforeEach(() => { + mockRequest.mockReset(); + mockRequest.mockResolvedValue({ data: [] }); + }); + + it('resolves project path params and issues correct methods', async () => { + const plugin = prisma(); + const e = plugin.endpoints as NonNullable; + await e.projects.get(mockCtx, { projectId: 'clx-project' }); + await e.projects.delete(mockCtx, { projectId: 'clx-project' }); + await e.projects.transfer(mockCtx, { + projectId: 'clx-project', + body: { recipientAccessToken: 'oauth-token' }, + }); + + expect(mockRequest.mock.calls[0][1].url).toBe('/projects/clx-project'); + expect(mockRequest.mock.calls[0][1].method).toBe('GET'); + expect(mockRequest.mock.calls[1][1].url).toBe('/projects/clx-project'); + expect(mockRequest.mock.calls[1][1].method).toBe('DELETE'); + expect(mockRequest.mock.calls[2][1].url).toBe( + '/projects/clx-project/transfer', + ); + expect((mockRequest.mock.calls[2][1] as { body: unknown }).body).toEqual({ + recipientAccessToken: 'oauth-token', + }); + }); + + it('builds database sub-resource paths from path params', async () => { + const plugin = prisma(); + const e = plugin.endpoints as NonNullable; + await e.databases.list(mockCtx, { projectId: 'p1' }); + await e.databases.get(mockCtx, { databaseId: 'db1' }); + await e.backups.list(mockCtx, { databaseId: 'db1' }); + await e.backups.restore(mockCtx, { + targetDatabaseId: 'db2', + body: { backupId: 'b1' }, + }); + await e.integrations.list(mockCtx, { workspaceId: 'ws1' }); + + expect(mockRequest.mock.calls[0][1].url).toBe('/projects/p1/databases'); + expect(mockRequest.mock.calls[1][1].url).toBe('/databases/db1'); + expect(mockRequest.mock.calls[2][1].url).toBe('/databases/db1/backups'); + expect(mockRequest.mock.calls[3][1].url).toBe('/databases/db2/restore'); + expect(mockRequest.mock.calls[4][1].url).toBe( + '/workspaces/ws1/integrations', + ); + }); + + it('passes cursor/limit pagination and usage period as query params', async () => { + mockRequest.mockResolvedValue({ data: [] }); + const plugin = prisma(); + const e = plugin.endpoints as NonNullable; + await e.projects.list(mockCtx, { cursor: 'c1', limit: 20 }); + await e.databases.getUsage(mockCtx, { + databaseId: 'db1', + startDate: '2025-01-01', + endDate: '2025-01-31', + }); + + expect( + (mockRequest.mock.calls[0][1] as { query: unknown }).query, + ).toMatchObject({ cursor: 'c1', limit: 20 }); + expect( + (mockRequest.mock.calls[1][1] as { query: unknown }).query, + ).toMatchObject({ startDate: '2025-01-01', endDate: '2025-01-31' }); + }); + + it('writes caches for create responses and returns the pristine payload', async () => { + const plugin = prisma({ key: 'test-token' }); + const ctxWithDb = { + ...mockCtx, + db: { + connections: { upsertByEntityId: jest.fn() }, + }, + } as unknown as PrismaContext; + mockRequest.mockResolvedValueOnce({ + data: { + id: 'conn1', + name: 'demo', + connectionString: 'postgres://secret', + }, + }); + + const result = await (plugin.endpoints as any).connections.create( + ctxWithDb, + { body: { name: 'demo', databaseId: 'db1' } }, + ); + + expect( + ctxWithDb.db.connections.upsertByEntityId as jest.Mock, + ).toHaveBeenCalledWith( + 'conn1', + expect.objectContaining({ id: 'conn1', name: 'demo' }), + ); + expect(result).toMatchObject({ + data: { + id: 'conn1', + name: 'demo', + connectionString: 'postgres://secret', + }, + }); + }); +}); + +describe('Prisma direct-postgres endpoints', () => { + beforeEach(() => { + mockExecutePostgresQuery.mockReset(); + mockExecutePostgresQuery.mockResolvedValue({ + rows: [], + rowCount: 0, + command: 'SELECT', + }); + }); + + it('routes read-only queries and write commands through the pg client', async () => { + const plugin = prisma(); + const e = plugin.endpoints as NonNullable; + const input = { + host: 'db.prisma.io', + user: 'u', + password: 'p', + database: 'd', + sql: 'SELECT * FROM users', + params: [], + }; + + await e.sql.query(mockCtx, input); + expect(mockExecutePostgresQuery).toHaveBeenCalledWith( + expect.objectContaining({ host: 'db.prisma.io' }), + 'SELECT * FROM users', + [], + 'read', + ); + + mockExecutePostgresQuery.mockResolvedValue({ + rows: [], + rowCount: 1, + command: 'INSERT', + }); + await e.sql.execute(mockCtx, { + ...input, + sql: 'INSERT INTO users (name) VALUES ($1)', + params: ['alice'], + }); + expect(mockExecutePostgresQuery).toHaveBeenLastCalledWith( + expect.anything(), + expect.stringContaining('INSERT'), + ['alice'], + 'write', + ); + }); + + it('enumerates every listed operation a matching schema entry', () => { + for (const op of prismaOperations) { + expect(prismaEndpointSchemas[`${op.group}.${op.name}`]).toBeDefined(); + } + }); +}); diff --git a/packages/prisma/client.ts b/packages/prisma/client.ts new file mode 100644 index 000000000..ab72d8961 --- /dev/null +++ b/packages/prisma/client.ts @@ -0,0 +1,79 @@ +import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; +import type { PrismaMethod } from './endpoints/operations'; + +export class PrismaAPIError extends Error { + constructor( + message: string, + public readonly status?: number, + public readonly code?: string, + public readonly retryAfter?: number, + ) { + super(message); + this.name = 'PrismaAPIError'; + } +} + +export const PRISMA_API_BASE = 'https://api.prisma.io/v1'; + +export type PrismaRequestOptions = { + method?: PrismaMethod; + // bodies and query values are operation-specific json; the prisma + // management api validates their shape, so they intentionally stay unknown + body?: unknown; + query?: Record; + headers?: Record; + baseUrl?: string; +}; + +export async function makePrismaRequest( + endpoint: string, + apiKey: string, + options: PrismaRequestOptions = {}, +): Promise { + const { + method = 'GET', + body, + query, + headers, + baseUrl = PRISMA_API_BASE, + } = options; + + const config: OpenAPIConfig = { + BASE: baseUrl, + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + // TOKEN is the single source of auth: corsair/http builds the + // `Authorization: Bearer` header from it on every request + TOKEN: apiKey, + HEADERS: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...headers, + }, + }; + + const hasBody = !['GET', 'HEAD', 'OPTIONS'].includes(method); + const requestOptions: ApiRequestOptions = { + method, + url: endpoint, + body: hasBody ? body : undefined, + mediaType: 'application/json', + query, + }; + + try { + return await request(config, requestOptions); + } catch (error) { + if (error instanceof ApiError) { + throw new PrismaAPIError( + error.message, + error.status, + undefined, + error.retryAfter, + ); + } + throw error; + } +} diff --git a/packages/prisma/endpoints/backups.ts b/packages/prisma/endpoints/backups.ts new file mode 100644 index 000000000..f40308474 --- /dev/null +++ b/packages/prisma/endpoints/backups.ts @@ -0,0 +1,45 @@ +import { backupsOperations } from '../operations/backups'; +import type { PrismaEndpoint } from './factory'; +import { + logPrismaOperation, + requestPrismaOperation, + syncPrismaOperationResult, +} from './factory'; + +function getOperation(name: (typeof backupsOperations)[number]['name']) { + const operation = backupsOperations.find( + (candidate) => candidate.name === name, + ); + if (!operation) { + throw new Error(`[prisma] missing operation: ${name}`); + } + return operation; +} + +const listBackupsDefinition = getOperation('list'); +export const listBackups: PrismaEndpoint = async (ctx, input = {}) => { + const result = await requestPrismaOperation( + ctx, + input, + listBackupsDefinition, + ); + await syncPrismaOperationResult(ctx, listBackupsDefinition, input, result); + await logPrismaOperation(ctx, input, listBackupsDefinition); + return result; +}; + +const restoreBackupDefinition = getOperation('restore'); +export const restoreBackup: PrismaEndpoint = async (ctx, input = {}) => { + const result = await requestPrismaOperation( + ctx, + input, + restoreBackupDefinition, + ); + await logPrismaOperation(ctx, input, restoreBackupDefinition); + return result; +}; + +export const BackupsEndpoints = { + list: listBackups, + restore: restoreBackup, +} as const; diff --git a/packages/prisma/endpoints/connections.ts b/packages/prisma/endpoints/connections.ts new file mode 100644 index 000000000..933df518b --- /dev/null +++ b/packages/prisma/endpoints/connections.ts @@ -0,0 +1,74 @@ +import { connectionsOperations } from '../operations/connections'; +import type { PrismaEndpoint } from './factory'; +import { + logPrismaOperation, + requestPrismaOperation, + syncPrismaOperationResult, +} from './factory'; + +function getOperation(name: (typeof connectionsOperations)[number]['name']) { + const operation = connectionsOperations.find( + (candidate) => candidate.name === name, + ); + if (!operation) { + throw new Error(`[prisma] missing operation: ${name}`); + } + return operation; +} + +const createConnectionDefinition = getOperation('create'); +export const createConnection: PrismaEndpoint = async (ctx, input = {}) => { + const result = await requestPrismaOperation( + ctx, + input, + createConnectionDefinition, + ); + await syncPrismaOperationResult( + ctx, + createConnectionDefinition, + input, + result, + ); + await logPrismaOperation(ctx, input, createConnectionDefinition); + return result; +}; + +const listConnectionsDefinition = getOperation('list'); +export const listConnections: PrismaEndpoint = async (ctx, input = {}) => { + const result = await requestPrismaOperation( + ctx, + input, + listConnectionsDefinition, + ); + await syncPrismaOperationResult( + ctx, + listConnectionsDefinition, + input, + result, + ); + await logPrismaOperation(ctx, input, listConnectionsDefinition); + return result; +}; + +const deleteConnectionDefinition = getOperation('delete'); +export const deleteConnection: PrismaEndpoint = async (ctx, input = {}) => { + const result = await requestPrismaOperation( + ctx, + input, + deleteConnectionDefinition, + ); + await syncPrismaOperationResult( + ctx, + deleteConnectionDefinition, + input, + result, + ); + await logPrismaOperation(ctx, input, deleteConnectionDefinition); + return result; +}; + +export const ConnectionsEndpoints = { + create: createConnection, + list: listConnections, + delete: deleteConnection, +} as const; diff --git a/packages/prisma/endpoints/databases.ts b/packages/prisma/endpoints/databases.ts new file mode 100644 index 000000000..68ad5b7af --- /dev/null +++ b/packages/prisma/endpoints/databases.ts @@ -0,0 +1,86 @@ +import { databasesOperations } from '../operations/databases'; +import type { PrismaEndpoint } from './factory'; +import { + logPrismaOperation, + requestPrismaOperation, + syncPrismaOperationResult, +} from './factory'; +import { inspectDatabaseSchema } from './sql'; + +function getOperation(name: (typeof databasesOperations)[number]['name']) { + const operation = databasesOperations.find( + (candidate) => candidate.name === name, + ); + if (!operation) { + throw new Error(`[prisma] missing operation: ${name}`); + } + return operation; +} + +const createDatabaseDefinition = getOperation('create'); +export const createDatabase: PrismaEndpoint = async (ctx, input = {}) => { + const result = await requestPrismaOperation( + ctx, + input, + createDatabaseDefinition, + ); + await syncPrismaOperationResult(ctx, createDatabaseDefinition, input, result); + await logPrismaOperation(ctx, input, createDatabaseDefinition); + return result; +}; + +const getDatabaseDefinition = getOperation('get'); +export const getDatabase: PrismaEndpoint = async (ctx, input = {}) => { + const result = await requestPrismaOperation( + ctx, + input, + getDatabaseDefinition, + ); + await syncPrismaOperationResult(ctx, getDatabaseDefinition, input, result); + await logPrismaOperation(ctx, input, getDatabaseDefinition); + return result; +}; + +const listDatabasesDefinition = getOperation('list'); +export const listDatabases: PrismaEndpoint = async (ctx, input = {}) => { + const result = await requestPrismaOperation( + ctx, + input, + listDatabasesDefinition, + ); + await syncPrismaOperationResult(ctx, listDatabasesDefinition, input, result); + await logPrismaOperation(ctx, input, listDatabasesDefinition); + return result; +}; + +const deleteDatabaseDefinition = getOperation('delete'); +export const deleteDatabase: PrismaEndpoint = async (ctx, input = {}) => { + const result = await requestPrismaOperation( + ctx, + input, + deleteDatabaseDefinition, + ); + await syncPrismaOperationResult(ctx, deleteDatabaseDefinition, input, result); + await logPrismaOperation(ctx, input, deleteDatabaseDefinition); + return result; +}; + +const getDatabaseUsageDefinition = getOperation('getUsage'); +export const getDatabaseUsage: PrismaEndpoint = async (ctx, input = {}) => { + const result = await requestPrismaOperation( + ctx, + input, + getDatabaseUsageDefinition, + ); + await logPrismaOperation(ctx, input, getDatabaseUsageDefinition); + return result; +}; + +export const DatabasesEndpoints = { + create: createDatabase, + get: getDatabase, + list: listDatabases, + delete: deleteDatabase, + getUsage: getDatabaseUsage, + inspectSchema: inspectDatabaseSchema, +} as const; diff --git a/packages/prisma/endpoints/factory.ts b/packages/prisma/endpoints/factory.ts new file mode 100644 index 000000000..5cce941b4 --- /dev/null +++ b/packages/prisma/endpoints/factory.ts @@ -0,0 +1,319 @@ +import type { CorsairEndpoint } from 'corsair/core'; +import { logEventFromContext } from 'corsair/core'; +import { makePrismaRequest } from '../client'; +import type { PrismaContext } from '../index'; +import type { PrismaOperation } from './operations'; +import type { PrismaEndpointInput } from './types'; + +const PATH_PARAM_KEYS = [ + 'workspaceId', + 'projectId', + 'databaseId', + 'connectionId', + 'targetDatabaseId', + 'backupId', + 'regionId', +] as const; + +const INPUT_CONTROL_KEYS = new Set(['body', 'query', 'headers', 'baseUrl']); + +export type PrismaEndpoint = CorsairEndpoint< + PrismaContext, + PrismaEndpointInput, + unknown +>; + +type CacheRule = { + entity: string; + idKeys: string[]; + listKeys?: string[]; + itemKeys?: string[]; + deleteInputKeys?: string[]; + omitKeys?: string[]; +}; + +const CACHE_RULES: Record = { + listWorkspaces: { + entity: 'workspaces', + idKeys: ['id'], + listKeys: ['data', 'items'], + }, + createProject: { + entity: 'projects', + idKeys: ['id'], + itemKeys: ['data', 'project'], + }, + getProject: { + entity: 'projects', + idKeys: ['id'], + itemKeys: ['data', 'project'], + }, + listProjects: { + entity: 'projects', + idKeys: ['id'], + listKeys: ['data', 'items'], + }, + deleteProject: { + entity: 'projects', + idKeys: ['id'], + deleteInputKeys: ['projectId'], + }, + transferProject: { entity: 'projects', idKeys: ['id'], itemKeys: ['data'] }, + createDatabase: { + entity: 'databases', + idKeys: ['id'], + itemKeys: ['data', 'database'], + }, + getDatabase: { + entity: 'databases', + idKeys: ['id'], + itemKeys: ['data', 'database'], + }, + listDatabases: { + entity: 'databases', + idKeys: ['id'], + listKeys: ['data', 'items'], + }, + deleteDatabase: { + entity: 'databases', + idKeys: ['id'], + deleteInputKeys: ['databaseId'], + }, + createConnection: { + entity: 'connections', + idKeys: ['id'], + itemKeys: ['data', 'connection'], + }, + listConnections: { + entity: 'connections', + idKeys: ['id'], + listKeys: ['data', 'items'], + }, + deleteConnection: { + entity: 'connections', + idKeys: ['id'], + deleteInputKeys: ['connectionId'], + }, + listBackups: { + entity: 'backups', + idKeys: ['id'], + listKeys: ['data', 'items'], + }, + listRegions: { + entity: 'regions', + idKeys: ['id', 'region'], + listKeys: ['data', 'items'], + }, + listPostgresRegions: { + entity: 'regions', + idKeys: ['id', 'region'], + listKeys: ['data', 'items'], + }, + listWorkspaceIntegrations: { + entity: 'integrations', + idKeys: ['id'], + listKeys: ['data', 'items'], + }, +}; + +function encodePathPart(value: unknown): string { + if (typeof value === 'number') { + return encodeURIComponent(String(value)); + } + if (typeof value !== 'string' || value.length === 0) { + throw new Error('[prisma] missing required path parameter'); + } + return encodeURIComponent(value); +} + +export function resolvePath(path: string, input: PrismaEndpointInput): string { + return path.replace(/\{([^}]+)\}/g, (_, key: string) => + encodePathPart(input[key]), + ); +} + +function extraInputEntries( + operation: PrismaOperation, + input: PrismaEndpointInput, +) { + const pathParams = new Set(operation.pathParams ?? []); + const controlKeys = new Set([ + ...INPUT_CONTROL_KEYS, + 'cursor', + 'limit', + 'startDate', + 'endDate', + ]); + return Object.entries(input).filter(([key, value]) => { + return !pathParams.has(key) && !controlKeys.has(key) && value !== undefined; + }); +} + +function requestBody( + operation: PrismaOperation, + input: PrismaEndpointInput, +): unknown { + if ('body' in input) return input.body; + + const body = Object.fromEntries(extraInputEntries(operation, input)); + return Object.keys(body).length > 0 ? body : undefined; +} + +function requestQuery( + operation: PrismaOperation, + input: PrismaEndpointInput, +): Record | undefined { + if (operation.method !== 'GET') { + return input.query; + } + + const query = { + ...Object.fromEntries(extraInputEntries(operation, input)), + ...input.query, + cursor: input.cursor, + limit: input.limit, + startDate: input.startDate, + endDate: input.endDate, + }; + const cleanQuery = Object.fromEntries( + Object.entries(query).filter(([, value]) => value !== undefined), + ); + return Object.keys(cleanQuery).length > 0 ? cleanQuery : undefined; +} + +function safeLogInput(input: PrismaEndpointInput) { + const logInput: Record = {}; + for (const key of PATH_PARAM_KEYS) { + if (input[key] !== undefined) logInput[key] = input[key]; + } + if (input.query) logInput.query = input.query; + if (input.body !== undefined) logInput.hasBody = true; + return logInput; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function cacheItems(response: unknown, rule: CacheRule) { + if (Array.isArray(response)) return response.filter(isRecord); + if (!isRecord(response)) return []; + + for (const key of rule.listKeys ?? []) { + const value = response[key]; + if (Array.isArray(value)) return value.filter(isRecord); + } + + for (const key of rule.itemKeys ?? []) { + const value = response[key]; + if (isRecord(value)) return [value]; + } + + return [response]; +} + +function cacheData(item: Record, rule: CacheRule) { + if (!rule.omitKeys?.length) return item; + const data = { ...item }; + for (const key of rule.omitKeys) { + delete data[key]; + } + return data; +} + +function cacheEntityId(item: Record, rule: CacheRule) { + for (const key of rule.idKeys) { + const value = item[key]; + if (typeof value === 'string' && value.length > 0) return value; + if (typeof value === 'number') return String(value); + } + return undefined; +} + +function cacheDeleteEntityId(input: PrismaEndpointInput, rule: CacheRule) { + for (const key of rule.deleteInputKeys ?? []) { + const value = input[key]; + if (typeof value === 'string' && value.length > 0) return value; + if (typeof value === 'number') return String(value); + } + return undefined; +} + +export async function syncPrismaOperationResult( + ctx: PrismaContext, + operation: PrismaOperation, + input: PrismaEndpointInput, + response: unknown, +) { + const rule = CACHE_RULES[operation.key]; + if (!rule) return; + + const db = ctx.db as + | Record< + string, + | { + upsertByEntityId?: ( + entityId: string, + data: Record, + ) => Promise; + deleteByEntityId?: (entityId: string) => Promise; + } + | undefined + > + | undefined; + const client = db?.[rule.entity]; + + try { + if (operation.method === 'DELETE' && rule.deleteInputKeys) { + const entityId = cacheDeleteEntityId(input, rule); + if (entityId && client?.deleteByEntityId) { + await client.deleteByEntityId(entityId); + } + return; + } + + if (!client?.upsertByEntityId) return; + + for (const item of cacheItems(response, rule)) { + const entityId = cacheEntityId(item, rule); + if (!entityId) continue; + await client.upsertByEntityId(entityId, cacheData(item, rule)); + } + } catch (error) { + console.warn(`[prisma] failed to sync ${rule.entity} cache:`, error); + } +} + +export async function logPrismaOperation( + ctx: PrismaContext, + input: PrismaEndpointInput, + operation: PrismaOperation, +) { + try { + await logEventFromContext( + ctx, + `prisma.${operation.group}.${operation.name}`, + safeLogInput(input), + 'completed', + ); + } catch (error) { + console.warn( + `[prisma] failed to log ${operation.group}.${operation.name}:`, + error, + ); + } +} + +export async function requestPrismaOperation( + ctx: PrismaContext, + input: PrismaEndpointInput, + operation: PrismaOperation, +) { + return makePrismaRequest(resolvePath(operation.path, input), ctx.key, { + method: operation.method, + body: requestBody(operation, input), + query: requestQuery(operation, input), + headers: input.headers, + baseUrl: input.baseUrl, + }); +} diff --git a/packages/prisma/endpoints/index.ts b/packages/prisma/endpoints/index.ts new file mode 100644 index 000000000..27012c365 --- /dev/null +++ b/packages/prisma/endpoints/index.ts @@ -0,0 +1,57 @@ +import type { RequiredPluginEndpointMeta } from 'corsair/core'; +import { BackupsEndpoints } from './backups'; +import { ConnectionsEndpoints } from './connections'; +import { DatabasesEndpoints } from './databases'; +import { IntegrationsEndpoints } from './integrations'; +import type { PrismaOperation } from './operations'; +import { prismaOperations } from './operations'; +import { ProjectsEndpoints } from './projects'; +import { RegionsEndpoints } from './regions'; +import { SqlEndpoints } from './sql'; +import { + PrismaEndpointInputSchemas, + PrismaEndpointOutputSchemas, +} from './types'; +import { WorkspacesEndpoints } from './workspaces'; + +export const prismaEndpointsNested = { + workspaces: WorkspacesEndpoints, + projects: ProjectsEndpoints, + databases: DatabasesEndpoints, + sql: SqlEndpoints, + connections: ConnectionsEndpoints, + backups: BackupsEndpoints, + regions: RegionsEndpoints, + integrations: IntegrationsEndpoints, +} as const; + +// Object.fromEntries widens keys to string; assert to the meta map keyed +// by nested endpoint paths, which the entries mirror 1:1 (every operation +// in prismaOperations has a matching handler, verified by api.test.ts) +export const prismaEndpointMeta = Object.fromEntries( + prismaOperations.map((operation: PrismaOperation) => [ + `${operation.group}.${operation.name}`, + { + riskLevel: operation.riskLevel, + irreversible: operation.irreversible, + description: operation.description, + }, + ]), +) as RequiredPluginEndpointMeta; + +export const prismaEndpointSchemas = Object.fromEntries( + prismaOperations.map((operation: PrismaOperation) => [ + `${operation.group}.${operation.name}`, + { + input: PrismaEndpointInputSchemas[operation.key], + output: PrismaEndpointOutputSchemas[operation.key], + }, + ]), +); + +export * from './operations'; +export * from './types'; +export { + PrismaEndpointInputSchemas, + PrismaEndpointOutputSchemas, +} from './types'; diff --git a/packages/prisma/endpoints/integrations.ts b/packages/prisma/endpoints/integrations.ts new file mode 100644 index 000000000..c31e6b40a --- /dev/null +++ b/packages/prisma/endpoints/integrations.ts @@ -0,0 +1,41 @@ +import { integrationsOperations } from '../operations/integrations'; +import type { PrismaEndpoint } from './factory'; +import { + logPrismaOperation, + requestPrismaOperation, + syncPrismaOperationResult, +} from './factory'; + +function getOperation(name: (typeof integrationsOperations)[number]['name']) { + const operation = integrationsOperations.find( + (candidate) => candidate.name === name, + ); + if (!operation) { + throw new Error(`[prisma] missing operation: ${name}`); + } + return operation; +} + +const listWorkspaceIntegrationsDefinition = getOperation('list'); +export const listWorkspaceIntegrations: PrismaEndpoint = async ( + ctx, + input = {}, +) => { + const result = await requestPrismaOperation( + ctx, + input, + listWorkspaceIntegrationsDefinition, + ); + await syncPrismaOperationResult( + ctx, + listWorkspaceIntegrationsDefinition, + input, + result, + ); + await logPrismaOperation(ctx, input, listWorkspaceIntegrationsDefinition); + return result; +}; + +export const IntegrationsEndpoints = { + list: listWorkspaceIntegrations, +} as const; diff --git a/packages/prisma/endpoints/operation-types.ts b/packages/prisma/endpoints/operation-types.ts new file mode 100644 index 000000000..e5e78c241 --- /dev/null +++ b/packages/prisma/endpoints/operation-types.ts @@ -0,0 +1,21 @@ +import type { EndpointRiskLevel } from 'corsair/core'; + +export type PrismaMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + +// operations that do not hit the REST Management API (they connect +// directly to a Postgres instance over the wire protocol) carry a kind that +// routes them to dedicated handlers instead of the generic factory +export type PrismaOperationKind = 'sql' | 'schema'; + +export type PrismaOperation = { + key: string; + group: string; + name: string; + method: PrismaMethod; + path: string; + description: string; + pathParams?: readonly string[]; + riskLevel: EndpointRiskLevel; + irreversible?: boolean; + kind?: PrismaOperationKind; +}; diff --git a/packages/prisma/endpoints/operations.ts b/packages/prisma/endpoints/operations.ts new file mode 100644 index 000000000..0b9b1dc39 --- /dev/null +++ b/packages/prisma/endpoints/operations.ts @@ -0,0 +1,25 @@ +import { backupsOperations } from '../operations/backups'; +import { connectionsOperations } from '../operations/connections'; +import { databasesOperations } from '../operations/databases'; +import { integrationsOperations } from '../operations/integrations'; +import { projectsOperations } from '../operations/projects'; +import { regionsOperations } from '../operations/regions'; +import { sqlOperations } from '../operations/sql'; +import { workspacesOperations } from '../operations/workspaces'; + +export type { + PrismaMethod, + PrismaOperation, + PrismaOperationKind, +} from './operation-types'; + +export const prismaOperations = [ + ...workspacesOperations, + ...projectsOperations, + ...databasesOperations, + ...sqlOperations, + ...connectionsOperations, + ...backupsOperations, + ...regionsOperations, + ...integrationsOperations, +] as const; diff --git a/packages/prisma/endpoints/projects.ts b/packages/prisma/endpoints/projects.ts new file mode 100644 index 000000000..93bea7977 --- /dev/null +++ b/packages/prisma/endpoints/projects.ts @@ -0,0 +1,86 @@ +import { projectsOperations } from '../operations/projects'; +import type { PrismaEndpoint } from './factory'; +import { + logPrismaOperation, + requestPrismaOperation, + syncPrismaOperationResult, +} from './factory'; + +function getOperation(name: (typeof projectsOperations)[number]['name']) { + const operation = projectsOperations.find( + (candidate) => candidate.name === name, + ); + if (!operation) { + throw new Error(`[prisma] missing operation: ${name}`); + } + return operation; +} + +const createProjectDefinition = getOperation('create'); +export const createProject: PrismaEndpoint = async (ctx, input = {}) => { + const result = await requestPrismaOperation( + ctx, + input, + createProjectDefinition, + ); + await syncPrismaOperationResult(ctx, createProjectDefinition, input, result); + await logPrismaOperation(ctx, input, createProjectDefinition); + return result; +}; + +const getProjectDefinition = getOperation('get'); +export const getProject: PrismaEndpoint = async (ctx, input = {}) => { + const result = await requestPrismaOperation(ctx, input, getProjectDefinition); + await syncPrismaOperationResult(ctx, getProjectDefinition, input, result); + await logPrismaOperation(ctx, input, getProjectDefinition); + return result; +}; + +const listProjectsDefinition = getOperation('list'); +export const listProjects: PrismaEndpoint = async (ctx, input = {}) => { + const result = await requestPrismaOperation( + ctx, + input, + listProjectsDefinition, + ); + await syncPrismaOperationResult(ctx, listProjectsDefinition, input, result); + await logPrismaOperation(ctx, input, listProjectsDefinition); + return result; +}; + +const deleteProjectDefinition = getOperation('delete'); +export const deleteProject: PrismaEndpoint = async (ctx, input = {}) => { + const result = await requestPrismaOperation( + ctx, + input, + deleteProjectDefinition, + ); + await syncPrismaOperationResult(ctx, deleteProjectDefinition, input, result); + await logPrismaOperation(ctx, input, deleteProjectDefinition); + return result; +}; + +const transferProjectDefinition = getOperation('transfer'); +export const transferProject: PrismaEndpoint = async (ctx, input = {}) => { + const result = await requestPrismaOperation( + ctx, + input, + transferProjectDefinition, + ); + await syncPrismaOperationResult( + ctx, + transferProjectDefinition, + input, + result, + ); + await logPrismaOperation(ctx, input, transferProjectDefinition); + return result; +}; + +export const ProjectsEndpoints = { + create: createProject, + get: getProject, + list: listProjects, + delete: deleteProject, + transfer: transferProject, +} as const; diff --git a/packages/prisma/endpoints/regions.ts b/packages/prisma/endpoints/regions.ts new file mode 100644 index 000000000..8343fb6de --- /dev/null +++ b/packages/prisma/endpoints/regions.ts @@ -0,0 +1,51 @@ +import { regionsOperations } from '../operations/regions'; +import type { PrismaEndpoint } from './factory'; +import { + logPrismaOperation, + requestPrismaOperation, + syncPrismaOperationResult, +} from './factory'; + +function getOperation(name: (typeof regionsOperations)[number]['name']) { + const operation = regionsOperations.find( + (candidate) => candidate.name === name, + ); + if (!operation) { + throw new Error(`[prisma] missing operation: ${name}`); + } + return operation; +} + +const listRegionsDefinition = getOperation('list'); +export const listRegions: PrismaEndpoint = async (ctx, input = {}) => { + const result = await requestPrismaOperation( + ctx, + input, + listRegionsDefinition, + ); + await syncPrismaOperationResult(ctx, listRegionsDefinition, input, result); + await logPrismaOperation(ctx, input, listRegionsDefinition); + return result; +}; + +const listPostgresRegionsDefinition = getOperation('listPostgres'); +export const listPostgresRegions: PrismaEndpoint = async (ctx, input = {}) => { + const result = await requestPrismaOperation( + ctx, + input, + listPostgresRegionsDefinition, + ); + await syncPrismaOperationResult( + ctx, + listPostgresRegionsDefinition, + input, + result, + ); + await logPrismaOperation(ctx, input, listPostgresRegionsDefinition); + return result; +}; + +export const RegionsEndpoints = { + list: listRegions, + listPostgres: listPostgresRegions, +} as const; diff --git a/packages/prisma/endpoints/sql-helpers.ts b/packages/prisma/endpoints/sql-helpers.ts new file mode 100644 index 000000000..90c3bebc8 --- /dev/null +++ b/packages/prisma/endpoints/sql-helpers.ts @@ -0,0 +1,10 @@ +// Builds a safe log payload for direct-postgres operations: never log the +// password, and omit the raw SQL so sensitive values stay out of the event log. +export function safeLogPostgresInput(input: Record) { + const logInput: Record = {}; + for (const key of ['host', 'port', 'user', 'database']) { + if (input[key] !== undefined) logInput[key] = input[key]; + } + logInput.hasSql = typeof input.sql === 'string' && input.sql.length > 0; + return logInput; +} diff --git a/packages/prisma/endpoints/sql.ts b/packages/prisma/endpoints/sql.ts new file mode 100644 index 000000000..e0d43062d --- /dev/null +++ b/packages/prisma/endpoints/sql.ts @@ -0,0 +1,108 @@ +import { logEventFromContext } from 'corsair/core'; +import type { PrismaContext } from '../index'; +import { executePostgresQuery, inspectPostgresSchema } from '../pg-client'; +import type { PrismaEndpoint } from './factory'; +import { safeLogPostgresInput } from './sql-helpers'; +import type { PrismaEndpointInput } from './types'; + +type PostgresInput = { + host: string; + port?: number; + user: string; + password: string; + database: string; + sslRejectUnauthorized?: boolean; +}; + +// The generic endpoint input is a permissive record; narrow the direct +// postgres hand-written schemas when reading their connection fields. +function postgresInput(input: PrismaEndpointInput): PostgresInput { + return { + host: String(input.host), + port: input.port !== undefined ? Number(input.port) : undefined, + user: String(input.user), + password: String(input.password), + database: String(input.database), + sslRejectUnauthorized: + input.sslRejectUnauthorized !== undefined + ? Boolean(input.sslRejectUnauthorized) + : undefined, + }; +} + +function sqlCommand(input: PrismaEndpointInput): string { + return String(input.sql); +} + +function sqlParams(input: PrismaEndpointInput): unknown[] { + return Array.isArray(input.params) ? (input.params as unknown[]) : []; +} + +export const queryDatabase: PrismaEndpoint = async (ctx, input = {}) => { + const result = await executePostgresQuery( + postgresInput(input), + sqlCommand(input), + sqlParams(input), + 'read', + ); + + try { + await logEventFromContext( + ctx as PrismaContext, + 'prisma.sql.query', + safeLogPostgresInput(input), + 'completed', + ); + } catch (error) { + console.warn('[prisma] failed to log sql.query:', error); + } + return result; +}; + +export const executeDatabaseCommand: PrismaEndpoint = async ( + ctx, + input = {}, +) => { + const result = await executePostgresQuery( + postgresInput(input), + sqlCommand(input), + sqlParams(input), + 'write', + ); + + try { + await logEventFromContext( + ctx as PrismaContext, + 'prisma.sql.execute', + safeLogPostgresInput(input), + 'completed', + ); + } catch (error) { + console.warn('[prisma] failed to log sql.execute:', error); + } + return result; +}; + +export const inspectDatabaseSchema: PrismaEndpoint = async ( + ctx, + input = {}, +) => { + const result = await inspectPostgresSchema(postgresInput(input)); + + try { + await logEventFromContext( + ctx as PrismaContext, + 'prisma.databases.inspectSchema', + safeLogPostgresInput(input), + 'completed', + ); + } catch (error) { + console.warn('[prisma] failed to log databases.inspectSchema:', error); + } + return result; +}; + +export const SqlEndpoints = { + query: queryDatabase, + execute: executeDatabaseCommand, +} as const; diff --git a/packages/prisma/endpoints/types.ts b/packages/prisma/endpoints/types.ts new file mode 100644 index 000000000..aef3c3978 --- /dev/null +++ b/packages/prisma/endpoints/types.ts @@ -0,0 +1,130 @@ +import { z } from 'zod'; +import type { PrismaOperation } from './operations'; +import { prismaOperations } from './operations'; + +const QuerySchema = z.record(z.string(), z.unknown()); + +export const PrismaEndpointInputBaseSchema = z.object({ + workspaceId: z.string().min(1).optional(), + projectId: z.string().min(1).optional(), + databaseId: z.string().min(1).optional(), + connectionId: z.string().min(1).optional(), + targetDatabaseId: z.string().min(1).optional(), + backupId: z.string().min(1).optional(), + regionId: z.string().min(1).optional(), + // cursor pagination + usage period are first-class on list/usage ops + cursor: z.string().optional(), + limit: z.number().int().positive().optional(), + startDate: z.string().optional(), + endDate: z.string().optional(), + // request bodies are operation-specific json; the prisma api validates + // their shape, so they intentionally stay unknown at this layer + body: z.unknown().optional(), + query: QuerySchema.optional(), + headers: z.record(z.string(), z.string()).optional(), + baseUrl: z.string().url().optional(), +}); + +// direct postgres connection fields shared by the sql + schema operations +const PostgresConnectionShape = { + host: z.string().min(1), + port: z.number().int().positive().optional(), + user: z.string().min(1), + password: z.string().min(1), + database: z.string().min(1), + sslRejectUnauthorized: z.boolean().optional(), +} as const; + +export type PrismaEndpointInput = z.infer< + typeof PrismaEndpointInputBaseSchema +> & { + [key: string]: unknown; +}; + +// responses are operation-specific json passed through to callers; they +// intentionally stay unknown here and callers narrow them as needed +export type PrismaEndpointOutput = unknown; + +export type PrismaEndpointInputs = Record; + +export type PrismaEndpointOutputs = Record; + +export const PrismaEndpointOutputSchema = z.unknown(); + +function inputSchemaForOperation(operation: PrismaOperation) { + const requiredParams = Object.fromEntries( + (operation.pathParams ?? []).map((param) => [param, z.string().min(1)]), + ); + return PrismaEndpointInputBaseSchema.extend(requiredParams); +} + +export const QueryDatabaseInputSchema = PrismaEndpointInputBaseSchema.extend({ + ...PostgresConnectionShape, + sql: z.string().min(1), + params: z.array(z.unknown()).optional(), +}); + +export const ExecuteDatabaseCommandInputSchema = QueryDatabaseInputSchema; + +export const InspectDatabaseSchemaInputSchema = + PrismaEndpointInputBaseSchema.extend(PostgresConnectionShape); + +export const PostgresQueryResultSchema = z.object({ + rows: z.array(z.record(z.string(), z.unknown())), + rowCount: z.number().nullable(), + command: z.string(), +}); + +export const InspectDatabaseSchemaOutputSchema = z.object({ + tables: z.array( + z.object({ + schema: z.string(), + name: z.string(), + columns: z.array( + z.object({ + name: z.string(), + type: z.string(), + nullable: z.boolean(), + default: z.string().nullable(), + }), + ), + foreignKeys: z.array( + z.object({ + column: z.string(), + foreignTable: z.string(), + foreignColumn: z.string(), + }), + ), + }), + ), +}); + +// Object.fromEntries infers a value type union across all entries; assert to +// the homogeneous record the entries are built as (one zod schema per +// operation key from prismaOperations) +export const PrismaEndpointInputSchemas = Object.fromEntries( + prismaOperations.map((operation: PrismaOperation) => [ + operation.key, + operation.kind === 'sql' + ? operation.name === 'execute' + ? ExecuteDatabaseCommandInputSchema + : QueryDatabaseInputSchema + : operation.kind === 'schema' + ? InspectDatabaseSchemaInputSchema + : inputSchemaForOperation(operation), + ]), +) as Record; + +// same rationale as PrismaEndpointInputSchemas above; only the direct +// postgres operations have a shaped output, everything else passes through +export const PrismaEndpointOutputSchemas = Object.fromEntries( + prismaOperations.map((operation: PrismaOperation) => { + if (operation.kind === 'sql') { + return [operation.key, PostgresQueryResultSchema]; + } + if (operation.kind === 'schema') { + return [operation.key, InspectDatabaseSchemaOutputSchema]; + } + return [operation.key, PrismaEndpointOutputSchema]; + }), +) as Record; diff --git a/packages/prisma/endpoints/workspaces.ts b/packages/prisma/endpoints/workspaces.ts new file mode 100644 index 000000000..7a88f1a0a --- /dev/null +++ b/packages/prisma/endpoints/workspaces.ts @@ -0,0 +1,33 @@ +import { workspacesOperations } from '../operations/workspaces'; +import type { PrismaEndpoint } from './factory'; +import { + logPrismaOperation, + requestPrismaOperation, + syncPrismaOperationResult, +} from './factory'; + +function getOperation(name: (typeof workspacesOperations)[number]['name']) { + const operation = workspacesOperations.find( + (candidate) => candidate.name === name, + ); + if (!operation) { + throw new Error(`[prisma] missing operation: ${name}`); + } + return operation; +} + +const listWorkspacesDefinition = getOperation('list'); +export const listWorkspaces: PrismaEndpoint = async (ctx, input = {}) => { + const result = await requestPrismaOperation( + ctx, + input, + listWorkspacesDefinition, + ); + await syncPrismaOperationResult(ctx, listWorkspacesDefinition, input, result); + await logPrismaOperation(ctx, input, listWorkspacesDefinition); + return result; +}; + +export const WorkspacesEndpoints = { + list: listWorkspaces, +} as const; diff --git a/packages/prisma/error-handlers.ts b/packages/prisma/error-handlers.ts new file mode 100644 index 000000000..5a4f4c19f --- /dev/null +++ b/packages/prisma/error-handlers.ts @@ -0,0 +1,31 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import { ApiError } from 'corsair/http'; + +export const errorHandlers = { + RATE_LIMIT_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 429) return true; + const msg = error.message.toLowerCase(); + return msg.includes('rate_limited') || msg.includes('429'); + }, + handler: async (error: Error) => { + let retryAfterMs: number | undefined; + if (error instanceof ApiError && error.retryAfter !== undefined) { + retryAfterMs = error.retryAfter; + } + return { maxRetries: 5, headersRetryAfterMs: retryAfterMs }; + }, + }, + AUTH_ERROR: { + match: (error: Error) => { + if (error instanceof ApiError && error.status === 401) return true; + const msg = error.message.toLowerCase(); + return msg.includes('unauthorized') || msg.includes('invalid_auth'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + DEFAULT: { + match: () => true, + handler: async () => ({ maxRetries: 0 }), + }, +} satisfies CorsairErrorHandler; diff --git a/packages/prisma/index.ts b/packages/prisma/index.ts new file mode 100644 index 000000000..568e809b2 --- /dev/null +++ b/packages/prisma/index.ts @@ -0,0 +1,128 @@ +import type { + AuthTypes, + BindEndpoints, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, +} from 'corsair/core'; +import { AuthMissingError } from 'corsair/core'; +import { + prismaEndpointMeta as generatedPrismaEndpointMeta, + prismaEndpointSchemas, + prismaEndpointsNested, +} from './endpoints'; +import { errorHandlers } from './error-handlers'; +import { PrismaSchema } from './schema'; + +export const prismaEndpointMeta = + generatedPrismaEndpointMeta satisfies RequiredPluginEndpointMeta< + typeof prismaEndpointsNested + >; + +export type PrismaPluginOptions = { + authType?: PickAuth<'api_key' | 'oauth_2'>; + key?: string; + hooks?: InternalPrismaPlugin['hooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export type PrismaContext = CorsairPluginContext< + typeof PrismaSchema, + PrismaPluginOptions +>; + +export type PrismaKeyBuilderContext = KeyBuilderContext; + +export type PrismaBoundEndpoints = BindEndpoints; + +export type PrismaEndpoints = typeof prismaEndpointsNested; + +const defaultAuthType: AuthTypes = 'api_key' as const; + +export const prismaAuthConfig = { + api_key: { + account: ['tenant_external_id'] as const, + }, + oauth_2: { + account: ['tenant_external_id'] as const, + }, +} as const satisfies PluginAuthConfig; + +export type BasePrismaPlugin = CorsairPlugin< + 'prisma', + typeof PrismaSchema, + typeof prismaEndpointsNested, + {}, + T, + typeof defaultAuthType, + typeof prismaAuthConfig +>; + +export type InternalPrismaPlugin = BasePrismaPlugin; + +export type ExternalPrismaPlugin = + BasePrismaPlugin; + +export function prisma( + // The empty object keeps plugin setup ergonomic while preserving selected auth options. + incomingOptions: PrismaPluginOptions & T = {} as PrismaPluginOptions & T, +): ExternalPrismaPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'prisma', + schema: PrismaSchema, + options: options, + authConfig: prismaAuthConfig, + hooks: options.hooks, + endpoints: prismaEndpointsNested, + webhooks: {}, + endpointMeta: prismaEndpointMeta, + endpointSchemas: prismaEndpointSchemas, + pluginWebhookMatcher: () => false, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: PrismaKeyBuilderContext, source) => { + if (source === 'endpoint' && options.key) { + return options.key; + } + + if (source === 'endpoint' && ctx.authType === 'api_key') { + const res = await ctx.keys.get_api_key(); + if (!res) { + throw new AuthMissingError('prisma', 'api_key'); + } + return res; + } + + if (source === 'endpoint' && ctx.authType === 'oauth_2') { + const res = await ctx.keys.get_access_token(); + if (!res) { + throw new AuthMissingError('prisma', 'oauth_2'); + } + return res; + } + + throw new AuthMissingError('prisma', ctx.authType); + }, + } satisfies InternalPrismaPlugin; +} + +export type { + PrismaEndpointInput, + PrismaEndpointInputs, + PrismaEndpointOutput, + PrismaEndpointOutputs, +} from './endpoints/types'; + +export { prismaEndpointSchemas, prismaEndpointsNested }; diff --git a/packages/prisma/jest.config.cjs b/packages/prisma/jest.config.cjs new file mode 100644 index 000000000..8c6218f64 --- /dev/null +++ b/packages/prisma/jest.config.cjs @@ -0,0 +1,55 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: [ + '**/*.test.ts', + '**/tests/**/*.test.ts', + '**/plugins/**/*.test.ts', + '**/setup/**/*.test.ts', + ], + collectCoverageFrom: [ + '**/*.ts', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/dist/**', + '!jest.config.ts', + '!tests/**', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + verbatimModuleSyntax: false, + module: 'ESNext', + moduleResolution: 'Bundler', + }, + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + moduleNameMapper: { + '^corsair/core$': '/../corsair/core.ts', + '^corsair/http$': '/../corsair/http.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/packages/prisma/operations/backups.ts b/packages/prisma/operations/backups.ts new file mode 100644 index 000000000..1cc67dcdf --- /dev/null +++ b/packages/prisma/operations/backups.ts @@ -0,0 +1,25 @@ +import type { PrismaOperation } from '../endpoints/operation-types'; + +export const backupsOperations = [ + { + key: 'listBackups', + group: 'backups', + name: 'list', + method: 'GET', + path: '/databases/{databaseId}/backups', + pathParams: ['databaseId'], + riskLevel: 'read', + description: 'List backups for a database', + }, + { + key: 'restoreBackup', + group: 'backups', + name: 'restore', + method: 'POST', + path: '/databases/{targetDatabaseId}/restore', + pathParams: ['targetDatabaseId'], + riskLevel: 'destructive', + description: + 'Restore a backup onto the target database (async, overwrites current data)', + }, +] as const satisfies readonly PrismaOperation[]; diff --git a/packages/prisma/operations/connections.ts b/packages/prisma/operations/connections.ts new file mode 100644 index 000000000..c6c84c954 --- /dev/null +++ b/packages/prisma/operations/connections.ts @@ -0,0 +1,34 @@ +import type { PrismaOperation } from '../endpoints/operation-types'; + +export const connectionsOperations = [ + { + key: 'createConnection', + group: 'connections', + name: 'create', + method: 'POST', + path: '/connections', + riskLevel: 'write', + description: + 'Create a connection (returns a ready-to-use connection string)', + }, + { + key: 'listConnections', + group: 'connections', + name: 'list', + method: 'GET', + path: '/connections', + riskLevel: 'read', + description: 'List connections', + }, + { + key: 'deleteConnection', + group: 'connections', + name: 'delete', + method: 'DELETE', + path: '/connections/{connectionId}', + pathParams: ['connectionId'], + riskLevel: 'destructive', + irreversible: true, + description: 'Delete/revoke a connection and access for anything using it', + }, +] as const satisfies readonly PrismaOperation[]; diff --git a/packages/prisma/operations/databases.ts b/packages/prisma/operations/databases.ts new file mode 100644 index 000000000..7bada6bb5 --- /dev/null +++ b/packages/prisma/operations/databases.ts @@ -0,0 +1,67 @@ +import type { PrismaOperation } from '../endpoints/operation-types'; + +export const databasesOperations = [ + { + key: 'createDatabase', + group: 'databases', + name: 'create', + method: 'POST', + path: '/projects/{projectId}/databases', + pathParams: ['projectId'], + riskLevel: 'write', + description: 'Create a database in a project', + }, + { + key: 'getDatabase', + group: 'databases', + name: 'get', + method: 'GET', + path: '/databases/{databaseId}', + pathParams: ['databaseId'], + riskLevel: 'read', + description: 'Retrieve database details', + }, + { + key: 'listDatabases', + group: 'databases', + name: 'list', + method: 'GET', + path: '/projects/{projectId}/databases', + pathParams: ['projectId'], + riskLevel: 'read', + description: 'List databases for a project', + }, + { + key: 'deleteDatabase', + group: 'databases', + name: 'delete', + method: 'DELETE', + path: '/databases/{databaseId}', + pathParams: ['databaseId'], + riskLevel: 'destructive', + irreversible: true, + description: 'Delete a database and all of its data', + }, + { + key: 'getDatabaseUsage', + group: 'databases', + name: 'getUsage', + method: 'GET', + path: '/databases/{databaseId}/usage', + pathParams: ['databaseId'], + riskLevel: 'read', + description: 'Retrieve database usage metrics for a time period', + }, + { + key: 'inspectDatabaseSchema', + group: 'databases', + name: 'inspectSchema', + method: 'POST', + path: '/schema', + pathParams: [], + riskLevel: 'read', + kind: 'schema', + description: + 'Inspect a database schema (tables, columns, types, constraints)', + }, +] as const satisfies readonly PrismaOperation[]; diff --git a/packages/prisma/operations/integrations.ts b/packages/prisma/operations/integrations.ts new file mode 100644 index 000000000..f62b1f8ca --- /dev/null +++ b/packages/prisma/operations/integrations.ts @@ -0,0 +1,15 @@ +import type { PrismaOperation } from '../endpoints/operation-types'; + +export const integrationsOperations = [ + { + key: 'listWorkspaceIntegrations', + group: 'integrations', + name: 'list', + method: 'GET', + path: '/workspaces/{workspaceId}/integrations', + pathParams: ['workspaceId'], + riskLevel: 'read', + description: + 'List integrations (OAuth clients, granted scopes) for a workspace', + }, +] as const satisfies readonly PrismaOperation[]; diff --git a/packages/prisma/operations/projects.ts b/packages/prisma/operations/projects.ts new file mode 100644 index 000000000..1b48e8e90 --- /dev/null +++ b/packages/prisma/operations/projects.ts @@ -0,0 +1,53 @@ +import type { PrismaOperation } from '../endpoints/operation-types'; + +export const projectsOperations = [ + { + key: 'createProject', + group: 'projects', + name: 'create', + method: 'POST', + path: '/projects', + riskLevel: 'write', + description: 'Create a project and its default/database environment', + }, + { + key: 'getProject', + group: 'projects', + name: 'get', + method: 'GET', + path: '/projects/{projectId}', + pathParams: ['projectId'], + riskLevel: 'read', + description: 'Retrieve project details', + }, + { + key: 'listProjects', + group: 'projects', + name: 'list', + method: 'GET', + path: '/projects', + riskLevel: 'read', + description: 'List projects the token has access to', + }, + { + key: 'deleteProject', + group: 'projects', + name: 'delete', + method: 'DELETE', + path: '/projects/{projectId}', + pathParams: ['projectId'], + riskLevel: 'destructive', + irreversible: true, + description: 'Delete a project and all of its data', + }, + { + key: 'transferProject', + group: 'projects', + name: 'transfer', + method: 'POST', + path: '/projects/{projectId}/transfer', + pathParams: ['projectId'], + riskLevel: 'write', + description: 'Transfer a project to another workspace', + }, +] as const satisfies readonly PrismaOperation[]; diff --git a/packages/prisma/operations/regions.ts b/packages/prisma/operations/regions.ts new file mode 100644 index 000000000..acdeb5d1a --- /dev/null +++ b/packages/prisma/operations/regions.ts @@ -0,0 +1,23 @@ +import type { PrismaOperation } from '../endpoints/operation-types'; + +export const regionsOperations = [ + { + key: 'listRegions', + group: 'regions', + name: 'list', + method: 'GET', + path: '/regions', + riskLevel: 'read', + description: + 'List all available regions across products (optionally by product)', + }, + { + key: 'listPostgresRegions', + group: 'regions', + name: 'listPostgres', + method: 'GET', + path: '/regions/postgres', + riskLevel: 'read', + description: 'List all available Prisma Postgres regions with availability', + }, +] as const satisfies readonly PrismaOperation[]; diff --git a/packages/prisma/operations/sql.ts b/packages/prisma/operations/sql.ts new file mode 100644 index 000000000..3f442db91 --- /dev/null +++ b/packages/prisma/operations/sql.ts @@ -0,0 +1,28 @@ +import type { PrismaOperation } from '../endpoints/operation-types'; + +export const sqlOperations = [ + { + key: 'queryDatabase', + group: 'sql', + name: 'query', + method: 'POST', + path: '/sql/query', + pathParams: [], + riskLevel: 'read', + kind: 'sql', + description: + 'Execute a read-only SQL query (SELECT) over the Postgres connection', + }, + { + key: 'executeDatabaseCommand', + group: 'sql', + name: 'execute', + method: 'POST', + path: '/sql/execute', + pathParams: [], + riskLevel: 'write', + kind: 'sql', + description: + 'Execute a SQL command (INSERT/UPDATE/DELETE/DDL) over the Postgres connection', + }, +] as const satisfies readonly PrismaOperation[]; diff --git a/packages/prisma/operations/workspaces.ts b/packages/prisma/operations/workspaces.ts new file mode 100644 index 000000000..3f11adcec --- /dev/null +++ b/packages/prisma/operations/workspaces.ts @@ -0,0 +1,13 @@ +import type { PrismaOperation } from '../endpoints/operation-types'; + +export const workspacesOperations = [ + { + key: 'listWorkspaces', + group: 'workspaces', + name: 'list', + method: 'GET', + path: '/workspaces', + riskLevel: 'read', + description: 'List workspaces the token can access', + }, +] as const satisfies readonly PrismaOperation[]; diff --git a/packages/prisma/package.json b/packages/prisma/package.json new file mode 100644 index 000000000..29b7bc830 --- /dev/null +++ b/packages/prisma/package.json @@ -0,0 +1,48 @@ +{ + "name": "@corsair-dev/prisma", + "version": "0.1.0", + "description": "Prisma plugin for Corsair", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "dev-source": "./index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "peerDependencies": { + "corsair": ">=0.1.0", + "zod": "^4.1.13" + }, + "dependencies": { + "pg": "^8.21.0" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "corsair": "workspace:*", + "jest": "^29.7.0", + "ts-jest": "^29.4.9", + "tsup": "^8.0.1", + "typescript": "catalog:", + "zod": "^4.1.13", + "@types/pg": "^8.15.6" + }, + "keywords": [ + "corsair", + "prisma", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/prisma/pg-client.ts b/packages/prisma/pg-client.ts new file mode 100644 index 000000000..4f6bcf463 --- /dev/null +++ b/packages/prisma/pg-client.ts @@ -0,0 +1,199 @@ +import { Client } from 'pg'; + +export type PostgresConnectionInput = { + host: string; + port?: number; + user: string; + password: string; + database: string; + // prisma postgres endpoints are served over TLS; every connection + // verifies the server certificate unless explicitly disabled + sslRejectUnauthorized?: boolean; +}; + +export type PostgresQueryResult = { + rows: Record[]; + rowCount: number | null; + command: string; +}; + +const READ_ONLY_RE = /^\s*\(\s*(select)/i; + +function isReadOnly(sql: string): boolean { + const trimmed = sql.trim(); + // reject anything that starts with a non-select statement after + // stripping leading whitespace/parens (covers WITH...INSERT mutations) + if (trimmed.startsWith('(')) { + return READ_ONLY_RE.test(trimmed); + } + if (!/^\s*select\b/i.test(trimmed)) { + return false; + } + return true; +} + +function assertReadOnlyQuery(sql: string): void { + if (!isReadOnly(sql)) { + throw new Error( + '[prisma] read-only SQL endpoint rejected a non-SELECT statement', + ); + } +} + +/** + * Executes a statement against a Postgres instance over the wire protocol + * with TLS. Read-only mode only permits SELECT statements so read paths can + * never mutate data; write mode allows INSERT/UPDATE/DELETE/DDL. + */ +export async function executePostgresQuery( + connection: PostgresConnectionInput, + sql: string, + params: unknown[], + mode: 'read' | 'write', +): Promise { + if (mode === 'read') { + assertReadOnlyQuery(sql); + } + + const client = new Client({ + host: connection.host, + port: connection.port ?? 5432, + user: connection.user, + password: connection.password, + database: connection.database, + ssl: { + rejectUnauthorized: connection.sslRejectUnauthorized ?? false, + }, + }); + + try { + await client.connect(); + const result = await client.query(sql, params); + return { + rows: (result.rows ?? []) as Record[], + rowCount: result.rowCount ?? null, + command: result.command, + }; + } finally { + await client.end(); + } +} + +export type TableColumn = { + name: string; + type: string; + nullable: boolean; + default: string | null; +}; + +export type TableForeignKey = { + column: string; + foreignTable: string; + foreignColumn: string; +}; + +export type SchemaTable = { + schema: string; + name: string; + columns: TableColumn[]; + foreignKeys: TableForeignKey[]; +}; + +export type SchemaInspection = { + tables: SchemaTable[]; +}; + +const COLUMNS_SQL = ` +SELECT table_schema, table_name, column_name, data_type, is_nullable, + column_default, ordinal_position +FROM information_schema.columns +WHERE table_schema NOT IN ('information_schema', 'pg_catalog') +ORDER BY table_schema, table_name, ordinal_position; +`; + +const FOREIGN_KEYS_SQL = ` +SELECT tc.table_schema, tc.table_name, kcu.column_name, + ccu.table_name AS foreign_table, ccu.column_name AS foreign_column +FROM information_schema.table_constraints tc +JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema +JOIN information_schema.constraint_column_usage ccu + ON tc.constraint_name = ccu.constraint_name + AND tc.table_schema = ccu.table_schema +WHERE tc.constraint_type = 'FOREIGN KEY' + AND tc.table_schema NOT IN ('information_schema', 'pg_catalog') +ORDER BY tc.table_schema, tc.table_name, kcu.ordinal_position; +`; + +/** + * Inspects a database schema by querying the information_schema over the + * postgres wire protocol. Returns tables with their columns and foreign keys. + */ +export async function inspectPostgresSchema( + connection: PostgresConnectionInput, +): Promise { + const client = new Client({ + host: connection.host, + port: connection.port ?? 5432, + user: connection.user, + password: connection.password, + database: connection.database, + ssl: { + rejectUnauthorized: connection.sslRejectUnauthorized ?? false, + }, + }); + + try { + await client.connect(); + const columns = await client.query(COLUMNS_SQL); + const foreignKeys = await client.query(FOREIGN_KEYS_SQL); + + // group columns by table + const tableMap = new Map< + string, + { + schema: string; + name: string; + columns: TableColumn[]; + foreignKeys: TableForeignKey[]; + } + >(); + const keyOf = (schema: string, name: string) => `${schema}.${name}`; + + for (const row of columns.rows) { + const key = keyOf(row.table_schema, row.table_name); + let table = tableMap.get(key); + if (!table) { + table = { + schema: row.table_schema, + name: row.table_name, + columns: [], + foreignKeys: [], + }; + tableMap.set(key, table); + } + table.columns.push({ + name: row.column_name, + type: row.data_type, + nullable: row.is_nullable === 'YES', + default: row.column_default ?? null, + }); + } + + for (const row of foreignKeys.rows) { + const key = keyOf(row.table_schema, row.table_name); + const table = tableMap.get(key); + if (!table) continue; + table.foreignKeys.push({ + column: row.column_name, + foreignTable: row.foreign_table, + foreignColumn: row.foreign_column, + }); + } + + return { tables: [...tableMap.values()] }; + } finally { + await client.end(); + } +} diff --git a/packages/prisma/plugin-docs.yaml b/packages/prisma/plugin-docs.yaml new file mode 100644 index 000000000..9f1abf533 --- /dev/null +++ b/packages/prisma/plugin-docs.yaml @@ -0,0 +1,11 @@ +displayName: Prisma +description: Prisma plugin for Corsair +overviewNote: | + Prisma is the company behind Prisma ORM and the Prisma Data Platform: Prisma + Postgres (managed PostgreSQL), Accelerate (global database cache), and the + Console for managing workspaces, projects, databases, API keys, backups, and + integrations. This plugin drives all of that through the Prisma Management + API (https://api.prisma.io/v1) plus direct Postgres wire-protocol access for + read-only queries, write commands, and schema inspection. Use Corsair + permissions for destructive actions such as deleting projects, deleting + databases, restoring backups, and revoking connections. \ No newline at end of file diff --git a/packages/prisma/schema.test.ts b/packages/prisma/schema.test.ts new file mode 100644 index 000000000..41df91f7d --- /dev/null +++ b/packages/prisma/schema.test.ts @@ -0,0 +1,20 @@ +import { PrismaSchema } from './schema'; + +describe('Prisma schema', () => { + it('declares a semver version', () => { + expect(PrismaSchema.version).toBeDefined(); + expect(PrismaSchema.version).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('declares an entities map', () => { + expect(typeof PrismaSchema.entities).toBe('object'); + expect(PrismaSchema.entities).not.toBeNull(); + expect(Array.isArray(Object.keys(PrismaSchema.entities))).toBe(true); + for (const entity of Object.values(PrismaSchema.entities)) { + expect(entity).toBeDefined(); + } + }); +}); + +// Per .github/PLUGIN_PR_RULES.md (R2), every implemented endpoint +// needs a corresponding test. diff --git a/packages/prisma/schema/database.ts b/packages/prisma/schema/database.ts new file mode 100644 index 000000000..25c259276 --- /dev/null +++ b/packages/prisma/schema/database.ts @@ -0,0 +1,78 @@ +import { z } from 'zod'; + +export const PrismaWorkspace = z + .object({ + id: z.string().optional(), + name: z.string().optional(), + }) + .passthrough(); + +export const PrismaProject = z + .object({ + id: z.string().optional(), + name: z.string().optional(), + displayName: z.string().nullable().optional(), + workspaceId: z.string().optional(), + region: z.string().optional(), + logicalId: z.string().optional(), + createdAt: z.string().optional(), + }) + .passthrough(); + +export const PrismaDatabase = z + .object({ + id: z.string().optional(), + name: z.string().optional(), + projectId: z.string().optional(), + region: z.string().optional(), + isDefault: z.boolean().optional(), + status: z.string().optional(), + createdAt: z.string().optional(), + }) + .passthrough(); + +export const PrismaConnection = z + .object({ + id: z.string().optional(), + name: z.string().optional(), + databaseId: z.string().optional(), + type: z.string().optional(), + createdAt: z.string().optional(), + }) + .passthrough(); + +export const PrismaBackup = z + .object({ + id: z.string().optional(), + databaseId: z.string().optional(), + status: z.string().optional(), + createdAt: z.string().optional(), + }) + .passthrough(); + +export const PrismaRegion = z + .object({ + id: z.string().optional(), + region: z.string().optional(), + displayName: z.string().optional(), + available: z.boolean().optional(), + product: z.string().optional(), + }) + .passthrough(); + +export const PrismaIntegration = z + .object({ + id: z.string().optional(), + name: z.string().optional(), + workspaceId: z.string().optional(), + type: z.string().optional(), + }) + .passthrough(); + +export type PrismaWorkspace = z.infer; +export type PrismaProject = z.infer; +export type PrismaDatabase = z.infer; +export type PrismaConnection = z.infer; +export type PrismaBackup = z.infer; +export type PrismaRegion = z.infer; +export type PrismaIntegration = z.infer; diff --git a/packages/prisma/schema/index.ts b/packages/prisma/schema/index.ts new file mode 100644 index 000000000..c90a483eb --- /dev/null +++ b/packages/prisma/schema/index.ts @@ -0,0 +1,22 @@ +import { + PrismaBackup, + PrismaConnection, + PrismaDatabase, + PrismaIntegration, + PrismaProject, + PrismaRegion, + PrismaWorkspace, +} from './database'; + +export const PrismaSchema = { + version: '1.0.0', + entities: { + workspaces: PrismaWorkspace, + projects: PrismaProject, + databases: PrismaDatabase, + connections: PrismaConnection, + backups: PrismaBackup, + regions: PrismaRegion, + integrations: PrismaIntegration, + }, +} as const; diff --git a/packages/prisma/tsconfig.json b/packages/prisma/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/prisma/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"], + "types": ["node", "jest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "incremental": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["./**/*"], + "exclude": ["dist", "node_modules"], + "references": [] +} diff --git a/packages/prisma/tsup.config.ts b/packages/prisma/tsup.config.ts new file mode 100644 index 000000000..a76174234 --- /dev/null +++ b/packages/prisma/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: false, + dts: false, + format: ['esm'], + target: 'esnext', + platform: 'node', + bundle: true, + splitting: true, + minify: true, + outDir: 'dist', + external: ['corsair', 'zod', 'pg'], + entry: ['index.ts'], +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e2e91d2ba..7d4b68c06 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2717,6 +2717,37 @@ importers: specifier: 4.4.3 version: 4.4.3 + packages/prisma: + dependencies: + pg: + specifier: ^8.21.0 + version: 8.21.0 + devDependencies: + '@types/jest': + specifier: ^29.5.14 + version: 29.5.14 + '@types/pg': + specifier: ^8.15.6 + version: 8.15.6 + corsair: + specifier: workspace:* + version: link:../corsair + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)) + ts-jest: + specifier: ^29.4.9 + version: 29.4.9(@babel/core@7.29.7)(@jest/transform@29.7.0)(@jest/types@30.4.1)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.27.0)(jest-util@30.4.1)(jest@29.7.0(@types/node@24.10.1)(ts-node@10.9.2(@types/node@24.10.1)(typescript@5.9.3)))(typescript@5.9.3) + tsup: + specifier: ^8.0.1 + version: 8.5.1(jiti@2.7.0)(postcss@8.5.15)(tsx@4.22.4)(typescript@5.9.3)(yaml@2.9.0) + typescript: + specifier: 'catalog:' + version: 5.9.3 + zod: + specifier: 4.4.3 + version: 4.4.3 + packages/razorpay: devDependencies: '@types/jest': @@ -20779,7 +20810,7 @@ snapshots: '@types/pg@8.15.6': dependencies: '@types/node': 24.10.1 - pg-protocol: 1.10.3 + pg-protocol: 1.14.0 pg-types: 2.2.0 '@types/prismjs@1.26.6': {} From 0fbf9e7e436416e61548dd380d023463bad236a9 Mon Sep 17 00:00:00 2001 From: ambikeesshh Date: Fri, 14 Aug 2026 22:49:30 +0530 Subject: [PATCH 02/13] fix(prisma): enforce read-only SQL and default TLS verify --- packages/prisma/pg-client.ts | 95 ++++++++++++++++++++++-------------- 1 file changed, 59 insertions(+), 36 deletions(-) diff --git a/packages/prisma/pg-client.ts b/packages/prisma/pg-client.ts index 4f6bcf463..ec57fb11e 100644 --- a/packages/prisma/pg-client.ts +++ b/packages/prisma/pg-client.ts @@ -17,18 +17,19 @@ export type PostgresQueryResult = { command: string; }; -const READ_ONLY_RE = /^\s*\(\s*(select)/i; +const READ_ONLY_PREFIX = /^\s*(\(\s*)?(select|with)\b/i; +const SELECT_INTO = /\binto\s+(?!stdout\b|outfile\b)/i; +const ROW_LOCK = /\bfor\s+(update|no\s+key\s+update|share|key\s+share)\b/i; function isReadOnly(sql: string): boolean { - const trimmed = sql.trim(); - // reject anything that starts with a non-select statement after - // stripping leading whitespace/parens (covers WITH...INSERT mutations) - if (trimmed.startsWith('(')) { - return READ_ONLY_RE.test(trimmed); - } - if (!/^\s*select\b/i.test(trimmed)) { - return false; - } + const trimmed = sql + .trim() + .replace(/;+\s*$/g, '') + .trim(); + if (trimmed.includes(';')) return false; + if (!READ_ONLY_PREFIX.test(trimmed)) return false; + if (SELECT_INTO.test(trimmed)) return false; + if (ROW_LOCK.test(trimmed)) return false; return true; } @@ -40,6 +41,33 @@ function assertReadOnlyQuery(sql: string): void { } } +function postgresClientConfig(connection: PostgresConnectionInput) { + return { + host: connection.host, + port: connection.port ?? 5432, + user: connection.user, + password: connection.password, + database: connection.database, + ssl: { + rejectUnauthorized: connection.sslRejectUnauthorized ?? true, + }, + connectionTimeoutMillis: 10_000, + query_timeout: 30_000, + }; +} + +function toQueryResult(result: { + rows?: unknown[]; + rowCount?: number | null; + command?: string; +}): PostgresQueryResult { + return { + rows: (result.rows ?? []) as Record[], + rowCount: result.rowCount ?? null, + command: result.command ?? '', + }; +} + /** * Executes a statement against a Postgres instance over the wire protocol * with TLS. Read-only mode only permits SELECT statements so read paths can @@ -55,25 +83,29 @@ export async function executePostgresQuery( assertReadOnlyQuery(sql); } - const client = new Client({ - host: connection.host, - port: connection.port ?? 5432, - user: connection.user, - password: connection.password, - database: connection.database, - ssl: { - rejectUnauthorized: connection.sslRejectUnauthorized ?? false, - }, - }); + const client = new Client(postgresClientConfig(connection)); + const queryParams = Array.isArray(params) ? params : []; try { await client.connect(); - const result = await client.query(sql, params); - return { - rows: (result.rows ?? []) as Record[], - rowCount: result.rowCount ?? null, - command: result.command, - }; + if (mode !== 'read') { + const result = await client.query(sql, queryParams); + return toQueryResult(result); + } + + await client.query('BEGIN READ ONLY'); + try { + const result = await client.query(sql, queryParams); + await client.query('COMMIT'); + return toQueryResult(result); + } catch (error) { + try { + await client.query('ROLLBACK'); + } catch { + // the original query error is the one callers need + } + throw error; + } } finally { await client.end(); } @@ -133,16 +165,7 @@ ORDER BY tc.table_schema, tc.table_name, kcu.ordinal_position; export async function inspectPostgresSchema( connection: PostgresConnectionInput, ): Promise { - const client = new Client({ - host: connection.host, - port: connection.port ?? 5432, - user: connection.user, - password: connection.password, - database: connection.database, - ssl: { - rejectUnauthorized: connection.sslRejectUnauthorized ?? false, - }, - }); + const client = new Client(postgresClientConfig(connection)); try { await client.connect(); From 82e1bf8a287161968e6fea4468aa0176588ffb51 Mon Sep 17 00:00:00 2001 From: ambikeesshh Date: Fri, 14 Aug 2026 22:49:30 +0530 Subject: [PATCH 03/13] fix(prisma): pin API origin and strip cached secrets --- packages/prisma/endpoints/factory.ts | 28 +++++++++++++++++++++++++--- packages/prisma/endpoints/types.ts | 1 - 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/prisma/endpoints/factory.ts b/packages/prisma/endpoints/factory.ts index 5cce941b4..c016064c6 100644 --- a/packages/prisma/endpoints/factory.ts +++ b/packages/prisma/endpoints/factory.ts @@ -83,11 +83,13 @@ const CACHE_RULES: Record = { entity: 'connections', idKeys: ['id'], itemKeys: ['data', 'connection'], + omitKeys: ['connectionString', 'pass', 'directConnection', 'endpoints'], }, listConnections: { entity: 'connections', idKeys: ['id'], listKeys: ['data', 'items'], + omitKeys: ['connectionString', 'pass', 'directConnection', 'endpoints'], }, deleteConnection: { entity: 'connections', @@ -212,9 +214,30 @@ function cacheItems(response: unknown, rule: CacheRule) { return [response]; } +const CACHE_SECRET_KEYS = new Set([ + 'connectionString', + 'pass', + 'password', + 'directConnection', + 'endpoints', +]); + +function stripCacheSecrets(value: unknown): unknown { + if (Array.isArray(value)) return value.map(stripCacheSecrets); + if (!isRecord(value)) return value; + const data: Record = {}; + for (const [key, nested] of Object.entries(value)) { + if (CACHE_SECRET_KEYS.has(key)) continue; + data[key] = stripCacheSecrets(nested); + } + return data; +} + function cacheData(item: Record, rule: CacheRule) { - if (!rule.omitKeys?.length) return item; - const data = { ...item }; + const stripped = stripCacheSecrets(item); + if (!isRecord(stripped)) return item; + if (!rule.omitKeys?.length) return stripped; + const data = { ...stripped }; for (const key of rule.omitKeys) { delete data[key]; } @@ -314,6 +337,5 @@ export async function requestPrismaOperation( body: requestBody(operation, input), query: requestQuery(operation, input), headers: input.headers, - baseUrl: input.baseUrl, }); } diff --git a/packages/prisma/endpoints/types.ts b/packages/prisma/endpoints/types.ts index aef3c3978..cbd380dcd 100644 --- a/packages/prisma/endpoints/types.ts +++ b/packages/prisma/endpoints/types.ts @@ -22,7 +22,6 @@ export const PrismaEndpointInputBaseSchema = z.object({ body: z.unknown().optional(), query: QuerySchema.optional(), headers: z.record(z.string(), z.string()).optional(), - baseUrl: z.string().url().optional(), }); // direct postgres connection fields shared by the sql + schema operations From 218f98782218332f2f06c10849f7f7ed93f2700e Mon Sep 17 00:00:00 2001 From: ambikeesshh Date: Fri, 14 Aug 2026 22:49:30 +0530 Subject: [PATCH 04/13] fix(prisma): retry 429s from PrismaAPIError --- packages/prisma/error-handlers.ts | 24 +++++++++++++++++------- packages/prisma/index.ts | 7 +++++-- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/packages/prisma/error-handlers.ts b/packages/prisma/error-handlers.ts index 5a4f4c19f..5d3034336 100644 --- a/packages/prisma/error-handlers.ts +++ b/packages/prisma/error-handlers.ts @@ -1,24 +1,34 @@ import type { CorsairErrorHandler } from 'corsair/core'; import { ApiError } from 'corsair/http'; +import { PrismaAPIError } from './client'; + +function rateLimitMeta(error: Error): { + status?: number; + retryAfter?: number; +} { + if (error instanceof ApiError || error instanceof PrismaAPIError) { + return { status: error.status, retryAfter: error.retryAfter }; + } + return {}; +} export const errorHandlers = { RATE_LIMIT_ERROR: { match: (error: Error) => { - if (error instanceof ApiError && error.status === 429) return true; + const { status } = rateLimitMeta(error); + if (status === 429) return true; const msg = error.message.toLowerCase(); return msg.includes('rate_limited') || msg.includes('429'); }, handler: async (error: Error) => { - let retryAfterMs: number | undefined; - if (error instanceof ApiError && error.retryAfter !== undefined) { - retryAfterMs = error.retryAfter; - } - return { maxRetries: 5, headersRetryAfterMs: retryAfterMs }; + const { retryAfter } = rateLimitMeta(error); + return { maxRetries: 5, headersRetryAfterMs: retryAfter }; }, }, AUTH_ERROR: { match: (error: Error) => { - if (error instanceof ApiError && error.status === 401) return true; + const { status } = rateLimitMeta(error); + if (status === 401) return true; const msg = error.message.toLowerCase(); return msg.includes('unauthorized') || msg.includes('invalid_auth'); }, diff --git a/packages/prisma/index.ts b/packages/prisma/index.ts index 568e809b2..f80d96027 100644 --- a/packages/prisma/index.ts +++ b/packages/prisma/index.ts @@ -89,8 +89,11 @@ export function prisma( endpointSchemas: prismaEndpointSchemas, pluginWebhookMatcher: () => false, errorHandlers: { - ...errorHandlers, - ...options.errorHandlers, + ...(({ DEFAULT: _defaultHandler, ...rest }) => rest)(errorHandlers), + ...(({ DEFAULT: _customDefault, ...rest }) => rest)( + options.errorHandlers ?? {}, + ), + DEFAULT: options.errorHandlers?.DEFAULT ?? errorHandlers.DEFAULT, }, keyBuilder: async (ctx: PrismaKeyBuilderContext, source) => { if (source === 'endpoint' && options.key) { From e79bec3af57016b4eb58b6d30889854456623e5c Mon Sep 17 00:00:00 2001 From: ambikeesshh Date: Fri, 14 Aug 2026 22:49:30 +0530 Subject: [PATCH 05/13] fix(prisma): mark execute and restore as destructive --- packages/prisma/operations/backups.ts | 1 + packages/prisma/operations/sql.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/prisma/operations/backups.ts b/packages/prisma/operations/backups.ts index 1cc67dcdf..bd00c2295 100644 --- a/packages/prisma/operations/backups.ts +++ b/packages/prisma/operations/backups.ts @@ -19,6 +19,7 @@ export const backupsOperations = [ path: '/databases/{targetDatabaseId}/restore', pathParams: ['targetDatabaseId'], riskLevel: 'destructive', + irreversible: true, description: 'Restore a backup onto the target database (async, overwrites current data)', }, diff --git a/packages/prisma/operations/sql.ts b/packages/prisma/operations/sql.ts index 3f442db91..6229bdadc 100644 --- a/packages/prisma/operations/sql.ts +++ b/packages/prisma/operations/sql.ts @@ -20,7 +20,8 @@ export const sqlOperations = [ method: 'POST', path: '/sql/execute', pathParams: [], - riskLevel: 'write', + riskLevel: 'destructive', + irreversible: true, kind: 'sql', description: 'Execute a SQL command (INSERT/UPDATE/DELETE/DDL) over the Postgres connection', From 801d68677397156bca64daaac86b38a3bfdde711 Mon Sep 17 00:00:00 2001 From: ambikeesshh Date: Fri, 14 Aug 2026 22:49:30 +0530 Subject: [PATCH 06/13] test(prisma): cover SQL guard, TLS, origin, cache, and 429s --- packages/prisma/api.test.ts | 65 +++++++++++++++-- packages/prisma/pg-client.test.ts | 115 ++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 packages/prisma/pg-client.test.ts diff --git a/packages/prisma/api.test.ts b/packages/prisma/api.test.ts index 5613cc58b..7c84e670c 100644 --- a/packages/prisma/api.test.ts +++ b/packages/prisma/api.test.ts @@ -1,8 +1,9 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { request } from 'corsair/http'; -import { makePrismaRequest, PRISMA_API_BASE } from './client'; +import { ApiError, request } from 'corsair/http'; +import { makePrismaRequest, PRISMA_API_BASE, PrismaAPIError } from './client'; import { prismaOperations } from './endpoints'; +import { errorHandlers } from './error-handlers'; import type { PrismaContext } from './index'; import { prisma, prismaEndpointSchemas } from './index'; import { executePostgresQuery } from './pg-client'; @@ -112,6 +113,8 @@ describe('Prisma plugin shape', () => { expect(meta[key]!.irreversible).toBe(true); } expect(meta['backups.restore']!.riskLevel).toBe('destructive'); + expect(meta['backups.restore']!.irreversible).toBe(true); + expect(meta['sql.execute']!.riskLevel).toBe('destructive'); }); it('uses api key auth by default and supports oauth', () => { @@ -145,7 +148,35 @@ describe('Prisma request client', () => { region: 'aws-us-east-1', }); }); + + it('retries Management API 429s from PrismaAPIError', async () => { + const error = new PrismaAPIError('Too Many Requests', 429, undefined, 1500); + expect(errorHandlers.RATE_LIMIT_ERROR.match(error)).toBe(true); + await expect( + errorHandlers.RATE_LIMIT_ERROR.handler(error), + ).resolves.toEqual({ + maxRetries: 5, + headersRetryAfterMs: 1500, + }); + }); + + it('still matches corsair ApiError 429s', async () => { + const error = new ApiError( + { method: 'GET', url: '/projects' }, + { + url: 'https://api.prisma.io/v1/projects', + ok: false, + status: 429, + statusText: 'Too Many Requests', + body: {}, + }, + 'Too Many Requests', + { retryAfter: 2000 }, + ); + expect(errorHandlers.RATE_LIMIT_ERROR.match(error)).toBe(true); + }); }); + describe('Prisma REST endpoints', () => { beforeEach(() => { mockRequest.mockReset(); @@ -174,6 +205,17 @@ describe('Prisma REST endpoints', () => { }); }); + it('does not send the bearer token to a caller-supplied origin', async () => { + const plugin = prisma(); + const e = plugin.endpoints as NonNullable; + await e.projects.list(mockCtx, { + baseUrl: 'https://evil.example', + } as never); + + expect(mockRequest.mock.calls[0][0].BASE).toBe(PRISMA_API_BASE); + expect(mockRequest.mock.calls[0][0].TOKEN).toBe('test-token'); + }); + it('builds database sub-resource paths from path params', async () => { const plugin = prisma(); const e = plugin.endpoints as NonNullable; @@ -230,10 +272,18 @@ describe('Prisma REST endpoints', () => { }, }); - const result = await (plugin.endpoints as any).connections.create( - ctxWithDb, - { body: { name: 'demo', databaseId: 'db1' } }, - ); + const result = await ( + plugin.endpoints as { + connections: { + create: ( + ctx: PrismaContext, + input: Record, + ) => Promise; + }; + } + ).connections.create(ctxWithDb, { + body: { name: 'demo', databaseId: 'db1' }, + }); expect( ctxWithDb.db.connections.upsertByEntityId as jest.Mock, @@ -241,6 +291,9 @@ describe('Prisma REST endpoints', () => { 'conn1', expect.objectContaining({ id: 'conn1', name: 'demo' }), ); + expect( + (ctxWithDb.db.connections.upsertByEntityId as jest.Mock).mock.calls[0][1], + ).not.toHaveProperty('connectionString'); expect(result).toMatchObject({ data: { id: 'conn1', diff --git a/packages/prisma/pg-client.test.ts b/packages/prisma/pg-client.test.ts new file mode 100644 index 000000000..f477e88b0 --- /dev/null +++ b/packages/prisma/pg-client.test.ts @@ -0,0 +1,115 @@ +import { Client } from 'pg'; +import { executePostgresQuery, inspectPostgresSchema } from './pg-client'; + +jest.mock('pg', () => { + const query = jest.fn(); + const connect = jest.fn(); + const end = jest.fn(); + const Client = jest.fn().mockImplementation(() => ({ + connect, + query, + end, + })); + return { Client, query, connect, end }; +}); + +const MockClient = Client as unknown as jest.Mock; +const pgMocks = jest.requireMock('pg') as { + query: jest.Mock; + connect: jest.Mock; + end: jest.Mock; +}; + +const connection = { + host: 'db.prisma.io', + user: 'u', + password: 'p', + database: 'd', +}; + +function stubSuccessfulQuery() { + pgMocks.query.mockImplementation(async (sql: string) => { + if (typeof sql === 'string' && /^BEGIN/i.test(sql)) { + return { rows: [], rowCount: 0, command: 'BEGIN' }; + } + if (sql === 'COMMIT' || sql === 'ROLLBACK') { + return { rows: [], rowCount: 0, command: sql }; + } + return { rows: [{ id: 1 }], rowCount: 1, command: 'SELECT' }; + }); +} + +describe('executePostgresQuery', () => { + beforeEach(() => { + MockClient.mockClear(); + pgMocks.query.mockReset(); + pgMocks.connect.mockReset(); + pgMocks.end.mockReset(); + pgMocks.connect.mockResolvedValue(undefined); + pgMocks.end.mockResolvedValue(undefined); + stubSuccessfulQuery(); + }); + + it('verifies TLS certificates by default', async () => { + await executePostgresQuery(connection, 'SELECT 1', [], 'read'); + expect(MockClient).toHaveBeenCalledWith( + expect.objectContaining({ + ssl: { rejectUnauthorized: true }, + connectionTimeoutMillis: 10_000, + query_timeout: 30_000, + }), + ); + }); + + it('runs read queries inside a READ ONLY transaction', async () => { + await executePostgresQuery(connection, 'SELECT 1', [], 'read'); + expect(pgMocks.query.mock.calls.map((call) => call[0])).toEqual([ + 'BEGIN READ ONLY', + 'SELECT 1', + 'COMMIT', + ]); + }); + + it('rejects SELECT INTO and multi-statement queries before connecting', async () => { + await expect( + executePostgresQuery( + connection, + 'SELECT * INTO stolen FROM users', + [], + 'read', + ), + ).rejects.toThrow(/non-SELECT|read-only/i); + await expect( + executePostgresQuery( + connection, + 'SELECT 1; INSERT INTO users VALUES (1)', + [], + 'read', + ), + ).rejects.toThrow(/non-SELECT|read-only/i); + expect(MockClient).not.toHaveBeenCalled(); + }); +}); + +describe('inspectPostgresSchema', () => { + beforeEach(() => { + MockClient.mockClear(); + pgMocks.query.mockReset(); + pgMocks.connect.mockReset(); + pgMocks.end.mockReset(); + pgMocks.connect.mockResolvedValue(undefined); + pgMocks.end.mockResolvedValue(undefined); + pgMocks.query.mockResolvedValue({ rows: [] }); + }); + + it('verifies TLS certificates by default', async () => { + await inspectPostgresSchema(connection); + expect(MockClient).toHaveBeenCalledWith( + expect.objectContaining({ + ssl: { rejectUnauthorized: true }, + connectionTimeoutMillis: 10_000, + query_timeout: 30_000, + }), + ); + }); +}); From 2c18ed21d8cb97612a66730dc02d8e19bd2c1d35 Mon Sep 17 00:00:00 2001 From: Mayank Saini Date: Fri, 14 Aug 2026 23:57:07 +0530 Subject: [PATCH 07/13] fix(prisma): token-aware read-only SQL guard --- packages/prisma/pg-client.test.ts | 60 ++++++++++++- packages/prisma/pg-client.ts | 139 +++++++++++++++++++++++++++--- 2 files changed, 184 insertions(+), 15 deletions(-) diff --git a/packages/prisma/pg-client.test.ts b/packages/prisma/pg-client.test.ts index f477e88b0..6c81d7f88 100644 --- a/packages/prisma/pg-client.test.ts +++ b/packages/prisma/pg-client.test.ts @@ -1,5 +1,9 @@ import { Client } from 'pg'; -import { executePostgresQuery, inspectPostgresSchema } from './pg-client'; +import { + executePostgresQuery, + inspectPostgresSchema, + isReadOnlySql, +} from './pg-client'; jest.mock('pg', () => { const query = jest.fn(); @@ -113,3 +117,57 @@ describe('inspectPostgresSchema', () => { ); }); }); +describe('isReadOnlySql token-aware validation', () => { + it('accepts plain SELECTs', () => { + expect(isReadOnlySql('SELECT * FROM users')).toBe(true); + expect(isReadOnlySql(' SELECT 1')).toBe(true); + expect(isReadOnlySql('(SELECT 1)')).toBe(true); + }); + + it('rejects mutations and DML', () => { + expect(isReadOnlySql('DROP TABLE users')).toBe(false); + expect(isReadOnlySql('INSERT INTO users (id) VALUES (1)')).toBe(false); + expect(isReadOnlySql('UPDATE users SET id = 1')).toBe(false); + expect(isReadOnlySql('DELETE FROM users')).toBe(false); + }); + + it('rejects WITH CTE mutations even without a semicolon', () => { + expect( + isReadOnlySql( + 'WITH x AS (DELETE FROM users RETURNING *) SELECT * FROM x', + ), + ).toBe(false); + expect( + isReadOnlySql( + 'WITH x AS (UPDATE users SET id = 1 RETURNING *) SELECT * FROM x', + ), + ).toBe(false); + }); + + it('rejects SELECT INTO and row-lock forms', () => { + expect(isReadOnlySql('SELECT * INTO newtab FROM oldtab')).toBe(false); + expect(isReadOnlySql('SELECT * FROM users FOR UPDATE')).toBe(false); + expect(isReadOnlySql('SELECT * FROM users FOR SHARE')).toBe(false); + }); + + it('ignores keywords inside string literals and quoted identifiers', () => { + expect(isReadOnlySql("SELECT 'for update' AS note")).toBe(true); + expect(isReadOnlySql('SELECT "into" FROM users')).toBe(true); + expect(isReadOnlySql("SELECT 'in; no comment' AS note")).toBe(true); + }); + + it('ignores keywords inside comments', () => { + expect(isReadOnlySql('SELECT * -- for update\n FROM users')).toBe(true); + expect(isReadOnlySql('SELECT * /* into */ FROM users')).toBe(true); + }); + + it('rejects multi-statement input', () => { + expect(isReadOnlySql('SELECT 1; INSERT INTO t VALUES (1)')).toBe(false); + expect(isReadOnlySql("SELECT 'a;b'; SELECT 2")).toBe(false); + }); + + it('rejects oversized input quickly', () => { + const big = 'SELECT 1' + ' '.repeat(70 * 1024); + expect(isReadOnlySql(big)).toBe(false); + }); +}); diff --git a/packages/prisma/pg-client.ts b/packages/prisma/pg-client.ts index ec57fb11e..dfdfbd882 100644 --- a/packages/prisma/pg-client.ts +++ b/packages/prisma/pg-client.ts @@ -17,24 +17,135 @@ export type PostgresQueryResult = { command: string; }; -const READ_ONLY_PREFIX = /^\s*(\(\s*)?(select|with)\b/i; -const SELECT_INTO = /\binto\s+(?!stdout\b|outfile\b)/i; -const ROW_LOCK = /\bfor\s+(update|no\s+key\s+update|share|key\s+share)\b/i; - -function isReadOnly(sql: string): boolean { - const trimmed = sql - .trim() - .replace(/;+\s*$/g, '') - .trim(); - if (trimmed.includes(';')) return false; - if (!READ_ONLY_PREFIX.test(trimmed)) return false; - if (SELECT_INTO.test(trimmed)) return false; - if (ROW_LOCK.test(trimmed)) return false; +// Read-only guard. SQL is classified by a single linear-time scan that tracks +// string literals, quoted identifiers, and line/block comments so keywords are +// only recognized in real SQL code (CodeQL flagged the prior regex alternatives +// as potentially polynomial on crafted input). The maximum length is bounded to +// keep validation cheap and deterministic on uncontrolled data. +const MAX_SQL_LENGTH = 64 * 1024; + +/** + * Returns true when the statement is a pure read-only SELECT. + * + * - Only an unquoted top-level `SELECT` prefix is accepted; `WITH` is rejected + * because `WITH x AS (DELETE ...) RETURNING *` is a single-statement mutation + * with no second statement / semicolon to detect. + * - `SELECT ... INTO` (disk writes) and row-lock forms (`FOR UPDATE / FOR + * SHARE`) are rejected, but only when they appear as real tokens, not inside + * string literals, quoted identifiers, or comments. + * - A `;` outside literals/comments (multi-statement input) is rejected. + */ +export function isReadOnlySql(sql: string): boolean { + if (sql.length > MAX_SQL_LENGTH) return false; + + const s = sql; + const n = s.length; + let i = 0; + + const isWordChar = (ch: string): boolean => /[A-Za-z0-9_$]/.test(ch); + + // skip leading whitespace and optional wrapping `(` + while (i < n && /\s/.test(s.charAt(i))) i += 1; + while (i < n && s.charAt(i) === '(') i += 1; + while (i < n && /\s/.test(s.charAt(i))) i += 1; + + // must start with SELECT (case-insensitive, followed by a non-word char) + if (!/^select\b/i.test(s.slice(i, Math.min(i + 16, n)))) { + return false; + } + + let inString = false; + let inIdent = false; + let inLineComment = false; + let inBlockComment = false; + let sawForUpdate = false; + let sawSelectInto = false; + + while (i < n) { + const c = s.charAt(i); + const next = s.charAt(i + 1); + + if (inLineComment) { + if (c === '\n') inLineComment = false; + i += 1; + continue; + } + if (inBlockComment) { + if (c === '*' && next === '/') { + inBlockComment = false; + i += 2; + } else { + i += 1; + } + continue; + } + if (inString) { + if (c === '\\') { + i += 2; + continue; + } + if (c === "'") inString = false; + i += 1; + continue; + } + if (inIdent) { + if (c === '"') inIdent = false; + i += 1; + continue; + } + + // line / block comments + if (c === '-' && next === '-') { + inLineComment = true; + i += 2; + continue; + } + if (c === '/' && next === '*') { + inBlockComment = true; + i += 2; + continue; + } + // string literal (handle escaped '' and backslash) + if (c === "'") { + inString = true; + i += 1; + continue; + } + // quoted identifier + if (c === '"') { + inIdent = true; + i += 1; + continue; + } + // multi-statement + if (c === ';') return false; + + // read the next SQL word token (only continues in plain code) + if (isWordChar(c)) { + let j = i; + while (j < n && isWordChar(s.charAt(j))) j += 1; + const word = s.slice(i, j).toLowerCase(); + if (word === 'into') sawSelectInto = true; + if (word === 'for') { + // look ahead for update/share row-lock modes + const after = s.slice(j).trimStart().toLowerCase(); + if (/^(update|share|no\s+key\s+update|key\s+share)\b/.test(after)) { + sawForUpdate = true; + } + } + i = j; + continue; + } + + i += 1; + } + + if (sawForUpdate || sawSelectInto) return false; return true; } function assertReadOnlyQuery(sql: string): void { - if (!isReadOnly(sql)) { + if (!isReadOnlySql(sql)) { throw new Error( '[prisma] read-only SQL endpoint rejected a non-SELECT statement', ); From 1699d7fc014f30dbe66f1d217c171d43c81aae3a Mon Sep 17 00:00:00 2001 From: Mayank Saini Date: Sat, 15 Aug 2026 00:47:29 +0530 Subject: [PATCH 08/13] fix(prisma): per-operation REST output schemas --- packages/prisma/api.test.ts | 18 +++ packages/prisma/endpoints/types.ts | 198 ++++++++++++++++++++++++++++- 2 files changed, 212 insertions(+), 4 deletions(-) diff --git a/packages/prisma/api.test.ts b/packages/prisma/api.test.ts index 7c84e670c..da4625cb4 100644 --- a/packages/prisma/api.test.ts +++ b/packages/prisma/api.test.ts @@ -2,7 +2,9 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { ApiError, request } from 'corsair/http'; import { makePrismaRequest, PRISMA_API_BASE, PrismaAPIError } from './client'; +import type { PrismaOperation } from './endpoints'; import { prismaOperations } from './endpoints'; +import { PrismaEndpointOutputSchemas } from './endpoints/types'; import { errorHandlers } from './error-handlers'; import type { PrismaContext } from './index'; import { prisma, prismaEndpointSchemas } from './index'; @@ -99,6 +101,22 @@ describe('Prisma plugin shape', () => { ); }); + it('gives list/get/create REST operations concrete output schemas', () => { + const enforced = prismaOperations + .filter( + (op: PrismaOperation) => op.kind !== 'sql' && op.kind !== 'schema', + ) + .filter((op: PrismaOperation) => !op.key.startsWith('delete')) + .map((op: PrismaOperation) => op.key); + for (const key of enforced) { + const schema = PrismaEndpointOutputSchemas[key]; + expect(schema).toBeDefined(); + expect(typeof (schema as unknown as { parse?: unknown }).parse).toBe( + 'function', + ); + } + }); + it('marks destructive operations as irreversible', () => { const meta = prisma().endpointMeta as Record< string, diff --git a/packages/prisma/endpoints/types.ts b/packages/prisma/endpoints/types.ts index cbd380dcd..1b0689a55 100644 --- a/packages/prisma/endpoints/types.ts +++ b/packages/prisma/endpoints/types.ts @@ -54,7 +54,13 @@ function inputSchemaForOperation(operation: PrismaOperation) { const requiredParams = Object.fromEntries( (operation.pathParams ?? []).map((param) => [param, z.string().min(1)]), ); - return PrismaEndpointInputBaseSchema.extend(requiredParams); + const bodySchema = PRISMA_REST_BODY_INPUT_SCHEMAS[operation.key]; + return PrismaEndpointInputBaseSchema.extend({ + ...requiredParams, + // narrowed body schema (if any) so callers passing a known request + // body get it validated instead of silently accepted + body: bodySchema ?? z.unknown(), + }); } export const QueryDatabaseInputSchema = PrismaEndpointInputBaseSchema.extend({ @@ -98,6 +104,187 @@ export const InspectDatabaseSchemaOutputSchema = z.object({ ), }); +// ---- REST output schemas --------------------------------------------------- +// Verified against the Management API (public getting-started guide returns +// bare camelCase resources, e.g. create project => { id, createdAt, name, +// databases[] }). Fields beyond the documented ones are passed through. + +const PrismaApiKeySchema = z + .object({ + id: z.string().optional(), + createdAt: z.string().optional(), + apiKey: z.string().optional(), + connectionString: z.string().optional(), + ppgDirectConnection: z + .object({ + host: z.string().optional(), + user: z.string().optional(), + pass: z.string().optional(), + }) + .passthrough() + .optional(), + }) + .passthrough(); + +const PrismaDatabaseSchema = z + .object({ + id: z.string().optional(), + createdAt: z.string().optional(), + name: z.string().optional(), + connectionString: z.string().optional(), + region: z.string().optional(), + status: z.string().optional(), + isDefault: z.boolean().optional(), + apiKeys: z.array(PrismaApiKeySchema).optional(), + }) + .passthrough(); + +const PrismaProjectSchema = z + .object({ + id: z.string().optional(), + createdAt: z.string().optional(), + name: z.string().optional(), + displayName: z.string().nullable().optional(), + workspaceId: z.string().optional(), + region: z.string().optional(), + logicalId: z.string().optional(), + databases: z.array(PrismaDatabaseSchema).optional(), + }) + .passthrough(); + +const PrismaWorkspaceSchema = z + .object({ + id: z.string().optional(), + name: z.string().optional(), + }) + .passthrough(); + +const PrismaConnectionSchema = z + .object({ + id: z.string().optional(), + name: z.string().optional(), + databaseId: z.string().optional(), + connectionString: z.string().optional(), + type: z.string().optional(), + createdAt: z.string().optional(), + }) + .passthrough(); + +const PrismaBackupSchema = z + .object({ + id: z.string().optional(), + databaseId: z.string().optional(), + status: z.string().optional(), + createdAt: z.string().optional(), + }) + .passthrough(); + +const PrismaRegionSchema = z + .object({ + id: z.string().optional(), + region: z.string().optional(), + displayName: z.string().optional(), + available: z.boolean().optional(), + product: z.string().optional(), + }) + .passthrough(); + +const PrismaIntegrationSchema = z + .object({ + id: z.string().optional(), + name: z.string().optional(), + workspaceId: z.string().optional(), + type: z.string().optional(), + }) + .passthrough(); + +// a single resource or a list envelope (the API returns a bare resource for +// get/create and an array/envelope for lists) +const resourceOrList = (schema: T) => + z.union([ + schema, + z.array(schema), + z.object({ items: z.array(schema) }).passthrough(), + ]); + +const ListWorkspacesOutputSchema = resourceOrList(PrismaWorkspaceSchema); +const CreateProjectOutputSchema = PrismaProjectSchema.passthrough(); +const GetProjectOutputSchema = PrismaProjectSchema.passthrough(); +const ListProjectsOutputSchema = resourceOrList(PrismaProjectSchema); +const TransferProjectOutputSchema = PrismaProjectSchema.passthrough(); +const CreateDatabaseOutputSchema = PrismaDatabaseSchema.passthrough(); +const GetDatabaseOutputSchema = PrismaDatabaseSchema.passthrough(); +const ListDatabasesOutputSchema = resourceOrList(PrismaDatabaseSchema); +const GetDatabaseUsageOutputSchema = z.record(z.string(), z.unknown()); +const CreateConnectionOutputSchema = PrismaConnectionSchema.passthrough(); +const ListConnectionsOutputSchema = resourceOrList(PrismaConnectionSchema); +const ListBackupsOutputSchema = resourceOrList(PrismaBackupSchema); +const ListRegionsOutputSchema = resourceOrList(PrismaRegionSchema); +const ListPostgresRegionsOutputSchema = resourceOrList(PrismaRegionSchema); +const ListWorkspaceIntegrationsOutputSchema = resourceOrList( + PrismaIntegrationSchema, +); + +const PRISMA_REST_OUTPUT_SCHEMAS: Record = { + listWorkspaces: ListWorkspacesOutputSchema, + createProject: CreateProjectOutputSchema, + getProject: GetProjectOutputSchema, + listProjects: ListProjectsOutputSchema, + transferProject: TransferProjectOutputSchema, + createDatabase: CreateDatabaseOutputSchema, + getDatabase: GetDatabaseOutputSchema, + listDatabases: ListDatabasesOutputSchema, + getDatabaseUsage: GetDatabaseUsageOutputSchema, + createConnection: CreateConnectionOutputSchema, + listConnections: ListConnectionsOutputSchema, + listBackups: ListBackupsOutputSchema, + listRegions: ListRegionsOutputSchema, + listPostgresRegions: ListPostgresRegionsOutputSchema, + listWorkspaceIntegrations: ListWorkspaceIntegrationsOutputSchema, +}; + +// ---- POST body input schemas ---------------------------------------------- + +const CreateProjectBodySchema = z + .object({ + name: z.string().min(1), + displayName: z.string().optional(), + region: z.string().min(1), + createDatabase: z.boolean().optional(), + }) + .passthrough(); + +const TransferProjectBodySchema = z.object({ + recipientAccessToken: z.string().min(1), +}); + +const CreateDatabaseBodySchema = z + .object({ + name: z.string().min(1), + region: z.string().min(1), + isDefault: z.boolean().optional(), + }) + .passthrough(); + +const RestoreBackupBodySchema = z.object({ + backupId: z.string().min(1), +}); + +const CreateConnectionBodySchema = z + .object({ + name: z.string().min(1), + databaseId: z.string().min(1), + }) + .passthrough(); + +const PRISMA_REST_BODY_INPUT_SCHEMAS: Record = { + createProject: CreateProjectBodySchema, + transferProject: TransferProjectBodySchema, + createDatabase: CreateDatabaseBodySchema, + restoreBackup: RestoreBackupBodySchema, + createConnection: CreateConnectionBodySchema, +}; + // Object.fromEntries infers a value type union across all entries; assert to // the homogeneous record the entries are built as (one zod schema per // operation key from prismaOperations) @@ -114,8 +301,8 @@ export const PrismaEndpointInputSchemas = Object.fromEntries( ]), ) as Record; -// same rationale as PrismaEndpointInputSchemas above; only the direct -// postgres operations have a shaped output, everything else passes through +// operation-specific output schemas for the REST Management API operations; +// the direct-postgres operations keep their shaped (non-unknown) schemas export const PrismaEndpointOutputSchemas = Object.fromEntries( prismaOperations.map((operation: PrismaOperation) => { if (operation.kind === 'sql') { @@ -124,6 +311,9 @@ export const PrismaEndpointOutputSchemas = Object.fromEntries( if (operation.kind === 'schema') { return [operation.key, InspectDatabaseSchemaOutputSchema]; } - return [operation.key, PrismaEndpointOutputSchema]; + return [ + operation.key, + PRISMA_REST_OUTPUT_SCHEMAS[operation.key] ?? z.unknown(), + ]; }), ) as Record; From 2cf06133e26c55b7e4eb54553d7d15cf75966f22 Mon Sep 17 00:00:00 2001 From: Mayank Saini Date: Sat, 15 Aug 2026 02:02:27 +0530 Subject: [PATCH 09/13] fix(prisma): correct PostgreSQL lexical scanner, tighten REST schemas --- packages/prisma/api.test.ts | 39 +++-- packages/prisma/endpoints/backups.ts | 15 +- packages/prisma/endpoints/connections.ts | 23 ++- packages/prisma/endpoints/databases.ts | 24 ++- packages/prisma/endpoints/factory.ts | 21 ++- packages/prisma/endpoints/integrations.ts | 16 +- packages/prisma/endpoints/projects.ts | 21 +-- packages/prisma/endpoints/regions.ts | 18 +-- packages/prisma/endpoints/types.ts | 44 +++-- packages/prisma/endpoints/workspaces.ts | 13 +- packages/prisma/pg-client.test.ts | 50 ++++++ packages/prisma/pg-client.ts | 187 +++++++++++++++------- 12 files changed, 299 insertions(+), 172 deletions(-) diff --git a/packages/prisma/api.test.ts b/packages/prisma/api.test.ts index da4625cb4..db09118ec 100644 --- a/packages/prisma/api.test.ts +++ b/packages/prisma/api.test.ts @@ -101,20 +101,33 @@ describe('Prisma plugin shape', () => { ); }); - it('gives list/get/create REST operations concrete output schemas', () => { - const enforced = prismaOperations - .filter( - (op: PrismaOperation) => op.kind !== 'sql' && op.kind !== 'schema', - ) - .filter((op: PrismaOperation) => !op.key.startsWith('delete')) - .map((op: PrismaOperation) => op.key); - for (const key of enforced) { - const schema = PrismaEndpointOutputSchemas[key]; - expect(schema).toBeDefined(); - expect(typeof (schema as unknown as { parse?: unknown }).parse).toBe( - 'function', - ); + it('gives every REST operation a concrete output schema (no z.unknown)', () => { + const restOps = prismaOperations.filter( + (op: PrismaOperation) => op.kind !== 'sql' && op.kind !== 'schema', + ); + for (const op of restOps) { + expect(PrismaEndpointOutputSchemas[op.key]).toBeDefined(); } + // resource endpoints require a stable id: empty objects must not validate + expect(PrismaEndpointOutputSchemas.getProject!.safeParse({}).success).toBe( + false, + ); + expect( + PrismaEndpointOutputSchemas.listWorkspaces!.safeParse([{}]).success, + ).toBe(false); + expect(PrismaEndpointOutputSchemas.getDatabase!.safeParse({}).success).toBe( + false, + ); + // delete/restore endpoints legitimately return no content + expect( + PrismaEndpointOutputSchemas.deleteProject!.safeParse(undefined).success, + ).toBe(true); + expect( + PrismaEndpointOutputSchemas.deleteConnection!.safeParse(null).success, + ).toBe(true); + expect( + PrismaEndpointOutputSchemas.restoreBackup!.safeParse({}).success, + ).toBe(true); }); it('marks destructive operations as irreversible', () => { diff --git a/packages/prisma/endpoints/backups.ts b/packages/prisma/endpoints/backups.ts index f40308474..f71335dd0 100644 --- a/packages/prisma/endpoints/backups.ts +++ b/packages/prisma/endpoints/backups.ts @@ -1,22 +1,13 @@ import { backupsOperations } from '../operations/backups'; import type { PrismaEndpoint } from './factory'; import { + findOperation, logPrismaOperation, requestPrismaOperation, syncPrismaOperationResult, } from './factory'; -function getOperation(name: (typeof backupsOperations)[number]['name']) { - const operation = backupsOperations.find( - (candidate) => candidate.name === name, - ); - if (!operation) { - throw new Error(`[prisma] missing operation: ${name}`); - } - return operation; -} - -const listBackupsDefinition = getOperation('list'); +const listBackupsDefinition = findOperation(backupsOperations, 'list'); export const listBackups: PrismaEndpoint = async (ctx, input = {}) => { const result = await requestPrismaOperation( ctx, @@ -28,7 +19,7 @@ export const listBackups: PrismaEndpoint = async (ctx, input = {}) => { return result; }; -const restoreBackupDefinition = getOperation('restore'); +const restoreBackupDefinition = findOperation(backupsOperations, 'restore'); export const restoreBackup: PrismaEndpoint = async (ctx, input = {}) => { const result = await requestPrismaOperation( ctx, diff --git a/packages/prisma/endpoints/connections.ts b/packages/prisma/endpoints/connections.ts index 933df518b..85eb2d323 100644 --- a/packages/prisma/endpoints/connections.ts +++ b/packages/prisma/endpoints/connections.ts @@ -1,22 +1,16 @@ import { connectionsOperations } from '../operations/connections'; import type { PrismaEndpoint } from './factory'; import { + findOperation, logPrismaOperation, requestPrismaOperation, syncPrismaOperationResult, } from './factory'; -function getOperation(name: (typeof connectionsOperations)[number]['name']) { - const operation = connectionsOperations.find( - (candidate) => candidate.name === name, - ); - if (!operation) { - throw new Error(`[prisma] missing operation: ${name}`); - } - return operation; -} - -const createConnectionDefinition = getOperation('create'); +const createConnectionDefinition = findOperation( + connectionsOperations, + 'create', +); export const createConnection: PrismaEndpoint = async (ctx, input = {}) => { const result = await requestPrismaOperation( ctx, @@ -33,7 +27,7 @@ export const createConnection: PrismaEndpoint = async (ctx, input = {}) => { return result; }; -const listConnectionsDefinition = getOperation('list'); +const listConnectionsDefinition = findOperation(connectionsOperations, 'list'); export const listConnections: PrismaEndpoint = async (ctx, input = {}) => { const result = await requestPrismaOperation( ctx, @@ -50,7 +44,10 @@ export const listConnections: PrismaEndpoint = async (ctx, input = {}) => { return result; }; -const deleteConnectionDefinition = getOperation('delete'); +const deleteConnectionDefinition = findOperation( + connectionsOperations, + 'delete', +); export const deleteConnection: PrismaEndpoint = async (ctx, input = {}) => { const result = await requestPrismaOperation( ctx, diff --git a/packages/prisma/endpoints/databases.ts b/packages/prisma/endpoints/databases.ts index 68ad5b7af..dda413baf 100644 --- a/packages/prisma/endpoints/databases.ts +++ b/packages/prisma/endpoints/databases.ts @@ -1,23 +1,14 @@ import { databasesOperations } from '../operations/databases'; import type { PrismaEndpoint } from './factory'; import { + findOperation, logPrismaOperation, requestPrismaOperation, syncPrismaOperationResult, } from './factory'; import { inspectDatabaseSchema } from './sql'; -function getOperation(name: (typeof databasesOperations)[number]['name']) { - const operation = databasesOperations.find( - (candidate) => candidate.name === name, - ); - if (!operation) { - throw new Error(`[prisma] missing operation: ${name}`); - } - return operation; -} - -const createDatabaseDefinition = getOperation('create'); +const createDatabaseDefinition = findOperation(databasesOperations, 'create'); export const createDatabase: PrismaEndpoint = async (ctx, input = {}) => { const result = await requestPrismaOperation( ctx, @@ -29,7 +20,7 @@ export const createDatabase: PrismaEndpoint = async (ctx, input = {}) => { return result; }; -const getDatabaseDefinition = getOperation('get'); +const getDatabaseDefinition = findOperation(databasesOperations, 'get'); export const getDatabase: PrismaEndpoint = async (ctx, input = {}) => { const result = await requestPrismaOperation( ctx, @@ -41,7 +32,7 @@ export const getDatabase: PrismaEndpoint = async (ctx, input = {}) => { return result; }; -const listDatabasesDefinition = getOperation('list'); +const listDatabasesDefinition = findOperation(databasesOperations, 'list'); export const listDatabases: PrismaEndpoint = async (ctx, input = {}) => { const result = await requestPrismaOperation( ctx, @@ -53,7 +44,7 @@ export const listDatabases: PrismaEndpoint = async (ctx, input = {}) => { return result; }; -const deleteDatabaseDefinition = getOperation('delete'); +const deleteDatabaseDefinition = findOperation(databasesOperations, 'delete'); export const deleteDatabase: PrismaEndpoint = async (ctx, input = {}) => { const result = await requestPrismaOperation( ctx, @@ -65,7 +56,10 @@ export const deleteDatabase: PrismaEndpoint = async (ctx, input = {}) => { return result; }; -const getDatabaseUsageDefinition = getOperation('getUsage'); +const getDatabaseUsageDefinition = findOperation( + databasesOperations, + 'getUsage', +); export const getDatabaseUsage: PrismaEndpoint = async (ctx, input = {}) => { const result = await requestPrismaOperation( ctx, diff --git a/packages/prisma/endpoints/factory.ts b/packages/prisma/endpoints/factory.ts index c016064c6..62f5b9e5d 100644 --- a/packages/prisma/endpoints/factory.ts +++ b/packages/prisma/endpoints/factory.ts @@ -118,22 +118,37 @@ const CACHE_RULES: Record = { }, }; -function encodePathPart(value: unknown): string { +function encodePathPart(value: unknown, key: string): string { if (typeof value === 'number') { return encodeURIComponent(String(value)); } if (typeof value !== 'string' || value.length === 0) { - throw new Error('[prisma] missing required path parameter'); + throw new Error(`[prisma] missing required path parameter: ${key}`); } return encodeURIComponent(value); } export function resolvePath(path: string, input: PrismaEndpointInput): string { return path.replace(/\{([^}]+)\}/g, (_, key: string) => - encodePathPart(input[key]), + encodePathPart(input[key], key), ); } +/** + * Shared operation lookup used by every endpoint module — throws a clear error + * naming the missing operation when the route is absent. + */ +export function findOperation( + operations: TOperations, + name: TOperations[number]['name'], +): TOperations[number] { + const operation = operations.find((candidate) => candidate.name === name); + if (!operation) { + throw new Error(`[prisma] missing operation: ${name}`); + } + return operation; +} + function extraInputEntries( operation: PrismaOperation, input: PrismaEndpointInput, diff --git a/packages/prisma/endpoints/integrations.ts b/packages/prisma/endpoints/integrations.ts index c31e6b40a..4eaf279b8 100644 --- a/packages/prisma/endpoints/integrations.ts +++ b/packages/prisma/endpoints/integrations.ts @@ -1,22 +1,16 @@ import { integrationsOperations } from '../operations/integrations'; import type { PrismaEndpoint } from './factory'; import { + findOperation, logPrismaOperation, requestPrismaOperation, syncPrismaOperationResult, } from './factory'; -function getOperation(name: (typeof integrationsOperations)[number]['name']) { - const operation = integrationsOperations.find( - (candidate) => candidate.name === name, - ); - if (!operation) { - throw new Error(`[prisma] missing operation: ${name}`); - } - return operation; -} - -const listWorkspaceIntegrationsDefinition = getOperation('list'); +const listWorkspaceIntegrationsDefinition = findOperation( + integrationsOperations, + 'list', +); export const listWorkspaceIntegrations: PrismaEndpoint = async ( ctx, input = {}, diff --git a/packages/prisma/endpoints/projects.ts b/packages/prisma/endpoints/projects.ts index 93bea7977..b21280ae4 100644 --- a/packages/prisma/endpoints/projects.ts +++ b/packages/prisma/endpoints/projects.ts @@ -1,22 +1,13 @@ import { projectsOperations } from '../operations/projects'; import type { PrismaEndpoint } from './factory'; import { + findOperation, logPrismaOperation, requestPrismaOperation, syncPrismaOperationResult, } from './factory'; -function getOperation(name: (typeof projectsOperations)[number]['name']) { - const operation = projectsOperations.find( - (candidate) => candidate.name === name, - ); - if (!operation) { - throw new Error(`[prisma] missing operation: ${name}`); - } - return operation; -} - -const createProjectDefinition = getOperation('create'); +const createProjectDefinition = findOperation(projectsOperations, 'create'); export const createProject: PrismaEndpoint = async (ctx, input = {}) => { const result = await requestPrismaOperation( ctx, @@ -28,7 +19,7 @@ export const createProject: PrismaEndpoint = async (ctx, input = {}) => { return result; }; -const getProjectDefinition = getOperation('get'); +const getProjectDefinition = findOperation(projectsOperations, 'get'); export const getProject: PrismaEndpoint = async (ctx, input = {}) => { const result = await requestPrismaOperation(ctx, input, getProjectDefinition); await syncPrismaOperationResult(ctx, getProjectDefinition, input, result); @@ -36,7 +27,7 @@ export const getProject: PrismaEndpoint = async (ctx, input = {}) => { return result; }; -const listProjectsDefinition = getOperation('list'); +const listProjectsDefinition = findOperation(projectsOperations, 'list'); export const listProjects: PrismaEndpoint = async (ctx, input = {}) => { const result = await requestPrismaOperation( ctx, @@ -48,7 +39,7 @@ export const listProjects: PrismaEndpoint = async (ctx, input = {}) => { return result; }; -const deleteProjectDefinition = getOperation('delete'); +const deleteProjectDefinition = findOperation(projectsOperations, 'delete'); export const deleteProject: PrismaEndpoint = async (ctx, input = {}) => { const result = await requestPrismaOperation( ctx, @@ -60,7 +51,7 @@ export const deleteProject: PrismaEndpoint = async (ctx, input = {}) => { return result; }; -const transferProjectDefinition = getOperation('transfer'); +const transferProjectDefinition = findOperation(projectsOperations, 'transfer'); export const transferProject: PrismaEndpoint = async (ctx, input = {}) => { const result = await requestPrismaOperation( ctx, diff --git a/packages/prisma/endpoints/regions.ts b/packages/prisma/endpoints/regions.ts index 8343fb6de..9a117a18c 100644 --- a/packages/prisma/endpoints/regions.ts +++ b/packages/prisma/endpoints/regions.ts @@ -1,22 +1,13 @@ import { regionsOperations } from '../operations/regions'; import type { PrismaEndpoint } from './factory'; import { + findOperation, logPrismaOperation, requestPrismaOperation, syncPrismaOperationResult, } from './factory'; -function getOperation(name: (typeof regionsOperations)[number]['name']) { - const operation = regionsOperations.find( - (candidate) => candidate.name === name, - ); - if (!operation) { - throw new Error(`[prisma] missing operation: ${name}`); - } - return operation; -} - -const listRegionsDefinition = getOperation('list'); +const listRegionsDefinition = findOperation(regionsOperations, 'list'); export const listRegions: PrismaEndpoint = async (ctx, input = {}) => { const result = await requestPrismaOperation( ctx, @@ -28,7 +19,10 @@ export const listRegions: PrismaEndpoint = async (ctx, input = {}) => { return result; }; -const listPostgresRegionsDefinition = getOperation('listPostgres'); +const listPostgresRegionsDefinition = findOperation( + regionsOperations, + 'listPostgres', +); export const listPostgresRegions: PrismaEndpoint = async (ctx, input = {}) => { const result = await requestPrismaOperation( ctx, diff --git a/packages/prisma/endpoints/types.ts b/packages/prisma/endpoints/types.ts index 1b0689a55..393897a1b 100644 --- a/packages/prisma/endpoints/types.ts +++ b/packages/prisma/endpoints/types.ts @@ -111,7 +111,7 @@ export const InspectDatabaseSchemaOutputSchema = z.object({ const PrismaApiKeySchema = z .object({ - id: z.string().optional(), + id: z.string().min(1), createdAt: z.string().optional(), apiKey: z.string().optional(), connectionString: z.string().optional(), @@ -128,7 +128,7 @@ const PrismaApiKeySchema = z const PrismaDatabaseSchema = z .object({ - id: z.string().optional(), + id: z.string().min(1), createdAt: z.string().optional(), name: z.string().optional(), connectionString: z.string().optional(), @@ -141,7 +141,7 @@ const PrismaDatabaseSchema = z const PrismaProjectSchema = z .object({ - id: z.string().optional(), + id: z.string().min(1), createdAt: z.string().optional(), name: z.string().optional(), displayName: z.string().nullable().optional(), @@ -154,14 +154,14 @@ const PrismaProjectSchema = z const PrismaWorkspaceSchema = z .object({ - id: z.string().optional(), + id: z.string().min(1), name: z.string().optional(), }) .passthrough(); const PrismaConnectionSchema = z .object({ - id: z.string().optional(), + id: z.string().min(1), name: z.string().optional(), databaseId: z.string().optional(), connectionString: z.string().optional(), @@ -172,7 +172,7 @@ const PrismaConnectionSchema = z const PrismaBackupSchema = z .object({ - id: z.string().optional(), + id: z.string().min(1), databaseId: z.string().optional(), status: z.string().optional(), createdAt: z.string().optional(), @@ -181,7 +181,7 @@ const PrismaBackupSchema = z const PrismaRegionSchema = z .object({ - id: z.string().optional(), + id: z.string().min(1), region: z.string().optional(), displayName: z.string().optional(), available: z.boolean().optional(), @@ -191,7 +191,7 @@ const PrismaRegionSchema = z const PrismaIntegrationSchema = z .object({ - id: z.string().optional(), + id: z.string().min(1), name: z.string().optional(), workspaceId: z.string().optional(), type: z.string().optional(), @@ -225,19 +225,31 @@ const ListWorkspaceIntegrationsOutputSchema = resourceOrList( PrismaIntegrationSchema, ); +// destructive DELETE / restore endpoints return 204 No Content (or an empty +// 202 Accepted body) — the response carries no resource payload +const EmptyResponseSchema = z.union([ + z.undefined(), + z.null(), + z.object({}).passthrough(), +]); + const PRISMA_REST_OUTPUT_SCHEMAS: Record = { listWorkspaces: ListWorkspacesOutputSchema, createProject: CreateProjectOutputSchema, getProject: GetProjectOutputSchema, listProjects: ListProjectsOutputSchema, transferProject: TransferProjectOutputSchema, + deleteProject: EmptyResponseSchema, createDatabase: CreateDatabaseOutputSchema, getDatabase: GetDatabaseOutputSchema, listDatabases: ListDatabasesOutputSchema, + deleteDatabase: EmptyResponseSchema, getDatabaseUsage: GetDatabaseUsageOutputSchema, createConnection: CreateConnectionOutputSchema, listConnections: ListConnectionsOutputSchema, + deleteConnection: EmptyResponseSchema, listBackups: ListBackupsOutputSchema, + restoreBackup: EmptyResponseSchema, listRegions: ListRegionsOutputSchema, listPostgresRegions: ListPostgresRegionsOutputSchema, listWorkspaceIntegrations: ListWorkspaceIntegrationsOutputSchema, @@ -302,7 +314,10 @@ export const PrismaEndpointInputSchemas = Object.fromEntries( ) as Record; // operation-specific output schemas for the REST Management API operations; -// the direct-postgres operations keep their shaped (non-unknown) schemas +// the direct-postgres operations keep their shaped (non-unknown) schemas. +// Every REST operation must have an explicit registration in +// PRISMA_REST_OUTPUT_SCHEMAS — there is no z.unknown() fallback, and a missing +// registration fails schema construction rather than accepting any payload. export const PrismaEndpointOutputSchemas = Object.fromEntries( prismaOperations.map((operation: PrismaOperation) => { if (operation.kind === 'sql') { @@ -311,9 +326,12 @@ export const PrismaEndpointOutputSchemas = Object.fromEntries( if (operation.kind === 'schema') { return [operation.key, InspectDatabaseSchemaOutputSchema]; } - return [ - operation.key, - PRISMA_REST_OUTPUT_SCHEMAS[operation.key] ?? z.unknown(), - ]; + const schema = PRISMA_REST_OUTPUT_SCHEMAS[operation.key]; + if (!schema) { + throw new Error( + `[prisma] missing REST output schema for ${operation.key}`, + ); + } + return [operation.key, schema]; }), ) as Record; diff --git a/packages/prisma/endpoints/workspaces.ts b/packages/prisma/endpoints/workspaces.ts index 7a88f1a0a..00e29a887 100644 --- a/packages/prisma/endpoints/workspaces.ts +++ b/packages/prisma/endpoints/workspaces.ts @@ -1,22 +1,13 @@ import { workspacesOperations } from '../operations/workspaces'; import type { PrismaEndpoint } from './factory'; import { + findOperation, logPrismaOperation, requestPrismaOperation, syncPrismaOperationResult, } from './factory'; -function getOperation(name: (typeof workspacesOperations)[number]['name']) { - const operation = workspacesOperations.find( - (candidate) => candidate.name === name, - ); - if (!operation) { - throw new Error(`[prisma] missing operation: ${name}`); - } - return operation; -} - -const listWorkspacesDefinition = getOperation('list'); +const listWorkspacesDefinition = findOperation(workspacesOperations, 'list'); export const listWorkspaces: PrismaEndpoint = async (ctx, input = {}) => { const result = await requestPrismaOperation( ctx, diff --git a/packages/prisma/pg-client.test.ts b/packages/prisma/pg-client.test.ts index 6c81d7f88..77ddba351 100644 --- a/packages/prisma/pg-client.test.ts +++ b/packages/prisma/pg-client.test.ts @@ -170,4 +170,54 @@ describe('isReadOnlySql token-aware validation', () => { const big = 'SELECT 1' + ' '.repeat(70 * 1024); expect(isReadOnlySql(big)).toBe(false); }); + + it('cannot hide COMMIT behind a backslash (standard_conforming_strings)', () => { + // PostgreSQL standard strings do NOT treat backslash as an escape, so the + // string ends at the quote right after it and the ';' starts a second + // statement — a scanner that skips \' would miss the COMMIT entirely. + expect(isReadOnlySql("SELECT '\\'; COMMIT; DROP TABLE users; --'")).toBe( + false, + ); + expect(isReadOnlySql("SELECT 'a\\'; ROLLBACK; SELECT 1; --'")).toBe(false); + }); + + it('still accepts literal backslashes in standard strings', () => { + expect(isReadOnlySql("SELECT * FROM t WHERE path LIKE 'C:\\Users%'")).toBe( + true, + ); + }); + + it('handles doubled-quote escapes in standard strings', () => { + expect(isReadOnlySql("SELECT 'it''s'")).toBe(true); + expect(isReadOnlySql("SELECT 'a'''; COMMIT")).toBe(false); + }); + + it('handles E-string backslash escapes correctly', () => { + expect(isReadOnlySql("SELECT E'it\\'s'")).toBe(true); + // escaped backslash then closing quote: '; COMMIT' is a real boundary + expect(isReadOnlySql("SELECT E'x\\\\'; COMMIT; DROP TABLE t; --'")).toBe( + false, + ); + }); + + it('handles nesting block comments', () => { + expect(isReadOnlySql('SELECT /* a /* b */ c */ 1')).toBe(true); + expect(isReadOnlySql("SELECT /* ' */ ; COMMIT; -- '")).toBe(false); + }); + + it('handles dollar-quoted strings', () => { + expect(isReadOnlySql('SELECT $$hello; world$$')).toBe(true); + expect(isReadOnlySql('SELECT $tag$; DROP$tag$ FROM t')).toBe(true); + expect(isReadOnlySql('SELECT $$x$$; COMMIT')).toBe(false); + }); + + it('rejects transaction-control tokens in plain code', () => { + expect(isReadOnlySql('SELECT 1 FROM t COMMIT')).toBe(false); + expect(isReadOnlySql('SELECT rollback FROM t')).toBe(false); + }); + + it('rejects sequence-mutating functions', () => { + expect(isReadOnlySql("SELECT nextval('s')")).toBe(false); + expect(isReadOnlySql("SELECT setval('s', 1)")).toBe(false); + }); }); diff --git a/packages/prisma/pg-client.ts b/packages/prisma/pg-client.ts index dfdfbd882..24ae6d0a6 100644 --- a/packages/prisma/pg-client.ts +++ b/packages/prisma/pg-client.ts @@ -17,23 +17,50 @@ export type PostgresQueryResult = { command: string; }; -// Read-only guard. SQL is classified by a single linear-time scan that tracks -// string literals, quoted identifiers, and line/block comments so keywords are -// only recognized in real SQL code (CodeQL flagged the prior regex alternatives -// as potentially polynomial on crafted input). The maximum length is bounded to -// keep validation cheap and deterministic on uncontrolled data. +// Read-only guard. SQL is classified by a single linear-time scan that mirrors +// PostgreSQL lexical rules for string literals, quoted identifiers, dollar +// quoting, and (nesting) block comments, so keywords are only recognized in +// real SQL code. The maximum length is bounded to keep validation cheap and +// deterministic on uncontrolled input (fixes the polynomial-regex finding). const MAX_SQL_LENGTH = 64 * 1024; +type ScanState = + | 'code' + | 'string' // standard '...' (standard_conforming_strings=on: no backslash escapes) + | 'estring' // E'...' escape string: backslash escapes + | 'ident' // "..." quoted identifier + | 'lineComment' + | 'blockComment' + | 'dollarQuote'; + +const TRANSACTION_CONTROL = new Set([ + 'commit', + 'rollback', + 'abort', + 'begin', + 'start', + 'end', + 'checkpoint', +]); + /** * Returns true when the statement is a pure read-only SELECT. * - * - Only an unquoted top-level `SELECT` prefix is accepted; `WITH` is rejected - * because `WITH x AS (DELETE ...) RETURNING *` is a single-statement mutation - * with no second statement / semicolon to detect. - * - `SELECT ... INTO` (disk writes) and row-lock forms (`FOR UPDATE / FOR - * SHARE`) are rejected, but only when they appear as real tokens, not inside - * string literals, quoted identifiers, or comments. - * - A `;` outside literals/comments (multi-statement input) is rejected. + * Parsing follows PostgreSQL (standard_conforming_strings=on): + * - `'...'` strings escape a quote by doubling it (`''`); a backslash is NOT an + * escape, so `'...\' ; COMMIT` cannot hide the `COMMIT` from this scanner the + * way it would a scanner that assumes C-style backslash escapes. + * - `E'...'`/`U&'...'` strings do support backslash escapes (`\'`, `\\`). + * - `"..."` quoted identifiers escape by doubling (`""`). + * - `--` line comments and nestable `/* ... *\/` block comments are skipped. + * - `$tag$ ... $tag$` dollar-quoted strings are skipped. + * + * Only an unquoted top-level `SELECT` prefix is accepted (WITH is rejected so + * CTE-hidden mutations like `WITH x AS (DELETE ...) RETURNING *` fail), any + * top-level `;` (multi-statement), transaction-control tokens, `SELECT INTO`, + * row-lock `FOR UPDATE/SHARE` forms, and sequence-mutating `nextval`/`setval` + * are rejected, and every keyword check runs only in plain code — never inside + * literals, identifiers, or comments. */ export function isReadOnlySql(sql: string): boolean { if (sql.length > MAX_SQL_LENGTH) return false; @@ -54,83 +81,135 @@ export function isReadOnlySql(sql: string): boolean { return false; } - let inString = false; - let inIdent = false; - let inLineComment = false; - let inBlockComment = false; - let sawForUpdate = false; + let state: ScanState = 'code'; + let blockDepth = 0; + let dollarTag = ''; let sawSelectInto = false; + let sawRowLock = false; + let sawSequenceMutation = false; while (i < n) { const c = s.charAt(i); const next = s.charAt(i + 1); - if (inLineComment) { - if (c === '\n') inLineComment = false; - i += 1; - continue; - } - if (inBlockComment) { - if (c === '*' && next === '/') { - inBlockComment = false; - i += 2; - } else { + switch (state) { + case 'lineComment': + if (c === '\n') state = 'code'; i += 1; - } - continue; - } - if (inString) { - if (c === '\\') { - i += 2; continue; - } - if (c === "'") inString = false; - i += 1; - continue; - } - if (inIdent) { - if (c === '"') inIdent = false; - i += 1; - continue; + case 'blockComment': + if (c === '/' && next === '*') { + blockDepth += 1; + i += 2; + continue; + } + if (c === '*' && next === '/') { + blockDepth -= 1; + if (blockDepth === 0) state = 'code'; + i += 2; + continue; + } + i += 1; + continue; + case 'string': + if (c === "'" && next === "'") { + i += 2; + continue; + } + if (c === "'") state = 'code'; + i += 1; + continue; + case 'estring': + if (c === '\\') { + i += 2; + continue; + } + if (c === "'" && next === "'") { + i += 2; + continue; + } + if (c === "'") state = 'code'; + i += 1; + continue; + case 'ident': + if (c === '"' && next === '"') { + i += 2; + continue; + } + if (c === '"') state = 'code'; + i += 1; + continue; + case 'dollarQuote': + if (c === '$' && s.startsWith(dollarTag, i)) { + state = 'code'; + i += dollarTag.length; + continue; + } + i += 1; + continue; + default: + break; } - // line / block comments + // code state: classify tokens if (c === '-' && next === '-') { - inLineComment = true; + state = 'lineComment'; i += 2; continue; } if (c === '/' && next === '*') { - inBlockComment = true; + state = 'blockComment'; + blockDepth = 1; i += 2; continue; } - // string literal (handle escaped '' and backslash) if (c === "'") { - inString = true; + state = 'string'; i += 1; continue; } - // quoted identifier if (c === '"') { - inIdent = true; + state = 'ident'; i += 1; continue; } - // multi-statement + if (c === '$') { + const tagMatch = /^\$[A-Za-z0-9_]*\$/.exec( + s.slice(i, Math.min(i + 64, n)), + ); + if (tagMatch) { + state = 'dollarQuote'; + dollarTag = tagMatch[0]; + i += dollarTag.length; + continue; + } + } if (c === ';') return false; - // read the next SQL word token (only continues in plain code) if (isWordChar(c)) { let j = i; while (j < n && isWordChar(s.charAt(j))) j += 1; const word = s.slice(i, j).toLowerCase(); + + // E'...' / U&'...' strings use backslash escapes + if (word === 'e' && s.charAt(j) === "'") { + state = 'estring'; + i = j + 1; + continue; + } + if (word === 'u' && s.charAt(j) === '&' && s.charAt(j + 1) === "'") { + state = 'estring'; + i = j + 2; + continue; + } + + if (TRANSACTION_CONTROL.has(word)) return false; if (word === 'into') sawSelectInto = true; + if (word === 'nextval' || word === 'setval') sawSequenceMutation = true; if (word === 'for') { - // look ahead for update/share row-lock modes const after = s.slice(j).trimStart().toLowerCase(); if (/^(update|share|no\s+key\s+update|key\s+share)\b/.test(after)) { - sawForUpdate = true; + sawRowLock = true; } } i = j; @@ -140,7 +219,7 @@ export function isReadOnlySql(sql: string): boolean { i += 1; } - if (sawForUpdate || sawSelectInto) return false; + if (sawSelectInto || sawRowLock || sawSequenceMutation) return false; return true; } From 6a150e502a903f917185c5437045037f868d27b5 Mon Sep 17 00:00:00 2001 From: Mayank Saini Date: Sat, 15 Aug 2026 18:17:45 +0530 Subject: [PATCH 10/13] fix(prisma): enforce read-only SQL guards and strict outputs --- packages/prisma/api.test.ts | 20 ++++ packages/prisma/endpoints/types.ts | 7 +- packages/prisma/pg-client.test.ts | 74 +++++++++++++-- packages/prisma/pg-client.ts | 142 +++++++++++++++++++++++++++-- 4 files changed, 227 insertions(+), 16 deletions(-) diff --git a/packages/prisma/api.test.ts b/packages/prisma/api.test.ts index db09118ec..ffa516700 100644 --- a/packages/prisma/api.test.ts +++ b/packages/prisma/api.test.ts @@ -128,6 +128,26 @@ describe('Prisma plugin shape', () => { expect( PrismaEndpointOutputSchemas.restoreBackup!.safeParse({}).success, ).toBe(true); + // ... but a non-empty object is an incompatible payload and must fail + expect( + PrismaEndpointOutputSchemas.deleteProject!.safeParse({ + unexpected: 'field', + }).success, + ).toBe(false); + expect( + PrismaEndpointOutputSchemas.deleteDatabase!.safeParse({ + deleted: true, + }).success, + ).toBe(false); + expect( + PrismaEndpointOutputSchemas.deleteConnection!.safeParse({ id: 'c1' }) + .success, + ).toBe(false); + expect( + PrismaEndpointOutputSchemas.restoreBackup!.safeParse({ + status: 'restored', + }).success, + ).toBe(false); }); it('marks destructive operations as irreversible', () => { diff --git a/packages/prisma/endpoints/types.ts b/packages/prisma/endpoints/types.ts index 393897a1b..6853763bf 100644 --- a/packages/prisma/endpoints/types.ts +++ b/packages/prisma/endpoints/types.ts @@ -226,11 +226,14 @@ const ListWorkspaceIntegrationsOutputSchema = resourceOrList( ); // destructive DELETE / restore endpoints return 204 No Content (or an empty -// 202 Accepted body) — the response carries no resource payload +// 202 Accepted body) — the response carries no resource payload. Only an +// actually-empty response is valid: undefined, null, or a bare `{}` — a +// non-empty object means the provider returned an incompatible payload, so it +// must fail output validation instead of passing through. const EmptyResponseSchema = z.union([ z.undefined(), z.null(), - z.object({}).passthrough(), + z.object({}).strict(), ]); const PRISMA_REST_OUTPUT_SCHEMAS: Record = { diff --git a/packages/prisma/pg-client.test.ts b/packages/prisma/pg-client.test.ts index 77ddba351..9473d7665 100644 --- a/packages/prisma/pg-client.test.ts +++ b/packages/prisma/pg-client.test.ts @@ -67,11 +67,31 @@ describe('executePostgresQuery', () => { it('runs read queries inside a READ ONLY transaction', async () => { await executePostgresQuery(connection, 'SELECT 1', [], 'read'); - expect(pgMocks.query.mock.calls.map((call) => call[0])).toEqual([ - 'BEGIN READ ONLY', - 'SELECT 1', - 'COMMIT', - ]); + + const calls = pgMocks.query.mock.calls.map((call) => call[0]); + expect(calls[0]).toBe( + 'SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY', + ); + expect(calls[1]).toBe('BEGIN READ ONLY'); + expect(calls[2]).toMatchObject({ + text: 'SELECT 1', + values: [], + queryMode: 'extended', + }); + expect(calls[3]).toBe('COMMIT'); + }); + + it('forces the extended protocol for the read query', async () => { + await executePostgresQuery(connection, 'SELECT $1::int AS n', [7], 'read'); + const queryCall = pgMocks.query.mock.calls.find( + (call) => call[0] && typeof call[0] === 'object', + ); + expect(queryCall).toBeDefined(); + expect(queryCall[0]).toMatchObject({ + text: 'SELECT $1::int AS n', + values: [7], + queryMode: 'extended', + }); }); it('rejects SELECT INTO and multi-statement queries before connecting', async () => { @@ -131,7 +151,9 @@ describe('isReadOnlySql token-aware validation', () => { expect(isReadOnlySql('DELETE FROM users')).toBe(false); }); - it('rejects WITH CTE mutations even without a semicolon', () => { + it('rejects WITH queries because they are not a plain SELECT', () => { + // A top-level WITH cannot start a read-only query, so both CTE-based + // mutations and read-only CTEs with a trailing SELECT are rejected. expect( isReadOnlySql( 'WITH x AS (DELETE FROM users RETURNING *) SELECT * FROM x', @@ -142,6 +164,7 @@ describe('isReadOnlySql token-aware validation', () => { 'WITH x AS (UPDATE users SET id = 1 RETURNING *) SELECT * FROM x', ), ).toBe(false); + expect(isReadOnlySql('WITH x AS (SELECT 1) SELECT * FROM x')).toBe(false); }); it('rejects SELECT INTO and row-lock forms', () => { @@ -220,4 +243,43 @@ describe('isReadOnlySql token-aware validation', () => { expect(isReadOnlySql("SELECT nextval('s')")).toBe(false); expect(isReadOnlySql("SELECT setval('s', 1)")).toBe(false); }); + + it('rejects side-effecting functions inside a SELECT', () => { + expect(isReadOnlySql("SELECT pg_advisory_lock('k')")).toBe(false); + expect(isReadOnlySql("SELECT pg_advisory_xact_lock('k')")).toBe(false); + expect(isReadOnlySql("SELECT pg_try_advisory_lock('k')")).toBe(false); + expect( + isReadOnlySql("SELECT dblink('conn','INSERT INTO t VALUES (1)')"), + ).toBe(false); + expect(isReadOnlySql("SELECT dblink_exec('conn', 'DELETE FROM t')")).toBe( + false, + ); + expect(isReadOnlySql("SELECT lo_import('/etc/passwd')")).toBe(false); + expect(isReadOnlySql("SELECT set_config('x.a', '1', false)")).toBe(false); + expect(isReadOnlySql('SELECT pg_terminate_backend(42)')).toBe(false); + expect(isReadOnlySql('SELECT pg_cancel_backend(42)')).toBe(false); + // quoted identifiers are not invocations and stay allowed + expect(isReadOnlySql('SELECT "pg_advisory_lock" FROM functions')).toBe( + true, + ); + // the deny list only matches unquoted identifiers + expect(isReadOnlySql("SELECT 'dblink' AS note")).toBe(true); + }); + + it('ends line comments at carriage returns too', () => { + // CR-only and CRLF line endings must close the comment; otherwise a + // scanner that only looks for \n would swallow a following statement. + expect(isReadOnlySql('SELECT 1 -- note\r; COMMIT')).toBe(false); + expect(isReadOnlySql('SELECT 1 -- note\r\n; COMMIT')).toBe(false); + expect(isReadOnlySql('SELECT 1 -- note\r\nFROM users')).toBe(true); + }); + + it('rejects row-lock variants after FOR', () => { + expect(isReadOnlySql('SELECT * FROM users FOR UPDATE')).toBe(false); + expect(isReadOnlySql('SELECT * FROM users FOR SHARE')).toBe(false); + expect(isReadOnlySql('SELECT * FROM users FOR NO KEY UPDATE')).toBe(false); + expect(isReadOnlySql('SELECT * FROM users FOR KEY SHARE')).toBe(false); + // "for" used as an alias or in prose is not a row lock + expect(isReadOnlySql('SELECT 1 AS "for"')).toBe(true); + }); }); diff --git a/packages/prisma/pg-client.ts b/packages/prisma/pg-client.ts index 24ae6d0a6..b692f5c5f 100644 --- a/packages/prisma/pg-client.ts +++ b/packages/prisma/pg-client.ts @@ -1,5 +1,10 @@ +import type { QueryConfig } from 'pg'; import { Client } from 'pg'; +// pg supports queryMode: 'extended' in its runtime QueryConfig (forces the +// single-statement prepared-query protocol) but @types/pg predates the field. +type ExtendedQueryConfig = QueryConfig & { queryMode: 'extended' }; + export type PostgresConnectionInput = { host: string; port?: number; @@ -43,6 +48,84 @@ const TRANSACTION_CONTROL = new Set([ 'checkpoint', ]); +// Functions whose invocation has operational side effects even inside a +// read-only transaction: advisory locks, remote writes via dblink, sequence +// mutation (beyond the nextval/setval tokens), configuration/snapshot and +// large-object writes, and backend control. The read-only endpoint refuses +// SELECTs that reference them so a read-only classification can never be +// stretched into server disruption. +const SIDE_EFFECT_FUNCTIONS = new Set([ + // advisory locks acquire/release session-level resources + 'pg_advisory_lock', + 'pg_advisory_lock_shared', + 'pg_advisory_unlock', + 'pg_advisory_unlock_all', + 'pg_advisory_unlock_shared', + 'pg_advisory_xact_lock', + 'pg_advisory_xact_lock_shared', + 'pg_try_advisory_lock', + 'pg_try_advisory_lock_shared', + 'pg_try_advisory_xact_lock', + 'pg_try_advisory_xact_lock_shared', + // dblink runs statements on a remote server through its own connection + 'dblink', + 'dblink_cancel_query', + 'dblink_close', + 'dblink_connect', + 'dblink_connect_u', + 'dblink_disconnect', + 'dblink_exec', + 'dblink_get_result', + 'dblink_open', + 'dblink_send_query', + 'dblink_send_query_async', + // session/system mutation + 'set_config', + 'pg_cancel_backend', + 'pg_terminate_backend', + 'pg_reload_conf', + 'pg_rotate_logfile', + 'pg_log_backend_memory_contexts', + 'pg_notify', + 'pg_listen', + 'pg_unlisten', + 'pg_switch_wal', + 'pg_switch_xlog', + 'pg_create_restore_point', + 'pg_promote', + 'pg_import_snapshot', + 'pg_export_snapshot', + 'pg_write_restartpoint_dir', + 'pg_switch_redaction', + // large objects write to the database + 'lo_import', + 'lo_import_with_oid', + 'lo_export', + 'lo_unlink', + 'lo_create', + 'lo_creat', + 'lo_from_bytea', + 'lo_put', + 'lo_open', + 'lo_write', + 'lo_truncate', + 'lo_truncate64', + 'lo_lseek', + 'lo_lseek64', + 'lo_close', + 'lo_tell', + 'lo_tell64', + // logical replication emission + 'pg_logical_emit_message', + 'pg_replication_origin_create', + 'pg_replication_origin_drop', + 'pg_replication_origin_setup', + 'pg_replication_origin_reset', + 'pg_replication_origin_xact_setup', + 'pg_replication_origin_xact_reset', + 'pg_replication_origin_advance', +]); + /** * Returns true when the statement is a pure read-only SELECT. * @@ -58,9 +141,12 @@ const TRANSACTION_CONTROL = new Set([ * Only an unquoted top-level `SELECT` prefix is accepted (WITH is rejected so * CTE-hidden mutations like `WITH x AS (DELETE ...) RETURNING *` fail), any * top-level `;` (multi-statement), transaction-control tokens, `SELECT INTO`, - * row-lock `FOR UPDATE/SHARE` forms, and sequence-mutating `nextval`/`setval` - * are rejected, and every keyword check runs only in plain code — never inside - * literals, identifiers, or comments. + * row-lock `FOR UPDATE/SHARE` forms, sequence-mutating `nextval`/`setval`, + * and functions whose invocation carries side effects even inside a read-only + * transaction (advisory locks, dblink remote writes, large-object writes, + * backend/config control — see `SIDE_EFFECT_FUNCTIONS`) are rejected, and + * every keyword check runs only in plain code — never inside literals, + * identifiers, or comments. */ export function isReadOnlySql(sql: string): boolean { if (sql.length > MAX_SQL_LENGTH) return false; @@ -87,6 +173,8 @@ export function isReadOnlySql(sql: string): boolean { let sawSelectInto = false; let sawRowLock = false; let sawSequenceMutation = false; + let sawSideEffectFunction = false; + let pendingFor = false; while (i < n) { const c = s.charAt(i); @@ -94,7 +182,7 @@ export function isReadOnlySql(sql: string): boolean { switch (state) { case 'lineComment': - if (c === '\n') state = 'code'; + if (c === '\n' || c === '\r') state = 'code'; i += 1; continue; case 'blockComment': @@ -206,11 +294,31 @@ export function isReadOnlySql(sql: string): boolean { if (TRANSACTION_CONTROL.has(word)) return false; if (word === 'into') sawSelectInto = true; if (word === 'nextval' || word === 'setval') sawSequenceMutation = true; + // Only flag a side-effect function when it is actually invoked: + // the word is treated as a call when '(' follows (whitespace + // tolerated), so columns/values that merely share the name stay + // allowed. + if (SIDE_EFFECT_FUNCTIONS.has(word)) { + let k = j; + while (k < n && /\s/.test(s.charAt(k))) k += 1; + if (s.charAt(k) === '(') sawSideEffectFunction = true; + } + // Row-lock forms: FOR UPDATE | FOR SHARE | FOR NO KEY UPDATE | + // FOR KEY SHARE. Row locks serialize on rows so a read-only query + // must not hold them. Detect via a one-token lookahead instead of + // slicing the remainder of the buffer on every `for` token. if (word === 'for') { - const after = s.slice(j).trimStart().toLowerCase(); - if (/^(update|share|no\s+key\s+update|key\s+share)\b/.test(after)) { + pendingFor = true; + } else if (pendingFor) { + if ( + word === 'update' || + word === 'share' || + word === 'no' || + word === 'key' + ) { sawRowLock = true; } + pendingFor = false; } i = j; continue; @@ -219,7 +327,13 @@ export function isReadOnlySql(sql: string): boolean { i += 1; } - if (sawSelectInto || sawRowLock || sawSequenceMutation) return false; + if ( + sawSelectInto || + sawRowLock || + sawSequenceMutation || + sawSideEffectFunction + ) + return false; return true; } @@ -283,9 +397,21 @@ export async function executePostgresQuery( return toQueryResult(result); } + // Enforce read-only at the connection session, not just in the first + // transaction: even a statement smuggling `COMMIT` opens a new + // transaction that stays read-only, and any function that bypasses the + // scanner's SELECT check still cannot start DDL/DML. + await client.query('SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY'); await client.query('BEGIN READ ONLY'); try { - const result = await client.query(sql, queryParams); + // queryMode: 'extended' forces the wire protocol's single-statement + // prepared-query path, so multi-statement payloads (`...; DROP ...`) + // are rejected by the server even if they slip past the scan. + const result = await client.query({ + text: sql, + values: queryParams, + queryMode: 'extended', + } as ExtendedQueryConfig); await client.query('COMMIT'); return toQueryResult(result); } catch (error) { From 2ce25d5ca8c888c05a4fe891e129ce71b0e62ec7 Mon Sep 17 00:00:00 2001 From: Mayank Saini Date: Sat, 15 Aug 2026 21:35:55 +0530 Subject: [PATCH 11/13] fix(prisma): allowlist read-only SQL function calls --- packages/prisma/pg-client.test.ts | 83 ++++- packages/prisma/pg-client.ts | 577 +++++++++++++++++++++++++----- 2 files changed, 558 insertions(+), 102 deletions(-) diff --git a/packages/prisma/pg-client.test.ts b/packages/prisma/pg-client.test.ts index 9473d7665..ba87ab495 100644 --- a/packages/prisma/pg-client.test.ts +++ b/packages/prisma/pg-client.test.ts @@ -244,7 +244,7 @@ describe('isReadOnlySql token-aware validation', () => { expect(isReadOnlySql("SELECT setval('s', 1)")).toBe(false); }); - it('rejects side-effecting functions inside a SELECT', () => { + it('rejects side-effecting and unknown function calls (allowlist)', () => { expect(isReadOnlySql("SELECT pg_advisory_lock('k')")).toBe(false); expect(isReadOnlySql("SELECT pg_advisory_xact_lock('k')")).toBe(false); expect(isReadOnlySql("SELECT pg_try_advisory_lock('k')")).toBe(false); @@ -258,14 +258,91 @@ describe('isReadOnlySql token-aware validation', () => { expect(isReadOnlySql("SELECT set_config('x.a', '1', false)")).toBe(false); expect(isReadOnlySql('SELECT pg_terminate_backend(42)')).toBe(false); expect(isReadOnlySql('SELECT pg_cancel_backend(42)')).toBe(false); - // quoted identifiers are not invocations and stay allowed + // functions Greptile specifically called out that a denylist missed + expect(isReadOnlySql('SELECT pg_sleep(30)')).toBe(false); + expect(isReadOnlySql('SELECT pg_stat_reset()')).toBe(false); + expect(isReadOnlySql('SELECT pg_stat_clear_snapshot()')).toBe(false); + // any unknown or user-defined function is rejected by default + expect(isReadOnlySql('SELECT my_custom_function(1)')).toBe(false); + expect(isReadOnlySql('SELECT public.my_custom_function(1)')).toBe(false); + // quoted invocations of unlisted functions are rejected too + expect(isReadOnlySql("SELECT \"dblink\"('c', 'INSERT')")).toBe(false); + // ...but a quoted identifier that is not invoked stays allowed expect(isReadOnlySql('SELECT "pg_advisory_lock" FROM functions')).toBe( true, ); - // the deny list only matches unquoted identifiers + // string literals never trigger the check expect(isReadOnlySql("SELECT 'dblink' AS note")).toBe(true); }); + it('accepts allowlisted built-in calls and keyword constructs', () => { + expect(isReadOnlySql('SELECT count(*) FROM users')).toBe(true); + expect(isReadOnlySql('SELECT max(id), sum(amount) FROM users')).toBe(true); + expect( + isReadOnlySql( + "SELECT lower(name), trim(' x '), length(nickname) FROM users", + ), + ).toBe(true); + expect(isReadOnlySql('SELECT coalesce(email, phone) FROM users')).toBe( + true, + ); + expect(isReadOnlySql('SELECT now(), current_date FROM users')).toBe(true); + expect( + isReadOnlySql( + 'SELECT * FROM users WHERE id IN (1, 2) AND (active OR EXISTS (SELECT 1 FROM orgs))', + ), + ).toBe(true); + expect( + isReadOnlySql('SELECT count(*) FILTER (WHERE active > 0) FROM users'), + ).toBe(true); + expect( + isReadOnlySql( + 'SELECT rank() OVER (PARTITION BY org_id ORDER BY created_at) FROM users', + ), + ).toBe(true); + expect(isReadOnlySql('SELECT * FROM users JOIN orgs USING (org_id)')).toBe( + true, + ); + expect(isReadOnlySql('SELECT CAST(id AS numeric(10, 2)) FROM users')).toBe( + true, + ); + expect(isReadOnlySql('SELECT pg_catalog.version()')).toBe(true); + expect( + isReadOnlySql( + 'SELECT (SELECT count(*) FROM orgs) AS org_count FROM users', + ), + ).toBe(true); + // function-call-style casts of types + expect(isReadOnlySql("SELECT int4('42')")).toBe(true); + expect(isReadOnlySql('SELECT text(7)')).toBe(true); + }); + + it('rejects data-modifying keywords anywhere, including subquery CTEs', () => { + expect( + isReadOnlySql( + 'SELECT * FROM (WITH t AS (DELETE FROM users RETURNING *) SELECT * FROM t) s', + ), + ).toBe(false); + expect( + isReadOnlySql( + 'SELECT * FROM (WITH t AS (INSERT INTO users VALUES (1) RETURNING *) SELECT * FROM t) s', + ), + ).toBe(false); + expect( + isReadOnlySql( + 'SELECT * FROM (WITH t AS (UPDATE users SET id = 1 RETURNING *) SELECT * FROM t) s', + ), + ).toBe(false); + // WITH itself is disallowed everywhere (read-only queries must be a + // plain top-level SELECT), so even a read-only CTE subquery is refused + // rather than trusted to not hide a mutation. + expect( + isReadOnlySql( + 'SELECT * FROM (WITH t AS (SELECT 1 AS x) SELECT * FROM t) s', + ), + ).toBe(false); + }); + it('ends line comments at carriage returns too', () => { // CR-only and CRLF line endings must close the comment; otherwise a // scanner that only looks for \n would swallow a following statement. diff --git a/packages/prisma/pg-client.ts b/packages/prisma/pg-client.ts index b692f5c5f..db40b1341 100644 --- a/packages/prisma/pg-client.ts +++ b/packages/prisma/pg-client.ts @@ -48,84 +48,435 @@ const TRANSACTION_CONTROL = new Set([ 'checkpoint', ]); -// Functions whose invocation has operational side effects even inside a -// read-only transaction: advisory locks, remote writes via dblink, sequence -// mutation (beyond the nextval/setval tokens), configuration/snapshot and -// large-object writes, and backend control. The read-only endpoint refuses -// SELECTs that reference them so a read-only classification can never be -// stretched into server disruption. -const SIDE_EFFECT_FUNCTIONS = new Set([ - // advisory locks acquire/release session-level resources - 'pg_advisory_lock', - 'pg_advisory_lock_shared', - 'pg_advisory_unlock', - 'pg_advisory_unlock_all', - 'pg_advisory_unlock_shared', - 'pg_advisory_xact_lock', - 'pg_advisory_xact_lock_shared', - 'pg_try_advisory_lock', - 'pg_try_advisory_lock_shared', - 'pg_try_advisory_xact_lock', - 'pg_try_advisory_xact_lock_shared', - // dblink runs statements on a remote server through its own connection - 'dblink', - 'dblink_cancel_query', - 'dblink_close', - 'dblink_connect', - 'dblink_connect_u', - 'dblink_disconnect', - 'dblink_exec', - 'dblink_get_result', - 'dblink_open', - 'dblink_send_query', - 'dblink_send_query_async', - // session/system mutation - 'set_config', - 'pg_cancel_backend', - 'pg_terminate_backend', - 'pg_reload_conf', - 'pg_rotate_logfile', - 'pg_log_backend_memory_contexts', - 'pg_notify', - 'pg_listen', - 'pg_unlisten', - 'pg_switch_wal', - 'pg_switch_xlog', - 'pg_create_restore_point', - 'pg_promote', - 'pg_import_snapshot', - 'pg_export_snapshot', - 'pg_write_restartpoint_dir', - 'pg_switch_redaction', - // large objects write to the database - 'lo_import', - 'lo_import_with_oid', - 'lo_export', - 'lo_unlink', - 'lo_create', - 'lo_creat', - 'lo_from_bytea', - 'lo_put', - 'lo_open', - 'lo_write', - 'lo_truncate', - 'lo_truncate64', - 'lo_lseek', - 'lo_lseek64', - 'lo_close', - 'lo_tell', - 'lo_tell64', - // logical replication emission - 'pg_logical_emit_message', - 'pg_replication_origin_create', - 'pg_replication_origin_drop', - 'pg_replication_origin_setup', - 'pg_replication_origin_reset', - 'pg_replication_origin_xact_setup', - 'pg_replication_origin_xact_reset', - 'pg_replication_origin_advance', +// Reserved data-modifying statement keywords. These cannot appear as unquoted +// identifiers in any position in a statement, so rejecting them anywhere in +// plain code also blocks data-modifying CTEs smuggled into subqueries, e.g. +// `SELECT * FROM (WITH t AS (DELETE FROM u RETURNING *) SELECT * FROM t) s`. +const WRITE_STATEMENT_KEYWORDS = new Set([ + 'insert', + 'update', + 'delete', + 'merge', + 'with', + 'grant', + 'revoke', + 'truncate', + 'create', + 'alter', + 'drop', + 'call', + 'do', + 'returning', + 'show', ]); +// Allowlist of functions, keyword constructs, and type names that may appear +// as a call site in a read-only SELECT. This is an allowlist, NOT a denylist: +// any identifier directly followed by '(' whose name is not listed here — +// pg_sleep(), pg_stat_reset(), advisory locks, dblink, large-object helpers, +// pg_read_file(), and every user-defined or extension function — is rejected +// before the query touches the server, so an omitted unsafe function can never +// slip through (the previous denylist could always be bypassed by a function +// name that was simply left off the list). +const SAFE_FUNCTIONS = new Set( + // prettier-ignore + [ + // SQL keyword constructs that can be followed by '(' in valid SQL: + // subqueries, set membership, row/type/value constructors, casts, + // aggregate/window framing, and join/derived-table aliases. + 'select', + 'from', + 'where', + 'and', + 'or', + 'not', + 'in', + 'exists', + 'any', + 'all', + 'some', + 'cast', + 'as', + 'on', + 'using', + 'join', + 'inner', + 'left', + 'right', + 'full', + 'outer', + 'cross', + 'lateral', + 'union', + 'intersect', + 'except', + 'distinct', + 'over', + 'filter', + 'within', + 'group', + 'between', + 'like', + 'ilike', + 'is', + 'asc', + 'desc', + 'nulls', + 'first', + 'last', + 'values', + 'row', + 'array', + // type names usable as function-style casts: int4('42'), text(7), jsonb(...) + 'int', + 'int2', + 'int4', + 'int8', + 'smallint', + 'integer', + 'bigint', + 'real', + 'float4', + 'float8', + 'double', + 'numeric', + 'decimal', + 'money', + 'boolean', + 'bool', + 'text', + 'varchar', + 'char', + 'bpchar', + 'name', + 'bytea', + 'date', + 'time', + 'timetz', + 'timestamp', + 'timestamptz', + 'interval', + 'oid', + 'json', + 'jsonb', + 'uuid', + 'inet', + 'cidr', + 'macaddr', + 'macaddr8', + 'bit', + 'varbit', + 'tsvector', + 'tsquery', + 'xml', + 'point', + 'line', + 'lseg', + 'box', + 'path', + 'polygon', + 'circle', + // aggregates + 'count', + 'sum', + 'avg', + 'min', + 'max', + 'array_agg', + 'string_agg', + 'json_agg', + 'jsonb_agg', + 'json_object_agg', + 'jsonb_object_agg', + 'bool_and', + 'bool_or', + 'every', + 'bit_and', + 'bit_or', + 'stddev', + 'stddev_pop', + 'stddev_samp', + 'variance', + 'var_pop', + 'var_samp', + 'corr', + 'covar_pop', + 'covar_samp', + 'regr_slope', + 'regr_intercept', + 'regr_avgx', + 'regr_avgy', + 'regr_count', + 'regr_r2', + 'regr_sxx', + 'regr_sxy', + 'regr_syy', + 'percentile_cont', + 'percentile_disc', + 'mode', + // window functions + 'row_number', + 'rank', + 'dense_rank', + 'percent_rank', + 'cume_dist', + 'ntile', + 'lag', + 'lead', + 'first_value', + 'last_value', + 'nth_value', + // string / character + 'ascii', + 'bit_length', + 'btrim', + 'char_length', + 'character_length', + 'chr', + 'concat', + 'concat_ws', + 'format', + 'initcap', + 'left', + 'length', + 'lower', + 'lpad', + 'ltrim', + 'md5', + 'normalize', + 'octet_length', + 'overlay', + 'position', + 'repeat', + 'replace', + 'reverse', + 'right', + 'rpad', + 'rtrim', + 'split_part', + 'strpos', + 'substr', + 'substring', + 'translate', + 'trim', + 'upper', + 'to_char', + 'to_number', + 'quote_ident', + 'quote_literal', + 'quote_nullable', + 'encode', + 'decode', + 'to_hex', + // pattern matching + 'regexp_like', + 'regexp_match', + 'regexp_matches', + 'regexp_replace', + 'regexp_split_to_array', + 'regexp_split_to_table', + 'regexp_count', + 'regexp_instr', + 'regexp_substr', + // numeric / math + 'abs', + 'cbrt', + 'ceil', + 'ceiling', + 'degrees', + 'div', + 'exp', + 'factorial', + 'floor', + 'ln', + 'log', + 'log10', + 'mod', + 'pi', + 'power', + 'radians', + 'round', + 'scale', + 'sign', + 'sin', + 'cos', + 'tan', + 'cot', + 'asin', + 'acos', + 'atan', + 'atan2', + 'sinh', + 'cosh', + 'tanh', + 'asinh', + 'acosh', + 'atanh', + 'sqrt', + 'trunc', + 'width_bucket', + 'gcd', + 'lcm', + // date / time + 'age', + 'clock_timestamp', + 'current_date', + 'current_time', + 'current_timestamp', + 'current_catalog', + 'current_schema', + 'current_schemas', + 'current_user', + 'date_bin', + 'date_part', + 'date_trunc', + 'extract', + 'isfinite', + 'justify_days', + 'justify_hours', + 'justify_interval', + 'localtime', + 'localtimestamp', + 'make_date', + 'make_interval', + 'make_time', + 'make_timestamp', + 'make_timestamptz', + 'now', + 'statement_timestamp', + 'session_user', + 'timeofday', + 'transaction_timestamp', + 'to_date', + 'to_timestamp', + 'user', + // network / inet + 'abbrev', + 'broadcast', + 'family', + 'host', + 'hostmask', + 'netmask', + 'network', + 'set_masklen', + 'masklen', + // arrays / sets + 'generate_series', + 'generate_subscripts', + 'unnest', + 'array_append', + 'array_cat', + 'array_dims', + 'array_fill', + 'array_length', + 'array_lower', + 'array_ndims', + 'array_position', + 'array_positions', + 'array_prepend', + 'array_remove', + 'array_replace', + 'array_to_string', + 'cardinality', + 'string_to_array', + // json / jsonb + 'to_json', + 'to_jsonb', + 'array_to_json', + 'row_to_json', + 'json_build_array', + 'jsonb_build_array', + 'json_build_object', + 'jsonb_build_object', + 'json_object', + 'json_typeof', + 'jsonb_typeof', + 'json_array_length', + 'jsonb_array_length', + 'json_each', + 'jsonb_each', + 'json_extract_path', + 'jsonb_extract_path', + 'json_extract_path_text', + 'jsonb_extract_path_text', + 'json_object_keys', + 'json_populate_record', + 'jsonb_populate_record', + 'json_populate_recordset', + 'jsonb_populate_recordset', + 'json_array_elements', + 'jsonb_array_elements', + 'json_array_elements_text', + 'jsonb_array_elements_text', + 'json_strip_nulls', + 'jsonb_strip_nulls', + 'jsonb_pretty', + 'jsonb_set', + 'jsonb_insert', + // full text + 'to_tsvector', + 'to_tsquery', + 'plainto_tsquery', + 'phraseto_tsquery', + 'websearch_to_tsquery', + 'ts_rank', + 'ts_rank_cd', + 'ts_headline', + 'setweight', + 'numnode', + 'querytree', + 'strip', + 'get_current_ts_config', + // uuid / enums / ranges / conditionals / misc + 'gen_random_uuid', + 'enum_first', + 'enum_last', + 'enum_range', + 'coalesce', + 'nullif', + 'greatest', + 'least', + 'version', + 'pg_backend_pid', + 'pg_is_in_recovery', + 'pg_postmaster_start_time', + 'pg_conf_load_time', + 'pg_size_bytes', + 'pg_size_pretty', + 'pg_column_size', + 'pg_relation_size', + 'pg_table_size', + 'pg_total_relation_size', + 'pg_indexes_size', + 'pg_database_size', + 'pg_tablespace_size', + 'col_description', + 'obj_description', + 'shobj_description', + 'format_type', + 'pg_get_expr', + 'pg_get_constraintdef', + 'pg_get_indexdef', + 'pg_get_triggerdef', + 'pg_get_functiondef', + 'pg_get_function_arguments', + 'pg_get_function_identity_arguments', + 'pg_get_function_result', + 'pg_get_keywords', + 'pg_get_partkeydef', + 'pg_get_partition_constraintdef', + 'pg_get_serial_sequence', + 'pg_get_statisticsobjdef_columns', + 'pg_get_userbyid', + 'pg_table_is_visible', + 'pg_type_is_visible', + 'pg_function_is_visible', + 'pg_encoding_to_char', + 'pg_char_to_encoding', + 'pg_typeof', + 'pg_tablespace_location', + ], +); /** * Returns true when the statement is a pure read-only SELECT. * @@ -141,11 +492,12 @@ const SIDE_EFFECT_FUNCTIONS = new Set([ * Only an unquoted top-level `SELECT` prefix is accepted (WITH is rejected so * CTE-hidden mutations like `WITH x AS (DELETE ...) RETURNING *` fail), any * top-level `;` (multi-statement), transaction-control tokens, `SELECT INTO`, - * row-lock `FOR UPDATE/SHARE` forms, sequence-mutating `nextval`/`setval`, - * and functions whose invocation carries side effects even inside a read-only - * transaction (advisory locks, dblink remote writes, large-object writes, - * backend/config control — see `SIDE_EFFECT_FUNCTIONS`) are rejected, and - * every keyword check runs only in plain code — never inside literals, + * row-lock `FOR UPDATE/SHARE` forms, data-modifying keywords (`INSERT`, + * `UPDATE`, `DELETE`, `MERGE`, ...) anywhere in plain code, and any function + * invocation whose name is not on the `SAFE_FUNCTIONS` allowlist (including + * advisory locks, dblink remote writes, large-object writers, `pg_sleep`, + * `pg_stat_reset`, and every user-defined or extension function) are rejected, + * and every keyword check runs only in plain code — never inside literals, * identifiers, or comments. */ export function isReadOnlySql(sql: string): boolean { @@ -155,7 +507,19 @@ export function isReadOnlySql(sql: string): boolean { const n = s.length; let i = 0; - const isWordChar = (ch: string): boolean => /[A-Za-z0-9_$]/.test(ch); + // Unicode-aware word characters: PostgreSQL identifiers can contain + // non-ASCII letters, so treat any code unit over 0x7f as part of a word + // token (fails closed — a unicode identifier is never on the allowlist). + const isWordChar = (ch: string): boolean => { + const code = ch.charCodeAt(0); + return ( + /[A-Za-z0-9_$]/.test(ch) || + (code > 0x7f && + ![' ', '\n', '\r', '\t', "'", '"', '(', ')', ';', '.', ','].includes( + ch, + )) + ); + }; // skip leading whitespace and optional wrapping `(` while (i < n && /\s/.test(s.charAt(i))) i += 1; @@ -172,8 +536,7 @@ export function isReadOnlySql(sql: string): boolean { let dollarTag = ''; let sawSelectInto = false; let sawRowLock = false; - let sawSequenceMutation = false; - let sawSideEffectFunction = false; + let prevWord = ''; let pendingFor = false; while (i < n) { @@ -224,7 +587,19 @@ export function isReadOnlySql(sql: string): boolean { i += 2; continue; } - if (c === '"') state = 'code'; + if (c === '"') { + state = 'code'; + // A quoted identifier directly followed by '(' is a function + // invocation with a user-chosen name ("dblink"(...)). Quoted + // names are never on the allowlist, so reject it — this + // closes the case where an unlisted unsafe function is called + // through a quoted identifier. + let k = i + 1; + while (k < n && /\s/.test(s.charAt(k))) k += 1; + if (s.charAt(k) === '(') return false; + i += 1; + continue; + } i += 1; continue; case 'dollarQuote': @@ -292,16 +667,25 @@ export function isReadOnlySql(sql: string): boolean { } if (TRANSACTION_CONTROL.has(word)) return false; + if (WRITE_STATEMENT_KEYWORDS.has(word)) return false; if (word === 'into') sawSelectInto = true; - if (word === 'nextval' || word === 'setval') sawSequenceMutation = true; - // Only flag a side-effect function when it is actually invoked: - // the word is treated as a call when '(' follows (whitespace - // tolerated), so columns/values that merely share the name stay - // allowed. - if (SIDE_EFFECT_FUNCTIONS.has(word)) { + // Allowlist check for function invocations: a word directly + // followed by '(' is a call site. Unless it is the `AS` alias + // column-list form (`FROM f(x) AS t(a, b)` — where `t(a,b)` names + // output columns and is not a call), the function name must be on + // the allowlist or the statement is rejected. This is fail-closed: + // any function not explicitly allowed — pg_sleep(), pg_stat_reset(), + // advisory locks, dblink, user/extension functions — is refused. + { let k = j; while (k < n && /\s/.test(s.charAt(k))) k += 1; - if (s.charAt(k) === '(') sawSideEffectFunction = true; + if ( + s.charAt(k) === '(' && + prevWord !== 'as' && + !SAFE_FUNCTIONS.has(word) + ) { + return false; + } } // Row-lock forms: FOR UPDATE | FOR SHARE | FOR NO KEY UPDATE | // FOR KEY SHARE. Row locks serialize on rows so a read-only query @@ -320,6 +704,7 @@ export function isReadOnlySql(sql: string): boolean { } pendingFor = false; } + prevWord = word; i = j; continue; } @@ -327,13 +712,7 @@ export function isReadOnlySql(sql: string): boolean { i += 1; } - if ( - sawSelectInto || - sawRowLock || - sawSequenceMutation || - sawSideEffectFunction - ) - return false; + if (sawSelectInto || sawRowLock) return false; return true; } From 19f5332d6f623da9bc82c6a4b76740ebb1f0b825 Mon Sep 17 00:00:00 2001 From: Mayank Saini Date: Sat, 15 Aug 2026 22:37:24 +0530 Subject: [PATCH 12/13] fix(prisma): close comment-separated call-site bypass in SQL guard PostgreSQL treats comments as token separators, so 'name /*c*/ (' and 'name -- c\n(' are still function invocations. The read-only allowlist lookahead skipped only whitespace, letting unlisted side-effecting functions (pg_sleep, dblink_exec, ...) hide their '(' behind a comment. Add a skipSqlTrivia helper (whitespace + line comments + nested block comments, mirroring the main scanner) and use it for both the bare-word and quoted-identifier call-site checks, with regression tests for block/line/nested/mixed comment vectors and allowlisted-name allowances. --- packages/prisma/pg-client.test.ts | 40 ++++++++++++++++++ packages/prisma/pg-client.ts | 70 ++++++++++++++++++++++++------- 2 files changed, 96 insertions(+), 14 deletions(-) diff --git a/packages/prisma/pg-client.test.ts b/packages/prisma/pg-client.test.ts index ba87ab495..e61529851 100644 --- a/packages/prisma/pg-client.test.ts +++ b/packages/prisma/pg-client.test.ts @@ -275,6 +275,46 @@ describe('isReadOnlySql token-aware validation', () => { expect(isReadOnlySql("SELECT 'dblink' AS note")).toBe(true); }); + it('sees through comments between a function name and its parenthesis', () => { + // PostgreSQL treats comments as token separators, so every one of these + // is a real function call on the server; a whitespace-only lookahead + // misses the '(' and lets unlisted side-effecting functions run. + expect(isReadOnlySql('SELECT pg_sleep /*comment*/ (30)')).toBe(false); + expect( + isReadOnlySql("SELECT dblink_exec /*comment*/ ('conn', 'DELETE FROM t')"), + ).toBe(false); + expect(isReadOnlySql('SELECT pg_sleep -- comment\n(30)')).toBe(false); + expect(isReadOnlySql('SELECT pg_sleep -- comment\r(30)')).toBe(false); + expect( + isReadOnlySql( + 'SELECT pg_sleep /* outer /* inner */ still comment */ (30)', + ), + ).toBe(false); + expect(isReadOnlySql('SELECT pg_sleep\n/* mixed */\n-- line\n(30)')).toBe( + false, + ); + // quoted invocations get the same comment-aware treatment + expect(isReadOnlySql('SELECT "pg_sleep" /*comment*/ (30)')).toBe(false); + expect(isReadOnlySql("SELECT \"dblink\" -- comment\n('c', 'x')")).toBe( + false, + ); + // allowlisted names still pass when a comment intervenes + expect(isReadOnlySql('SELECT count /* tally */ (*) FROM users')).toBe(true); + expect( + isReadOnlySql('SELECT * FROM generate_series -- series\n(1, 3)'), + ).toBe(true); + // the AS alias column-list exemption still works across comments + expect( + isReadOnlySql( + 'SELECT * FROM generate_series(1, 3) AS /* cols */ t(a, b)', + ), + ).toBe(true); + // a comment between a non-call word and '(' is not a call site + expect(isReadOnlySql('SELECT 1 AS /* alias */ total FROM users')).toBe( + true, + ); + }); + it('accepts allowlisted built-in calls and keyword constructs', () => { expect(isReadOnlySql('SELECT count(*) FROM users')).toBe(true); expect(isReadOnlySql('SELECT max(id), sum(amount) FROM users')).toBe(true); diff --git a/packages/prisma/pg-client.ts b/packages/prisma/pg-client.ts index db40b1341..9f2e69b79 100644 --- a/packages/prisma/pg-client.ts +++ b/packages/prisma/pg-client.ts @@ -496,8 +496,10 @@ const SAFE_FUNCTIONS = new Set( * `UPDATE`, `DELETE`, `MERGE`, ...) anywhere in plain code, and any function * invocation whose name is not on the `SAFE_FUNCTIONS` allowlist (including * advisory locks, dblink remote writes, large-object writers, `pg_sleep`, - * `pg_stat_reset`, and every user-defined or extension function) are rejected, - * and every keyword check runs only in plain code — never inside literals, + * `pg_stat_reset`, and every user-defined or extension function) are rejected. + * A function name and its `(` may be separated by whitespace and comments — + * `pg_sleep /*c*\/ (30)` is still a call — so the call-site lookahead skips + * both. Every keyword check runs only in plain code — never inside literals, * identifiers, or comments. */ export function isReadOnlySql(sql: string): boolean { @@ -521,6 +523,44 @@ export function isReadOnlySql(sql: string): boolean { ); }; + // PostgreSQL treats comments as token separators, so a call site can hide + // its '(' behind whitespace AND comments: `pg_sleep /*c*/ (30)` and + // `pg_sleep -- c\n(30)` are both function invocations on the server. Skip + // that trivia here so the allowlist checks below see the real next token — + // a whitespace-only lookahead would let unlisted functions slip through. + // Block comments nest, mirroring the main scanner. An unterminated comment + // runs to end-of-input and simply yields a non-'(' position (the server + // rejects the statement as a syntax error, so nothing executes). + const skipSqlTrivia = (from: number): number => { + let k = from; + for (;;) { + while (k < n && /\s/.test(s.charAt(k))) k += 1; + if (s.charAt(k) === '-' && s.charAt(k + 1) === '-') { + while (k < n && s.charAt(k) !== '\n' && s.charAt(k) !== '\r') { + k += 1; + } + continue; + } + if (s.charAt(k) === '/' && s.charAt(k + 1) === '*') { + let depth = 1; + k += 2; + while (k < n && depth > 0) { + if (s.charAt(k) === '/' && s.charAt(k + 1) === '*') { + depth += 1; + k += 2; + } else if (s.charAt(k) === '*' && s.charAt(k + 1) === '/') { + depth -= 1; + k += 2; + } else { + k += 1; + } + } + continue; + } + return k; + } + }; + // skip leading whitespace and optional wrapping `(` while (i < n && /\s/.test(s.charAt(i))) i += 1; while (i < n && s.charAt(i) === '(') i += 1; @@ -593,9 +633,9 @@ export function isReadOnlySql(sql: string): boolean { // invocation with a user-chosen name ("dblink"(...)). Quoted // names are never on the allowlist, so reject it — this // closes the case where an unlisted unsafe function is called - // through a quoted identifier. - let k = i + 1; - while (k < n && /\s/.test(s.charAt(k))) k += 1; + // through a quoted identifier. Trivia is skipped so a comment + // cannot hide the '(' ("dblink" /*c*/ (...) still rejects). + const k = skipSqlTrivia(i + 1); if (s.charAt(k) === '(') return false; i += 1; continue; @@ -669,16 +709,18 @@ export function isReadOnlySql(sql: string): boolean { if (TRANSACTION_CONTROL.has(word)) return false; if (WRITE_STATEMENT_KEYWORDS.has(word)) return false; if (word === 'into') sawSelectInto = true; - // Allowlist check for function invocations: a word directly - // followed by '(' is a call site. Unless it is the `AS` alias - // column-list form (`FROM f(x) AS t(a, b)` — where `t(a,b)` names - // output columns and is not a call), the function name must be on - // the allowlist or the statement is rejected. This is fail-closed: - // any function not explicitly allowed — pg_sleep(), pg_stat_reset(), - // advisory locks, dblink, user/extension functions — is refused. + // Allowlist check for function invocations: a word followed by '(' + // — with only whitespace and/or comments in between, which + // PostgreSQL treats as token separators — is a call site. Unless it + // is the `AS` alias column-list form (`FROM f(x) AS t(a, b)` — where + // `t(a,b)` names output columns and is not a call), the function + // name must be on the allowlist or the statement is rejected. This + // is fail-closed: any function not explicitly allowed — pg_sleep(), + // pg_stat_reset(), advisory locks, dblink, user/extension functions + // — is refused, and `name /*c*/ (` / `name -- c\n(` cannot hide the + // call from this check. { - let k = j; - while (k < n && /\s/.test(s.charAt(k))) k += 1; + const k = skipSqlTrivia(j); if ( s.charAt(k) === '(' && prevWord !== 'as' && From d2893a309a13ccdf551b5ec3f159e359337c5a4b Mon Sep 17 00:00:00 2001 From: Mayank Saini Date: Sat, 15 Aug 2026 23:05:10 +0530 Subject: [PATCH 13/13] fix(prisma): accept quoted AS alias column lists in SQL guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quoted-identifier call check rejected valid read-only queries such as FROM f(x) AS "series" /* comment */ (value): after AS the quoted name is a table alias and the parenthesized list names output columns, it is not a function invocation. Mirror the bare-word branch's AS exemption for quoted identifiers, and clear prevWord after any quoted identifier so the exemption cannot leak to a following call — AS "series"(pg_sleep(1)) still rejects. Regression tests cover the alias forms with and without comments and the still-rejected call forms. --- packages/prisma/pg-client.test.ts | 29 +++++++++++++++++++++++++++++ packages/prisma/pg-client.ts | 14 +++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/prisma/pg-client.test.ts b/packages/prisma/pg-client.test.ts index e61529851..da0b61b54 100644 --- a/packages/prisma/pg-client.test.ts +++ b/packages/prisma/pg-client.test.ts @@ -315,6 +315,35 @@ describe('isReadOnlySql token-aware validation', () => { ); }); + it('accepts quoted alias column lists after AS, comments included', () => { + // FROM f(x) AS "series" (value) — the quoted name is a table alias and + // the parens hold output-column names, not a function invocation. + expect( + isReadOnlySql('SELECT * FROM generate_series(1, 3) AS "series"(value)'), + ).toBe(true); + expect( + isReadOnlySql( + 'SELECT * FROM generate_series(1, 3) AS "series" /* comment */ (value)', + ), + ).toBe(true); + expect( + isReadOnlySql( + 'SELECT * FROM generate_series(1, 3) AS /* c */ "series" (value)', + ), + ).toBe(true); + // the exemption does not extend past the alias: unsafe calls anywhere + // else — including inside the column list — are still rejected + expect(isReadOnlySql("SELECT \"dblink\"('c', 'INSERT')")).toBe(false); + expect(isReadOnlySql('SELECT "pg_sleep" /*c*/ (30)')).toBe(false); + expect( + isReadOnlySql( + 'SELECT * FROM generate_series(1, 3) AS "series"(pg_sleep(1))', + ), + ).toBe(false); + // and a quoted name followed by '(' without AS is still treated as a call + expect(isReadOnlySql('SELECT "pg_stat_reset"()')).toBe(false); + }); + it('accepts allowlisted built-in calls and keyword constructs', () => { expect(isReadOnlySql('SELECT count(*) FROM users')).toBe(true); expect(isReadOnlySql('SELECT max(id), sum(amount) FROM users')).toBe(true); diff --git a/packages/prisma/pg-client.ts b/packages/prisma/pg-client.ts index 9f2e69b79..2085a2249 100644 --- a/packages/prisma/pg-client.ts +++ b/packages/prisma/pg-client.ts @@ -635,8 +635,20 @@ export function isReadOnlySql(sql: string): boolean { // closes the case where an unlisted unsafe function is called // through a quoted identifier. Trivia is skipped so a comment // cannot hide the '(' ("dblink" /*c*/ (...) still rejects). + // Exception: after AS the quoted name is a table alias and + // the parens hold a column-alias list, not a call — e.g. + // `FROM f(x) AS "series" /*c*/ (value)`. A real call in that + // position is a syntax error the server rejects, and the + // column list itself is scanned normally, so the exemption + // cannot smuggle an invocation. const k = skipSqlTrivia(i + 1); - if (s.charAt(k) === '(') return false; + if (s.charAt(k) === '(' && prevWord !== 'as') return false; + // Do not let the AS exemption leak past the alias: clear + // prevWord so a call right after a quoted alias — e.g. + // `AS "series"(pg_sleep(1))` — is still classified against + // the allowlist (the bare-word branch gets this for free by + // recording the alias name; quoted idents record nothing). + prevWord = ''; i += 1; continue; }