@opensaas/stack-core 0.20.1 → 0.21.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 +1 -1
- package/CHANGELOG.md +72 -0
- package/CLAUDE.md +18 -2
- package/dist/access/access-filter.d.ts +29 -0
- package/dist/access/access-filter.d.ts.map +1 -0
- package/dist/access/access-filter.js +68 -0
- package/dist/access/access-filter.js.map +1 -0
- package/dist/access/engine.d.ts +15 -48
- package/dist/access/engine.d.ts.map +1 -1
- package/dist/access/engine.js +14 -280
- package/dist/access/engine.js.map +1 -1
- package/dist/access/field-access.d.ts +44 -0
- package/dist/access/field-access.d.ts.map +1 -0
- package/dist/access/field-access.js +123 -0
- package/dist/access/field-access.js.map +1 -0
- package/dist/access/field-access.test.d.ts +2 -0
- package/dist/access/field-access.test.d.ts.map +1 -0
- package/dist/access/{engine.test.js → field-access.test.js} +2 -2
- package/dist/access/field-access.test.js.map +1 -0
- package/dist/access/field-visibility.d.ts +13 -0
- package/dist/access/field-visibility.d.ts.map +1 -0
- package/dist/access/field-visibility.js +155 -0
- package/dist/access/field-visibility.js.map +1 -0
- package/dist/access/index.d.ts +4 -1
- package/dist/access/index.d.ts.map +1 -1
- package/dist/access/index.js +8 -1
- package/dist/access/index.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/types.d.ts +45 -4
- package/dist/config/types.d.ts.map +1 -1
- package/dist/context/hook-pipeline.d.ts +49 -0
- package/dist/context/hook-pipeline.d.ts.map +1 -0
- package/dist/context/hook-pipeline.js +75 -0
- package/dist/context/hook-pipeline.js.map +1 -0
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +30 -462
- package/dist/context/index.js.map +1 -1
- package/dist/context/nested-operations.d.ts.map +1 -1
- package/dist/context/nested-operations.js +72 -68
- package/dist/context/nested-operations.js.map +1 -1
- package/dist/context/write-pipeline.d.ts +158 -0
- package/dist/context/write-pipeline.d.ts.map +1 -0
- package/dist/context/write-pipeline.js +306 -0
- package/dist/context/write-pipeline.js.map +1 -0
- package/dist/extend.d.ts +3 -0
- package/dist/extend.d.ts.map +1 -0
- package/dist/extend.js +10 -0
- package/dist/extend.js.map +1 -0
- package/dist/fields/index.d.ts +1 -0
- package/dist/fields/index.d.ts.map +1 -1
- package/dist/fields/index.js +213 -2
- package/dist/fields/index.js.map +1 -1
- package/dist/hooks/index.d.ts +20 -0
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +202 -0
- package/dist/hooks/index.js.map +1 -1
- package/dist/index.d.ts +5 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -10
- package/dist/index.js.map +1 -1
- package/dist/internal.d.ts +8 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +16 -0
- package/dist/internal.js.map +1 -0
- package/dist/validation/field-config.d.ts +55 -0
- package/dist/validation/field-config.d.ts.map +1 -0
- package/dist/validation/field-config.js +100 -0
- package/dist/validation/field-config.js.map +1 -0
- package/dist/validation/field-config.test.d.ts +2 -0
- package/dist/validation/field-config.test.d.ts.map +1 -0
- package/dist/validation/field-config.test.js +159 -0
- package/dist/validation/field-config.test.js.map +1 -0
- package/package.json +11 -3
- package/src/access/access-filter.ts +97 -0
- package/src/access/engine.ts +13 -396
- package/src/access/{engine.test.ts → field-access.test.ts} +1 -1
- package/src/access/field-access.ts +159 -0
- package/src/access/field-visibility.ts +247 -0
- package/src/access/index.ts +7 -4
- package/src/config/index.ts +1 -0
- package/src/config/types.ts +51 -4
- package/src/context/hook-pipeline.ts +160 -0
- package/src/context/index.ts +29 -667
- package/src/context/nested-operations.ts +142 -111
- package/src/context/write-pipeline.ts +543 -0
- package/src/extend.ts +14 -0
- package/src/fields/index.ts +310 -2
- package/src/hooks/index.ts +227 -0
- package/src/index.ts +27 -90
- package/src/internal.ts +49 -0
- package/src/validation/field-config.test.ts +199 -0
- package/src/validation/field-config.ts +145 -0
- package/tests/access-relationships.test.ts +4 -4
- package/tests/access.test.ts +1 -1
- package/tests/field-hooks.test.ts +410 -0
- package/tests/field-types.test.ts +1 -1
- package/tests/hook-pipeline.test.ts +233 -0
- package/tests/nested-operation-registry.test.ts +206 -0
- package/tests/write-pipeline.test.ts +588 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/vitest.config.ts +43 -1
- package/dist/access/engine.test.d.ts +0 -2
- package/dist/access/engine.test.d.ts.map +0 -1
- package/dist/access/engine.test.js.map +0 -1
package/.turbo/turbo-build.log
CHANGED
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,77 @@
|
|
|
1
1
|
# @opensaas/stack-core
|
|
2
2
|
|
|
3
|
+
## 0.21.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#415](https://github.com/OpenSaasAU/stack/pull/415) [`8980ff3`](https://github.com/OpenSaasAU/stack/commit/8980ff36ffb0879d8f4409740493dd940572cc9d) Thanks [@borisno2](https://github.com/borisno2)! - Curate the `@opensaas/stack-core` public surface into clearly-scoped entry points
|
|
8
|
+
|
|
9
|
+
The root entry point now exposes only the everyday consumer surface — `config`,
|
|
10
|
+
`list`, `getContext`, the naming helpers (`getDbKey`, `getUrlKey`,
|
|
11
|
+
`getListKeyFromUrl`), `ValidationError`, and the config/access types you annotate
|
|
12
|
+
with. Plugin and field authoring contracts move to a new `/extend` path, and the
|
|
13
|
+
plumbing shared with sibling packages and generated code moves to `/internal`.
|
|
14
|
+
|
|
15
|
+
```typescript
|
|
16
|
+
// Everyday usage (unchanged)
|
|
17
|
+
import { config, list, getContext } from '@opensaas/stack-core'
|
|
18
|
+
|
|
19
|
+
// Authoring a plugin or a third-party field package
|
|
20
|
+
import type { Plugin, BaseFieldConfig, TypeInfo } from '@opensaas/stack-core/extend'
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`@opensaas/stack-core/internal` carries no semver guarantees; application code
|
|
24
|
+
should never import from it. `Session` stays on the root entry point because it is
|
|
25
|
+
the module-augmentation target.
|
|
26
|
+
|
|
27
|
+
Removed from the public surface (zero callers): the nine `*HookArgs` types and the
|
|
28
|
+
callerless typed-query runtime types. The other `@opensaas/*` packages and the CLI
|
|
29
|
+
generator are updated to import from the new paths.
|
|
30
|
+
|
|
31
|
+
- [#416](https://github.com/OpenSaasAU/stack/pull/416) [`841a836`](https://github.com/OpenSaasAU/stack/commit/841a836494e2647f390ae19a8c4121d38ebd2fa4) Thanks [@borisno2](https://github.com/borisno2)! - Move field-config types to `@opensaas/stack-core/fields`, beside their builders
|
|
32
|
+
|
|
33
|
+
The concrete field-config types (`TextField`, `IntegerField`, `CheckboxField`,
|
|
34
|
+
`TimestampField`, `PasswordField`, `SelectField`, `RelationshipField`,
|
|
35
|
+
`JsonField`, `VirtualField`, plus `DecimalField`, `CalendarDayField`, and
|
|
36
|
+
`PrismaRelationResult`) now live on the `/fields` entry point alongside the
|
|
37
|
+
builders that produce them, instead of the root barrel. One concept, one import
|
|
38
|
+
path:
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
import { text, decimal } from '@opensaas/stack-core/fields'
|
|
42
|
+
import type { TextField, DecimalField } from '@opensaas/stack-core/fields'
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
`DecimalField` and `CalendarDayField` were previously defined but exported from
|
|
46
|
+
nowhere — they are now public, and the CLI's lists generator maps `decimal`/
|
|
47
|
+
`calendarDay` fields to their precise types instead of the generic
|
|
48
|
+
`BaseFieldConfig` fallback. The umbrella `FieldConfig` stays on the root entry
|
|
49
|
+
point and `BaseFieldConfig` stays on `/extend`.
|
|
50
|
+
|
|
51
|
+
### Patch Changes
|
|
52
|
+
|
|
53
|
+
- [#441](https://github.com/OpenSaasAU/stack/pull/441) [`bc20bf4`](https://github.com/OpenSaasAU/stack/commit/bc20bf447cf724bd0ee153ea9a69d54cc26a6bb2) Thanks [@borisno2](https://github.com/borisno2)! - Validate field self-containment at config load instead of failing deep in generation
|
|
54
|
+
|
|
55
|
+
Core now exports `validateFieldConfig(field, fieldKey, listKey?)` and `validateConfigFields(config)` (plus the `FieldConfigValidationError` type). They check each field implements its generation contract — `getPrismaType`, `getTypeScriptType`, and `getZodSchema` (or `getPrismaRelation` for relationships; virtual fields skip `getPrismaType`) — and return structured per-field errors. `opensaas generate` runs this first and fails fast with a clear message naming the list, field, and missing method, rather than throwing an opaque stack trace mid-generation.
|
|
56
|
+
|
|
57
|
+
- [#428](https://github.com/OpenSaasAU/stack/pull/428) [`50371ea`](https://github.com/OpenSaasAU/stack/commit/50371ea3dd134f6b3718f347fed2c0d3b7dc63ce) Thanks [@borisno2](https://github.com/borisno2)! - Fix outdated SQLite adapter guidance to match the installed `@prisma/adapter-better-sqlite3` API (`PrismaBetterSqlite3` constructed with `{ url }`), so copied examples actually run. Updates the CLI "missing adapter" error message and the migration config it generates, plus the `prismaClientConstructor` JSDoc example.
|
|
58
|
+
|
|
59
|
+
- [#440](https://github.com/OpenSaasAU/stack/pull/440) [`70b4f53`](https://github.com/OpenSaasAU/stack/commit/70b4f538d380bbf546af50a985d29b48a71d3b4d) Thanks [@borisno2](https://github.com/borisno2)! - Refactor nested-operation dispatch into a handler registry (internal, no behaviour change)
|
|
60
|
+
|
|
61
|
+
- [#397](https://github.com/OpenSaasAU/stack/pull/397) [`8e394ab`](https://github.com/OpenSaasAU/stack/commit/8e394abe9df2da53ba23b93836853516bb4e25d5) Thanks [@borisno2](https://github.com/borisno2)! - Move relationship Prisma schema generation into the relationship field builder
|
|
62
|
+
|
|
63
|
+
The relationship field now exposes a `getPrismaRelation()` method that returns its complete Prisma schema contribution (FK line, relation line, synthetic back-relation). The Prisma generator delegates to this method instead of special-casing relationships, keeping it a neutral coordinator. Generated schemas are unchanged.
|
|
64
|
+
|
|
65
|
+
- [#455](https://github.com/OpenSaasAU/stack/pull/455) [`d3fdf2a`](https://github.com/OpenSaasAU/stack/commit/d3fdf2a2e5374302bc7fe1fe814cb0f567a349df) Thanks [@borisno2](https://github.com/borisno2)! - Exclude `**/dist/**` from Vitest test discovery and gate coverage on `src/access`, `src/context`, and `src/validation` via per-file thresholds.
|
|
66
|
+
|
|
67
|
+
- [#403](https://github.com/OpenSaasAU/stack/pull/403) [`0f9c644`](https://github.com/OpenSaasAU/stack/commit/0f9c644a115ad747e338e6138b4762b4a48a9144) Thanks [@borisno2](https://github.com/borisno2)! - Split the access engine into named two-phase-read modules: Access Filter (pre-query), Field Visibility (post-query), and a shared field-access evaluator. No behaviour or public API change.
|
|
68
|
+
|
|
69
|
+
- [#411](https://github.com/OpenSaasAU/stack/pull/411) [`96258b0`](https://github.com/OpenSaasAU/stack/commit/96258b00bb762d9e38cfb83eacae65ce670b161f) Thanks [@borisno2](https://github.com/borisno2)! - Deduplicate field-level hook execution helpers by promoting them to `hooks/index.ts`, and remove a stray `console.log` that ran on every create/update.
|
|
70
|
+
|
|
71
|
+
- [#439](https://github.com/OpenSaasAU/stack/pull/439) [`898e477`](https://github.com/OpenSaasAU/stack/commit/898e47747abc02e457a54e2a78939450d16da5fb) Thanks [@borisno2](https://github.com/borisno2)! - Internal refactor: extract the write transform+validate span into a single Hook Pipeline that the Write Pipeline delegates to. No behaviour change.
|
|
72
|
+
|
|
73
|
+
- [#438](https://github.com/OpenSaasAU/stack/pull/438) [`29966b2`](https://github.com/OpenSaasAU/stack/commit/29966b23597199bcf4233298b1d0de6401b91acd) Thanks [@borisno2](https://github.com/borisno2)! - Refactor the write path into a single Write Pipeline. The canonical secured write sequence (hooks, validation, access, writable-field filtering, nested operations, persistence, after-hooks, Field Visibility) now lives in one module; create/update/delete are thin adapters over it parameterised by a per-operation strategy. Internal refactor only — no public API or behaviour change.
|
|
74
|
+
|
|
3
75
|
## 0.20.1
|
|
4
76
|
|
|
5
77
|
## 0.20.0
|
package/CLAUDE.md
CHANGED
|
@@ -6,6 +6,18 @@ Core stack providing config system, access control engine, hooks, field types, a
|
|
|
6
6
|
|
|
7
7
|
The foundation of OpenSaas Stack. Defines the config DSL, executes access control, runs hooks, and generates Prisma schema and TypeScript types from config.
|
|
8
8
|
|
|
9
|
+
## Entry Points
|
|
10
|
+
|
|
11
|
+
The package exposes a curated surface across several import paths. Use the narrowest one that fits:
|
|
12
|
+
|
|
13
|
+
- **`@opensaas/stack-core`** (root) — the everyday consumer surface: `config`, `list`, `getContext`, the naming helpers (`getDbKey`, `getUrlKey`, `getListKeyFromUrl`), `ValidationError`, and the config/access types you annotate with (`OpenSaasConfig`, `ListConfig`, `FieldConfig`, `AccessControl`, `FieldAccess`, `Session`, `AccessContext`, `PrismaFilter`, `OperationAccess`).
|
|
14
|
+
- **`@opensaas/stack-core/fields`** — field builder functions (`text()`, `integer()`, …) and their config types (`TextField`, `IntegerField`, `DecimalField`, `CalendarDayField`, …, plus `PrismaRelationResult`). The builders and the types they produce live together here.
|
|
15
|
+
- **`@opensaas/stack-core/extend`** — authoring contracts: implement these to build a plugin (`Plugin`, `PluginContext`, `GeneratedFiles`) or a third-party field package (`BaseFieldConfig`, `TypeInfo`, `TypeDescriptor`).
|
|
16
|
+
- **`@opensaas/stack-core/mcp`** — MCP runtime handlers.
|
|
17
|
+
- **`@opensaas/stack-core/internal`** — `@internal` plumbing shared between the `@opensaas/*` packages and generated `.opensaas/` code. **No semver guarantees**; application code should never import from here.
|
|
18
|
+
|
|
19
|
+
`Session` deliberately stays on the root entry point because it is the module-augmentation target (`declare module '@opensaas/stack-core'`).
|
|
20
|
+
|
|
9
21
|
## Key Files & Exports
|
|
10
22
|
|
|
11
23
|
### Config (`src/config/`)
|
|
@@ -115,12 +127,16 @@ Hook types:
|
|
|
115
127
|
|
|
116
128
|
Run via CLI: `pnpm generate`
|
|
117
129
|
|
|
118
|
-
### Utilities (`src/utils.ts`)
|
|
130
|
+
### Utilities (`src/lib/case-utils.ts`)
|
|
131
|
+
|
|
132
|
+
Public naming helpers (exported from the root entry point):
|
|
119
133
|
|
|
120
134
|
- `getDbKey(listKey)` - PascalCase → camelCase (e.g., `BlogPost` → `blogPost`)
|
|
121
135
|
- `getUrlKey(listKey)` - PascalCase → kebab-case (e.g., `BlogPost` → `blog-post`)
|
|
122
136
|
- `getListKeyFromUrl(urlKey)` - kebab-case → PascalCase (e.g., `blog-post` → `BlogPost`)
|
|
123
137
|
|
|
138
|
+
The lower-level converters (`pascalToCamel`, `pascalToKebab`, `kebabToPascal`, `kebabToCamel`) are internal plumbing on `@opensaas/stack-core/internal`.
|
|
139
|
+
|
|
124
140
|
## Architecture Patterns
|
|
125
141
|
|
|
126
142
|
### Field Self-Containment
|
|
@@ -206,7 +222,7 @@ const context = createContext<typeof prisma>(config, prisma, session)
|
|
|
206
222
|
|
|
207
223
|
### With Third-Party Field Packages
|
|
208
224
|
|
|
209
|
-
- Packages export field builders implementing `BaseFieldConfig`
|
|
225
|
+
- Packages export field builders implementing `BaseFieldConfig` (imported from `@opensaas/stack-core/extend`)
|
|
210
226
|
- No changes needed to core - fields are self-contained
|
|
211
227
|
- Example: `@opensaas/stack-tiptap` provides `richText()` field
|
|
212
228
|
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Session, AccessContext, PrismaFilter } from './types.js';
|
|
2
|
+
import type { OpenSaasConfig, FieldConfig } from '../config/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Access Filter — phase 1 of the two-phase read (pre-query).
|
|
5
|
+
*
|
|
6
|
+
* This module scopes which rows and relationships the database is allowed to
|
|
7
|
+
* return, before the query runs. It evaluates *operation-level* `query` access
|
|
8
|
+
* on related lists and turns the results into a Prisma `include`/`where` clause,
|
|
9
|
+
* so denied rows and relations never leave the database.
|
|
10
|
+
*
|
|
11
|
+
* Phase 2 (post-query field stripping + `resolveOutput` + virtual computation)
|
|
12
|
+
* lives in `field-visibility.ts`. The two phases cannot be merged: virtual
|
|
13
|
+
* fields are computed in JavaScript and post-query field access can depend on
|
|
14
|
+
* the fetched row, neither of which is expressible in SQL. See
|
|
15
|
+
* `docs/adr/0001-access-control-is-a-two-phase-read.md` and the access-control
|
|
16
|
+
* glossary in `CONTEXT.md`.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Build Prisma include object with access control filters
|
|
20
|
+
* This allows us to filter relationships at the database level instead of in memory
|
|
21
|
+
*/
|
|
22
|
+
export declare function buildIncludeWithAccessControl(fieldConfigs: Record<string, FieldConfig>, args: {
|
|
23
|
+
session: Session | null;
|
|
24
|
+
context: AccessContext;
|
|
25
|
+
}, config: OpenSaasConfig, depth?: number): Promise<Record<string, boolean | {
|
|
26
|
+
where?: PrismaFilter;
|
|
27
|
+
include?: Record<string, boolean | /*elided*/ any>;
|
|
28
|
+
}> | undefined>;
|
|
29
|
+
//# sourceMappingURL=access-filter.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"access-filter.d.ts","sourceRoot":"","sources":["../../src/access/access-filter.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AACtE,OAAO,KAAK,EAAE,cAAc,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAA;AAGrE;;;;;;;;;;;;;;GAcG;AAEH;;;GAGG;AACH,wBAAsB,6BAA6B,CACjD,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,EACzC,IAAI,EAAE;IACJ,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;IACvB,OAAO,EAAE,aAAa,CAAA;CACvB,EACD,MAAM,EAAE,cAAc,EACtB,KAAK,GAAE,MAAU;YAeuB,YAAY;cAAY,MAAM,CAAC,MAAM,2BAAe;gBAkD7F"}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { checkAccess, getRelatedListConfig } from './engine.js';
|
|
2
|
+
/**
|
|
3
|
+
* Access Filter — phase 1 of the two-phase read (pre-query).
|
|
4
|
+
*
|
|
5
|
+
* This module scopes which rows and relationships the database is allowed to
|
|
6
|
+
* return, before the query runs. It evaluates *operation-level* `query` access
|
|
7
|
+
* on related lists and turns the results into a Prisma `include`/`where` clause,
|
|
8
|
+
* so denied rows and relations never leave the database.
|
|
9
|
+
*
|
|
10
|
+
* Phase 2 (post-query field stripping + `resolveOutput` + virtual computation)
|
|
11
|
+
* lives in `field-visibility.ts`. The two phases cannot be merged: virtual
|
|
12
|
+
* fields are computed in JavaScript and post-query field access can depend on
|
|
13
|
+
* the fetched row, neither of which is expressible in SQL. See
|
|
14
|
+
* `docs/adr/0001-access-control-is-a-two-phase-read.md` and the access-control
|
|
15
|
+
* glossary in `CONTEXT.md`.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Build Prisma include object with access control filters
|
|
19
|
+
* This allows us to filter relationships at the database level instead of in memory
|
|
20
|
+
*/
|
|
21
|
+
export async function buildIncludeWithAccessControl(fieldConfigs, args, config, depth = 0) {
|
|
22
|
+
const MAX_DEPTH = 5;
|
|
23
|
+
if (depth >= MAX_DEPTH) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
// Skip auto-including relationships when inside a resolveOutput hook
|
|
27
|
+
// This prevents infinite loops when hooks make DB queries that include
|
|
28
|
+
// relationships back to the same entity (e.g., User virtual field queries Posts
|
|
29
|
+
// which includes author back to User, triggering the virtual field again)
|
|
30
|
+
if (args.context._resolveOutputCounter.depth > 0) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
const include = {};
|
|
34
|
+
let hasRelationships = false;
|
|
35
|
+
for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
|
|
36
|
+
if (fieldConfig?.type === 'relationship' && 'ref' in fieldConfig && fieldConfig.ref) {
|
|
37
|
+
hasRelationships = true;
|
|
38
|
+
const relatedConfig = getRelatedListConfig(fieldConfig.ref, config);
|
|
39
|
+
if (relatedConfig) {
|
|
40
|
+
// Check query access for the related list
|
|
41
|
+
const queryAccess = relatedConfig.listConfig.access?.operation?.query;
|
|
42
|
+
const accessResult = await checkAccess(queryAccess, {
|
|
43
|
+
session: args.session,
|
|
44
|
+
context: args.context,
|
|
45
|
+
});
|
|
46
|
+
// If access is completely denied, exclude this relationship
|
|
47
|
+
if (accessResult === false) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
// Build the include entry
|
|
51
|
+
const includeEntry = {};
|
|
52
|
+
// If access returns a filter, add it to the where clause
|
|
53
|
+
if (typeof accessResult === 'object') {
|
|
54
|
+
includeEntry.where = accessResult;
|
|
55
|
+
}
|
|
56
|
+
// Recursively build nested includes
|
|
57
|
+
const nestedInclude = await buildIncludeWithAccessControl(relatedConfig.listConfig.fields, args, config, depth + 1);
|
|
58
|
+
if (nestedInclude && Object.keys(nestedInclude).length > 0) {
|
|
59
|
+
includeEntry.include = nestedInclude;
|
|
60
|
+
}
|
|
61
|
+
// Add to include object
|
|
62
|
+
include[fieldName] = Object.keys(includeEntry).length > 0 ? includeEntry : true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return hasRelationships ? include : undefined;
|
|
67
|
+
}
|
|
68
|
+
//# sourceMappingURL=access-filter.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"access-filter.js","sourceRoot":"","sources":["../../src/access/access-filter.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,WAAW,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAA;AAE/D;;;;;;;;;;;;;;GAcG;AAEH;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,6BAA6B,CACjD,YAAyC,EACzC,IAGC,EACD,MAAsB,EACtB,QAAgB,CAAC;IAEjB,MAAM,SAAS,GAAG,CAAC,CAAA;IACnB,IAAI,KAAK,IAAI,SAAS,EAAE,CAAC;QACvB,OAAO,SAAS,CAAA;IAClB,CAAC;IAED,qEAAqE;IACrE,uEAAuE;IACvE,gFAAgF;IAChF,0EAA0E;IAC1E,IAAI,IAAI,CAAC,OAAO,CAAC,qBAAqB,CAAC,KAAK,GAAG,CAAC,EAAE,CAAC;QACjD,OAAO,SAAS,CAAA;IAClB,CAAC;IAID,MAAM,OAAO,GAAiC,EAAE,CAAA;IAChD,IAAI,gBAAgB,GAAG,KAAK,CAAA;IAE5B,KAAK,MAAM,CAAC,SAAS,EAAE,WAAW,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,YAAY,CAAC,EAAE,CAAC;QACpE,IAAI,WAAW,EAAE,IAAI,KAAK,cAAc,IAAI,KAAK,IAAI,WAAW,IAAI,WAAW,CAAC,GAAG,EAAE,CAAC;YACpF,gBAAgB,GAAG,IAAI,CAAA;YACvB,MAAM,aAAa,GAAG,oBAAoB,CAAC,WAAW,CAAC,GAAa,EAAE,MAAM,CAAC,CAAA;YAE7E,IAAI,aAAa,EAAE,CAAC;gBAClB,0CAA0C;gBAC1C,MAAM,WAAW,GAAG,aAAa,CAAC,UAAU,CAAC,MAAM,EAAE,SAAS,EAAE,KAAK,CAAA;gBACrE,MAAM,YAAY,GAAG,MAAM,WAAW,CAAC,WAAW,EAAE;oBAClD,OAAO,EAAE,IAAI,CAAC,OAAO;oBACrB,OAAO,EAAE,IAAI,CAAC,OAAO;iBACtB,CAAC,CAAA;gBAEF,4DAA4D;gBAC5D,IAAI,YAAY,KAAK,KAAK,EAAE,CAAC;oBAC3B,SAAQ;gBACV,CAAC;gBAED,0BAA0B;gBAC1B,MAAM,YAAY,GAA4B,EAAE,CAAA;gBAEhD,yDAAyD;gBACzD,IAAI,OAAO,YAAY,KAAK,QAAQ,EAAE,CAAC;oBACrC,YAAY,CAAC,KAAK,GAAG,YAAY,CAAA;gBACnC,CAAC;gBAED,oCAAoC;gBACpC,MAAM,aAAa,GAAG,MAAM,6BAA6B,CACvD,aAAa,CAAC,UAAU,CAAC,MAAM,EAC/B,IAAI,EACJ,MAAM,EACN,KAAK,GAAG,CAAC,CACV,CAAA;gBAED,IAAI,aAAa,IAAI,MAAM,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;oBAC3D,YAAY,CAAC,OAAO,GAAG,aAAa,CAAA;gBACtC,CAAC;gBAED,wBAAwB;gBACxB,OAAO,CAAC,SAAS,CAAC,GAAG,MAAM,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAA;YACjF,CAAC;QACH,CAAC;IACH,CAAC;IAED,OAAO,gBAAgB,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAA;AAC/C,CAAC"}
|
package/dist/access/engine.d.ts
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
import type { AccessControl, Session, AccessContext, PrismaFilter } from './types.js';
|
|
2
|
-
import type {
|
|
3
|
-
|
|
2
|
+
import type { OpenSaasConfig, ListConfig } from '../config/types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Access engine — operation-level access control and shared helpers.
|
|
5
|
+
*
|
|
6
|
+
* This module holds the *operation-level* (list-level) access primitives and
|
|
7
|
+
* the ref-parsing helper shared across both phases of the two-phase read:
|
|
8
|
+
*
|
|
9
|
+
* - Phase 1, Access Filter (pre-query row/relation scoping): `access-filter.ts`
|
|
10
|
+
* - Phase 2, Field Visibility (post-query field stripping + resolveOutput +
|
|
11
|
+
* virtual fields): `field-visibility.ts`
|
|
12
|
+
*
|
|
13
|
+
* Field-level access evaluation is centralized in `field-access.ts`
|
|
14
|
+
* (`checkFieldAccess`). See `docs/adr/0001-access-control-is-a-two-phase-read.md`
|
|
15
|
+
* and the access-control glossary in `CONTEXT.md`.
|
|
16
|
+
*/
|
|
4
17
|
/**
|
|
5
18
|
* Check if access control result is a boolean
|
|
6
19
|
*/
|
|
@@ -33,50 +46,4 @@ export declare function checkAccess<T = Record<string, unknown>>(accessControl:
|
|
|
33
46
|
* Merge user filter with access control filter
|
|
34
47
|
*/
|
|
35
48
|
export declare function mergeFilters(userFilter: PrismaFilter | undefined, accessFilter: boolean | PrismaFilter): PrismaFilter | null;
|
|
36
|
-
/**
|
|
37
|
-
* Check field-level access for a specific operation
|
|
38
|
-
*/
|
|
39
|
-
export declare function checkFieldAccess(fieldAccess: FieldAccess | undefined, operation: 'read' | 'create' | 'update', args: {
|
|
40
|
-
session: Session | null;
|
|
41
|
-
item?: Record<string, unknown>;
|
|
42
|
-
context: AccessContext & {
|
|
43
|
-
_isSudo?: boolean;
|
|
44
|
-
};
|
|
45
|
-
inputData?: Record<string, unknown>;
|
|
46
|
-
}): Promise<boolean>;
|
|
47
|
-
/**
|
|
48
|
-
* Build Prisma include object with access control filters
|
|
49
|
-
* This allows us to filter relationships at the database level instead of in memory
|
|
50
|
-
*/
|
|
51
|
-
export declare function buildIncludeWithAccessControl(fieldConfigs: Record<string, FieldConfig>, args: {
|
|
52
|
-
session: Session | null;
|
|
53
|
-
context: AccessContext;
|
|
54
|
-
}, config: OpenSaasConfig, depth?: number): Promise<Record<string, boolean | {
|
|
55
|
-
where?: PrismaFilter;
|
|
56
|
-
include?: Record<string, boolean | /*elided*/ any>;
|
|
57
|
-
}> | undefined>;
|
|
58
|
-
/**
|
|
59
|
-
* Filter fields from an object based on read access
|
|
60
|
-
* Recursively applies access control to nested relationships
|
|
61
|
-
*/
|
|
62
|
-
export declare function filterReadableFields<T extends Record<string, unknown>>(item: T, fieldConfigs: Record<string, FieldConfig>, args: {
|
|
63
|
-
session: Session | null;
|
|
64
|
-
context: AccessContext & {
|
|
65
|
-
_isSudo?: boolean;
|
|
66
|
-
};
|
|
67
|
-
}, config?: OpenSaasConfig, depth?: number, listKey?: string): Promise<Partial<T>>;
|
|
68
|
-
/**
|
|
69
|
-
* Filter fields from input data based on write access (create/update)
|
|
70
|
-
*/
|
|
71
|
-
export declare function filterWritableFields<T extends Record<string, unknown>>(data: T, fieldConfigs: Record<string, {
|
|
72
|
-
access?: FieldAccess;
|
|
73
|
-
type?: string;
|
|
74
|
-
}>, operation: 'create' | 'update', args: {
|
|
75
|
-
session: Session | null;
|
|
76
|
-
item?: Record<string, unknown>;
|
|
77
|
-
context: AccessContext & {
|
|
78
|
-
_isSudo?: boolean;
|
|
79
|
-
};
|
|
80
|
-
inputData?: Record<string, unknown>;
|
|
81
|
-
}): Promise<Partial<T>>;
|
|
82
49
|
//# sourceMappingURL=engine.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../src/access/engine.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AACrF,OAAO,KAAK,EAAE,
|
|
1
|
+
{"version":3,"file":"engine.d.ts","sourceRoot":"","sources":["../../src/access/engine.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,OAAO,EAAE,aAAa,EAAE,YAAY,EAAE,MAAM,YAAY,CAAA;AACrF,OAAO,KAAK,EAAE,cAAc,EAAE,UAAU,EAAE,MAAM,oBAAoB,CAAA;AAEpE;;;;;;;;;;;;;GAaG;AAEH;;GAEG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,OAAO,CAE1D;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,YAAY,CAEpE;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,eAAe,EAAE,MAAM,EACvB,MAAM,EAAE,cAAc,GAErB;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,UAAU,EAAE,UAAU,CAAC,GAAG,CAAC,CAAA;CAAE,GAAG,IAAI,CAe1D;AAED;;GAEG;AACH,wBAAsB,WAAW,CAAC,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC3D,aAAa,EAAE,aAAa,CAAC,CAAC,CAAC,GAAG,SAAS,EAC3C,IAAI,EAAE;IACJ,OAAO,EAAE,OAAO,GAAG,IAAI,CAAA;IACvB,IAAI,CAAC,EAAE,CAAC,CAAA;IACR,OAAO,EAAE,aAAa,CAAA;CACvB,GACA,OAAO,CAAC,OAAO,GAAG,YAAY,CAAC,CAAC,CAAC,CAAC,CAUpC;AAED;;GAEG;AACH,wBAAgB,YAAY,CAC1B,UAAU,EAAE,YAAY,GAAG,SAAS,EACpC,YAAY,EAAE,OAAO,GAAG,YAAY,GACnC,YAAY,GAAG,IAAI,CAoBrB"}
|
package/dist/access/engine.js
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Access engine — operation-level access control and shared helpers.
|
|
3
|
+
*
|
|
4
|
+
* This module holds the *operation-level* (list-level) access primitives and
|
|
5
|
+
* the ref-parsing helper shared across both phases of the two-phase read:
|
|
6
|
+
*
|
|
7
|
+
* - Phase 1, Access Filter (pre-query row/relation scoping): `access-filter.ts`
|
|
8
|
+
* - Phase 2, Field Visibility (post-query field stripping + resolveOutput +
|
|
9
|
+
* virtual fields): `field-visibility.ts`
|
|
10
|
+
*
|
|
11
|
+
* Field-level access evaluation is centralized in `field-access.ts`
|
|
12
|
+
* (`checkFieldAccess`). See `docs/adr/0001-access-control-is-a-two-phase-read.md`
|
|
13
|
+
* and the access-control glossary in `CONTEXT.md`.
|
|
14
|
+
*/
|
|
1
15
|
/**
|
|
2
16
|
* Check if access control result is a boolean
|
|
3
17
|
*/
|
|
@@ -64,284 +78,4 @@ export function mergeFilters(userFilter, accessFilter) {
|
|
|
64
78
|
AND: [accessFilter, userFilter],
|
|
65
79
|
};
|
|
66
80
|
}
|
|
67
|
-
/**
|
|
68
|
-
* Check field-level access for a specific operation
|
|
69
|
-
*/
|
|
70
|
-
export async function checkFieldAccess(fieldAccess, operation, args) {
|
|
71
|
-
// Skip access check in sudo mode
|
|
72
|
-
if (args.context._isSudo) {
|
|
73
|
-
return true;
|
|
74
|
-
}
|
|
75
|
-
if (!fieldAccess) {
|
|
76
|
-
return true; // No field access means allow
|
|
77
|
-
}
|
|
78
|
-
const accessControl = fieldAccess[operation];
|
|
79
|
-
if (!accessControl) {
|
|
80
|
-
return true; // No specific access control means allow
|
|
81
|
-
}
|
|
82
|
-
const result = await accessControl({
|
|
83
|
-
session: args.session,
|
|
84
|
-
item: args.item,
|
|
85
|
-
context: args.context,
|
|
86
|
-
inputData: args.inputData,
|
|
87
|
-
operation,
|
|
88
|
-
});
|
|
89
|
-
// If result is false, deny access
|
|
90
|
-
if (result === false) {
|
|
91
|
-
return false;
|
|
92
|
-
}
|
|
93
|
-
// If result is true, allow access
|
|
94
|
-
if (result === true) {
|
|
95
|
-
return true;
|
|
96
|
-
}
|
|
97
|
-
// Default to allowing access if we can't determine
|
|
98
|
-
return true;
|
|
99
|
-
}
|
|
100
|
-
/**
|
|
101
|
-
* Simple filter matching for field-level access
|
|
102
|
-
* Checks if an item matches a Prisma-like filter object
|
|
103
|
-
*/
|
|
104
|
-
function matchesFilter(item, filter) {
|
|
105
|
-
for (const [key, condition] of Object.entries(filter)) {
|
|
106
|
-
if (typeof condition === 'object' && condition !== null) {
|
|
107
|
-
// Handle nested conditions like { equals: value }
|
|
108
|
-
if ('equals' in condition) {
|
|
109
|
-
if (item[key] !== condition.equals) {
|
|
110
|
-
return false;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
else if ('not' in condition) {
|
|
114
|
-
if (item[key] === condition.not) {
|
|
115
|
-
return false;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
// Add more condition types as needed
|
|
119
|
-
}
|
|
120
|
-
else {
|
|
121
|
-
// Direct equality check
|
|
122
|
-
if (item[key] !== condition) {
|
|
123
|
-
return false;
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
return true;
|
|
128
|
-
}
|
|
129
|
-
/**
|
|
130
|
-
* Build Prisma include object with access control filters
|
|
131
|
-
* This allows us to filter relationships at the database level instead of in memory
|
|
132
|
-
*/
|
|
133
|
-
export async function buildIncludeWithAccessControl(fieldConfigs, args, config, depth = 0) {
|
|
134
|
-
const MAX_DEPTH = 5;
|
|
135
|
-
if (depth >= MAX_DEPTH) {
|
|
136
|
-
return undefined;
|
|
137
|
-
}
|
|
138
|
-
// Skip auto-including relationships when inside a resolveOutput hook
|
|
139
|
-
// This prevents infinite loops when hooks make DB queries that include
|
|
140
|
-
// relationships back to the same entity (e.g., User virtual field queries Posts
|
|
141
|
-
// which includes author back to User, triggering the virtual field again)
|
|
142
|
-
if (args.context._resolveOutputCounter.depth > 0) {
|
|
143
|
-
return undefined;
|
|
144
|
-
}
|
|
145
|
-
const include = {};
|
|
146
|
-
let hasRelationships = false;
|
|
147
|
-
for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
|
|
148
|
-
if (fieldConfig?.type === 'relationship' && 'ref' in fieldConfig && fieldConfig.ref) {
|
|
149
|
-
hasRelationships = true;
|
|
150
|
-
const relatedConfig = getRelatedListConfig(fieldConfig.ref, config);
|
|
151
|
-
if (relatedConfig) {
|
|
152
|
-
// Check query access for the related list
|
|
153
|
-
const queryAccess = relatedConfig.listConfig.access?.operation?.query;
|
|
154
|
-
const accessResult = await checkAccess(queryAccess, {
|
|
155
|
-
session: args.session,
|
|
156
|
-
context: args.context,
|
|
157
|
-
});
|
|
158
|
-
// If access is completely denied, exclude this relationship
|
|
159
|
-
if (accessResult === false) {
|
|
160
|
-
continue;
|
|
161
|
-
}
|
|
162
|
-
// Build the include entry
|
|
163
|
-
const includeEntry = {};
|
|
164
|
-
// If access returns a filter, add it to the where clause
|
|
165
|
-
if (typeof accessResult === 'object') {
|
|
166
|
-
includeEntry.where = accessResult;
|
|
167
|
-
}
|
|
168
|
-
// Recursively build nested includes
|
|
169
|
-
const nestedInclude = await buildIncludeWithAccessControl(relatedConfig.listConfig.fields, args, config, depth + 1);
|
|
170
|
-
if (nestedInclude && Object.keys(nestedInclude).length > 0) {
|
|
171
|
-
includeEntry.include = nestedInclude;
|
|
172
|
-
}
|
|
173
|
-
// Add to include object
|
|
174
|
-
include[fieldName] = Object.keys(includeEntry).length > 0 ? includeEntry : true;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
return hasRelationships ? include : undefined;
|
|
179
|
-
}
|
|
180
|
-
/**
|
|
181
|
-
* Filter fields from an object based on read access
|
|
182
|
-
* Recursively applies access control to nested relationships
|
|
183
|
-
*/
|
|
184
|
-
export async function filterReadableFields(item, fieldConfigs, args, config, depth = 0, listKey) {
|
|
185
|
-
const filtered = {};
|
|
186
|
-
const MAX_DEPTH = 5; // Prevent infinite recursion
|
|
187
|
-
// Process existing fields from the database result
|
|
188
|
-
for (const [fieldName, value] of Object.entries(item)) {
|
|
189
|
-
const fieldConfig = fieldConfigs[fieldName];
|
|
190
|
-
// Always include id, createdAt, updatedAt
|
|
191
|
-
if (['id', 'createdAt', 'updatedAt'].includes(fieldName)) {
|
|
192
|
-
filtered[fieldName] = value;
|
|
193
|
-
continue;
|
|
194
|
-
}
|
|
195
|
-
// Check field access (checkFieldAccess already handles sudo mode)
|
|
196
|
-
const canRead = await checkFieldAccess(fieldConfig?.access, 'read', {
|
|
197
|
-
...args,
|
|
198
|
-
item,
|
|
199
|
-
});
|
|
200
|
-
if (!canRead) {
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
|
-
// Handle relationship fields - recursively filter fields within related items
|
|
204
|
-
// Note: Access control filtering is now done at database level via buildIncludeWithAccessControl
|
|
205
|
-
// This only handles field-level access (hiding sensitive fields)
|
|
206
|
-
if (config &&
|
|
207
|
-
fieldConfig?.type === 'relationship' &&
|
|
208
|
-
'ref' in fieldConfig &&
|
|
209
|
-
fieldConfig.ref &&
|
|
210
|
-
value !== null &&
|
|
211
|
-
value !== undefined &&
|
|
212
|
-
depth < MAX_DEPTH) {
|
|
213
|
-
const relatedConfig = getRelatedListConfig(fieldConfig.ref, config);
|
|
214
|
-
if (relatedConfig) {
|
|
215
|
-
// For many relationships (arrays) - recursively filter fields in each item
|
|
216
|
-
// The recursive call already handles applying resolveOutput hooks
|
|
217
|
-
if (Array.isArray(value)) {
|
|
218
|
-
filtered[fieldName] = await Promise.all(value.map((relatedItem) => filterReadableFields(relatedItem, relatedConfig.listConfig.fields, args, config, depth + 1, relatedConfig.listName)));
|
|
219
|
-
}
|
|
220
|
-
// For single relationships (objects) - recursively filter fields
|
|
221
|
-
// The recursive call already handles applying resolveOutput hooks
|
|
222
|
-
else if (typeof value === 'object') {
|
|
223
|
-
filtered[fieldName] = await filterReadableFields(value, relatedConfig.listConfig.fields, args, config, depth + 1, relatedConfig.listName);
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
else {
|
|
227
|
-
// Related config not found, include the value as-is
|
|
228
|
-
filtered[fieldName] = value;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
else {
|
|
232
|
-
// Non-relationship field or no config provided - apply resolveOutput hook if present
|
|
233
|
-
if (fieldConfig?.hooks?.resolveOutput && listKey) {
|
|
234
|
-
// Cast to runtime type for generic execution
|
|
235
|
-
// At runtime, the hook will receive the correct value type for the field
|
|
236
|
-
const hook = fieldConfig.hooks.resolveOutput;
|
|
237
|
-
// Increment depth counter to prevent infinite loops from hooks making DB queries
|
|
238
|
-
// that include relationships back to the same entity
|
|
239
|
-
args.context._resolveOutputCounter.depth++;
|
|
240
|
-
try {
|
|
241
|
-
// Use Promise.resolve() to handle both sync and async hooks
|
|
242
|
-
filtered[fieldName] = await Promise.resolve(hook({
|
|
243
|
-
value,
|
|
244
|
-
operation: 'query',
|
|
245
|
-
fieldName,
|
|
246
|
-
listKey,
|
|
247
|
-
item,
|
|
248
|
-
context: args.context,
|
|
249
|
-
}));
|
|
250
|
-
}
|
|
251
|
-
finally {
|
|
252
|
-
args.context._resolveOutputCounter.depth--;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
else {
|
|
256
|
-
filtered[fieldName] = value;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
// Process virtual fields - compute values from other fields
|
|
261
|
-
// Virtual fields don't exist in the database result, so we need to compute them separately
|
|
262
|
-
for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
|
|
263
|
-
// Skip if already processed (from database result)
|
|
264
|
-
if (fieldName in filtered) {
|
|
265
|
-
continue;
|
|
266
|
-
}
|
|
267
|
-
// Only process virtual fields
|
|
268
|
-
if (!fieldConfig.virtual) {
|
|
269
|
-
continue;
|
|
270
|
-
}
|
|
271
|
-
// Check field access
|
|
272
|
-
const canRead = await checkFieldAccess(fieldConfig.access, 'read', {
|
|
273
|
-
...args,
|
|
274
|
-
item,
|
|
275
|
-
});
|
|
276
|
-
if (!canRead) {
|
|
277
|
-
continue;
|
|
278
|
-
}
|
|
279
|
-
// Virtual fields must have resolveOutput hook to compute their value
|
|
280
|
-
if (fieldConfig.hooks?.resolveOutput && listKey) {
|
|
281
|
-
const hook = fieldConfig.hooks.resolveOutput;
|
|
282
|
-
// Increment depth counter to prevent infinite loops from hooks making DB queries
|
|
283
|
-
// that include relationships back to the same entity
|
|
284
|
-
args.context._resolveOutputCounter.depth++;
|
|
285
|
-
try {
|
|
286
|
-
// Use Promise.resolve() to handle both sync and async hooks
|
|
287
|
-
filtered[fieldName] = await Promise.resolve(hook({
|
|
288
|
-
value: undefined, // Virtual fields don't have a database value
|
|
289
|
-
operation: 'query',
|
|
290
|
-
fieldName,
|
|
291
|
-
listKey,
|
|
292
|
-
item: filtered, // Pass filtered item so virtual field can access other fields
|
|
293
|
-
context: args.context,
|
|
294
|
-
}));
|
|
295
|
-
}
|
|
296
|
-
finally {
|
|
297
|
-
args.context._resolveOutputCounter.depth--;
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
return filtered;
|
|
302
|
-
}
|
|
303
|
-
/**
|
|
304
|
-
* Filter fields from input data based on write access (create/update)
|
|
305
|
-
*/
|
|
306
|
-
export async function filterWritableFields(data, fieldConfigs, operation, args) {
|
|
307
|
-
const filtered = {};
|
|
308
|
-
// Build a set of foreign key field names to exclude
|
|
309
|
-
// Foreign keys should not be in the data when using Prisma's relation syntax
|
|
310
|
-
const foreignKeyFields = new Set();
|
|
311
|
-
for (const [fieldName, fieldConfig] of Object.entries(fieldConfigs)) {
|
|
312
|
-
if (fieldConfig.type === 'relationship') {
|
|
313
|
-
// For non-many relationships, Prisma creates a foreign key field named `${fieldName}Id`
|
|
314
|
-
const relConfig = fieldConfig;
|
|
315
|
-
if (!relConfig.many) {
|
|
316
|
-
foreignKeyFields.add(`${fieldName}Id`);
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
for (const [fieldName, value] of Object.entries(data)) {
|
|
321
|
-
const fieldConfig = fieldConfigs[fieldName];
|
|
322
|
-
// Skip system fields
|
|
323
|
-
if (['id', 'createdAt', 'updatedAt'].includes(fieldName)) {
|
|
324
|
-
continue;
|
|
325
|
-
}
|
|
326
|
-
// Skip virtual fields - they don't store in database
|
|
327
|
-
// Virtual fields with resolveInput hooks handle side effects separately
|
|
328
|
-
if (fieldConfig && 'virtual' in fieldConfig && fieldConfig.virtual) {
|
|
329
|
-
continue;
|
|
330
|
-
}
|
|
331
|
-
// Skip foreign key fields (e.g., authorId) when their corresponding relationship field exists
|
|
332
|
-
// This prevents conflicts when using Prisma's relation syntax (e.g., author: { connect: { id } })
|
|
333
|
-
if (foreignKeyFields.has(fieldName)) {
|
|
334
|
-
continue;
|
|
335
|
-
}
|
|
336
|
-
// Check field access (checkFieldAccess already handles sudo mode)
|
|
337
|
-
const canWrite = await checkFieldAccess(fieldConfig?.access, operation, {
|
|
338
|
-
...args,
|
|
339
|
-
inputData: args.inputData,
|
|
340
|
-
});
|
|
341
|
-
if (canWrite) {
|
|
342
|
-
filtered[fieldName] = value;
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
return filtered;
|
|
346
|
-
}
|
|
347
81
|
//# sourceMappingURL=engine.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"engine.js","sourceRoot":"","sources":["../../src/access/engine.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"engine.js","sourceRoot":"","sources":["../../src/access/engine.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;GAaG;AAEH;;GAEG;AACH,MAAM,UAAU,SAAS,CAAC,KAAc;IACtC,OAAO,OAAO,KAAK,KAAK,SAAS,CAAA;AACnC,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,cAAc,CAAC,KAAc;IAC3C,OAAO,OAAO,KAAK,KAAK,QAAQ,IAAI,KAAK,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,CAAA;AAC7E,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,oBAAoB,CAClC,eAAuB,EACvB,MAAsB;IAGtB,uDAAuD;IACvD,MAAM,KAAK,GAAG,eAAe,CAAC,KAAK,CAAC,GAAG,CAAC,CAAA;IACxC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;QAC7C,OAAO,IAAI,CAAA;IACb,CAAC;IAED,MAAM,QAAQ,GAAG,KAAK,CAAC,CAAC,CAAC,CAAA;IACzB,MAAM,UAAU,GAAG,MAAM,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAA;IAEzC,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,OAAO,IAAI,CAAA;IACb,CAAC;IAED,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,CAAA;AACjC,CAAC;AAED;;GAEG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,aAA2C,EAC3C,IAIC;IAED,0CAA0C;IAC1C,IAAI,CAAC,aAAa,EAAE,CAAC;QACnB,OAAO,KAAK,CAAA;IACd,CAAC;IAED,sCAAsC;IACtC,MAAM,MAAM,GAAG,MAAM,aAAa,CAAC,IAAI,CAAC,CAAA;IAExC,OAAO,MAAM,CAAA;AACf,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,YAAY,CAC1B,UAAoC,EACpC,YAAoC;IAEpC,mCAAmC;IACnC,IAAI,YAAY,KAAK,KAAK,EAAE,CAAC;QAC3B,OAAO,IAAI,CAAA;IACb,CAAC;IAED,8CAA8C;IAC9C,IAAI,YAAY,KAAK,IAAI,EAAE,CAAC;QAC1B,OAAO,UAAU,IAAI,EAAE,CAAA;IACzB,CAAC;IAED,uCAAuC;IACvC,IAAI,CAAC,UAAU,EAAE,CAAC;QAChB,OAAO,YAAY,CAAA;IACrB,CAAC;IAED,2BAA2B;IAC3B,OAAO;QACL,GAAG,EAAE,CAAC,YAAY,EAAE,UAAU,CAAC;KAChC,CAAA;AACH,CAAC"}
|