Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zenstackhq/proxy",
"version": "0.3.0",
"version": "0.4.0",
"description": "A CLI tool to run an Express server that proxies CRUD requests to a ZenStack backend",
"main": "index.js",
"publishConfig": {
Expand Down Expand Up @@ -40,6 +40,7 @@
"express": "^4.19.2",
"mixpanel": "^0.19.1",
"semver": "^7.7.3",
"superjson": "^2.2.6",
"tsx": "^4.20.6",
"uuid": "^13.0.0"
},
Expand Down
25 changes: 25 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

126 changes: 126 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { getNodeModulesFolder, getPrismaVersion, getZenStackVersion } from './ut
import { blue, grey, red } from 'colors'
import semver from 'semver'
import { CliError } from './cli-error'
import SuperJSON from 'superjson'

export interface ServerOptions {
zenstackPath: string | undefined
Expand All @@ -20,6 +21,25 @@ type EnhancementKind = 'password' | 'omit' | 'policy' | 'validation' | 'delegate
// enable all enhancements except policy
const Enhancements: EnhancementKind[] = ['password', 'omit', 'validation', 'delegate', 'encryption']

const VALID_OPS = new Set([
'findMany',
'findUnique',
'findFirst',
'create',
'createMany',
'createManyAndReturn',
'update',
'updateMany',
'updateManyAndReturn',
'upsert',
'delete',
'deleteMany',
'count',
'aggregate',
'groupBy',
'exists',
])
Comment on lines +24 to +41
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does Prisma expose the same model delegate methods across all supported providers and versions, or are operations such as createManyAndReturn/updateManyAndReturn provider- or version-dependent?

💡 Result:

Prisma does not expose an identical set of model-delegate CRUD methods/behaviors across all providers and versions—some operations are provider- and/or version-dependent.

  • Version-dependent: createManyAndReturn() / updateManyAndReturn() are only available starting in Prisma ORM 5.14.0 (and later). [1]
  • Provider-dependent: those “AndReturn” bulk operations are supported only on PostgreSQL, CockroachDB, and SQLite (not on MySQL, SQL Server, MongoDB, etc.). [1][2]
  • Even when a method exists, capabilities can differ by provider (e.g., createMany({ skipDuplicates: true }) is not supported on some connectors like SQLite). [1]

So if you rely on createManyAndReturn / updateManyAndReturn, you must gate it by Prisma version and datasource provider (and regenerate Prisma Client after upgrades/provider changes). [1][2]

Sources:
[1] Prisma Docs – CRUD (v6) (notes provider + “available in 5.14.0 and later”) (docs.prisma.io)
[2] Prisma Docs – Prisma Client API reference (method-level support list) (docs.prisma.io)

Citations:


Validate operations against the actual loaded delegate, not only VALID_OPS.

The whitelist is static, but the Prisma delegate surface is provider- and version-dependent. Operations like createManyAndReturn and updateManyAndReturn are only available in Prisma 5.14.0+, and only on PostgreSQL, CockroachDB, and SQLite—not on MySQL, SQL Server, or MongoDB. If an op is in VALID_OPS but unavailable on the current client instance, the code at Line 293 will call undefined(...), resulting in a 500 error instead of validation failure.

The suggested fix prevents this by checking the delegate at request time before execution.

Suggested change
-    if (!VALID_OPS.has(itemOp)) {
+    const model = lowerCaseFirst(itemModel)
+    const delegate = (client as any)[model]
+    if (!VALID_OPS.has(itemOp) || typeof delegate?.[itemOp] !== 'function') {
       return makeError(`operation at index ${i} has invalid op: ${itemOp}`)
     }
@@
     processedOps.push({
-      model: lowerCaseFirst(itemModel),
+      model,
       op: itemOp,
       args: processRequestPayload(itemArgs),
     })

Also applies to: Lines 268–269, 293

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server.ts` around lines 24 - 41, The current static VALID_OPS whitelist
can include operations not available on the runtime Prisma delegate; update the
request validation to ensure the requested op is both in VALID_OPS and actually
exists as a function on the resolved delegate before invoking it. In the handler
that resolves the delegate (the code that gets the model delegate, e.g.
prisma[modelName] or the variable named delegate) add a runtime check like
typeof delegate[op] === 'function' and if false return a 4xx validation error
(bad request) instead of proceeding; only call delegate[op](...) when that check
passes. This ensures ops like createManyAndReturn/updateManyAndReturn are
rejected when not present on the loaded delegate.


/**
* Resolve the absolute path to the Prisma schema directory
*/
Expand Down Expand Up @@ -203,6 +223,96 @@ async function loadZenStackModules(
return { PrismaClient, modelMeta, enums, zenstackVersion, enhanceFunc }
}

function makeError(message: string, status = 400) {
return { status, body: { error: message } }
}

function lowerCaseFirst(input: string) {
return input.charAt(0).toLowerCase() + input.slice(1)
}

function isValidModel(modelMeta: any, modelName: string): boolean {
return lowerCaseFirst(modelName) in modelMeta.models
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isValidModel uses the in operator with untrusted input. This can return true for prototype properties like __proto__/constructor, potentially allowing unexpected property access when later indexing tx[model]. Use an own-property check (e.g., Object.hasOwn(...) / hasOwnProperty.call) against modelMeta.models instead of in.

Suggested change
return lowerCaseFirst(modelName) in modelMeta.models
const name = lowerCaseFirst(modelName)
return Object.prototype.hasOwnProperty.call(modelMeta.models, name)

Copilot uses AI. Check for mistakes.
}

function processRequestPayload(args: any) {
if (args === null || args === undefined) {
return args
}
const { meta, ...rest } = args
if (meta?.serialization) {
// superjson deserialization
return SuperJSON.deserialize({ json: rest, meta: meta.serialization })
Comment on lines +243 to +245
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

Does Express 4 automatically convert rejected promises from async route handlers into error responses, or do they need to be caught and passed to next(err) manually?

💡 Result:

No, Express 4 does not automatically convert rejected promises from async route handlers into error responses. They need to be caught manually and passed to next(err). In Express 4, only synchronous errors in route handlers are automatically caught by Express. For asynchronous code, including rejected promises from async/await handlers, developers must explicitly handle errors using try-catch blocks or .catch(next) and call next(err) to propagate them to Express error handling middleware. Unhandled rejections will not trigger Express error handlers and may cause the process to emit unhandledRejection events or crash. This behavior changes in Express 5, where rejected promises from async route handlers automatically call next with the error. However, since the query specifies Express 4 and both 4.x and 5.x are actively maintained as of 2026, Express 4 requires manual error handling for async routes.

Citations:


🏁 Script executed:

# First, find the server.ts file
fd -n "server.ts" -t f

# Then read the relevant section

Repository: zenstackhq/proxy

Length of output: 290


🏁 Script executed:

# Find server.ts file
fd "server\.ts" src/

# Then read the relevant section with line numbers
cat -n src/server.ts | sed -n '240,295p'

Repository: zenstackhq/proxy

Length of output: 2431


🏁 Script executed:

# Check what processRequestPayload returns and if it can throw
cat -n src/server.ts | sed -n '235,249p'

# Search for other calls to processRequestPayload
rg "processRequestPayload" src/

Repository: zenstackhq/proxy

Length of output: 651


🏁 Script executed:

# Search for where handleTransaction is called
rg "handleTransaction" src/

# Also check if there's a route handler wrapping it
rg -A 5 -B 5 "handleTransaction" src/ | head -80

Repository: zenstackhq/proxy

Length of output: 1227


🏁 Script executed:

# Find the exact line where handleTransaction is called
rg -n "handleTransaction" src/server.ts

# Then read that section

Repository: zenstackhq/proxy

Length of output: 199


🏁 Script executed:

# Read the route handler containing the handleTransaction call
cat -n src/server.ts | sed -n '345,380p'

Repository: zenstackhq/proxy

Length of output: 1015


🏁 Script executed:

# Check SuperJSON imports and usage
rg "SuperJSON" src/

# Also check if there's error handling elsewhere in the codebase for async route handlers
rg -B 3 -A 3 "async.*req.*res.*=>" src/server.ts | head -100

Repository: zenstackhq/proxy

Length of output: 456


Wrap processRequestPayload in try-catch to prevent unhandled rejection in async route handler.

processRequestPayload(itemArgs) can throw on malformed meta.serialization at line 285, before the try block at line 289. Since handleTransaction is async and called without error handling in the route handler (line 351), the unhandled rejection will not be automatically converted to the JSON error response by Express 4.

Suggested change
-    processedOps.push({
-      model: lowerCaseFirst(itemModel),
-      op: itemOp,
-      args: processRequestPayload(itemArgs),
-    })
+    let processedArgs: unknown
+    try {
+      processedArgs = processRequestPayload(itemArgs)
+    } catch {
+      return makeError(`operation at index ${i} has invalid serialization metadata`)
+    }
+
+    processedOps.push({
+      model: lowerCaseFirst(itemModel),
+      op: itemOp,
+      args: processedArgs,
+    })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server.ts` around lines 243 - 245, processRequestPayload is invoked
before the surrounding try block and can throw, which causes unhandled
rejections when handleTransaction is called from the async route handler; wrap
the call to processRequestPayload(itemArgs) in a try-catch (or move it inside
the existing try) and on error forward the exception to Express (e.g., call
next(err) or rethrow so the outer try/catch handles it) so that malformed
meta.serialization doesn't produce an unhandled rejection; update the code
around processRequestPayload, handleTransaction, and the route handler to ensure
thrown errors are caught and propagated to Express's error handling.

} else {
return args
}
}

async function handleTransaction(modelMeta: any, client: any, requestBody: unknown) {
const processedOps: Array<{ model: string; op: string; args: unknown }> = []
if (!requestBody || !Array.isArray(requestBody) || requestBody.length === 0) {
return makeError('request body must be a non-empty array of operations')
}
for (let i = 0; i < requestBody.length; i++) {
const item = requestBody[i]
if (!item || typeof item !== 'object') {
return makeError(`operation at index ${i} must be an object`)
}
const { model: itemModel, op: itemOp, args: itemArgs } = item as any
if (!itemModel || typeof itemModel !== 'string') {
return makeError(`operation at index ${i} is missing a valid "model" field`)
}
if (!itemOp || typeof itemOp !== 'string') {
return makeError(`operation at index ${i} is missing a valid "op" field`)
}
if (!VALID_OPS.has(itemOp)) {
return makeError(`operation at index ${i} has invalid op: ${itemOp}`)
}
if (!isValidModel(modelMeta, itemModel)) {
return makeError(`operation at index ${i} has unknown model: ${itemModel}`)
}
if (
itemArgs !== undefined &&
itemArgs !== null &&
(typeof itemArgs !== 'object' || Array.isArray(itemArgs))
) {
return makeError(`operation at index ${i} has invalid "args" field`)
}

processedOps.push({
model: lowerCaseFirst(itemModel),
op: itemOp,
args: processRequestPayload(itemArgs),
Comment on lines +282 to +285
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

args is allowed to be null and is passed through to Prisma delegate calls. Prisma client methods generally expect args to be an object or undefined; passing null will typically throw at runtime. Consider rejecting null in validation or normalizing null to undefined before invoking the operation.

Suggested change
processedOps.push({
model: lowerCaseFirst(itemModel),
op: itemOp,
args: processRequestPayload(itemArgs),
const normalizedArgs = itemArgs === null ? undefined : processRequestPayload(itemArgs)
processedOps.push({
model: lowerCaseFirst(itemModel),
op: itemOp,
args: normalizedArgs,

Copilot uses AI. Check for mistakes.
})
}

try {
const clientResult = await client.$transaction(async (tx: any) => {
const result: any[] = []
for (const { model, op, args } of processedOps) {
result.push(await (tx as any)[model][op](args))
}
return result
})

const { json, meta } = SuperJSON.serialize(clientResult)
const responseBody: any = { data: json }
if (meta) {
responseBody.meta = { serialization: meta }
}

const response = { status: 200, body: responseBody }

return response
} catch (err) {
console.error('error occurred when handling "$transaction" request:', err)
return makeError(
'Transaction failed: ' + (err instanceof Error ? err.message : String(err)),
500
)
Comment on lines +307 to +312
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't return raw backend exception text in every 500 response.

This branch turns all execution failures into 500 and echoes err.message back to the caller. That leaks internal query/schema details and misclassifies request errors as server faults. Keep the detailed error in logs, but return a generic 500 body after mapping expected client-side failures to 4xx.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/server.ts` around lines 307 - 312, In the catch block that handles the
"$transaction" request, stop returning the raw backend error message and avoid
treating all failures as 500; instead log the full error via console.error (or
processLogger) including err and stack, map expected client-side errors to
appropriate 4xx responses (e.g., detect types like ValidationError,
BadRequestError or an err.status/statusCode property) and return makeError with
a generic "Transaction failed" message and a 500 status for true server errors;
keep references to the existing symbols (the catch(err) block around the
"$transaction" handler and the makeError call) and implement detection of known
client error classes or status codes to return proper 4xx codes while always
logging detailed err internally.

}
}

/**
* Start the Express server with ZenStack proxy
*/
Expand Down Expand Up @@ -237,6 +347,22 @@ export async function startServer(options: ServerOptions) {
app.use(express.urlencoded({ extended: true, limit: '5mb' }))

// ZenStack API endpoint

app.post('/api/model/\\$transaction/sequential', async (_req, res) => {
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The route is registered as '/api/model/\\$transaction/sequential', which evaluates to a path containing a literal backslash (/api/model/\$transaction/sequential). Requests to /api/model/$transaction/sequential won’t match. Use a literal $ in the route string instead.

Suggested change
app.post('/api/model/\\$transaction/sequential', async (_req, res) => {
app.post('/api/model/$transaction/sequential', async (_req, res) => {

Copilot uses AI. Check for mistakes.
const response = await handleTransaction(
modelMeta,
enhanceFunc(
prisma,
{},
{
kinds: Enhancements,
}
),
_req.body
)
Comment on lines +351 to +362
Copy link

Copilot AI Mar 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The request object is named _req but is actively used (_req.body). Since the underscore prefix typically indicates an intentionally unused parameter, rename it to req to avoid confusion.

Copilot uses AI. Check for mistakes.
res.status(response.status).json(response.body)
})

app.use(
'/api/model',
ZenStackMiddleware({
Expand Down
Loading