@opensaas/stack-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +4 -0
- package/README.md +447 -0
- package/dist/access/engine.d.ts +73 -0
- package/dist/access/engine.d.ts.map +1 -0
- package/dist/access/engine.js +244 -0
- package/dist/access/engine.js.map +1 -0
- package/dist/access/field-transforms.d.ts +47 -0
- package/dist/access/field-transforms.d.ts.map +1 -0
- package/dist/access/field-transforms.js +2 -0
- package/dist/access/field-transforms.js.map +1 -0
- package/dist/access/index.d.ts +3 -0
- package/dist/access/index.d.ts.map +1 -0
- package/dist/access/index.js +2 -0
- package/dist/access/index.js.map +1 -0
- package/dist/access/types.d.ts +83 -0
- package/dist/access/types.d.ts.map +1 -0
- package/dist/access/types.js +2 -0
- package/dist/access/types.js.map +1 -0
- package/dist/config/index.d.ts +39 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +38 -0
- package/dist/config/index.js.map +1 -0
- package/dist/config/types.d.ts +413 -0
- package/dist/config/types.d.ts.map +1 -0
- package/dist/config/types.js +2 -0
- package/dist/config/types.js.map +1 -0
- package/dist/context/index.d.ts +31 -0
- package/dist/context/index.d.ts.map +1 -0
- package/dist/context/index.js +524 -0
- package/dist/context/index.js.map +1 -0
- package/dist/context/nested-operations.d.ts +10 -0
- package/dist/context/nested-operations.d.ts.map +1 -0
- package/dist/context/nested-operations.js +261 -0
- package/dist/context/nested-operations.js.map +1 -0
- package/dist/fields/index.d.ts +78 -0
- package/dist/fields/index.d.ts.map +1 -0
- package/dist/fields/index.js +381 -0
- package/dist/fields/index.js.map +1 -0
- package/dist/hooks/index.d.ts +58 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +79 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/case-utils.d.ts +49 -0
- package/dist/lib/case-utils.d.ts.map +1 -0
- package/dist/lib/case-utils.js +68 -0
- package/dist/lib/case-utils.js.map +1 -0
- package/dist/lib/case-utils.test.d.ts +2 -0
- package/dist/lib/case-utils.test.d.ts.map +1 -0
- package/dist/lib/case-utils.test.js +101 -0
- package/dist/lib/case-utils.test.js.map +1 -0
- package/dist/utils/password.d.ts +81 -0
- package/dist/utils/password.d.ts.map +1 -0
- package/dist/utils/password.js +132 -0
- package/dist/utils/password.js.map +1 -0
- package/dist/validation/schema.d.ts +17 -0
- package/dist/validation/schema.d.ts.map +1 -0
- package/dist/validation/schema.js +42 -0
- package/dist/validation/schema.js.map +1 -0
- package/dist/validation/schema.test.d.ts +2 -0
- package/dist/validation/schema.test.d.ts.map +1 -0
- package/dist/validation/schema.test.js +143 -0
- package/dist/validation/schema.test.js.map +1 -0
- package/docs/type-distribution-fix.md +136 -0
- package/package.json +48 -0
- package/src/access/engine.ts +360 -0
- package/src/access/field-transforms.ts +99 -0
- package/src/access/index.ts +20 -0
- package/src/access/types.ts +103 -0
- package/src/config/index.ts +71 -0
- package/src/config/types.ts +478 -0
- package/src/context/index.ts +814 -0
- package/src/context/nested-operations.ts +412 -0
- package/src/fields/index.ts +438 -0
- package/src/hooks/index.ts +132 -0
- package/src/index.ts +62 -0
- package/src/lib/case-utils.test.ts +127 -0
- package/src/lib/case-utils.ts +74 -0
- package/src/utils/password.ts +147 -0
- package/src/validation/schema.test.ts +171 -0
- package/src/validation/schema.ts +59 -0
- package/tests/access-relationships.test.ts +613 -0
- package/tests/access.test.ts +499 -0
- package/tests/config.test.ts +195 -0
- package/tests/context.test.ts +248 -0
- package/tests/hooks.test.ts +417 -0
- package/tests/password-type-distribution.test.ts +155 -0
- package/tests/password-types.test.ts +147 -0
- package/tests/password.test.ts +249 -0
- package/tsconfig.json +12 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +27 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Password Field Type Distribution Fix
|
|
2
|
+
|
|
3
|
+
## Problem
|
|
4
|
+
|
|
5
|
+
All fields in returned objects were being typed as `string | HashedPassword` instead of only the password field being typed as `HashedPassword`.
|
|
6
|
+
|
|
7
|
+
### Example of the Bug
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
const users = await context.db.user.findMany()
|
|
11
|
+
// Before fix:
|
|
12
|
+
// users: { name: string | HashedPassword, email: string | HashedPassword, password: string | HashedPassword, ... }
|
|
13
|
+
//
|
|
14
|
+
// Expected:
|
|
15
|
+
// users: { name: string, email: string, password: HashedPassword, ... }
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Root Cause
|
|
19
|
+
|
|
20
|
+
The issue was in how TypeScript's **distributive conditional types** work with union types.
|
|
21
|
+
|
|
22
|
+
### The Problem Code
|
|
23
|
+
|
|
24
|
+
In `packages/core/src/access/types.ts`, the `TransformObject` type was delegating to a `TransformField` helper:
|
|
25
|
+
|
|
26
|
+
```typescript
|
|
27
|
+
type TransformObject<TConfig, TListKey, TObj> = {
|
|
28
|
+
[K in keyof TObj]: K extends keyof TConfig['lists'][TListKey]['fields']
|
|
29
|
+
? TransformField<TConfig['lists'][TListKey]['fields'][K], TObj[K]>
|
|
30
|
+
: // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
|
31
|
+
// This is a UNION of all field config types!
|
|
32
|
+
TObj[K]
|
|
33
|
+
}
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
When TypeScript evaluated `TConfig['lists'][TListKey]['fields'][K]`, it couldn't narrow the type precisely, resulting in:
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
TextField | IntegerField | CheckboxField | TimestampField | PasswordField | ...
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
This union type then **distributed** over the conditional type in `TransformField`:
|
|
43
|
+
|
|
44
|
+
```typescript
|
|
45
|
+
type TransformField<TFieldConfig, TOriginal> = TFieldConfig extends { type: infer TType }
|
|
46
|
+
? 'password' extends TType // Distributes over the union!
|
|
47
|
+
? HashedPassword
|
|
48
|
+
: TOriginal
|
|
49
|
+
: TOriginal
|
|
50
|
+
|
|
51
|
+
// Becomes:
|
|
52
|
+
// TransformField<TextField, string> | TransformField<PasswordField, string> | ...
|
|
53
|
+
// = TOriginal | HashedPassword | ...
|
|
54
|
+
// = string | HashedPassword
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Solution
|
|
58
|
+
|
|
59
|
+
**Remove the `TransformField` helper and inline the logic directly in `TransformObject`.**
|
|
60
|
+
|
|
61
|
+
This allows TypeScript to narrow the field config type **before** applying the conditional type, preventing distribution.
|
|
62
|
+
|
|
63
|
+
### The Fix
|
|
64
|
+
|
|
65
|
+
```typescript
|
|
66
|
+
type TransformObject<TConfig, TListKey, TObj> =
|
|
67
|
+
TObj extends Record<string, any>
|
|
68
|
+
? {
|
|
69
|
+
[K in keyof TObj]: K extends keyof TConfig['lists'][TListKey]['fields']
|
|
70
|
+
? TConfig['lists'][TListKey]['fields'][K] extends { type: 'relationship', ref: infer TRef }
|
|
71
|
+
? /* relationship logic */
|
|
72
|
+
: // Inline password check - NO helper function
|
|
73
|
+
TConfig['lists'][TListKey]['fields'][K] extends { type: 'password' }
|
|
74
|
+
? TObj[K] extends string
|
|
75
|
+
? HashedPassword
|
|
76
|
+
: TObj[K]
|
|
77
|
+
: TObj[K] // Not password, preserve original type
|
|
78
|
+
: TransformIncludedRelationship<TConfig, K, TObj[K]>
|
|
79
|
+
}
|
|
80
|
+
: TObj
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Why This Works
|
|
84
|
+
|
|
85
|
+
By inlining the logic, TypeScript can:
|
|
86
|
+
|
|
87
|
+
1. **First narrow** `TConfig['lists'][TListKey]['fields'][K]` to the specific field type (e.g., `TextField`)
|
|
88
|
+
2. **Then check** if it extends `{ type: 'password' }`
|
|
89
|
+
3. **Return** the appropriate type without creating a union
|
|
90
|
+
|
|
91
|
+
The check `TConfig['lists'][TListKey]['fields'][K] extends { type: 'password' }` evaluates to:
|
|
92
|
+
|
|
93
|
+
- `TextField extends { type: 'password' }` → `false` → return `TObj[K]` (preserve original)
|
|
94
|
+
- `PasswordField extends { type: 'password' }` → `true` → return `HashedPassword`
|
|
95
|
+
|
|
96
|
+
No distribution occurs because we're not using a separate type alias that receives the union.
|
|
97
|
+
|
|
98
|
+
## Verification
|
|
99
|
+
|
|
100
|
+
### Tests
|
|
101
|
+
|
|
102
|
+
Created `tests/password-type-distribution.test.ts` with 3 tests:
|
|
103
|
+
|
|
104
|
+
1. Verifies non-password fields remain `string`
|
|
105
|
+
2. Verifies password field becomes `HashedPassword`
|
|
106
|
+
3. Verifies TypeScript narrowing works correctly
|
|
107
|
+
|
|
108
|
+
All 191 tests pass ✅
|
|
109
|
+
|
|
110
|
+
### Type Checks
|
|
111
|
+
|
|
112
|
+
```typescript
|
|
113
|
+
const users = await context.db.user.findMany()
|
|
114
|
+
const user = users[0]
|
|
115
|
+
|
|
116
|
+
// These now compile correctly:
|
|
117
|
+
const name: string = user.name // ✅ string
|
|
118
|
+
const email: string = user.email // ✅ string
|
|
119
|
+
const password: HashedPassword = user.password // ✅ HashedPassword
|
|
120
|
+
await password.compare('test') // ✅ Has compare method
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Files Changed
|
|
124
|
+
|
|
125
|
+
- `packages/core/src/access/types.ts`:
|
|
126
|
+
- Modified `TransformObject` type (lines 125-160) to inline password transformation logic
|
|
127
|
+
- Removed `TransformField` helper type (was lines 175-189)
|
|
128
|
+
- `packages/core/tests/password-type-distribution.test.ts`:
|
|
129
|
+
- New test file with 3 tests verifying the fix
|
|
130
|
+
|
|
131
|
+
## Lessons Learned
|
|
132
|
+
|
|
133
|
+
1. **Distributive conditional types** in TypeScript can cause unexpected union types
|
|
134
|
+
2. **Helper type aliases** can prevent proper type narrowing
|
|
135
|
+
3. **Inlining type logic** allows TypeScript to narrow types before applying conditionals
|
|
136
|
+
4. **Test both runtime and compile-time behavior** when working with TypeScript transformations
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@opensaas/stack-core",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Core stack for OpenSaas - schema definition, access control, and runtime utilities",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./fields": {
|
|
14
|
+
"types": "./dist/fields/index.d.ts",
|
|
15
|
+
"default": "./dist/fields/index.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"opensaas",
|
|
20
|
+
"nextjs",
|
|
21
|
+
"prisma",
|
|
22
|
+
"admin"
|
|
23
|
+
],
|
|
24
|
+
"author": "",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@prisma/client": "^6.17.0"
|
|
28
|
+
},
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"bcryptjs": "^3.0.2",
|
|
31
|
+
"zod": "^4.1.12"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@prisma/client": "^6.17.1",
|
|
35
|
+
"@types/bcryptjs": "^3.0.0",
|
|
36
|
+
"@types/node": "^24.7.2",
|
|
37
|
+
"typescript": "^5.9.3",
|
|
38
|
+
"vitest": "^4.0.0"
|
|
39
|
+
},
|
|
40
|
+
"scripts": {
|
|
41
|
+
"build": "tsc",
|
|
42
|
+
"dev": "tsc --watch",
|
|
43
|
+
"test": "vitest",
|
|
44
|
+
"test:ui": "vitest --ui",
|
|
45
|
+
"test:coverage": "vitest --coverage",
|
|
46
|
+
"clean": "rm -rf .turbo dist tsconfig.tsbuildinfo"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import type { AccessControl, Session, AccessContext, PrismaFilter } from './types.js'
|
|
2
|
+
import type { FieldAccess } from './types.js'
|
|
3
|
+
import type { OpenSaasConfig, ListConfig, FieldConfig } from '../config/types.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Check if access control result is a boolean
|
|
7
|
+
*/
|
|
8
|
+
export function isBoolean(value: unknown): value is boolean {
|
|
9
|
+
return typeof value === 'boolean'
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Check if access control result is a Prisma filter
|
|
14
|
+
*/
|
|
15
|
+
export function isPrismaFilter(value: unknown): value is PrismaFilter {
|
|
16
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Parse a relationship ref and get the related list configuration
|
|
21
|
+
* Relationship refs are in the format "ListName.fieldName"
|
|
22
|
+
*
|
|
23
|
+
* @param relationshipRef - The ref string (e.g., "Post.author")
|
|
24
|
+
* @param config - The OpenSaas configuration
|
|
25
|
+
* @returns The related list name and config, or null if not found
|
|
26
|
+
*/
|
|
27
|
+
export function getRelatedListConfig(
|
|
28
|
+
relationshipRef: string,
|
|
29
|
+
config: OpenSaasConfig,
|
|
30
|
+
): { listName: string; listConfig: ListConfig } | null {
|
|
31
|
+
// Parse ref format: "ListName.fieldName"
|
|
32
|
+
const parts = relationshipRef.split('.')
|
|
33
|
+
if (parts.length !== 2) {
|
|
34
|
+
return null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const listName = parts[0]
|
|
38
|
+
const listConfig = config.lists[listName]
|
|
39
|
+
|
|
40
|
+
if (!listConfig) {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { listName, listConfig }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Execute an access control function
|
|
49
|
+
*/
|
|
50
|
+
export async function checkAccess<T = Record<string, unknown>>(
|
|
51
|
+
accessControl: AccessControl<T> | undefined,
|
|
52
|
+
args: {
|
|
53
|
+
session: Session
|
|
54
|
+
item?: T
|
|
55
|
+
context: AccessContext
|
|
56
|
+
},
|
|
57
|
+
): Promise<boolean | PrismaFilter<T>> {
|
|
58
|
+
// No access control means deny by default
|
|
59
|
+
if (!accessControl) {
|
|
60
|
+
return false
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Execute the access control function
|
|
64
|
+
const result = await accessControl(args)
|
|
65
|
+
|
|
66
|
+
return result
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Merge user filter with access control filter
|
|
71
|
+
*/
|
|
72
|
+
export function mergeFilters(
|
|
73
|
+
userFilter: PrismaFilter | undefined,
|
|
74
|
+
accessFilter: boolean | PrismaFilter,
|
|
75
|
+
): PrismaFilter | null {
|
|
76
|
+
// If access is denied, return null
|
|
77
|
+
if (accessFilter === false) {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// If access is fully granted, use user filter
|
|
82
|
+
if (accessFilter === true) {
|
|
83
|
+
return userFilter || {}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Merge access filter with user filter
|
|
87
|
+
if (!userFilter) {
|
|
88
|
+
return accessFilter
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Combine filters with AND
|
|
92
|
+
return {
|
|
93
|
+
AND: [accessFilter, userFilter],
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Check field-level access for a specific operation
|
|
99
|
+
*/
|
|
100
|
+
export async function checkFieldAccess(
|
|
101
|
+
fieldAccess: FieldAccess | undefined,
|
|
102
|
+
operation: 'read' | 'create' | 'update',
|
|
103
|
+
args: {
|
|
104
|
+
session: Session
|
|
105
|
+
item?: Record<string, unknown>
|
|
106
|
+
context: AccessContext
|
|
107
|
+
},
|
|
108
|
+
): Promise<boolean> {
|
|
109
|
+
if (!fieldAccess) {
|
|
110
|
+
return true // No field access means allow
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const accessControl = fieldAccess[operation]
|
|
114
|
+
if (!accessControl) {
|
|
115
|
+
return true // No specific access control means allow
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const result = await accessControl(args)
|
|
119
|
+
|
|
120
|
+
// If result is false, deny access
|
|
121
|
+
if (result === false) {
|
|
122
|
+
return false
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// If result is true, allow access
|
|
126
|
+
if (result === true) {
|
|
127
|
+
return true
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// If result is a filter object, check if the item matches
|
|
131
|
+
// For field-level access, we need to evaluate the filter against the item
|
|
132
|
+
if (typeof result === 'object' && args.item) {
|
|
133
|
+
return matchesFilter(args.item, result)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Default to allowing access if we can't determine
|
|
137
|
+
return true
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Simple filter matching for field-level access
|
|
142
|
+
* Checks if an item matches a Prisma-like filter object
|
|
143
|
+
*/
|
|
144
|
+
function matchesFilter(item: Record<string, unknown>, filter: Record<string, unknown>): boolean {
|
|
145
|
+
for (const [key, condition] of Object.entries(filter)) {
|
|
146
|
+
if (typeof condition === 'object' && condition !== null) {
|
|
147
|
+
// Handle nested conditions like { equals: value }
|
|
148
|
+
if ('equals' in condition) {
|
|
149
|
+
if (item[key] !== condition.equals) {
|
|
150
|
+
return false
|
|
151
|
+
}
|
|
152
|
+
} else if ('not' in condition) {
|
|
153
|
+
if (item[key] === condition.not) {
|
|
154
|
+
return false
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
// Add more condition types as needed
|
|
158
|
+
} else {
|
|
159
|
+
// Direct equality check
|
|
160
|
+
if (item[key] !== condition) {
|
|
161
|
+
return false
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return true
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Build Prisma include object with access control filters
|
|
170
|
+
* This allows us to filter relationships at the database level instead of in memory
|
|
171
|
+
*/
|
|
172
|
+
export async function buildIncludeWithAccessControl(
|
|
173
|
+
fieldConfigs: Record<string, FieldConfig>,
|
|
174
|
+
args: {
|
|
175
|
+
session: Session
|
|
176
|
+
context: AccessContext
|
|
177
|
+
},
|
|
178
|
+
config: OpenSaasConfig,
|
|
179
|
+
depth: number = 0,
|
|
180
|
+
) {
|
|
181
|
+
const MAX_DEPTH = 5
|
|
182
|
+
if (depth >= MAX_DEPTH) {
|
|
183
|
+
return undefined
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
type IncludeEntry = boolean | { where?: PrismaFilter; include?: Record<string, IncludeEntry> }
|
|
187
|
+
|
|
188
|
+
const include: Record<string, IncludeEntry> = {}
|
|
189
|
+
let hasRelationships = false
|
|
190
|
+
|
|
191
|
+
for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
|
|
192
|
+
if (fieldConfig?.type === 'relationship' && 'ref' in fieldConfig && fieldConfig.ref) {
|
|
193
|
+
hasRelationships = true
|
|
194
|
+
const relatedConfig = getRelatedListConfig(fieldConfig.ref, config)
|
|
195
|
+
|
|
196
|
+
if (relatedConfig) {
|
|
197
|
+
// Check query access for the related list
|
|
198
|
+
const queryAccess = relatedConfig.listConfig.access?.operation?.query
|
|
199
|
+
const accessResult = await checkAccess(queryAccess, {
|
|
200
|
+
session: args.session,
|
|
201
|
+
context: args.context,
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
// If access is completely denied, exclude this relationship
|
|
205
|
+
if (accessResult === false) {
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Build the include entry
|
|
210
|
+
const includeEntry: Record<string, unknown> = {}
|
|
211
|
+
|
|
212
|
+
// If access returns a filter, add it to the where clause
|
|
213
|
+
if (typeof accessResult === 'object') {
|
|
214
|
+
includeEntry.where = accessResult
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Recursively build nested includes
|
|
218
|
+
const nestedInclude = await buildIncludeWithAccessControl(
|
|
219
|
+
relatedConfig.listConfig.fields,
|
|
220
|
+
args,
|
|
221
|
+
config,
|
|
222
|
+
depth + 1,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
if (nestedInclude && Object.keys(nestedInclude).length > 0) {
|
|
226
|
+
includeEntry.include = nestedInclude
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Add to include object
|
|
230
|
+
include[fieldName] = Object.keys(includeEntry).length > 0 ? includeEntry : true
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return hasRelationships ? include : undefined
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Filter fields from an object based on read access
|
|
240
|
+
* Recursively applies access control to nested relationships
|
|
241
|
+
*/
|
|
242
|
+
export async function filterReadableFields<T extends Record<string, unknown>>(
|
|
243
|
+
item: T,
|
|
244
|
+
fieldConfigs: Record<string, FieldConfig>,
|
|
245
|
+
args: {
|
|
246
|
+
session: Session
|
|
247
|
+
context: AccessContext
|
|
248
|
+
},
|
|
249
|
+
config?: OpenSaasConfig,
|
|
250
|
+
depth: number = 0,
|
|
251
|
+
): Promise<Partial<T>> {
|
|
252
|
+
const filtered: Record<string, unknown> = {}
|
|
253
|
+
const MAX_DEPTH = 5 // Prevent infinite recursion
|
|
254
|
+
|
|
255
|
+
for (const [fieldName, value] of Object.entries(item)) {
|
|
256
|
+
const fieldConfig = fieldConfigs[fieldName]
|
|
257
|
+
|
|
258
|
+
// Always include id, createdAt, updatedAt
|
|
259
|
+
if (['id', 'createdAt', 'updatedAt'].includes(fieldName)) {
|
|
260
|
+
filtered[fieldName] = value
|
|
261
|
+
continue
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Check field access
|
|
265
|
+
const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
|
|
266
|
+
...args,
|
|
267
|
+
item,
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
if (!canRead) {
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Handle relationship fields - recursively filter fields within related items
|
|
275
|
+
// Note: Access control filtering is now done at database level via buildIncludeWithAccessControl
|
|
276
|
+
// This only handles field-level access (hiding sensitive fields)
|
|
277
|
+
if (
|
|
278
|
+
config &&
|
|
279
|
+
fieldConfig?.type === 'relationship' &&
|
|
280
|
+
'ref' in fieldConfig &&
|
|
281
|
+
fieldConfig.ref &&
|
|
282
|
+
value !== null &&
|
|
283
|
+
value !== undefined &&
|
|
284
|
+
depth < MAX_DEPTH
|
|
285
|
+
) {
|
|
286
|
+
const relatedConfig = getRelatedListConfig(fieldConfig.ref, config)
|
|
287
|
+
|
|
288
|
+
if (relatedConfig) {
|
|
289
|
+
// For many relationships (arrays) - recursively filter fields in each item
|
|
290
|
+
if (Array.isArray(value)) {
|
|
291
|
+
filtered[fieldName] = await Promise.all(
|
|
292
|
+
value.map((relatedItem) =>
|
|
293
|
+
filterReadableFields(
|
|
294
|
+
relatedItem,
|
|
295
|
+
relatedConfig.listConfig.fields,
|
|
296
|
+
args,
|
|
297
|
+
config,
|
|
298
|
+
depth + 1,
|
|
299
|
+
),
|
|
300
|
+
),
|
|
301
|
+
)
|
|
302
|
+
}
|
|
303
|
+
// For single relationships (objects) - recursively filter fields
|
|
304
|
+
else if (typeof value === 'object') {
|
|
305
|
+
filtered[fieldName] = await filterReadableFields(
|
|
306
|
+
value as Record<string, unknown>,
|
|
307
|
+
relatedConfig.listConfig.fields,
|
|
308
|
+
args,
|
|
309
|
+
config,
|
|
310
|
+
depth + 1,
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
} else {
|
|
314
|
+
// Related config not found, include the value as-is
|
|
315
|
+
filtered[fieldName] = value
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
// Non-relationship field or no config provided
|
|
319
|
+
filtered[fieldName] = value
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return filtered as Partial<T>
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Filter fields from input data based on write access (create/update)
|
|
328
|
+
*/
|
|
329
|
+
export async function filterWritableFields<T extends Record<string, unknown>>(
|
|
330
|
+
data: T,
|
|
331
|
+
fieldConfigs: Record<string, { access?: FieldAccess }>,
|
|
332
|
+
operation: 'create' | 'update',
|
|
333
|
+
args: {
|
|
334
|
+
session: Session
|
|
335
|
+
item?: Record<string, unknown>
|
|
336
|
+
context: AccessContext
|
|
337
|
+
},
|
|
338
|
+
): Promise<Partial<T>> {
|
|
339
|
+
const filtered: Record<string, unknown> = {}
|
|
340
|
+
|
|
341
|
+
for (const [fieldName, value] of Object.entries(data)) {
|
|
342
|
+
const fieldConfig = fieldConfigs[fieldName]
|
|
343
|
+
|
|
344
|
+
// Skip system fields
|
|
345
|
+
if (['id', 'createdAt', 'updatedAt'].includes(fieldName)) {
|
|
346
|
+
continue
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Check field access
|
|
350
|
+
const canWrite = await checkFieldAccess(fieldConfig?.access, operation, {
|
|
351
|
+
...args,
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
if (canWrite) {
|
|
355
|
+
filtered[fieldName] = value
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return filtered as Partial<T>
|
|
360
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { OpenSaasConfig, FieldConfig } from '../config/types.js'
|
|
2
|
+
import type { HashedPassword } from '../utils/password.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Extract the return type of a field's afterOperation hook
|
|
6
|
+
* If the field has an afterOperation hook, infer its return type
|
|
7
|
+
* Otherwise, use the original type
|
|
8
|
+
*/
|
|
9
|
+
export type InferFieldReadType<TField extends FieldConfig, TOriginal> = TField extends {
|
|
10
|
+
// Generic `any` is required here for TypeScript's conditional type inference to work correctly
|
|
11
|
+
// This allows us to infer the exact return type `R` from hooks of any signature
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
13
|
+
hooks?: { afterOperation?: (...args: any[]) => infer R }
|
|
14
|
+
}
|
|
15
|
+
? R extends never
|
|
16
|
+
? TOriginal // No hook defined
|
|
17
|
+
: R // Hook return type
|
|
18
|
+
: TOriginal // No hooks at all
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Transform a Prisma model's field types based on OpenSaas field configs
|
|
22
|
+
* This applies afterOperation hook transformations to field types
|
|
23
|
+
*/
|
|
24
|
+
export type TransformModelFields<
|
|
25
|
+
// Generic constraint requires `any` to allow indexing by string keys from Prisma models
|
|
26
|
+
// This is necessary for mapped types to work with Prisma's generated model types
|
|
27
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
28
|
+
TModel extends Record<string, any>,
|
|
29
|
+
TFields extends Record<string, FieldConfig>,
|
|
30
|
+
> = {
|
|
31
|
+
[K in keyof TModel]: K extends keyof TFields
|
|
32
|
+
? InferFieldReadType<TFields[K], TModel[K]>
|
|
33
|
+
: TModel[K]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Get the field configs for a specific list from the OpenSaas config
|
|
38
|
+
*/
|
|
39
|
+
export type GetListFields<
|
|
40
|
+
TConfig extends OpenSaasConfig,
|
|
41
|
+
TListKey extends keyof TConfig['lists'],
|
|
42
|
+
> = TConfig['lists'][TListKey]['fields']
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Transform a Prisma model result based on OpenSaas config
|
|
46
|
+
* Applies field hooks transformations
|
|
47
|
+
*/
|
|
48
|
+
export type TransformResult<
|
|
49
|
+
TConfig extends OpenSaasConfig,
|
|
50
|
+
TListKey extends keyof TConfig['lists'],
|
|
51
|
+
TResult,
|
|
52
|
+
> =
|
|
53
|
+
// Generic constraint requires `any` to check if TResult is an object type that can be transformed
|
|
54
|
+
// This pattern is standard in TypeScript for conditional types on object shapes
|
|
55
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
56
|
+
TResult extends Record<string, any>
|
|
57
|
+
? TransformModelFields<TResult, GetListFields<TConfig, TListKey>>
|
|
58
|
+
: TResult
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Transform a Prisma operation's return type
|
|
62
|
+
* Handles single results, arrays, and null cases
|
|
63
|
+
*/
|
|
64
|
+
export type TransformOperationResult<
|
|
65
|
+
TConfig extends OpenSaasConfig,
|
|
66
|
+
TListKey extends keyof TConfig['lists'],
|
|
67
|
+
TResult,
|
|
68
|
+
> =
|
|
69
|
+
TResult extends Promise<infer R>
|
|
70
|
+
? Promise<
|
|
71
|
+
R extends Array<infer Item>
|
|
72
|
+
? Array<TransformResult<TConfig, TListKey, Item>>
|
|
73
|
+
: R extends null
|
|
74
|
+
? null
|
|
75
|
+
: TransformResult<TConfig, TListKey, R>
|
|
76
|
+
>
|
|
77
|
+
: never
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Known field type mappings for afterOperation hooks
|
|
81
|
+
* These provide concrete type hints for common field transformations
|
|
82
|
+
*/
|
|
83
|
+
export interface FieldTypeTransforms {
|
|
84
|
+
password: HashedPassword
|
|
85
|
+
// Future field types can be added here
|
|
86
|
+
// richText: TiptapContent
|
|
87
|
+
// json: JSONValue
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Helper to infer field type based on field config type
|
|
92
|
+
*/
|
|
93
|
+
export type InferFieldTypeTransform<TField extends FieldConfig> = TField extends {
|
|
94
|
+
type: infer TType
|
|
95
|
+
}
|
|
96
|
+
? TType extends keyof FieldTypeTransforms
|
|
97
|
+
? FieldTypeTransforms[TType]
|
|
98
|
+
: never
|
|
99
|
+
: never
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
AccessControl,
|
|
3
|
+
FieldAccess,
|
|
4
|
+
Session,
|
|
5
|
+
AccessContext,
|
|
6
|
+
PrismaFilter,
|
|
7
|
+
AccessControlledDB,
|
|
8
|
+
PrismaClientLike,
|
|
9
|
+
} from './types.js'
|
|
10
|
+
export {
|
|
11
|
+
checkAccess,
|
|
12
|
+
mergeFilters,
|
|
13
|
+
checkFieldAccess,
|
|
14
|
+
filterReadableFields,
|
|
15
|
+
filterWritableFields,
|
|
16
|
+
isBoolean,
|
|
17
|
+
isPrismaFilter,
|
|
18
|
+
getRelatedListConfig,
|
|
19
|
+
buildIncludeWithAccessControl,
|
|
20
|
+
} from './engine.js'
|