diff --git a/packages/plugins/trpc/tests/projects/t3-trpc-v10/package.json b/packages/plugins/trpc/tests/projects/t3-trpc-v10/package.json index 355df648c..179831693 100644 --- a/packages/plugins/trpc/tests/projects/t3-trpc-v10/package.json +++ b/packages/plugins/trpc/tests/projects/t3-trpc-v10/package.json @@ -36,7 +36,7 @@ "eslint": "^8.54.0", "eslint-config-next": "^14.0.4", "prisma": "^5.13.0", - "typescript": "^5.1.6" + "typescript": "^5.5.4" }, "ct3aMetadata": { "initVersion": "7.26.0" diff --git a/packages/plugins/trpc/tests/projects/t3-trpc-v10/src/pages/index.tsx b/packages/plugins/trpc/tests/projects/t3-trpc-v10/src/pages/index.tsx index 50a3d3e7d..5d563b369 100644 --- a/packages/plugins/trpc/tests/projects/t3-trpc-v10/src/pages/index.tsx +++ b/packages/plugins/trpc/tests/projects/t3-trpc-v10/src/pages/index.tsx @@ -4,19 +4,23 @@ import styles from './index.module.css'; export default function Home() { const hello = api.greet.hello.useQuery({ text: 'from tRPC' }); const posts = api.post.findMany.useQuery({ where: { published: true }, include: { author: true } }); - const postsTransformed = api.post.findMany.useQuery({}, { select: (data) => data.map((p) => ({ title: p.name })) }); + const postsTransformed = api.post.findMany.useQuery( + {}, + { select: (data) => data.map((p) => ({ id: p.id, title: p.name })) } + ); return ( <>
{hello.data &&

{hello.data.greeting}

} - {posts.data && - posts.data.map((post) => ( -

- {post.name} by {post.author.email} -

- ))} - {postsTransformed.data && postsTransformed.data.map((post) =>

{post.title}

)} + {posts.data?.map((post) => ( +

+ {post.name} by {post.author.email} +

+ ))} + {postsTransformed.data?.map((post) => ( +

{post.title}

+ ))}
); diff --git a/packages/runtime/package.json b/packages/runtime/package.json index 408991272..ec9e03ffb 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -25,9 +25,17 @@ "types": "./edge.d.ts", "default": "./edge.js" }, - "./enhancements": { - "types": "./enhancements/index.d.ts", - "default": "./enhancements/index.js" + "./enhancements/node": { + "types": "./enhancements/node/index.d.ts", + "default": "./enhancements/node/index.js" + }, + "./enhancements/edge": { + "types": "./enhancements/edge/index.d.ts", + "default": "./enhancements/edge/index.js" + }, + "./validation": { + "types": "./validation.d.ts", + "default": "./validation.js" }, "./constraint-solver": { "types": "./constraint-solver.d.ts", diff --git a/packages/runtime/res/enhance-edge.d.ts b/packages/runtime/res/enhance-edge.d.ts new file mode 100644 index 000000000..7f165c4d4 --- /dev/null +++ b/packages/runtime/res/enhance-edge.d.ts @@ -0,0 +1 @@ +export { auth, enhance, type PrismaClient } from '.zenstack/enhance-edge'; diff --git a/packages/runtime/res/enhance-edge.js b/packages/runtime/res/enhance-edge.js new file mode 100644 index 000000000..7c716a79a --- /dev/null +++ b/packages/runtime/res/enhance-edge.js @@ -0,0 +1,10 @@ +'use strict'; +Object.defineProperty(exports, '__esModule', { value: true }); + +try { + exports.enhance = require('.zenstack/enhance-edge').enhance; +} catch { + exports.enhance = function () { + throw new Error('Generated "enhance" function not found. Please run `zenstack generate` first.'); + }; +} diff --git a/packages/runtime/src/edge.ts b/packages/runtime/src/edge.ts deleted file mode 120000 index a2e78d748..000000000 --- a/packages/runtime/src/edge.ts +++ /dev/null @@ -1 +0,0 @@ -index.ts \ No newline at end of file diff --git a/packages/runtime/src/edge.ts b/packages/runtime/src/edge.ts new file mode 100644 index 000000000..cce09ec57 --- /dev/null +++ b/packages/runtime/src/edge.ts @@ -0,0 +1 @@ +export * from './enhance-edge'; diff --git a/packages/runtime/src/enhance-edge.d.ts b/packages/runtime/src/enhance-edge.d.ts new file mode 100644 index 000000000..2f6d6218e --- /dev/null +++ b/packages/runtime/src/enhance-edge.d.ts @@ -0,0 +1,2 @@ +// @ts-expect-error stub for re-exporting generated code +export { auth, enhance } from '.zenstack/enhance-edge'; diff --git a/packages/runtime/src/enhancements/edge/create-enhancement.ts b/packages/runtime/src/enhancements/edge/create-enhancement.ts new file mode 120000 index 000000000..e06076ec0 --- /dev/null +++ b/packages/runtime/src/enhancements/edge/create-enhancement.ts @@ -0,0 +1 @@ +../node/create-enhancement.ts \ No newline at end of file diff --git a/packages/runtime/src/enhancements/edge/default-auth.ts b/packages/runtime/src/enhancements/edge/default-auth.ts new file mode 120000 index 000000000..63249aec9 --- /dev/null +++ b/packages/runtime/src/enhancements/edge/default-auth.ts @@ -0,0 +1 @@ +../node/default-auth.ts \ No newline at end of file diff --git a/packages/runtime/src/enhancements/edge/delegate.ts b/packages/runtime/src/enhancements/edge/delegate.ts new file mode 120000 index 000000000..fc4213954 --- /dev/null +++ b/packages/runtime/src/enhancements/edge/delegate.ts @@ -0,0 +1 @@ +../node/delegate.ts \ No newline at end of file diff --git a/packages/runtime/src/enhancements/edge/index.ts b/packages/runtime/src/enhancements/edge/index.ts new file mode 120000 index 000000000..1baad1b89 --- /dev/null +++ b/packages/runtime/src/enhancements/edge/index.ts @@ -0,0 +1 @@ +../node/index.ts \ No newline at end of file diff --git a/packages/runtime/src/enhancements/edge/logger.ts b/packages/runtime/src/enhancements/edge/logger.ts new file mode 120000 index 000000000..b223d7301 --- /dev/null +++ b/packages/runtime/src/enhancements/edge/logger.ts @@ -0,0 +1 @@ +../node/logger.ts \ No newline at end of file diff --git a/packages/runtime/src/enhancements/edge/omit.ts b/packages/runtime/src/enhancements/edge/omit.ts new file mode 120000 index 000000000..eb506bb87 --- /dev/null +++ b/packages/runtime/src/enhancements/edge/omit.ts @@ -0,0 +1 @@ +../node/omit.ts \ No newline at end of file diff --git a/packages/runtime/src/enhancements/edge/password.ts b/packages/runtime/src/enhancements/edge/password.ts new file mode 120000 index 000000000..8d06b6e3c --- /dev/null +++ b/packages/runtime/src/enhancements/edge/password.ts @@ -0,0 +1 @@ +../node/password.ts \ No newline at end of file diff --git a/packages/runtime/src/enhancements/edge/policy/check-utils.ts b/packages/runtime/src/enhancements/edge/policy/check-utils.ts new file mode 100644 index 000000000..b3f27c4fb --- /dev/null +++ b/packages/runtime/src/enhancements/edge/policy/check-utils.ts @@ -0,0 +1,16 @@ +import { ModelMeta } from '..'; +import type { DbClientContract } from '../../../types'; +import { PermissionCheckArgs } from '../types'; +import { PolicyUtil } from './policy-utils'; + +export async function checkPermission( + _model: string, + _args: PermissionCheckArgs, + _modelMeta: ModelMeta, + _policyUtils: PolicyUtil, + _prisma: DbClientContract, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + _prismaModule: any +): Promise { + throw new Error('`check()` API is not supported on edge runtime'); +} diff --git a/packages/runtime/src/enhancements/edge/policy/handler.ts b/packages/runtime/src/enhancements/edge/policy/handler.ts new file mode 120000 index 000000000..1439205ac --- /dev/null +++ b/packages/runtime/src/enhancements/edge/policy/handler.ts @@ -0,0 +1 @@ +../../node/policy/handler.ts \ No newline at end of file diff --git a/packages/runtime/src/enhancements/edge/policy/index.ts b/packages/runtime/src/enhancements/edge/policy/index.ts new file mode 120000 index 000000000..3d4a1fa9c --- /dev/null +++ b/packages/runtime/src/enhancements/edge/policy/index.ts @@ -0,0 +1 @@ +../../node/policy/index.ts \ No newline at end of file diff --git a/packages/runtime/src/enhancements/edge/policy/policy-utils.ts b/packages/runtime/src/enhancements/edge/policy/policy-utils.ts new file mode 120000 index 000000000..7dadd4769 --- /dev/null +++ b/packages/runtime/src/enhancements/edge/policy/policy-utils.ts @@ -0,0 +1 @@ +../../node/policy/policy-utils.ts \ No newline at end of file diff --git a/packages/runtime/src/enhancements/edge/promise.ts b/packages/runtime/src/enhancements/edge/promise.ts new file mode 120000 index 000000000..a00819e60 --- /dev/null +++ b/packages/runtime/src/enhancements/edge/promise.ts @@ -0,0 +1 @@ +../node/promise.ts \ No newline at end of file diff --git a/packages/runtime/src/enhancements/edge/proxy.ts b/packages/runtime/src/enhancements/edge/proxy.ts new file mode 120000 index 000000000..de440c0e0 --- /dev/null +++ b/packages/runtime/src/enhancements/edge/proxy.ts @@ -0,0 +1 @@ +../node/proxy.ts \ No newline at end of file diff --git a/packages/runtime/src/enhancements/edge/query-utils.ts b/packages/runtime/src/enhancements/edge/query-utils.ts new file mode 120000 index 000000000..4a42067b1 --- /dev/null +++ b/packages/runtime/src/enhancements/edge/query-utils.ts @@ -0,0 +1 @@ +../node/query-utils.ts \ No newline at end of file diff --git a/packages/runtime/src/enhancements/edge/types.ts b/packages/runtime/src/enhancements/edge/types.ts new file mode 120000 index 000000000..3faa30754 --- /dev/null +++ b/packages/runtime/src/enhancements/edge/types.ts @@ -0,0 +1 @@ +../node/types.ts \ No newline at end of file diff --git a/packages/runtime/src/enhancements/edge/utils.ts b/packages/runtime/src/enhancements/edge/utils.ts new file mode 120000 index 000000000..c6f4d9b46 --- /dev/null +++ b/packages/runtime/src/enhancements/edge/utils.ts @@ -0,0 +1 @@ +../node/utils.ts \ No newline at end of file diff --git a/packages/runtime/src/enhancements/edge/where-visitor.ts b/packages/runtime/src/enhancements/edge/where-visitor.ts new file mode 120000 index 000000000..7b6cb94d8 --- /dev/null +++ b/packages/runtime/src/enhancements/edge/where-visitor.ts @@ -0,0 +1 @@ +../node/where-visitor.ts \ No newline at end of file diff --git a/packages/runtime/src/enhancements/create-enhancement.ts b/packages/runtime/src/enhancements/node/create-enhancement.ts similarity index 68% rename from packages/runtime/src/enhancements/create-enhancement.ts rename to packages/runtime/src/enhancements/node/create-enhancement.ts index 053580b8d..127574e26 100644 --- a/packages/runtime/src/enhancements/create-enhancement.ts +++ b/packages/runtime/src/enhancements/node/create-enhancement.ts @@ -1,68 +1,20 @@ import semver from 'semver'; -import { PRISMA_MINIMUM_VERSION } from '../constants'; -import { isDelegateModel, type ModelMeta } from '../cross'; -import type { AuthUser } from '../types'; +import { PRISMA_MINIMUM_VERSION } from '../../constants'; +import { isDelegateModel, type ModelMeta } from '../../cross'; +import type { EnhancementContext, EnhancementKind, EnhancementOptions, ZodSchemas } from '../../types'; import { withDefaultAuth } from './default-auth'; import { withDelegate } from './delegate'; import { Logger } from './logger'; import { withOmit } from './omit'; import { withPassword } from './password'; import { withPolicy } from './policy'; -import type { ErrorTransformer } from './proxy'; -import type { PolicyDef, ZodSchemas } from './types'; - -/** - * Kinds of enhancements to `PrismaClient` - */ -export type EnhancementKind = 'password' | 'omit' | 'policy' | 'validation' | 'delegate'; +import type { PolicyDef } from './types'; /** * All enhancement kinds */ const ALL_ENHANCEMENTS: EnhancementKind[] = ['password', 'omit', 'policy', 'validation', 'delegate']; -/** - * Transaction isolation levels: https://www.prisma.io/docs/orm/prisma-client/queries/transactions#transaction-isolation-level - */ -export type TransactionIsolationLevel = - | 'ReadUncommitted' - | 'ReadCommitted' - | 'RepeatableRead' - | 'Snapshot' - | 'Serializable'; - -export type EnhancementOptions = { - /** - * The kinds of enhancements to apply. By default all enhancements are applied. - */ - kinds?: EnhancementKind[]; - - /** - * Whether to log Prisma query - */ - logPrismaQuery?: boolean; - - /** - * Hook for transforming errors before they are thrown to the caller. - */ - errorTransformer?: ErrorTransformer; - - /** - * The `maxWait` option passed to `prisma.$transaction()` call for transactions initiated by ZenStack. - */ - transactionMaxWait?: number; - - /** - * The `timeout` option passed to `prisma.$transaction()` call for transactions initiated by ZenStack. - */ - transactionTimeout?: number; - - /** - * The `isolationLevel` option passed to `prisma.$transaction()` call for transactions initiated by ZenStack. - */ - transactionIsolationLevel?: TransactionIsolationLevel; -}; - /** * Options for {@link createEnhancement} * @@ -91,13 +43,6 @@ export type InternalEnhancementOptions = EnhancementOptions & { prismaModule: any; }; -/** - * Context for creating enhanced `PrismaClient` - */ -export type EnhancementContext = { - user?: User; -}; - /** * Gets a Prisma client enhanced with all enhancement behaviors, including access * policy, field validation, field omission and password hashing. diff --git a/packages/runtime/src/enhancements/default-auth.ts b/packages/runtime/src/enhancements/node/default-auth.ts similarity index 97% rename from packages/runtime/src/enhancements/default-auth.ts rename to packages/runtime/src/enhancements/node/default-auth.ts index 1408a93b7..10f4f3504 100644 --- a/packages/runtime/src/enhancements/default-auth.ts +++ b/packages/runtime/src/enhancements/node/default-auth.ts @@ -9,9 +9,9 @@ import { enumerate, getFields, requireField, -} from '../cross'; -import { DbClientContract } from '../types'; -import { EnhancementContext, InternalEnhancementOptions } from './create-enhancement'; +} from '../../cross'; +import { DbClientContract, EnhancementContext } from '../../types'; +import { InternalEnhancementOptions } from './create-enhancement'; import { DefaultPrismaProxyHandler, PrismaProxyActions, makeProxy } from './proxy'; import { isUnsafeMutate, prismaClientValidationError } from './utils'; diff --git a/packages/runtime/src/enhancements/delegate.ts b/packages/runtime/src/enhancements/node/delegate.ts similarity index 99% rename from packages/runtime/src/enhancements/delegate.ts rename to packages/runtime/src/enhancements/node/delegate.ts index f7398eeb1..8efad7568 100644 --- a/packages/runtime/src/enhancements/delegate.ts +++ b/packages/runtime/src/enhancements/node/delegate.ts @@ -2,7 +2,7 @@ import deepmerge, { type ArrayMergeOptions } from 'deepmerge'; import { isPlainObject } from 'is-plain-object'; import { lowerCaseFirst } from 'lower-case-first'; -import { DELEGATE_AUX_RELATION_PREFIX } from '../constants'; +import { DELEGATE_AUX_RELATION_PREFIX } from '../../constants'; import { FieldInfo, ModelInfo, @@ -13,8 +13,8 @@ import { getModelInfo, isDelegateModel, resolveField, -} from '../cross'; -import type { CrudContract, DbClientContract } from '../types'; +} from '../../cross'; +import type { CrudContract, DbClientContract } from '../../types'; import type { InternalEnhancementOptions } from './create-enhancement'; import { Logger } from './logger'; import { DefaultPrismaProxyHandler, makeProxy } from './proxy'; diff --git a/packages/runtime/src/enhancements/index.ts b/packages/runtime/src/enhancements/node/index.ts similarity index 75% rename from packages/runtime/src/enhancements/index.ts rename to packages/runtime/src/enhancements/node/index.ts index 3ddeddac0..a6f0e9446 100644 --- a/packages/runtime/src/enhancements/index.ts +++ b/packages/runtime/src/enhancements/node/index.ts @@ -1,4 +1,4 @@ -export * from '../cross'; +export * from '../../cross'; export * from './create-enhancement'; export * from './types'; export * from './utils'; diff --git a/packages/runtime/src/enhancements/logger.ts b/packages/runtime/src/enhancements/node/logger.ts similarity index 100% rename from packages/runtime/src/enhancements/logger.ts rename to packages/runtime/src/enhancements/node/logger.ts diff --git a/packages/runtime/src/enhancements/omit.ts b/packages/runtime/src/enhancements/node/omit.ts similarity index 96% rename from packages/runtime/src/enhancements/omit.ts rename to packages/runtime/src/enhancements/node/omit.ts index 52a4f2742..18c81cc18 100644 --- a/packages/runtime/src/enhancements/omit.ts +++ b/packages/runtime/src/enhancements/node/omit.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { enumerate, getModelFields, resolveField } from '../cross'; -import { DbClientContract } from '../types'; +import { enumerate, getModelFields, resolveField } from '../../cross'; +import { DbClientContract } from '../../types'; import { InternalEnhancementOptions } from './create-enhancement'; import { DefaultPrismaProxyHandler, makeProxy } from './proxy'; diff --git a/packages/runtime/src/enhancements/password.ts b/packages/runtime/src/enhancements/node/password.ts similarity index 95% rename from packages/runtime/src/enhancements/password.ts rename to packages/runtime/src/enhancements/node/password.ts index 297613e1b..8c1aeb959 100644 --- a/packages/runtime/src/enhancements/password.ts +++ b/packages/runtime/src/enhancements/node/password.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-unused-vars */ -import { DEFAULT_PASSWORD_SALT_LENGTH } from '../constants'; -import { NestedWriteVisitor, type PrismaWriteActionType } from '../cross'; -import { DbClientContract } from '../types'; +import { DEFAULT_PASSWORD_SALT_LENGTH } from '../../constants'; +import { NestedWriteVisitor, type PrismaWriteActionType } from '../../cross'; +import { DbClientContract } from '../../types'; import { InternalEnhancementOptions } from './create-enhancement'; import { DefaultPrismaProxyHandler, PrismaProxyActions, makeProxy } from './proxy'; diff --git a/packages/runtime/src/enhancements/node/policy/check-utils.ts b/packages/runtime/src/enhancements/node/policy/check-utils.ts new file mode 100644 index 000000000..bb8a2c88e --- /dev/null +++ b/packages/runtime/src/enhancements/node/policy/check-utils.ts @@ -0,0 +1,124 @@ +import { match, P } from 'ts-pattern'; +import { ModelMeta, requireField } from '..'; +import type { DbClientContract } from '../../../types'; +import { createDeferredPromise } from '../promise'; +import { PermissionCheckArgs, PermissionCheckerConstraint } from '../types'; +import { prismaClientValidationError } from '../utils'; +import { ConstraintSolver } from './constraint-solver'; +import { PolicyUtil } from './policy-utils'; + +export async function checkPermission( + model: string, + args: PermissionCheckArgs, + modelMeta: ModelMeta, + policyUtils: PolicyUtil, + prisma: DbClientContract, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaModule: any +) { + return createDeferredPromise(() => doCheckPermission(model, args, modelMeta, policyUtils, prisma, prismaModule)); +} + +async function doCheckPermission( + model: string, + args: PermissionCheckArgs, + modelMeta: ModelMeta, + policyUtils: PolicyUtil, + prisma: DbClientContract, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + prismaModule: any +) { + if (!['create', 'read', 'update', 'delete'].includes(args.operation)) { + throw prismaClientValidationError(prisma, prismaModule, `Invalid "operation" ${args.operation}`); + } + + let constraint = policyUtils.getCheckerConstraint(model, args.operation); + if (typeof constraint === 'boolean') { + return constraint; + } + + if (args.where) { + // combine runtime filters with generated constraints + + const extraConstraints: PermissionCheckerConstraint[] = []; + for (const [field, value] of Object.entries(args.where)) { + if (value === undefined) { + continue; + } + + if (value === null) { + throw prismaClientValidationError( + prisma, + prismaModule, + `Using "null" as filter value is not supported yet` + ); + } + + const fieldInfo = requireField(modelMeta, model, field); + + // relation and array fields are not supported + if (fieldInfo.isDataModel || fieldInfo.isArray) { + throw prismaClientValidationError( + prisma, + prismaModule, + `Providing filter for field "${field}" is not supported. Only scalar fields are allowed.` + ); + } + + // map field type to constraint type + const fieldType = match(fieldInfo.type) + .with(P.union('Int', 'BigInt', 'Float', 'Decimal'), () => 'number') + .with('String', () => 'string') + .with('Boolean', () => 'boolean') + .otherwise(() => { + throw prismaClientValidationError( + prisma, + prismaModule, + `Providing filter for field "${field}" is not supported. Only number, string, and boolean fields are allowed.` + ); + }); + + // check value type + const valueType = typeof value; + if (valueType !== 'number' && valueType !== 'string' && valueType !== 'boolean') { + throw prismaClientValidationError( + prisma, + prismaModule, + `Invalid value type for field "${field}". Only number, string or boolean is allowed.` + ); + } + + if (fieldType !== valueType) { + throw prismaClientValidationError( + prisma, + prismaModule, + `Invalid value type for field "${field}". Expected "${fieldType}".` + ); + } + + // check number validity + if (typeof value === 'number' && (!Number.isInteger(value) || value < 0)) { + throw prismaClientValidationError( + prisma, + prismaModule, + `Invalid value for field "${field}". Only non-negative integers are allowed.` + ); + } + + // build a constraint + extraConstraints.push({ + kind: 'eq', + left: { kind: 'variable', name: field, type: fieldType }, + right: { kind: 'value', value, type: fieldType }, + }); + } + + if (extraConstraints.length > 0) { + // combine the constraints + constraint = { kind: 'and', children: [constraint, ...extraConstraints] }; + } + } + + // check satisfiability + return new ConstraintSolver().checkSat(constraint); +} diff --git a/packages/runtime/src/enhancements/policy/constraint-solver.ts b/packages/runtime/src/enhancements/node/policy/constraint-solver.ts similarity index 100% rename from packages/runtime/src/enhancements/policy/constraint-solver.ts rename to packages/runtime/src/enhancements/node/policy/constraint-solver.ts diff --git a/packages/runtime/src/enhancements/policy/handler.ts b/packages/runtime/src/enhancements/node/policy/handler.ts similarity index 93% rename from packages/runtime/src/enhancements/policy/handler.ts rename to packages/runtime/src/enhancements/node/policy/handler.ts index 0a0cad8f8..999a7c9d9 100644 --- a/packages/runtime/src/enhancements/policy/handler.ts +++ b/packages/runtime/src/enhancements/node/policy/handler.ts @@ -3,10 +3,9 @@ import deepmerge from 'deepmerge'; import { lowerCaseFirst } from 'lower-case-first'; import invariant from 'tiny-invariant'; -import { P, match } from 'ts-pattern'; import { upperCaseFirst } from 'upper-case-first'; import { fromZodError } from 'zod-validation-error'; -import { CrudFailureReason } from '../../constants'; +import { CrudFailureReason } from '../../../constants'; import { ModelDataVisitor, NestedWriteVisitor, @@ -17,16 +16,16 @@ import { resolveField, type FieldInfo, type ModelMeta, -} from '../../cross'; -import { PolicyCrudKind, PolicyOperationKind, type CrudContract, type DbClientContract } from '../../types'; -import type { EnhancementContext, InternalEnhancementOptions } from '../create-enhancement'; +} from '../../../cross'; +import { EnhancementContext, PolicyOperationKind, type CrudContract, type DbClientContract } from '../../../types'; +import type { InternalEnhancementOptions } from '../create-enhancement'; import { Logger } from '../logger'; import { createDeferredPromise, createFluentPromise } from '../promise'; import { PrismaProxyHandler } from '../proxy'; import { QueryUtils } from '../query-utils'; -import type { EntityCheckerFunc, PermissionCheckerConstraint } from '../types'; +import type { EntityCheckerFunc, PermissionCheckArgs } from '../types'; import { formatObject, isUnsafeMutate, prismaClientValidationError } from '../utils'; -import { ConstraintSolver } from './constraint-solver'; +import { checkPermission } from './check-utils'; import { PolicyUtil } from './policy-utils'; // a record for post-write policy check @@ -39,12 +38,6 @@ type PostWriteCheckRecord = { type FindOperations = 'findUnique' | 'findUniqueOrThrow' | 'findFirst' | 'findFirstOrThrow' | 'findMany'; -// input arg type for `check` API -type PermissionCheckArgs = { - operation: PolicyCrudKind; - where?: Record; -}; - /** * Prisma proxy handler for injecting access policy check. */ @@ -1645,103 +1638,7 @@ export class PolicyProxyHandler implements Pr * @param fieldValues Extra field value filters to be combined with the policy constraints. */ async check(args: PermissionCheckArgs): Promise { - return createDeferredPromise(() => this.doCheck(args)); - } - - private async doCheck(args: PermissionCheckArgs) { - if (!['create', 'read', 'update', 'delete'].includes(args.operation)) { - throw prismaClientValidationError(this.prisma, this.prismaModule, `Invalid "operation" ${args.operation}`); - } - - let constraint = this.policyUtils.getCheckerConstraint(this.model, args.operation); - if (typeof constraint === 'boolean') { - return constraint; - } - - if (args.where) { - // combine runtime filters with generated constraints - - const extraConstraints: PermissionCheckerConstraint[] = []; - for (const [field, value] of Object.entries(args.where)) { - if (value === undefined) { - continue; - } - - if (value === null) { - throw prismaClientValidationError( - this.prisma, - this.prismaModule, - `Using "null" as filter value is not supported yet` - ); - } - - const fieldInfo = requireField(this.modelMeta, this.model, field); - - // relation and array fields are not supported - if (fieldInfo.isDataModel || fieldInfo.isArray) { - throw prismaClientValidationError( - this.prisma, - this.prismaModule, - `Providing filter for field "${field}" is not supported. Only scalar fields are allowed.` - ); - } - - // map field type to constraint type - const fieldType = match(fieldInfo.type) - .with(P.union('Int', 'BigInt', 'Float', 'Decimal'), () => 'number') - .with('String', () => 'string') - .with('Boolean', () => 'boolean') - .otherwise(() => { - throw prismaClientValidationError( - this.prisma, - this.prismaModule, - `Providing filter for field "${field}" is not supported. Only number, string, and boolean fields are allowed.` - ); - }); - - // check value type - const valueType = typeof value; - if (valueType !== 'number' && valueType !== 'string' && valueType !== 'boolean') { - throw prismaClientValidationError( - this.prisma, - this.prismaModule, - `Invalid value type for field "${field}". Only number, string or boolean is allowed.` - ); - } - - if (fieldType !== valueType) { - throw prismaClientValidationError( - this.prisma, - this.prismaModule, - `Invalid value type for field "${field}". Expected "${fieldType}".` - ); - } - - // check number validity - if (typeof value === 'number' && (!Number.isInteger(value) || value < 0)) { - throw prismaClientValidationError( - this.prisma, - this.prismaModule, - `Invalid value for field "${field}". Only non-negative integers are allowed.` - ); - } - - // build a constraint - extraConstraints.push({ - kind: 'eq', - left: { kind: 'variable', name: field, type: fieldType }, - right: { kind: 'value', value, type: fieldType }, - }); - } - - if (extraConstraints.length > 0) { - // combine the constraints - constraint = { kind: 'and', children: [constraint, ...extraConstraints] }; - } - } - - // check satisfiability - return new ConstraintSolver().checkSat(constraint); + return checkPermission(this.model, args, this.modelMeta, this.policyUtils, this.prisma, this.prismaModule); } //#endregion diff --git a/packages/runtime/src/enhancements/policy/index.ts b/packages/runtime/src/enhancements/node/policy/index.ts similarity index 88% rename from packages/runtime/src/enhancements/policy/index.ts rename to packages/runtime/src/enhancements/node/policy/index.ts index c76812a51..66834a802 100644 --- a/packages/runtime/src/enhancements/policy/index.ts +++ b/packages/runtime/src/enhancements/node/policy/index.ts @@ -1,9 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { getIdFields } from '../../cross'; -import { DbClientContract } from '../../types'; -import { hasAllFields } from '../../validation'; -import type { EnhancementContext, InternalEnhancementOptions } from '../create-enhancement'; +import { getIdFields } from '../../../cross'; +import { DbClientContract, EnhancementContext } from '../../../types'; +import { hasAllFields } from '../../../validation'; +import type { InternalEnhancementOptions } from '../create-enhancement'; import { Logger } from '../logger'; import { makeProxy } from '../proxy'; import { PolicyProxyHandler } from './handler'; diff --git a/packages/runtime/src/enhancements/policy/logic-solver.d.ts b/packages/runtime/src/enhancements/node/policy/logic-solver.d.ts similarity index 100% rename from packages/runtime/src/enhancements/policy/logic-solver.d.ts rename to packages/runtime/src/enhancements/node/policy/logic-solver.d.ts diff --git a/packages/runtime/src/enhancements/policy/policy-utils.ts b/packages/runtime/src/enhancements/node/policy/policy-utils.ts similarity index 99% rename from packages/runtime/src/enhancements/policy/policy-utils.ts rename to packages/runtime/src/enhancements/node/policy/policy-utils.ts index b676d84ba..ab4ed8fc2 100644 --- a/packages/runtime/src/enhancements/policy/policy-utils.ts +++ b/packages/runtime/src/enhancements/node/policy/policy-utils.ts @@ -1,11 +1,12 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import deepmerge from 'deepmerge'; +import { isPlainObject } from 'is-plain-object'; import { lowerCaseFirst } from 'lower-case-first'; import { upperCaseFirst } from 'upper-case-first'; import { z, type ZodError, type ZodObject, type ZodSchema } from 'zod'; import { fromZodError } from 'zod-validation-error'; -import { CrudFailureReason, PrismaErrorCode } from '../../constants'; +import { CrudFailureReason, PrismaErrorCode } from '../../../constants'; import { clone, enumerate, @@ -15,22 +16,23 @@ import { zip, type FieldInfo, type ModelMeta, -} from '../../cross'; +} from '../../../cross'; import { AuthUser, CrudContract, DbClientContract, + EnhancementContext, PolicyCrudKind, PolicyOperationKind, QueryContext, -} from '../../types'; -import { getVersion } from '../../version'; -import type { EnhancementContext, InternalEnhancementOptions } from '../create-enhancement'; + ZodSchemas, +} from '../../../types'; +import { getVersion } from '../../../version'; +import type { InternalEnhancementOptions } from '../create-enhancement'; import { Logger } from '../logger'; import { QueryUtils } from '../query-utils'; -import type { EntityChecker, ModelPolicyDef, PermissionCheckerFunc, PolicyDef, PolicyFunc, ZodSchemas } from '../types'; +import type { EntityChecker, ModelPolicyDef, PermissionCheckerFunc, PolicyDef, PolicyFunc } from '../types'; import { formatObject, prismaClientKnownRequestError } from '../utils'; -import { isPlainObject } from 'is-plain-object'; /** * Access policy enforcement utilities diff --git a/packages/runtime/src/enhancements/promise.ts b/packages/runtime/src/enhancements/node/promise.ts similarity index 98% rename from packages/runtime/src/enhancements/promise.ts rename to packages/runtime/src/enhancements/node/promise.ts index 28a211146..471fcb642 100644 --- a/packages/runtime/src/enhancements/promise.ts +++ b/packages/runtime/src/enhancements/node/promise.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { getModelInfo, type ModelMeta } from '../cross'; +import { getModelInfo, type ModelMeta } from '../../cross'; /** * Creates a promise that only executes when it's awaited or .then() is called. diff --git a/packages/runtime/src/enhancements/proxy.ts b/packages/runtime/src/enhancements/node/proxy.ts similarity index 97% rename from packages/runtime/src/enhancements/proxy.ts rename to packages/runtime/src/enhancements/node/proxy.ts index f1b1ded5f..3802e2390 100644 --- a/packages/runtime/src/enhancements/proxy.ts +++ b/packages/runtime/src/enhancements/node/proxy.ts @@ -1,9 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { PRISMA_PROXY_ENHANCER } from '../constants'; -import type { ModelMeta } from '../cross'; -import { clone } from '../cross'; -import type { DbClientContract } from '../types'; +import { PRISMA_PROXY_ENHANCER } from '../../constants'; +import { type ModelMeta, clone } from '../../cross'; +import type { DbClientContract, ErrorTransformer } from '../../types'; import type { InternalEnhancementOptions } from './create-enhancement'; import { createDeferredPromise, createFluentPromise } from './promise'; @@ -12,11 +11,6 @@ import { createDeferredPromise, createFluentPromise } from './promise'; */ export type BatchResult = { count: number }; -/** - * Function for transforming errors. - */ -export type ErrorTransformer = (error: unknown) => unknown; - /** * Interface for proxy that intercepts Prisma operations. */ diff --git a/packages/runtime/src/enhancements/query-utils.ts b/packages/runtime/src/enhancements/node/query-utils.ts similarity index 98% rename from packages/runtime/src/enhancements/query-utils.ts rename to packages/runtime/src/enhancements/node/query-utils.ts index e7864ceac..5d23c6d99 100644 --- a/packages/runtime/src/enhancements/query-utils.ts +++ b/packages/runtime/src/enhancements/node/query-utils.ts @@ -1,15 +1,15 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { + clone, getIdFields, getModelInfo, getUniqueConstraints, resolveField, type FieldInfo, type NestedWriteVisitorContext, -} from '../cross'; -import { clone } from '../cross'; -import type { CrudContract, DbClientContract } from '../types'; -import { getVersion } from '../version'; +} from '../../cross'; +import type { CrudContract, DbClientContract } from '../../types'; +import { getVersion } from '../../version'; import { InternalEnhancementOptions } from './create-enhancement'; import { prismaClientUnknownRequestError, prismaClientValidationError } from './utils'; diff --git a/packages/runtime/src/enhancements/types.ts b/packages/runtime/src/enhancements/node/types.ts similarity index 93% rename from packages/runtime/src/enhancements/types.ts rename to packages/runtime/src/enhancements/node/types.ts index a5cd85314..37a304b99 100644 --- a/packages/runtime/src/enhancements/types.ts +++ b/packages/runtime/src/enhancements/node/types.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { z } from 'zod'; -import type { CrudContract, PermissionCheckerContext, QueryContext } from '../types'; +import type { CrudContract, PermissionCheckerContext, PolicyCrudKind, QueryContext } from '../../types'; /** * Common options for PrismaClient enhancements @@ -258,16 +257,9 @@ type FieldUpdateDef = { }; /** - * Zod schemas for validation + * Permission check API (`check()`) arguments */ -export type ZodSchemas = { - /** - * Zod schema for each model - */ - models: Record; - - /** - * Zod schema for Prisma input types for each model - */ - input?: Record>; +export type PermissionCheckArgs = { + operation: PolicyCrudKind; + where?: Record; }; diff --git a/packages/runtime/src/enhancements/utils.ts b/packages/runtime/src/enhancements/node/utils.ts similarity index 96% rename from packages/runtime/src/enhancements/utils.ts rename to packages/runtime/src/enhancements/node/utils.ts index 50b53b996..347447618 100644 --- a/packages/runtime/src/enhancements/utils.ts +++ b/packages/runtime/src/enhancements/node/utils.ts @@ -1,6 +1,6 @@ import safeJsonStringify from 'safe-json-stringify'; -import { resolveField, type FieldInfo, type ModelMeta } from '..'; -import type { DbClientContract } from '../types'; +import { resolveField, type FieldInfo, type ModelMeta } from '../../cross'; +import type { DbClientContract } from '../../types'; /** * Formats an object for pretty printing. diff --git a/packages/runtime/src/enhancements/where-visitor.ts b/packages/runtime/src/enhancements/node/where-visitor.ts similarity index 99% rename from packages/runtime/src/enhancements/where-visitor.ts rename to packages/runtime/src/enhancements/node/where-visitor.ts index 17a7ec3bc..71b303985 100644 --- a/packages/runtime/src/enhancements/where-visitor.ts +++ b/packages/runtime/src/enhancements/node/where-visitor.ts @@ -1,7 +1,7 @@ /* eslint-disable @typescript-eslint/explicit-module-boundary-types */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { enumerate, resolveField, type FieldInfo, type ModelMeta } from '../cross'; +import { enumerate, resolveField, type FieldInfo, type ModelMeta } from '../../cross'; /** * Context for visiting diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 6a2609156..a735da875 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,7 +1,7 @@ export * from './constants'; -export * from './enhancements'; +export * from './cross'; +export * from './enhance'; export * from './error'; export * from './types'; export * from './validation'; export * from './version'; -export * from './enhance'; diff --git a/packages/runtime/src/types.ts b/packages/runtime/src/types.ts index 3a1c43e78..7c4df97c1 100644 --- a/packages/runtime/src/types.ts +++ b/packages/runtime/src/types.ts @@ -1,5 +1,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import type { z } from 'zod'; + export type PrismaPromise = Promise & Record PrismaPromise>; /** @@ -87,3 +89,80 @@ export type CrudContract = Record; export type DbClientContract = CrudContract & { $transaction: (action: (tx: CrudContract) => Promise, options?: unknown) => Promise; }; + +/** + * Transaction isolation levels: https://www.prisma.io/docs/orm/prisma-client/queries/transactions#transaction-isolation-level + */ +export type TransactionIsolationLevel = + | 'ReadUncommitted' + | 'ReadCommitted' + | 'RepeatableRead' + | 'Snapshot' + | 'Serializable'; + +/** + * Options for enhancing a PrismaClient. + */ +export type EnhancementOptions = { + /** + * The kinds of enhancements to apply. By default all enhancements are applied. + */ + kinds?: EnhancementKind[]; + + /** + * Whether to log Prisma query + */ + logPrismaQuery?: boolean; + + /** + * Hook for transforming errors before they are thrown to the caller. + */ + errorTransformer?: ErrorTransformer; + + /** + * The `maxWait` option passed to `prisma.$transaction()` call for transactions initiated by ZenStack. + */ + transactionMaxWait?: number; + + /** + * The `timeout` option passed to `prisma.$transaction()` call for transactions initiated by ZenStack. + */ + transactionTimeout?: number; + + /** + * The `isolationLevel` option passed to `prisma.$transaction()` call for transactions initiated by ZenStack. + */ + transactionIsolationLevel?: TransactionIsolationLevel; +}; + +/** + * Context for creating enhanced `PrismaClient` + */ +export type EnhancementContext = { + user?: User; +}; + +/** + * Kinds of enhancements to `PrismaClient` + */ +export type EnhancementKind = 'password' | 'omit' | 'policy' | 'validation' | 'delegate'; + +/** + * Function for transforming errors. + */ +export type ErrorTransformer = (error: unknown) => unknown; + +/** + * Zod schemas for validation + */ +export type ZodSchemas = { + /** + * Zod schema for each model + */ + models: Record; + + /** + * Zod schema for Prisma input types for each model + */ + input?: Record>; +}; diff --git a/packages/schema/src/plugins/enhancer/enhance/index.ts b/packages/schema/src/plugins/enhancer/enhance/index.ts index 64d4ef553..8d411eb19 100644 --- a/packages/schema/src/plugins/enhancer/enhance/index.ts +++ b/packages/schema/src/plugins/enhancer/enhance/index.ts @@ -94,11 +94,14 @@ export class EnhancerGenerator { const checkerTypes = this.generatePermissionChecker ? generateCheckerType(this.model) : ''; - const enhanceTs = this.project.createSourceFile( - path.join(this.outDir, 'enhance.ts'), - `/* eslint-disable */ + for (const target of ['node', 'edge']) { + // generate separate `enhance()` for node and edge runtime + const outFile = target === 'node' ? 'enhance.ts' : 'enhance-edge.ts'; + const enhanceTs = this.project.createSourceFile( + path.join(this.outDir, outFile), + `/* eslint-disable */ import { type EnhancementContext, type EnhancementOptions, type ZodSchemas, type AuthUser } from '@zenstackhq/runtime'; -import { createEnhancement } from '@zenstackhq/runtime/enhancements'; +import { createEnhancement } from '@zenstackhq/runtime/enhancements/${target}'; import modelMeta from './model-meta'; import policy from './policy'; ${ @@ -123,10 +126,11 @@ ${ : this.createSimplePrismaEnhanceFunction(authTypeParam) } `, - { overwrite: true } - ); + { overwrite: true } + ); - await this.saveSourceFile(enhanceTs); + await this.saveSourceFile(enhanceTs); + } return { dmmf }; } diff --git a/packages/schema/src/plugins/enhancer/policy/policy-guard-generator.ts b/packages/schema/src/plugins/enhancer/policy/policy-guard-generator.ts index 2c54949f4..aa54c80d8 100644 --- a/packages/schema/src/plugins/enhancer/policy/policy-guard-generator.ts +++ b/packages/schema/src/plugins/enhancer/policy/policy-guard-generator.ts @@ -95,14 +95,21 @@ export class PolicyGenerator { namedImports: [ { name: 'type QueryContext' }, { name: 'type CrudContract' }, - { name: 'allFieldsEqual' }, - { name: 'type PolicyDef' }, { name: 'type PermissionCheckerContext' }, - { name: 'type PermissionCheckerConstraint' }, ], moduleSpecifier: `${RUNTIME_PACKAGE}`, }); + sf.addImportDeclaration({ + namedImports: [{ name: 'allFieldsEqual' }], + moduleSpecifier: `${RUNTIME_PACKAGE}/validation`, + }); + + sf.addImportDeclaration({ + namedImports: [{ name: 'type PolicyDef' }, { name: 'type PermissionCheckerConstraint' }], + moduleSpecifier: `${RUNTIME_PACKAGE}/enhancements/node`, + }); + // import enums const prismaImport = getPrismaClientImportSpec(output, this.options); for (const e of model.declarations.filter((d) => isEnum(d) && isEnumReferenced(model, d))) { diff --git a/packages/schema/src/plugins/plugin-utils.ts b/packages/schema/src/plugins/plugin-utils.ts index 028761906..b443e6ca7 100644 --- a/packages/schema/src/plugins/plugin-utils.ts +++ b/packages/schema/src/plugins/plugin-utils.ts @@ -40,6 +40,10 @@ export function ensureDefaultOutputFolder(options: PluginRunnerOptions) { types: './enhance.d.ts', default: './enhance.js', }, + './enhance-edge': { + types: './enhance-edge.d.ts', + default: './enhance-edge.js', + }, './zod': { types: './zod/index.d.ts', default: './zod/index.js', diff --git a/packages/server/package.json b/packages/server/package.json index ef431f2af..f3e913956 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -35,7 +35,8 @@ "upper-case-first": "^2.0.2", "url-pattern": "^1.0.3", "zod": "^3.22.4", - "zod-validation-error": "^1.5.0" + "zod-validation-error": "^1.5.0", + "decimal.js": "^10.4.2" }, "devDependencies": { "@nestjs/common": "^10.3.7", @@ -47,7 +48,6 @@ "@types/supertest": "^2.0.12", "@zenstackhq/testtools": "workspace:*", "body-parser": "^1.20.2", - "decimal.js": "^10.4.2", "express": "^4.19.2", "fastify": "^4.14.1", "fastify-plugin": "^4.5.0", diff --git a/packages/testtools/src/schema.ts b/packages/testtools/src/schema.ts index 997b9ee00..7cce9bd36 100644 --- a/packages/testtools/src/schema.ts +++ b/packages/testtools/src/schema.ts @@ -3,12 +3,12 @@ import type { Model } from '@zenstackhq/language/ast'; import { DEFAULT_RUNTIME_LOAD_PATH, - PolicyDef, type AuthUser, type CrudContract, type EnhancementKind, type EnhancementOptions, } from '@zenstackhq/runtime'; +import type { PolicyDef } from '@zenstackhq/runtime/enhancements/node'; import { getDMMF, type DMMF } from '@zenstackhq/sdk/prisma'; import { execSync } from 'child_process'; import * as fs from 'fs'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 71318ecb6..552ed617a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -664,6 +664,9 @@ importers: change-case: specifier: ^4.1.2 version: 4.1.2 + decimal.js: + specifier: ^10.4.2 + version: 10.4.3 lower-case-first: specifier: ^2.0.2 version: 2.0.2 @@ -716,9 +719,6 @@ importers: body-parser: specifier: ^1.20.2 version: 1.20.2 - decimal.js: - specifier: ^10.4.2 - version: 10.4.3 express: specifier: ^4.19.2 version: 4.19.2