@edium/halifax 2.2.3 → 2.3.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/CHANGELOG.md +133 -0
- package/README_AUTH.md +2 -2
- package/README_AUTOCRUD.md +5 -4
- package/README_CACHE.md +6 -0
- package/README_CLASSES.md +13 -6
- package/README_INTERFACES.md +13 -11
- package/README_OPENAPI.md +1 -1
- package/README_REPO_ADAPTERS.md +10 -0
- package/dist/adapters/orm/drizzle/DrizzleAdapter.d.ts +5 -0
- package/dist/adapters/orm/drizzle/DrizzleAdapter.js +9 -1
- package/dist/adapters/orm/prisma/PrismaAdapter.d.ts +1 -2
- package/dist/adapters/orm/prisma/PrismaAdapter.js +106 -34
- package/dist/adapters/orm/prisma/astToPrisma.js +6 -0
- package/dist/auth/strategies/JwtClaimsAuthStrategy.js +2 -8
- package/dist/auth/strategies/PassportStrategies.js +3 -9
- package/dist/auth/strategies/types.d.ts +7 -0
- package/dist/auth/strategies/types.js +13 -1
- package/dist/core/cache/CacheStore.d.ts +12 -0
- package/dist/core/cache/createCachingRepository.js +10 -1
- package/dist/core/cache/in-memory/InMemoryCacheStore.d.ts +14 -1
- package/dist/core/cache/in-memory/InMemoryCacheStore.js +29 -1
- package/dist/core/cache/redis/RedisCacheStore.d.ts +6 -0
- package/dist/core/cache/redis/RedisCacheStore.js +14 -0
- package/dist/core/cache/redis/RedisLikeClient.d.ts +2 -0
- package/dist/core/crudRouter.js +2 -20
- package/dist/core/fields.d.ts +11 -1
- package/dist/core/fields.js +19 -0
- package/dist/core/handlerUtils.d.ts +6 -0
- package/dist/core/handlerUtils.js +15 -11
- package/dist/core/handlers/create.js +3 -2
- package/dist/core/handlers/query.js +3 -5
- package/dist/core/handlers/readMany.js +3 -5
- package/dist/core/handlers/readOne.js +3 -6
- package/dist/core/handlers/updateMany.js +3 -4
- package/dist/core/queryString.d.ts +10 -0
- package/dist/core/queryString.js +23 -0
- package/dist/core/validation.js +5 -11
- package/dist/openapi/specGenerator.js +19 -19
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,139 @@
|
|
|
3
3
|
All notable changes to this project are documented here. This project adheres to
|
|
4
4
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
5
5
|
|
|
6
|
+
## [2.3.0]
|
|
7
|
+
|
|
8
|
+
### Security
|
|
9
|
+
|
|
10
|
+
- **`PrismaAdapter.upsertOne` cross-tenant write vulnerability eliminated** — the previous scoped
|
|
11
|
+
upsert path performed `findFirst({ where: { id } })` without a tenant filter, then called
|
|
12
|
+
`delegate.upsert({ where: { id } })` also without a tenant filter. The application-level
|
|
13
|
+
ownership check between the two statements created a TOCTOU window: if another tenant's record
|
|
14
|
+
appeared at that ID between the check and the upsert, Prisma's `upsert` would fire its `update`
|
|
15
|
+
branch and write into that other tenant's row. The scoped path no longer uses `delegate.upsert`.
|
|
16
|
+
It now uses a scoped `findFirst(scopedWhere)` to detect existence, `updateMany(scopedWhere)` for
|
|
17
|
+
the update branch (atomic, same fix applied to `updateOne`), and `create(stampTenant)` for the
|
|
18
|
+
create branch. The `delegate.upsert` call is retained only for the unscoped path.
|
|
19
|
+
|
|
20
|
+
- **`PrismaAdapter.updateOne` and `deleteOne` unsafe fallbacks removed** — when `updateMany`
|
|
21
|
+
(for `updateOne`) or `deleteMany` (for `deleteOne`) were unavailable on the delegate, the code
|
|
22
|
+
fell back to a two-step scoped-check + unscoped-write sequence. Both halves now throw
|
|
23
|
+
`ServerError` immediately rather than proceeding with an unsafe path. Standard Prisma delegates
|
|
24
|
+
always expose both methods, so this only affects non-standard or mock delegates.
|
|
25
|
+
|
|
26
|
+
- **`requiredPermissions` now uses OR semantics consistently** — all three auth strategies
|
|
27
|
+
(`JwtClaimsAuthStrategy`, `PassportJwtStrategy`, `PassportSessionStrategy`) previously used
|
|
28
|
+
`.every()` (ALL permissions required), while the fallback path in `handlerUtils.ts` correctly
|
|
29
|
+
used `.some()` (ANY single permission grants access). The strategies were wrong. All four paths
|
|
30
|
+
now delegate to a shared `checkRequiredPermissions()` utility that applies `.some()` semantics:
|
|
31
|
+
a caller who holds *any one* of the listed permissions is authorized. This matches the documented
|
|
32
|
+
behavior of `readRoles`/`writeRoles` at the field level and is a consistent read of "required
|
|
33
|
+
permissions" as an OR list. **If you relied on the undocumented ALL-must-match behavior,
|
|
34
|
+
tighten your permission model accordingly.**
|
|
35
|
+
|
|
36
|
+
- **TOCTOU race in `PrismaAdapter.updateOne` under tenant scope eliminated** — the previous
|
|
37
|
+
scoped-update path did a `findFirst` ownership check and then a separate `update` call. Between
|
|
38
|
+
the two statements, a concurrent request could transfer the record to a different tenant,
|
|
39
|
+
allowing the update to land on a record the caller no longer owns. The fix uses
|
|
40
|
+
`updateMany({ where: scopedWhere })` as the primary path when the Prisma delegate supports it —
|
|
41
|
+
the tenant field is enforced atomically in a single SQL statement. A safe `findFirst`-then-update
|
|
42
|
+
fallback remains only for delegates that lack `updateMany`.
|
|
43
|
+
|
|
44
|
+
### Performance
|
|
45
|
+
|
|
46
|
+
- **O(n²) → O(n) field validation** — `validateSelectableFields` and `validateSortableFields`
|
|
47
|
+
previously called `.find()` inside a `.filter()`, giving O(n²) complexity per request. Both now
|
|
48
|
+
pre-build a `Map` from field names to definitions and do O(1) lookups in the filter pass.
|
|
49
|
+
|
|
50
|
+
- **`makeReadableFieldFilter` factory for bulk-response field stripping** — `filterReadableFields`
|
|
51
|
+
previously rebuilt the `fieldMap` `Map` and `userRoles` `Set` for every record in a batch. A
|
|
52
|
+
new exported factory `makeReadableFieldFilter(resource, auth)` builds both structures once and
|
|
53
|
+
returns a reusable `(record) => record` function. The `readMany`, `query`, and `updateMany`
|
|
54
|
+
handlers now call the factory once before `.map()` and pass the compiled function directly,
|
|
55
|
+
eliminating O(n) redundant Map constructions for responses with up to 5000 records.
|
|
56
|
+
|
|
57
|
+
- **`parseGetOneOptions` for lean GET /:id parsing** — `readOne` previously called
|
|
58
|
+
`parseListOptions`, which parses and validates `?fields=`, `?include=`, `?limit=`, `?offset=`,
|
|
59
|
+
`?order=`, and all filter fields — all of which are irrelevant to a single-record GET. A new
|
|
60
|
+
exported `parseGetOneOptions(query, resource)` in `queryString.ts` only parses and validates
|
|
61
|
+
`?fields=` and `?include=`, removing the wasted work and the silent discard of inapplicable
|
|
62
|
+
parameters on that route.
|
|
63
|
+
|
|
64
|
+
### Fixed
|
|
65
|
+
|
|
66
|
+
- **OpenAPI `distinct` type corrected** — the query-builder endpoint's `distinct` parameter was
|
|
67
|
+
documented as `{ type: 'boolean' }` in the generated spec. The actual type is `string[]` (an
|
|
68
|
+
array of field names to de-duplicate on). The spec now documents it as
|
|
69
|
+
`{ type: 'array', items: { type: 'string' } }` with an accurate description.
|
|
70
|
+
|
|
71
|
+
- **`normalizeEnvelope` and `mergeRelationDefinitions` extracted to `fields.ts`** — both
|
|
72
|
+
utilities existed in identical form in `crudRouter.ts` and `specGenerator.ts`. They are now
|
|
73
|
+
exported from `src/core/fields.ts` alongside `mergeFieldDefinitions`, and both files import
|
|
74
|
+
from there. Behavior is unchanged.
|
|
75
|
+
|
|
76
|
+
- **`checkRequiredPermissions` extracted to `src/auth/strategies/types.ts`** — the permission
|
|
77
|
+
check logic was duplicated (with incompatible `.every()` vs `.some()` semantics) across all
|
|
78
|
+
three strategy files and `handlerUtils.ts`. It is now a single exported function and all four
|
|
79
|
+
call sites use it. See the Security section above for the semantics fix.
|
|
80
|
+
|
|
81
|
+
- **`DrizzleAdapter` reports `supportsIncludes: false`** — the adapter has always silently ignored
|
|
82
|
+
`?include=` requests because Drizzle has no built-in relation eager-loading surface compatible
|
|
83
|
+
with Halifax's interface. The `capabilities` property now explicitly sets `supportsIncludes:
|
|
84
|
+
false`, which causes the router to reject `?include=` with `422 Unprocessable Entity` instead
|
|
85
|
+
of silently returning records with no related data.
|
|
86
|
+
|
|
87
|
+
- **`InMemoryCacheStore` memory growth bounded** — the store previously only evicted expired
|
|
88
|
+
entries lazily on reads, so a write-heavy workload with few reads could accumulate stale entries
|
|
89
|
+
without bound. The `set()` method now runs a full expired-entry sweep every 200 writes
|
|
90
|
+
(configurable via the `sweepEvery` constructor parameter). No external timers or additional
|
|
91
|
+
dependencies.
|
|
92
|
+
|
|
93
|
+
- **`CacheStore.increment` — atomic cache version bumps** — `createCachingRepository` previously
|
|
94
|
+
incremented the version key with a non-atomic `GET` + `SET`, creating a race window under
|
|
95
|
+
concurrent writes on Redis. The `CacheStore` interface gains an optional `increment(key):
|
|
96
|
+
Promise<number> | number` method. `RedisCacheStore` implements it with Redis `INCR` (atomic
|
|
97
|
+
by design). `InMemoryCacheStore` implements it synchronously (race-free in Node.js's
|
|
98
|
+
single-threaded event loop). `createCachingRepository` uses `store.increment` when available
|
|
99
|
+
and falls back to the non-atomic path only for custom stores that do not implement it.
|
|
100
|
+
|
|
101
|
+
- **`DrizzleAdapter.upsertOne` non-atomicity documented** — the method does a `getOne` check
|
|
102
|
+
followed by a `createOne` or `updateOne`, which is non-atomic under concurrent load. A JSDoc
|
|
103
|
+
comment now explains the race condition and recommends implementing a custom repository with a
|
|
104
|
+
database-native `INSERT … ON CONFLICT` clause when true atomicity is required.
|
|
105
|
+
|
|
106
|
+
- **`PrismaAdapter` import order** — all imports were moved above function definitions and
|
|
107
|
+
consolidated into a single, alphabetized block. No behavior change.
|
|
108
|
+
|
|
109
|
+
- **`parseId` dead code removed** — after `validateId(raw)` narrows `raw` to `string`, the
|
|
110
|
+
previous `typeof raw === 'string' ? parseInt(raw, 10) : raw` branch was unreachable. The
|
|
111
|
+
function now calls `parseInt(raw, 10)` directly, and the redundant type guard before the
|
|
112
|
+
UUID/ObjectId check is also removed.
|
|
113
|
+
|
|
114
|
+
- **`LIKE` interior-wildcard limitation documented** — `likeToPrisma` in
|
|
115
|
+
`src/adapters/orm/prisma/astToPrisma.ts` now carries a JSDoc comment explaining that patterns
|
|
116
|
+
with an interior wildcard (e.g. `'foo%bar'`) cannot be expressed via Prisma's string operators
|
|
117
|
+
and fall through to an exact `equals` match, not a wildcard match. Use `CONTAINS`, `STARTS
|
|
118
|
+
WITH`, or `ENDS WITH` comparisons instead of `LIKE` when possible.
|
|
119
|
+
|
|
120
|
+
### Documentation
|
|
121
|
+
|
|
122
|
+
- **`QueryBuilder` mutability warning** — the class JSDoc now clearly states that all chaining
|
|
123
|
+
methods mutate `this` and return the same instance. A before/after code example demonstrates
|
|
124
|
+
the correct pattern (create a new `QueryBuilder` per branch) and the incorrect pattern (sharing
|
|
125
|
+
one instance and calling `.limit()` twice).
|
|
126
|
+
|
|
127
|
+
- **`andGroup` / `orGroup` API design explained** — both methods require a `field`/`comparison`/
|
|
128
|
+
`value1` parent condition because the Halifax query AST attaches children to a parent filter
|
|
129
|
+
node. The JSDoc now explains this constraint, describes what the resulting predicate looks like
|
|
130
|
+
(`cond AND (children)` vs `cond OR (children)`), and suggests a workaround for expressing a
|
|
131
|
+
pure parenthesized group with no meaningful parent condition.
|
|
132
|
+
|
|
133
|
+
- **`specGenerator` raw-vs-normalized resource note** — a comment in `generateOpenApiSpec`
|
|
134
|
+
explains that the function operates on raw `ResourceDefinition` objects (not the normalized
|
|
135
|
+
forms produced by `crudRouter.normalizeResource`) and therefore re-derives merged fields and
|
|
136
|
+
relations independently. This is intentional — the spec generator is also usable as a
|
|
137
|
+
standalone function outside the router.
|
|
138
|
+
|
|
6
139
|
## [2.2.3]
|
|
7
140
|
|
|
8
141
|
### Added
|
package/README_AUTH.md
CHANGED
|
@@ -37,7 +37,7 @@ const authStrategy = new ApiKeyAuthStrategy(process.env.API_KEY ?? '')
|
|
|
37
37
|
const authStrategy = new ApiKeyAuthStrategy(process.env.API_KEY ?? '', 'x-token')
|
|
38
38
|
```
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
Missing header → 401 Unauthorized. Wrong key → 403 Forbidden.
|
|
41
41
|
|
|
42
42
|
### `JwtClaimsAuthStrategy`
|
|
43
43
|
|
|
@@ -194,7 +194,7 @@ class RoleBasedStrategy implements AuthStrategy {
|
|
|
194
194
|
|
|
195
195
|
authorize({ auth, action, resource, requiredPermissions }) {
|
|
196
196
|
if (auth.roles.includes('admin')) return true
|
|
197
|
-
return requiredPermissions.
|
|
197
|
+
return requiredPermissions.some((p) => auth.permissions.includes(p) || auth.roles.includes(p))
|
|
198
198
|
}
|
|
199
199
|
}
|
|
200
200
|
```
|
package/README_AUTOCRUD.md
CHANGED
|
@@ -100,9 +100,9 @@ Each entry in `fields` accepts four optional boolean flags. All default to `true
|
|
|
100
100
|
|
|
101
101
|
| Flag | Effect when `false` |
|
|
102
102
|
| ------------ | --------------------------------------------------------------------- |
|
|
103
|
-
| `filterable` | Rejects the field as a query-string filter (`?fieldName=value`) →
|
|
104
|
-
| `sortable` | Rejects the field in `?order=` and query-builder `orderBy` →
|
|
105
|
-
| `selectable` | Rejects the field in `?fields=` and query-builder `fields` →
|
|
103
|
+
| `filterable` | Rejects the field as a query-string filter (`?fieldName=value`) → 422 |
|
|
104
|
+
| `sortable` | Rejects the field in `?order=` and query-builder `orderBy` → 422 |
|
|
105
|
+
| `selectable` | Rejects the field in `?fields=` and query-builder `fields` → 422 |
|
|
106
106
|
| `writable` | Silently strips the field from POST / PATCH / PUT request bodies |
|
|
107
107
|
|
|
108
108
|
Example — `role` is fully locked down; `createdAt` can be read and sorted but never written or filtered:
|
|
@@ -295,7 +295,7 @@ The `?where` / query-builder `children` filter lets callers nest conditions. To
|
|
|
295
295
|
}
|
|
296
296
|
```
|
|
297
297
|
|
|
298
|
-
Requests that exceed the limit receive **
|
|
298
|
+
Requests that exceed the limit receive **422 UNPROCESSABLE_ENTITY**.
|
|
299
299
|
|
|
300
300
|
## Error Response Shape
|
|
301
301
|
|
|
@@ -320,6 +320,7 @@ All errors follow the same envelope — an `errors` array where each item has a
|
|
|
320
320
|
| 404 | `NOT_FOUND` | `NotFoundError` | Record not found |
|
|
321
321
|
| 405 | `METHOD_NOT_ALLOWED` | `MethodNotAllowedError` | HTTP method not enabled for this resource |
|
|
322
322
|
| 406 | `NOT_ACCEPTABLE` | `NotAcceptableError` | `Accept` header excludes `application/json` |
|
|
323
|
+
| 409 | `CONFLICT` | `ConflictError` | Write rejected due to a unique constraint violation |
|
|
323
324
|
| 415 | `UNSUPPORTED_MEDIA_TYPE` | `UnsupportedMediaTypeError` | Request body is not `application/json` |
|
|
324
325
|
| 422 | `UNPROCESSABLE_ENTITY` | `UnprocessableEntityError` | Semantic validation failure — unknown field, invalid filter, sort/select restriction, depth exceeded |
|
|
325
326
|
| 500 | `INTERNAL_ERROR` | `ServerError` | Repository misconfigured or unhandled internal error |
|
package/README_CACHE.md
CHANGED
|
@@ -149,6 +149,12 @@ class MyCacheStore implements CacheStore {
|
|
|
149
149
|
async delete(key: string): Promise<void> {
|
|
150
150
|
/* … */
|
|
151
151
|
}
|
|
152
|
+
// Optional — implement for atomic version bumps under concurrent writes.
|
|
153
|
+
// When omitted, Halifax falls back to a non-atomic GET + SET (safe for
|
|
154
|
+
// single-process stores like InMemoryCacheStore, but not for Redis).
|
|
155
|
+
async increment(key: string): Promise<number> {
|
|
156
|
+
/* … return new integer value */
|
|
157
|
+
}
|
|
152
158
|
}
|
|
153
159
|
```
|
|
154
160
|
|
package/README_CLASSES.md
CHANGED
|
@@ -28,13 +28,14 @@ Import: `@edium/halifax`
|
|
|
28
28
|
Reads a request header and compares it against a static shared secret.
|
|
29
29
|
|
|
30
30
|
```ts
|
|
31
|
-
new ApiKeyAuthStrategy(expectedApiKey: string, headerName?: string)
|
|
31
|
+
new ApiKeyAuthStrategy(expectedApiKey: string, headerName?: string, roles?: string[])
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
| Parameter | Default | Description
|
|
35
|
-
| ---------------- | ------------- |
|
|
36
|
-
| `expectedApiKey` | required | The secret key callers must supply.
|
|
37
|
-
| `headerName` | `'x-api-key'` | Header to read the key from.
|
|
34
|
+
| Parameter | Default | Description |
|
|
35
|
+
| ---------------- | ------------- | ------------------------------------------------------------------------ |
|
|
36
|
+
| `expectedApiKey` | required | The secret key callers must supply. |
|
|
37
|
+
| `headerName` | `'x-api-key'` | Header to read the key from. |
|
|
38
|
+
| `roles` | `[]` | Role strings attached to `auth.roles` on every successfully authed call. |
|
|
38
39
|
|
|
39
40
|
- Missing header → 401 Unauthorized
|
|
40
41
|
- Wrong key → 403 Forbidden
|
|
@@ -208,7 +209,12 @@ new PrismaAdapter<TRecord, TCreate, TUpdate>(options: PrismaAdapterOptions)
|
|
|
208
209
|
|
|
209
210
|
See `PrismaAdapterOptions` in [README_INTERFACES.md](./README_INTERFACES.md).
|
|
210
211
|
|
|
211
|
-
**Static
|
|
212
|
+
**Static methods:**
|
|
213
|
+
|
|
214
|
+
- `PrismaAdapter.fieldsFromModel(model: ModelSchema): FieldDefinition[]` — derives a Halifax field schema from a Prisma DMMF model. Used internally by the constructor when `options.model` is provided; also callable standalone when you want to inspect or override fields before constructing the adapter.
|
|
215
|
+
- `PrismaAdapter.relationsFromModel(model: ModelSchema): RelationDefinition[]` — derives relation definitions from object-kind fields in the model.
|
|
216
|
+
|
|
217
|
+
For auto-generating resources from all Prisma models at once, use `createPrismaResources` (a standalone function).
|
|
212
218
|
|
|
213
219
|
**Capabilities reported:**
|
|
214
220
|
|
|
@@ -314,6 +320,7 @@ Base class for all Halifax HTTP errors. Not instantiated directly.
|
|
|
314
320
|
| `NotFoundError` | 404 | Record with the given ID does not exist. Throw from a custom `Repository.getOne`. |
|
|
315
321
|
| `MethodNotAllowedError` | 405 | HTTP method not enabled for this resource. Used internally by Halifax. |
|
|
316
322
|
| `NotAcceptableError` | 406 | Client `Accept` header excludes `application/json`. Used internally by Halifax. |
|
|
323
|
+
| `ConflictError` | 409 | Write rejected due to a unique constraint violation. Thrown by `PrismaAdapter` and `DrizzleAdapter` automatically; throw it from a custom repository for the same semantics. |
|
|
317
324
|
| `UnsupportedMediaTypeError` | 415 | Body-carrying request with non-JSON `Content-Type`. Used internally by Halifax. |
|
|
318
325
|
| `UnprocessableEntityError` | 422 | Unknown fields in body, missing required filter, empty update payload. |
|
|
319
326
|
| `NotImplementedError` | 501 | Repository does not support this operation (e.g. `upsertOne` not implemented). |
|
package/README_INTERFACES.md
CHANGED
|
@@ -24,7 +24,7 @@ Full definition of a Halifax resource. Pass an array of these to `createExpressC
|
|
|
24
24
|
| `requiredPermissions` | `Partial<Record<CrudAction, string[]>>` | no | Permission strings per action checked by the auth strategy. |
|
|
25
25
|
| `defaultLimit` | `number` | no | Default page size when caller omits `?limit=`. Defaults to `DEFAULT_PAGE_LIMIT` (5000). `0` disables the default bound. |
|
|
26
26
|
| `maxLimit` | `number` | no | Hard cap on page size. Defaults to `MAX_PAGE_LIMIT` (5000). `0` removes the cap. |
|
|
27
|
-
| `maxFilterDepth` | `number` | no | Maximum nesting depth for `where` clause children. Defaults to
|
|
27
|
+
| `maxFilterDepth` | `number` | no | Maximum nesting depth for `where` clause children. Defaults to 4. |
|
|
28
28
|
| `cache` | `ResourceCacheConfig \| false` | no | Per-resource cache TTL. `false` disables caching even when an API-wide default is set. |
|
|
29
29
|
| `envelope` | `string \| null` | no | Wraps every success response body under this key (e.g. `'data'`). Overrides the API-wide `envelope` option. |
|
|
30
30
|
|
|
@@ -404,11 +404,12 @@ Import: `@edium/halifax`
|
|
|
404
404
|
|
|
405
405
|
Contract for pluggable cache backends. Implement this to use Redis, Memcached, or any other store.
|
|
406
406
|
|
|
407
|
-
| Method
|
|
408
|
-
|
|
|
409
|
-
| `get`
|
|
410
|
-
| `set`
|
|
411
|
-
| `delete`
|
|
407
|
+
| Method | Signature | Description |
|
|
408
|
+
| ----------- | ----------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
|
|
409
|
+
| `get` | `(key: string) => Promise<unknown> \| unknown` | Read a cached value. Returns `undefined` when absent or expired. |
|
|
410
|
+
| `set` | `(key, value, ttlSeconds?) => Promise<void> \| void` | Write a value. `ttlSeconds` `0` or omitted means no expiry. |
|
|
411
|
+
| `delete` | `(key: string) => Promise<void> \| void` | Delete a cached value. |
|
|
412
|
+
| `increment` | `(key: string) => Promise<number> \| number` *(opt.)* | Atomically increment an integer key and return the new value. Implement this for safe concurrent version bumps (e.g. Redis `INCR`). When omitted, Halifax falls back to a non-atomic `get`+`set`. |
|
|
412
413
|
|
|
413
414
|
---
|
|
414
415
|
|
|
@@ -433,11 +434,12 @@ Import: `@edium/halifax`
|
|
|
433
434
|
|
|
434
435
|
Minimal structural type for a Redis client. `RedisCacheStore` uses this interface so no specific Redis package is a hard dependency. `redis` v4's `get`, `set`, and `del` satisfy it directly.
|
|
435
436
|
|
|
436
|
-
| Method | Signature
|
|
437
|
-
| ------ |
|
|
438
|
-
| `get` | `(key: string) => Promise<string \| null>`
|
|
439
|
-
| `set` | `(key, value, options?) => Promise<unknown>`
|
|
440
|
-
| `del` | `(key: string) => Promise<unknown>`
|
|
437
|
+
| Method | Signature | Description |
|
|
438
|
+
| ------ | -------------------------------------------------- | ----------------------------------------------------------------------------------- |
|
|
439
|
+
| `get` | `(key: string) => Promise<string \| null>` | Read a key. |
|
|
440
|
+
| `set` | `(key, value, options?) => Promise<unknown>` | Write a key. `options.EX` sets TTL in seconds. |
|
|
441
|
+
| `del` | `(key: string) => Promise<unknown>` | Delete a key. |
|
|
442
|
+
| `incr` | `(key: string) => Promise<number>` *(optional)* | Atomically increment a key. Used by `RedisCacheStore.increment` for version bumps when the client exposes this method. |
|
|
441
443
|
|
|
442
444
|
---
|
|
443
445
|
|
package/README_OPENAPI.md
CHANGED
|
@@ -284,7 +284,7 @@ The query builder endpoint accepts a `QueryOptions` body for full-featured filte
|
|
|
284
284
|
]
|
|
285
285
|
}
|
|
286
286
|
],
|
|
287
|
-
"orderBy": [{ "field": "createdAt", "
|
|
287
|
+
"orderBy": [{ "field": "createdAt", "order": "DESC" }],
|
|
288
288
|
"limit": 20,
|
|
289
289
|
"offset": 0,
|
|
290
290
|
"fields": ["id", "title", "createdAt"],
|
package/README_REPO_ADAPTERS.md
CHANGED
|
@@ -186,6 +186,8 @@ A resource always needs a field schema — it's the allow-list that powers filte
|
|
|
186
186
|
|
|
187
187
|
Halifax's `peerDependencies` allow `@prisma/client >=6.0.0`, so it runs on **Prisma 6 or Prisma 7**. `PrismaAdapter` is database- and version-agnostic: it imports nothing from `@prisma/client` and only calls standard model-delegate methods (`findMany`, `findUnique`, `findFirst`, `create`, `createMany`, `update`, `updateMany`, `delete`, `deleteMany`, `upsert`, `count`) that behave identically across both majors. **You** construct the client and pass `prisma.<model>` as the `delegate` — Halifax never touches the parts that differ between the versions.
|
|
188
188
|
|
|
189
|
+
**Tenant-scoped paths require `updateMany` and `deleteMany` on the delegate.** When multi-tenant isolation is active, `updateOne` uses `updateMany(scopedWhere)` for an atomic ownership-enforced write, and `deleteOne` uses `deleteMany(scopedWhere)` for the same reason. If the delegate does not expose these methods, both operations throw `ServerError`. Standard Prisma delegates always expose them; this only affects non-standard or mock delegates.
|
|
190
|
+
|
|
189
191
|
> **Caveats.** Halifax's CI matrix exercises **Prisma 7 only** — Prisma 6 is supported on the strength of that stable delegate surface, not a dedicated CI leg, so treat it as best-effort and pin/test your own app against it. Prisma 7 is the recommended path; the main reason to stay on (or drop to) Prisma 6 today is **MongoDB**, which Prisma 7 does not yet support. When Prisma 7 restores MongoDB, prefer upgrading over remaining on 6.
|
|
190
192
|
|
|
191
193
|
What you implement differently on Prisma 6 (everything below is your project's Prisma setup — no Halifax code changes):
|
|
@@ -306,6 +308,14 @@ new DrizzleAdapter(db, table, config?, scope?)
|
|
|
306
308
|
| `config.idField` | `string` (optional) | Primary key field name. Defaults to auto-detecting the first column marked `.primaryKey()`. Set explicitly for composite PKs or non-standard names. |
|
|
307
309
|
| `scope` | `TenantScope \| null` | Tenant scope. Set by `withScope()` internally — do not pass directly. |
|
|
308
310
|
|
|
311
|
+
### Relation includes
|
|
312
|
+
|
|
313
|
+
`DrizzleAdapter` does **not** support `?include=` (relation eager-loading). It reports
|
|
314
|
+
`capabilities.supportsIncludes: false`, so the router rejects `?include=` requests with
|
|
315
|
+
`422 Unprocessable Entity` rather than silently returning records with no related data.
|
|
316
|
+
If you need related records, fetch them with a second query or use `PrismaAdapter` for
|
|
317
|
+
the resource that requires includes.
|
|
318
|
+
|
|
309
319
|
### Multi-tenancy
|
|
310
320
|
|
|
311
321
|
`DrizzleAdapter` supports per-resource tenant scoping via `withScope()` exactly like `PrismaAdapter`. See [README_MULTITENANCY.md](./README_MULTITENANCY.md) for how to configure it on the resource.
|
|
@@ -88,6 +88,11 @@ export declare class DrizzleAdapter<TRecord = Record<string, unknown>, TCreate =
|
|
|
88
88
|
private readonly table;
|
|
89
89
|
readonly fields: FieldDefinition[];
|
|
90
90
|
readonly idField: string;
|
|
91
|
+
/** Drizzle uses `.returning()` for inserts/updates, so it always returns created records. */
|
|
92
|
+
readonly capabilities: {
|
|
93
|
+
supportsIncludes: boolean;
|
|
94
|
+
supportsCreateManyReturn: boolean;
|
|
95
|
+
};
|
|
91
96
|
private readonly columns;
|
|
92
97
|
private readonly scope;
|
|
93
98
|
constructor(db: AnyDrizzleDB, table: Table, config?: DrizzleAdapterConfig, scope?: TenantScope | null);
|
|
@@ -68,6 +68,8 @@ export class DrizzleAdapter {
|
|
|
68
68
|
table;
|
|
69
69
|
fields;
|
|
70
70
|
idField;
|
|
71
|
+
/** Drizzle uses `.returning()` for inserts/updates, so it always returns created records. */
|
|
72
|
+
capabilities = { supportsIncludes: false, supportsCreateManyReturn: true };
|
|
71
73
|
columns;
|
|
72
74
|
scope;
|
|
73
75
|
constructor(db, table, config = {}, scope = null) {
|
|
@@ -237,6 +239,12 @@ export class DrizzleAdapter {
|
|
|
237
239
|
}
|
|
238
240
|
}
|
|
239
241
|
async upsertOne(id, data) {
|
|
242
|
+
// Non-atomic: the getOne check and the subsequent write are separate statements.
|
|
243
|
+
// Under concurrent load, two simultaneous upserts for the same absent ID can both
|
|
244
|
+
// pass the getOne check and then race on createOne — the loser gets a ConflictError.
|
|
245
|
+
// Drizzle has no single portable INSERT…ON CONFLICT across all databases, so this
|
|
246
|
+
// is the safest cross-provider implementation. Callers that need true atomicity
|
|
247
|
+
// should implement a custom repository using a database-specific ON CONFLICT clause.
|
|
240
248
|
const existing = await this.getOne(id);
|
|
241
249
|
if (existing) {
|
|
242
250
|
const updated = await this.updateOne(id, data);
|
|
@@ -285,6 +293,6 @@ export class DrizzleAdapter {
|
|
|
285
293
|
return { count: total, results: rows };
|
|
286
294
|
}
|
|
287
295
|
withScope(scope) {
|
|
288
|
-
return new DrizzleAdapter(this.db, this.table, { idField: this.idField }, scope
|
|
296
|
+
return new DrizzleAdapter(this.db, this.table, { idField: this.idField }, scope);
|
|
289
297
|
}
|
|
290
298
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { IQueryOptions } from '@edium/halifax-types';
|
|
2
|
-
import type {
|
|
3
|
-
import type { FieldDefinition, RelationDefinition, ModelSchema } from '../../../core/types.js';
|
|
2
|
+
import type { DeleteManyResult, FieldDefinition, ListOptions, ListResult, ModelSchema, QueryResult, RelationDefinition, Repository, RepositoryCapabilities, TenantScope, UpdateManyResult } from '../../../core/types.js';
|
|
4
3
|
import type { PrismaAdapterOptions } from './types.js';
|
|
5
4
|
/**
|
|
6
5
|
* PrismaAdapter is a generic repository implementation that uses Prisma delegates to perform
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { ConflictError } from '../../../errors/ConflictError.js';
|
|
2
|
-
import { NotImplementedError } from '../../../errors/NotImplementedError.js';
|
|
3
2
|
import { NotFoundError } from '../../../errors/NotFoundError.js';
|
|
3
|
+
import { NotImplementedError } from '../../../errors/NotImplementedError.js';
|
|
4
4
|
import { ServerError } from '../../../errors/ServerError.js';
|
|
5
|
-
import {
|
|
5
|
+
import { astToPrismaOrderBy, astToPrismaWhere } from './astToPrisma.js';
|
|
6
|
+
import { toInclude, toOrderBy, toSelect } from './helpers.js';
|
|
6
7
|
/** Returns true for Prisma's P2025 "record not found" error. */
|
|
7
8
|
function isNotFoundError(error) {
|
|
8
9
|
return (typeof error === 'object' &&
|
|
@@ -17,7 +18,19 @@ function isDuplicateError(error) {
|
|
|
17
18
|
'code' in error &&
|
|
18
19
|
error.code === 'P2002');
|
|
19
20
|
}
|
|
20
|
-
|
|
21
|
+
/**
|
|
22
|
+
* Returns true for SQL Server's "IDENTITY_INSERT is set to OFF" error (code 544).
|
|
23
|
+
* MSSQL IDENTITY columns reject any explicit-value INSERT via the driver adapter rather
|
|
24
|
+
* than surfacing a P2002 duplicate — so this must be caught separately.
|
|
25
|
+
*/
|
|
26
|
+
function isIdentityInsertError(error) {
|
|
27
|
+
if (typeof error !== 'object' || error === null)
|
|
28
|
+
return false;
|
|
29
|
+
const cause = error.cause;
|
|
30
|
+
return (typeof cause === 'object' &&
|
|
31
|
+
cause !== null &&
|
|
32
|
+
cause.code === 544);
|
|
33
|
+
}
|
|
21
34
|
function prismaTypeToOpenApi(prismaType) {
|
|
22
35
|
switch (prismaType) {
|
|
23
36
|
case 'Int':
|
|
@@ -293,19 +306,24 @@ export class PrismaAdapter {
|
|
|
293
306
|
* @throws ServerError if the Prisma delegate does not support the update method.
|
|
294
307
|
*/
|
|
295
308
|
async updateOne(id, data) {
|
|
296
|
-
// When scoped, confirm the row belongs to the caller's tenant before touching it,
|
|
297
|
-
// and strip the tenant field from the payload so the row can't be moved tenants.
|
|
298
309
|
if (this.scope) {
|
|
299
|
-
|
|
300
|
-
|
|
310
|
+
const scopedWhere = this.scopedWhere({ [this.idField]: id });
|
|
311
|
+
// Preferred path: delegate.updateMany lets us do a single atomic statement whose
|
|
312
|
+
// WHERE enforces the tenant boundary, eliminating the TOCTOU window.
|
|
313
|
+
if (this.delegate.updateMany && this.delegate.findFirst) {
|
|
314
|
+
const { count } = await this.delegate.updateMany({
|
|
315
|
+
where: scopedWhere,
|
|
316
|
+
data: this.stripTenant(data)
|
|
317
|
+
});
|
|
318
|
+
if (count === 0)
|
|
319
|
+
return null;
|
|
320
|
+
return (await this.delegate.findFirst({ where: scopedWhere }));
|
|
301
321
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
return null;
|
|
308
|
-
data = this.stripTenant(data);
|
|
322
|
+
// updateMany is unavailable — we cannot perform a single atomic scoped update.
|
|
323
|
+
// A two-step findFirst + unscoped update would introduce a TOCTOU window where a
|
|
324
|
+
// record could be transferred to another tenant between the check and the write.
|
|
325
|
+
// Refuse rather than risk a cross-tenant modification.
|
|
326
|
+
throw new ServerError('Prisma delegate does not support updateMany (required for safe tenant-scoped updateOne).');
|
|
309
327
|
}
|
|
310
328
|
try {
|
|
311
329
|
return (await this.delegate.update({ where: { [this.idField]: id }, data }));
|
|
@@ -355,35 +373,95 @@ export class PrismaAdapter {
|
|
|
355
373
|
* @throws ServerError if the Prisma delegate does not support the required methods for upserting records.
|
|
356
374
|
*/
|
|
357
375
|
async upsertOne(id, data) {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
// create, and forbid reassigning the tenant on update.
|
|
376
|
+
// Scoped upsert: do NOT use delegate.upsert with a bare id where-clause — Prisma's upsert
|
|
377
|
+
// would execute its `update` branch against any matching record regardless of tenant, giving
|
|
378
|
+
// a cross-tenant write if a race places another tenant's row at that id. Instead we decompose
|
|
379
|
+
// into a scoped findFirst + a scoped updateMany (create on miss) so the tenant constraint is
|
|
380
|
+
// enforced at every statement.
|
|
364
381
|
if (this.scope) {
|
|
365
382
|
if (!this.delegate.findFirst) {
|
|
366
383
|
throw new ServerError('Prisma delegate does not support findFirst (required for tenant scoping).');
|
|
367
384
|
}
|
|
385
|
+
const scopedWhere = this.scopedWhere({ [this.idField]: id });
|
|
368
386
|
const existing = (await this.delegate.findFirst({
|
|
369
|
-
where:
|
|
387
|
+
where: scopedWhere
|
|
370
388
|
}));
|
|
389
|
+
// Defense-in-depth: even though scopedWhere already filters by tenant, verify the
|
|
390
|
+
// returned record actually belongs to this tenant before treating it as owned.
|
|
371
391
|
if (existing && existing[this.scope.field] !== this.scope.value) {
|
|
372
392
|
throw new NotFoundError();
|
|
373
393
|
}
|
|
394
|
+
if (existing) {
|
|
395
|
+
// Record exists for this tenant — update it atomically via updateMany(scopedWhere)
|
|
396
|
+
// so the tenant constraint is enforced in the same SQL statement as the write.
|
|
397
|
+
if (this.delegate.updateMany) {
|
|
398
|
+
const { count } = await this.delegate.updateMany({
|
|
399
|
+
where: scopedWhere,
|
|
400
|
+
data: this.stripTenant(data)
|
|
401
|
+
});
|
|
402
|
+
if (count === 0) {
|
|
403
|
+
// Deleted in the tiny window between findFirst and updateMany — treat as a
|
|
404
|
+
// fresh create so the caller gets a record back (consistent with upsert semantics).
|
|
405
|
+
try {
|
|
406
|
+
return (await this.delegate.create({
|
|
407
|
+
data: this.stampTenant({ ...data, [this.idField]: id })
|
|
408
|
+
}));
|
|
409
|
+
}
|
|
410
|
+
catch (error) {
|
|
411
|
+
if (isDuplicateError(error))
|
|
412
|
+
throw new ConflictError();
|
|
413
|
+
if (isIdentityInsertError(error)) {
|
|
414
|
+
const anyMatch = await this.delegate.findFirst({ where: { [this.idField]: id } });
|
|
415
|
+
if (anyMatch)
|
|
416
|
+
throw new ConflictError();
|
|
417
|
+
return (await this.delegate.create({ data: this.stampTenant(data) }));
|
|
418
|
+
}
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return (await this.delegate.findFirst({ where: scopedWhere }));
|
|
423
|
+
}
|
|
424
|
+
// Fallback when updateMany is unavailable (non-standard delegate). The update is still
|
|
425
|
+
// scoped via the earlier findFirst; the TOCTOU window here is only closeable with a
|
|
426
|
+
// transaction, which we cannot guarantee across providers.
|
|
427
|
+
try {
|
|
428
|
+
return (await this.delegate.update({
|
|
429
|
+
where: { [this.idField]: id },
|
|
430
|
+
data: this.stripTenant(data)
|
|
431
|
+
}));
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
if (isNotFoundError(error))
|
|
435
|
+
throw new NotFoundError();
|
|
436
|
+
if (isDuplicateError(error))
|
|
437
|
+
throw new ConflictError();
|
|
438
|
+
throw error;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// Record does not exist for this tenant — create it with the tenant stamped.
|
|
374
442
|
try {
|
|
375
|
-
return (await this.delegate.
|
|
376
|
-
|
|
377
|
-
create: this.stampTenant(data),
|
|
378
|
-
update: this.stripTenant(data)
|
|
443
|
+
return (await this.delegate.create({
|
|
444
|
+
data: this.stampTenant({ ...data, [this.idField]: id })
|
|
379
445
|
}));
|
|
380
446
|
}
|
|
381
447
|
catch (error) {
|
|
382
448
|
if (isDuplicateError(error))
|
|
383
449
|
throw new ConflictError();
|
|
450
|
+
if (isIdentityInsertError(error)) {
|
|
451
|
+
// MSSQL IDENTITY columns reject any explicit-ID insert. Probe to distinguish a
|
|
452
|
+
// cross-tenant ID collision (another tenant owns this ID → ConflictError) from a
|
|
453
|
+
// genuinely new row (let the DB assign the ID instead).
|
|
454
|
+
const anyMatch = await this.delegate.findFirst({ where: { [this.idField]: id } });
|
|
455
|
+
if (anyMatch)
|
|
456
|
+
throw new ConflictError();
|
|
457
|
+
return (await this.delegate.create({ data: this.stampTenant(data) }));
|
|
458
|
+
}
|
|
384
459
|
throw error;
|
|
385
460
|
}
|
|
386
461
|
}
|
|
462
|
+
if (!this.delegate.upsert) {
|
|
463
|
+
throw new NotImplementedError('Prisma delegate does not support upsert.');
|
|
464
|
+
}
|
|
387
465
|
try {
|
|
388
466
|
return (await this.delegate.upsert({
|
|
389
467
|
where: { [this.idField]: id },
|
|
@@ -416,15 +494,9 @@ export class PrismaAdapter {
|
|
|
416
494
|
});
|
|
417
495
|
return (result?.count ?? 0) > 0;
|
|
418
496
|
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
const owned = await this.delegate.findFirst({
|
|
423
|
-
where: this.scopedWhere({ [this.idField]: id }),
|
|
424
|
-
select: { [this.idField]: true }
|
|
425
|
-
});
|
|
426
|
-
if (!owned)
|
|
427
|
-
return false;
|
|
497
|
+
// deleteMany is unavailable — we cannot do an atomic scoped delete. A two-step
|
|
498
|
+
// findFirst + unscoped delete would introduce a TOCTOU cross-tenant deletion risk.
|
|
499
|
+
throw new ServerError('Prisma delegate does not support deleteMany (required for safe tenant-scoped deleteOne).');
|
|
428
500
|
}
|
|
429
501
|
try {
|
|
430
502
|
await this.delegate.delete({ where: { [this.idField]: id } });
|
|
@@ -3,6 +3,12 @@ import { SqlComparison, SqlOperator, SqlOrder } from '@edium/halifax-types';
|
|
|
3
3
|
* Splits a `LIKE` pattern into a Prisma string operator based on its `%` wildcards.
|
|
4
4
|
* `%x%` → `contains`, `x%` → `startsWith`, `%x` → `endsWith`, and a wildcard-free value
|
|
5
5
|
* collapses to `equals` (matching SQL `LIKE 'x'` semantics).
|
|
6
|
+
*
|
|
7
|
+
* **Known limitation:** interior wildcards (`'foo%bar'`) cannot be expressed with Prisma's
|
|
8
|
+
* string operators — they fall through to `equals`, which is an exact match, not a
|
|
9
|
+
* wildcard match. Prisma has no `matches`/`glob` operator without raw SQL. Use `CONTAINS`,
|
|
10
|
+
* `STARTS WITH`, or `ENDS WITH` comparisons instead of `LIKE` when possible.
|
|
11
|
+
*
|
|
6
12
|
* @param value - The raw LIKE pattern.
|
|
7
13
|
* @returns A Prisma string-filter object.
|
|
8
14
|
*/
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AuthenticationError } from '../../errors/AuthenticationError.js';
|
|
2
|
+
import { checkRequiredPermissions } from './types.js';
|
|
2
3
|
/** Authenticates via a Bearer JWT and authorises using roles/permissions embedded in its claims. */
|
|
3
4
|
export class JwtClaimsAuthStrategy {
|
|
4
5
|
verifyToken;
|
|
@@ -31,14 +32,7 @@ export class JwtClaimsAuthStrategy {
|
|
|
31
32
|
* @returns `true` when all required permissions are satisfied, `false` otherwise.
|
|
32
33
|
*/
|
|
33
34
|
authorize(params) {
|
|
34
|
-
|
|
35
|
-
return true;
|
|
36
|
-
}
|
|
37
|
-
const permissions = new Set(params.auth.permissions ?? []);
|
|
38
|
-
const roles = new Set(params.auth.roles ?? []);
|
|
39
|
-
return params.requiredPermissions.every((permission) => {
|
|
40
|
-
return permissions.has(permission) || roles.has(permission);
|
|
41
|
-
});
|
|
35
|
+
return checkRequiredPermissions(params.auth, params.requiredPermissions);
|
|
42
36
|
}
|
|
43
37
|
openApiScheme() {
|
|
44
38
|
return { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' };
|