@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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AuthenticationError } from '../../errors/AuthenticationError.js';
|
|
2
|
+
import { checkRequiredPermissions } from './types.js';
|
|
2
3
|
/**
|
|
3
4
|
* Extracts `userId`, `roles`, `permissions`, and `claims` from a raw Passport user payload.
|
|
4
5
|
* @param user - The raw user object returned by Passport (typically a decoded JWT payload).
|
|
@@ -17,13 +18,6 @@ function defaultMapUser(user) {
|
|
|
17
18
|
ctx.userId = userId;
|
|
18
19
|
return ctx;
|
|
19
20
|
}
|
|
20
|
-
function checkPermissions(auth, requiredPermissions) {
|
|
21
|
-
if (!requiredPermissions.length)
|
|
22
|
-
return true;
|
|
23
|
-
const permissions = new Set(auth.permissions ?? []);
|
|
24
|
-
const roles = new Set(auth.roles ?? []);
|
|
25
|
-
return requiredPermissions.every((p) => permissions.has(p) || roles.has(p));
|
|
26
|
-
}
|
|
27
21
|
/** Delegates authentication to a caller-provided Passport authenticate wrapper. */
|
|
28
22
|
export class PassportAuthStrategy {
|
|
29
23
|
authenticateWithPassport;
|
|
@@ -85,7 +79,7 @@ export class PassportJwtStrategy {
|
|
|
85
79
|
* @returns `true` when all required permissions are satisfied, `false` otherwise.
|
|
86
80
|
*/
|
|
87
81
|
authorize(params) {
|
|
88
|
-
return
|
|
82
|
+
return checkRequiredPermissions(params.auth, params.requiredPermissions);
|
|
89
83
|
}
|
|
90
84
|
openApiScheme() {
|
|
91
85
|
return { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' };
|
|
@@ -129,7 +123,7 @@ export class PassportSessionStrategy {
|
|
|
129
123
|
* @returns `true` when all required permissions are satisfied, `false` otherwise.
|
|
130
124
|
*/
|
|
131
125
|
authorize(params) {
|
|
132
|
-
return
|
|
126
|
+
return checkRequiredPermissions(params.auth, params.requiredPermissions);
|
|
133
127
|
}
|
|
134
128
|
openApiScheme() {
|
|
135
129
|
return {
|
|
@@ -45,6 +45,13 @@ export type SecurityScheme = {
|
|
|
45
45
|
scheme: 'basic';
|
|
46
46
|
description?: string;
|
|
47
47
|
};
|
|
48
|
+
/**
|
|
49
|
+
* Checks whether an auth context satisfies `requiredPermissions`.
|
|
50
|
+
* Semantics: **any single match** in `auth.permissions` OR `auth.roles` grants access
|
|
51
|
+
* (i.e. the list is an OR — "user must have at least one of these"). This mirrors
|
|
52
|
+
* the documented behaviour of `FieldDefinition.readRoles` / `writeRoles`.
|
|
53
|
+
*/
|
|
54
|
+
export declare function checkRequiredPermissions(auth: AuthContext, requiredPermissions: string[]): boolean;
|
|
48
55
|
/** Contract for pluggable authentication and authorisation strategies. */
|
|
49
56
|
export interface AuthStrategy {
|
|
50
57
|
/**
|
|
@@ -1 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Checks whether an auth context satisfies `requiredPermissions`.
|
|
3
|
+
* Semantics: **any single match** in `auth.permissions` OR `auth.roles` grants access
|
|
4
|
+
* (i.e. the list is an OR — "user must have at least one of these"). This mirrors
|
|
5
|
+
* the documented behaviour of `FieldDefinition.readRoles` / `writeRoles`.
|
|
6
|
+
*/
|
|
7
|
+
export function checkRequiredPermissions(auth, requiredPermissions) {
|
|
8
|
+
if (!requiredPermissions.length)
|
|
9
|
+
return true;
|
|
10
|
+
const permissions = new Set(auth.permissions ?? []);
|
|
11
|
+
const roles = new Set(auth.roles ?? []);
|
|
12
|
+
return requiredPermissions.some((p) => permissions.has(p) || roles.has(p));
|
|
13
|
+
}
|
|
@@ -22,4 +22,16 @@ export interface CacheStore {
|
|
|
22
22
|
* @param key - Cache key to remove.
|
|
23
23
|
*/
|
|
24
24
|
delete(key: string): Promise<void> | void;
|
|
25
|
+
/**
|
|
26
|
+
* Atomically increment an integer counter and return the new value.
|
|
27
|
+
* When the key does not exist, it is initialised to `0` before incrementing.
|
|
28
|
+
*
|
|
29
|
+
* Implementing this method is **strongly recommended** for multi-process stores
|
|
30
|
+
* (e.g. Redis) — without it, `createCachingRepository` falls back to a non-atomic
|
|
31
|
+
* read-then-write version-bump that can lose invalidations under concurrent writes.
|
|
32
|
+
*
|
|
33
|
+
* Single-process stores (`InMemoryCacheStore`) can implement this synchronously and
|
|
34
|
+
* are immune to the race regardless, but should still implement it for correctness.
|
|
35
|
+
*/
|
|
36
|
+
increment?(key: string): Promise<number> | number;
|
|
25
37
|
}
|
|
@@ -34,7 +34,16 @@ export function createCachingRepository(repo, options) {
|
|
|
34
34
|
return typeof v === 'number' ? v : 0;
|
|
35
35
|
};
|
|
36
36
|
const bumpVersion = async () => {
|
|
37
|
-
|
|
37
|
+
// Use store.increment when available (atomic on Redis via INCR; race-free on
|
|
38
|
+
// InMemoryCacheStore because Node.js is single-threaded). Fall back to a non-atomic
|
|
39
|
+
// read-then-write only when the store does not expose increment — acceptable for
|
|
40
|
+
// custom single-process stores where concurrent writes cannot interleave.
|
|
41
|
+
if (store.increment) {
|
|
42
|
+
await store.increment(versionKey);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
await store.set(versionKey, (await readVersion()) + 1);
|
|
46
|
+
}
|
|
38
47
|
};
|
|
39
48
|
const keyFor = async (op, payload) => `${namespace}:v${await readVersion()}:${op}:${stableStringify(payload)}`;
|
|
40
49
|
const cachedRead = async (op, payload, run) => {
|
|
@@ -5,15 +5,28 @@ import type { CacheStore } from '../CacheStore.js';
|
|
|
5
5
|
* Suitable for single-process deployments and tests. For multi-instance deployments,
|
|
6
6
|
* inject a shared store (e.g. `RedisCacheStore`) instead so cache and invalidation are
|
|
7
7
|
* consistent across processes.
|
|
8
|
+
*
|
|
9
|
+
* Expired entries are lazily evicted on reads. To prevent unbounded accumulation under
|
|
10
|
+
* write-heavy workloads, a sweep of all expired entries runs automatically every
|
|
11
|
+
* `sweepEvery` writes (default: 200). Adjust via the constructor option.
|
|
8
12
|
*/
|
|
9
13
|
export declare class InMemoryCacheStore implements CacheStore {
|
|
10
14
|
private readonly now;
|
|
11
15
|
private readonly map;
|
|
16
|
+
private setCount;
|
|
17
|
+
private readonly sweepEvery;
|
|
12
18
|
/**
|
|
13
19
|
* @param now - Clock function (ms epoch). Injectable for tests; defaults to `Date.now`.
|
|
20
|
+
* @param sweepEvery - Run a full expired-entry sweep every N `set` calls. Defaults to 200.
|
|
14
21
|
*/
|
|
15
|
-
constructor(now?: () => number);
|
|
22
|
+
constructor(now?: () => number, sweepEvery?: number);
|
|
16
23
|
get(key: string): unknown;
|
|
17
24
|
set(key: string, value: unknown, ttlSeconds?: number): void;
|
|
18
25
|
delete(key: string): void;
|
|
26
|
+
/**
|
|
27
|
+
* Atomically increments an integer counter and returns the new value.
|
|
28
|
+
* Because Node.js is single-threaded this is race-free without a lock.
|
|
29
|
+
*/
|
|
30
|
+
increment(key: string): number;
|
|
31
|
+
private purgeExpired;
|
|
19
32
|
}
|
|
@@ -4,15 +4,23 @@
|
|
|
4
4
|
* Suitable for single-process deployments and tests. For multi-instance deployments,
|
|
5
5
|
* inject a shared store (e.g. `RedisCacheStore`) instead so cache and invalidation are
|
|
6
6
|
* consistent across processes.
|
|
7
|
+
*
|
|
8
|
+
* Expired entries are lazily evicted on reads. To prevent unbounded accumulation under
|
|
9
|
+
* write-heavy workloads, a sweep of all expired entries runs automatically every
|
|
10
|
+
* `sweepEvery` writes (default: 200). Adjust via the constructor option.
|
|
7
11
|
*/
|
|
8
12
|
export class InMemoryCacheStore {
|
|
9
13
|
now;
|
|
10
14
|
map = new Map();
|
|
15
|
+
setCount = 0;
|
|
16
|
+
sweepEvery;
|
|
11
17
|
/**
|
|
12
18
|
* @param now - Clock function (ms epoch). Injectable for tests; defaults to `Date.now`.
|
|
19
|
+
* @param sweepEvery - Run a full expired-entry sweep every N `set` calls. Defaults to 200.
|
|
13
20
|
*/
|
|
14
|
-
constructor(now = () => Date.now()) {
|
|
21
|
+
constructor(now = () => Date.now(), sweepEvery = 200) {
|
|
15
22
|
this.now = now;
|
|
23
|
+
this.sweepEvery = sweepEvery;
|
|
16
24
|
}
|
|
17
25
|
get(key) {
|
|
18
26
|
const entry = this.map.get(key);
|
|
@@ -27,8 +35,28 @@ export class InMemoryCacheStore {
|
|
|
27
35
|
set(key, value, ttlSeconds) {
|
|
28
36
|
const expiresAt = ttlSeconds && ttlSeconds > 0 ? this.now() + ttlSeconds * 1000 : null;
|
|
29
37
|
this.map.set(key, { value, expiresAt });
|
|
38
|
+
if (++this.setCount % this.sweepEvery === 0)
|
|
39
|
+
this.purgeExpired();
|
|
30
40
|
}
|
|
31
41
|
delete(key) {
|
|
32
42
|
this.map.delete(key);
|
|
33
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Atomically increments an integer counter and returns the new value.
|
|
46
|
+
* Because Node.js is single-threaded this is race-free without a lock.
|
|
47
|
+
*/
|
|
48
|
+
increment(key) {
|
|
49
|
+
const entry = this.map.get(key);
|
|
50
|
+
const current = typeof entry?.value === 'number' ? entry.value : 0;
|
|
51
|
+
const next = current + 1;
|
|
52
|
+
this.map.set(key, { value: next, expiresAt: null });
|
|
53
|
+
return next;
|
|
54
|
+
}
|
|
55
|
+
purgeExpired() {
|
|
56
|
+
const now = this.now();
|
|
57
|
+
for (const [key, entry] of this.map) {
|
|
58
|
+
if (entry.expiresAt !== null && entry.expiresAt <= now)
|
|
59
|
+
this.map.delete(key);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
34
62
|
}
|
|
@@ -25,4 +25,10 @@ export declare class RedisCacheStore implements CacheStore {
|
|
|
25
25
|
get(key: string): Promise<unknown>;
|
|
26
26
|
set(key: string, value: unknown, ttlSeconds?: number): Promise<void>;
|
|
27
27
|
delete(key: string): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Atomically increments the integer stored at `key` (using Redis `INCR`) and returns
|
|
30
|
+
* the new value. Falls back to a non-atomic get-then-set when the underlying client
|
|
31
|
+
* does not expose `incr` — this should not occur with standard Redis clients.
|
|
32
|
+
*/
|
|
33
|
+
increment(key: string): Promise<number>;
|
|
28
34
|
}
|
|
@@ -39,4 +39,18 @@ export class RedisCacheStore {
|
|
|
39
39
|
async delete(key) {
|
|
40
40
|
await this.client.del(this.prefix + key);
|
|
41
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* Atomically increments the integer stored at `key` (using Redis `INCR`) and returns
|
|
44
|
+
* the new value. Falls back to a non-atomic get-then-set when the underlying client
|
|
45
|
+
* does not expose `incr` — this should not occur with standard Redis clients.
|
|
46
|
+
*/
|
|
47
|
+
async increment(key) {
|
|
48
|
+
if (this.client.incr) {
|
|
49
|
+
return await this.client.incr(this.prefix + key);
|
|
50
|
+
}
|
|
51
|
+
const raw = await this.client.get(this.prefix + key);
|
|
52
|
+
const next = (raw !== null ? parseInt(raw, 10) : 0) + 1;
|
|
53
|
+
await this.client.set(this.prefix + key, String(next));
|
|
54
|
+
return next;
|
|
55
|
+
}
|
|
42
56
|
}
|
package/dist/core/crudRouter.js
CHANGED
|
@@ -6,7 +6,7 @@ import { ServerError } from '../errors/ServerError.js';
|
|
|
6
6
|
import { AuthorizationError } from '../errors/AuthorizationError.js';
|
|
7
7
|
import { MethodNotAllowedError } from '../errors/MethodNotAllowedError.js';
|
|
8
8
|
import { normalizeError, sendError, wantsCacheBust } from '../core/handlerUtils.js';
|
|
9
|
-
import { mergeFieldDefinitions } from '../core/fields.js';
|
|
9
|
+
import { mergeFieldDefinitions, mergeRelationDefinitions, normalizeEnvelope } from '../core/fields.js';
|
|
10
10
|
import { registerCreate } from '../core/handlers/create.js';
|
|
11
11
|
import { registerReadMany } from '../core/handlers/readMany.js';
|
|
12
12
|
import { registerReadOne } from '../core/handlers/readOne.js';
|
|
@@ -53,17 +53,6 @@ function resolveFields(resource, idField) {
|
|
|
53
53
|
...(field.writeRoles?.length ? { writeRoles: field.writeRoles } : {})
|
|
54
54
|
}));
|
|
55
55
|
}
|
|
56
|
-
/**
|
|
57
|
-
* Merges the repository's relation schema with the resource's own relations, by name.
|
|
58
|
-
*/
|
|
59
|
-
function resolveRelations(resource) {
|
|
60
|
-
const byName = new Map();
|
|
61
|
-
for (const relation of resource.repository?.relations ?? [])
|
|
62
|
-
byName.set(relation.name, relation);
|
|
63
|
-
for (const relation of resource.relations ?? [])
|
|
64
|
-
byName.set(relation.name, relation);
|
|
65
|
-
return [...byName.values()];
|
|
66
|
-
}
|
|
67
56
|
/**
|
|
68
57
|
* Produces a fully-resolved resource: `name` filled in, and `fields`/`relations` resolved
|
|
69
58
|
* from the repository schema + the resource's own entries. Every downstream stage operates
|
|
@@ -75,16 +64,9 @@ function normalizeResource(resource) {
|
|
|
75
64
|
...resource,
|
|
76
65
|
name: resource.name ?? deriveResourceName(resource.routePrefix),
|
|
77
66
|
fields: resolveFields(resource, idField),
|
|
78
|
-
relations:
|
|
67
|
+
relations: mergeRelationDefinitions(resource)
|
|
79
68
|
};
|
|
80
69
|
}
|
|
81
|
-
/**
|
|
82
|
-
* Resolves the effective envelope key. A non-empty string enables wrapping; `null`, `undefined`,
|
|
83
|
-
* and `''` all mean "no envelope".
|
|
84
|
-
*/
|
|
85
|
-
function normalizeEnvelope(value) {
|
|
86
|
-
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
87
|
-
}
|
|
88
70
|
/**
|
|
89
71
|
* Determines the column a resource is tenant-scoped on, with this precedence:
|
|
90
72
|
* explicit `resource.tenant` (or `false` to opt out) → auto-detect the API's default
|
package/dist/core/fields.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FieldDefinition, ResourceDefinition } from '../core/types.js';
|
|
1
|
+
import type { FieldDefinition, RelationDefinition, ResourceDefinition } from '../core/types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Merges a resource's field schema: repository fields are the base, and
|
|
4
4
|
* `resource.fields` entries are applied as sparse overrides (by name).
|
|
@@ -6,3 +6,13 @@ import type { FieldDefinition, ResourceDefinition } from '../core/types.js';
|
|
|
6
6
|
* the flags they care about on top of this.
|
|
7
7
|
*/
|
|
8
8
|
export declare function mergeFieldDefinitions(resource: ResourceDefinition): FieldDefinition[];
|
|
9
|
+
/**
|
|
10
|
+
* Merges a resource's relation schema: repository relations are the base, and
|
|
11
|
+
* `resource.relations` entries are applied as sparse overrides (by name).
|
|
12
|
+
*/
|
|
13
|
+
export declare function mergeRelationDefinitions(resource: ResourceDefinition): RelationDefinition[];
|
|
14
|
+
/**
|
|
15
|
+
* Resolves the effective envelope key. A non-empty string enables wrapping;
|
|
16
|
+
* `null`, `undefined`, and `''` all mean "no envelope".
|
|
17
|
+
*/
|
|
18
|
+
export declare function normalizeEnvelope(value: string | null | undefined): string | null;
|
package/dist/core/fields.js
CHANGED
|
@@ -12,3 +12,22 @@ export function mergeFieldDefinitions(resource) {
|
|
|
12
12
|
byName.set(f.name, { ...byName.get(f.name), ...f });
|
|
13
13
|
return [...byName.values()];
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Merges a resource's relation schema: repository relations are the base, and
|
|
17
|
+
* `resource.relations` entries are applied as sparse overrides (by name).
|
|
18
|
+
*/
|
|
19
|
+
export function mergeRelationDefinitions(resource) {
|
|
20
|
+
const byName = new Map();
|
|
21
|
+
for (const r of resource.repository?.relations ?? [])
|
|
22
|
+
byName.set(r.name, r);
|
|
23
|
+
for (const r of resource.relations ?? [])
|
|
24
|
+
byName.set(r.name, r);
|
|
25
|
+
return [...byName.values()];
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Resolves the effective envelope key. A non-empty string enables wrapping;
|
|
29
|
+
* `null`, `undefined`, and `''` all mean "no envelope".
|
|
30
|
+
*/
|
|
31
|
+
export function normalizeEnvelope(value) {
|
|
32
|
+
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
33
|
+
}
|
|
@@ -41,6 +41,12 @@ export declare function filterWritableFields(resource: ResourceDefinition, data:
|
|
|
41
41
|
* Fast-path returns the record unchanged when no fields carry read restrictions.
|
|
42
42
|
*/
|
|
43
43
|
export declare function filterReadableFields(resource: ResourceDefinition, record: Record<string, unknown>, auth?: AuthContext): Record<string, unknown>;
|
|
44
|
+
/**
|
|
45
|
+
* Returns a reusable filter function that strips read-restricted fields.
|
|
46
|
+
* Build this once per request (outside a `.map()` loop) so the fieldMap and
|
|
47
|
+
* userRoles Set are not reconstructed for every record in a bulk response.
|
|
48
|
+
*/
|
|
49
|
+
export declare function makeReadableFieldFilter(resource: ResourceDefinition, auth?: AuthContext): (record: Record<string, unknown>) => Record<string, unknown>;
|
|
44
50
|
/**
|
|
45
51
|
* Runs the auth strategy for `action` and throws {@link AuthorizationError} when not allowed.
|
|
46
52
|
*/
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { checkRequiredPermissions } from '../auth/strategies/types.js';
|
|
1
2
|
import { HttpError } from '../errors/HttpError.js';
|
|
2
3
|
import { NotAcceptableError } from '../errors/NotAcceptableError.js';
|
|
3
4
|
import { UnprocessableEntityError } from '../errors/UnprocessableEntityError.js';
|
|
@@ -63,9 +64,9 @@ export function writeSuccess(res, status, body, envelope) {
|
|
|
63
64
|
*/
|
|
64
65
|
export function parseId(raw) {
|
|
65
66
|
validateId(raw);
|
|
66
|
-
if (
|
|
67
|
+
if (isValidUuid(raw) || isValidObjectId(raw))
|
|
67
68
|
return raw;
|
|
68
|
-
return
|
|
69
|
+
return parseInt(raw, 10);
|
|
69
70
|
}
|
|
70
71
|
/**
|
|
71
72
|
* Strips non-writable fields from a request body and rejects unknown fields with a 422.
|
|
@@ -96,12 +97,20 @@ export function filterWritableFields(resource, data, auth) {
|
|
|
96
97
|
* Fast-path returns the record unchanged when no fields carry read restrictions.
|
|
97
98
|
*/
|
|
98
99
|
export function filterReadableFields(resource, record, auth) {
|
|
100
|
+
return makeReadableFieldFilter(resource, auth)(record);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Returns a reusable filter function that strips read-restricted fields.
|
|
104
|
+
* Build this once per request (outside a `.map()` loop) so the fieldMap and
|
|
105
|
+
* userRoles Set are not reconstructed for every record in a bulk response.
|
|
106
|
+
*/
|
|
107
|
+
export function makeReadableFieldFilter(resource, auth) {
|
|
99
108
|
const fields = resource.fields ?? [];
|
|
100
109
|
if (!fields.some((f) => (f.readRoles?.length ?? 0) > 0))
|
|
101
|
-
return
|
|
110
|
+
return (r) => r;
|
|
102
111
|
const fieldMap = new Map(fields.map((f) => [f.name, f]));
|
|
103
112
|
const userRoles = new Set([...(auth?.roles ?? []), ...(auth?.permissions ?? [])]);
|
|
104
|
-
return Object.fromEntries(Object.entries(record).filter(([key]) => {
|
|
113
|
+
return (record) => Object.fromEntries(Object.entries(record).filter(([key]) => {
|
|
105
114
|
const field = fieldMap.get(key);
|
|
106
115
|
if (!field?.readRoles?.length)
|
|
107
116
|
return true;
|
|
@@ -126,13 +135,8 @@ export async function authorizeRequest(req, resource, action, authStrategy) {
|
|
|
126
135
|
throw new AuthorizationError();
|
|
127
136
|
return auth;
|
|
128
137
|
}
|
|
129
|
-
if (requiredPermissions
|
|
130
|
-
|
|
131
|
-
const roles = new Set(auth.roles ?? []);
|
|
132
|
-
const allowed = requiredPermissions.some((permission) => permissions.has(permission) || roles.has(permission));
|
|
133
|
-
if (!allowed)
|
|
134
|
-
throw new AuthorizationError();
|
|
135
|
-
}
|
|
138
|
+
if (!checkRequiredPermissions(auth, requiredPermissions))
|
|
139
|
+
throw new AuthorizationError();
|
|
136
140
|
return auth;
|
|
137
141
|
}
|
|
138
142
|
/**
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { applyHook, authorizeRequest, filterReadableFields, filterWritableFields, getHeaderValue, wrap, writeSuccess } from '../../core/handlerUtils.js';
|
|
1
|
+
import { applyHook, authorizeRequest, filterReadableFields, filterWritableFields, getHeaderValue, makeReadableFieldFilter, wrap, writeSuccess } from '../../core/handlerUtils.js';
|
|
2
2
|
export function registerCreate(server, basePath, ctx) {
|
|
3
3
|
const { resource, authStrategy, envelope, hooks, resolveRepo } = ctx;
|
|
4
4
|
server.registerRoute('POST', basePath, wrap(async (req, res) => {
|
|
@@ -21,6 +21,7 @@ export function registerCreate(server, basePath, ctx) {
|
|
|
21
21
|
const results = hooks?.afterCreate
|
|
22
22
|
? await Promise.all(rawResults.map((r) => applyHook(hooks.afterCreate, r, hookCtx)))
|
|
23
23
|
: rawResults;
|
|
24
|
-
|
|
24
|
+
const filterRecord = makeReadableFieldFilter(resource, auth);
|
|
25
|
+
await writeSuccess(res, 201, results.map(filterRecord), envelope);
|
|
25
26
|
}));
|
|
26
27
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { validateAdvancedQuery } from '../../core/validation.js';
|
|
2
2
|
import { NotImplementedError } from '../../errors/NotImplementedError.js';
|
|
3
|
-
import { applyHook, authorizeRequest,
|
|
3
|
+
import { applyHook, authorizeRequest, makeReadableFieldFilter, wrap, writeSuccess } from '../../core/handlerUtils.js';
|
|
4
4
|
export function registerQuery(server, basePath, queryBuilderPath, ctx) {
|
|
5
5
|
const { resource, authStrategy, envelope, hooks, resolveRepo } = ctx;
|
|
6
6
|
server.registerRoute('POST', `${basePath}/${queryBuilderPath}`, wrap(async (req, res) => {
|
|
@@ -15,9 +15,7 @@ export function registerQuery(server, basePath, queryBuilderPath, ctx) {
|
|
|
15
15
|
validateAdvancedQuery(resource, query);
|
|
16
16
|
const rawResult = await repo.executeQuery(query);
|
|
17
17
|
const result = await applyHook(hooks?.afterQuery, rawResult, hookCtx);
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
results: result.results.map((r) => filterReadableFields(resource, r, auth))
|
|
21
|
-
}, envelope);
|
|
18
|
+
const filterRecord = makeReadableFieldFilter(resource, auth);
|
|
19
|
+
await writeSuccess(res, 200, { ...result, results: result.results.map(filterRecord) }, envelope);
|
|
22
20
|
}));
|
|
23
21
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { parseListOptions } from '../../core/queryString.js';
|
|
2
|
-
import { applyHook, authorizeRequest,
|
|
2
|
+
import { applyHook, authorizeRequest, makeReadableFieldFilter, wrap, writeSuccess } from '../../core/handlerUtils.js';
|
|
3
3
|
export function registerReadMany(server, basePath, ctx) {
|
|
4
4
|
const { resource, authStrategy, envelope, hooks, resolveRepo } = ctx;
|
|
5
5
|
server.registerRoute('GET', basePath, wrap(async (req, res) => {
|
|
@@ -10,9 +10,7 @@ export function registerReadMany(server, basePath, ctx) {
|
|
|
10
10
|
const listOptions = await applyHook(hooks?.beforeReadMany, parsedOptions, hookCtx);
|
|
11
11
|
const rawResult = await repo.getMany(listOptions);
|
|
12
12
|
const result = await applyHook(hooks?.afterReadMany, rawResult, hookCtx);
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
results: result.results.map((r) => filterReadableFields(resource, r, auth))
|
|
16
|
-
}, envelope);
|
|
13
|
+
const filterRecord = makeReadableFieldFilter(resource, auth);
|
|
14
|
+
await writeSuccess(res, 200, { ...result, results: result.results.map(filterRecord) }, envelope);
|
|
17
15
|
}));
|
|
18
16
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { parseGetOneOptions } from '../../core/queryString.js';
|
|
2
2
|
import { NotFoundError } from '../../errors/NotFoundError.js';
|
|
3
3
|
import { applyHook, authorizeRequest, filterReadableFields, parseId, wrap, writeSuccess } from '../../core/handlerUtils.js';
|
|
4
4
|
export function registerReadOne(server, basePath, ctx) {
|
|
@@ -10,11 +10,8 @@ export function registerReadOne(server, basePath, ctx) {
|
|
|
10
10
|
const hookCtx = { auth, resource, req };
|
|
11
11
|
if (hooks?.beforeReadOne)
|
|
12
12
|
await hooks.beforeReadOne(id, hookCtx);
|
|
13
|
-
const
|
|
14
|
-
const rawResult = await repo.getOne(id, {
|
|
15
|
-
fields: listOptions.fields,
|
|
16
|
-
include: listOptions.include
|
|
17
|
-
});
|
|
13
|
+
const { fields, include } = parseGetOneOptions(req.query, resource);
|
|
14
|
+
const rawResult = await repo.getOne(id, { fields, include });
|
|
18
15
|
if (!rawResult)
|
|
19
16
|
throw new NotFoundError();
|
|
20
17
|
const result = await applyHook(hooks?.afterReadOne, rawResult, hookCtx);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { validateAdvancedQuery } from '../../core/validation.js';
|
|
2
2
|
import { NotImplementedError } from '../../errors/NotImplementedError.js';
|
|
3
3
|
import { UnprocessableEntityError } from '../../errors/UnprocessableEntityError.js';
|
|
4
|
-
import { applyHook, authorizeRequest,
|
|
4
|
+
import { applyHook, authorizeRequest, filterWritableFields, makeReadableFieldFilter, wrap, writeSuccess } from '../../core/handlerUtils.js';
|
|
5
5
|
export function registerUpdateMany(server, basePath, ctx) {
|
|
6
6
|
const { resource, authStrategy, envelope, hooks, resolveRepo } = ctx;
|
|
7
7
|
server.registerRoute('PATCH', basePath, wrap(async (req, res) => {
|
|
@@ -22,12 +22,11 @@ export function registerUpdateMany(server, basePath, ctx) {
|
|
|
22
22
|
await hooks.beforeUpdateMany(query, filteredUpdate, hookCtx);
|
|
23
23
|
const rawResult = await repo.updateMany(query, filteredUpdate);
|
|
24
24
|
const result = await applyHook(hooks?.afterUpdateMany, rawResult, hookCtx);
|
|
25
|
+
const filterRecord = makeReadableFieldFilter(resource, auth);
|
|
25
26
|
await writeSuccess(res, 200, {
|
|
26
27
|
...result,
|
|
27
28
|
...(result.results
|
|
28
|
-
? {
|
|
29
|
-
results: result.results.map((r) => filterReadableFields(resource, r, auth))
|
|
30
|
-
}
|
|
29
|
+
? { results: result.results.map((r) => filterRecord(r)) }
|
|
31
30
|
: {})
|
|
32
31
|
}, envelope);
|
|
33
32
|
}));
|
|
@@ -1,4 +1,14 @@
|
|
|
1
1
|
import { type ListOptions, type ResourceDefinition } from '../core/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Parses and validates the query-string for a single-record GET (`GET /resource/:id`).
|
|
4
|
+
* Only `?fields=` and `?include=` are meaningful for that endpoint — this avoids the wasted
|
|
5
|
+
* work and silent-discard behaviour of calling the full `parseListOptions` on a by-ID route.
|
|
6
|
+
*
|
|
7
|
+
* @param query - The raw query-string object from the HTTP request.
|
|
8
|
+
* @param resource - The resource definition used for field and relation validation.
|
|
9
|
+
* @returns Typed projection options ready to pass to `repository.getOne()`.
|
|
10
|
+
*/
|
|
11
|
+
export declare function parseGetOneOptions(query: Record<string, unknown>, resource: ResourceDefinition): Pick<ListOptions, 'fields' | 'include'>;
|
|
2
12
|
/**
|
|
3
13
|
* Parses and validates the raw query-string from a GET request into typed {@link ListOptions}.
|
|
4
14
|
*
|
package/dist/core/queryString.js
CHANGED
|
@@ -31,6 +31,29 @@ function parseCsv(value) {
|
|
|
31
31
|
.map((item) => item.trim())
|
|
32
32
|
.filter(Boolean);
|
|
33
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Parses and validates the query-string for a single-record GET (`GET /resource/:id`).
|
|
36
|
+
* Only `?fields=` and `?include=` are meaningful for that endpoint — this avoids the wasted
|
|
37
|
+
* work and silent-discard behaviour of calling the full `parseListOptions` on a by-ID route.
|
|
38
|
+
*
|
|
39
|
+
* @param query - The raw query-string object from the HTTP request.
|
|
40
|
+
* @param resource - The resource definition used for field and relation validation.
|
|
41
|
+
* @returns Typed projection options ready to pass to `repository.getOne()`.
|
|
42
|
+
*/
|
|
43
|
+
export function parseGetOneOptions(query, resource) {
|
|
44
|
+
const fields = parseCsv(query.fields);
|
|
45
|
+
const include = parseCsv(query.include);
|
|
46
|
+
if (fields) {
|
|
47
|
+
validateFields(resource, fields);
|
|
48
|
+
validateSelectableFields(resource, fields);
|
|
49
|
+
}
|
|
50
|
+
if (include)
|
|
51
|
+
validateIncludes(resource, include);
|
|
52
|
+
if (fields && include) {
|
|
53
|
+
throw new UnprocessableEntityError('Cannot use both ?fields= and ?include= in the same request.');
|
|
54
|
+
}
|
|
55
|
+
return { fields, include };
|
|
56
|
+
}
|
|
34
57
|
/**
|
|
35
58
|
* Parses and validates the raw query-string from a GET request into typed {@link ListOptions}.
|
|
36
59
|
*
|
package/dist/core/validation.js
CHANGED
|
@@ -58,9 +58,7 @@ export function validateId(value) {
|
|
|
58
58
|
* @returns Array of field name strings.
|
|
59
59
|
*/
|
|
60
60
|
export function getFieldNames(resource) {
|
|
61
|
-
return (resource.fields ?? []).map((
|
|
62
|
-
return field.name;
|
|
63
|
-
});
|
|
61
|
+
return (resource.fields ?? []).map((f) => f.name);
|
|
64
62
|
}
|
|
65
63
|
/**
|
|
66
64
|
* Throws {@link UnprocessableEntityError} when any of `fields` are not defined on the resource.
|
|
@@ -82,10 +80,8 @@ export function validateFields(resource, fields = []) {
|
|
|
82
80
|
* @param fields - Field names to check for selectability.
|
|
83
81
|
*/
|
|
84
82
|
export function validateSelectableFields(resource, fields) {
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
return field?.selectable === false;
|
|
88
|
-
});
|
|
83
|
+
const fieldMap = new Map((resource.fields ?? []).map((f) => [f.name, f]));
|
|
84
|
+
const nonSelectable = fields.filter((name) => fieldMap.get(name)?.selectable === false);
|
|
89
85
|
if (nonSelectable.length) {
|
|
90
86
|
throw new UnprocessableEntityError(`Field(s) not selectable: ${nonSelectable.join(', ')}.`);
|
|
91
87
|
}
|
|
@@ -96,10 +92,8 @@ export function validateSelectableFields(resource, fields) {
|
|
|
96
92
|
* @param fields - Field names to check for sortability.
|
|
97
93
|
*/
|
|
98
94
|
export function validateSortableFields(resource, fields) {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
return field?.sortable === false;
|
|
102
|
-
});
|
|
95
|
+
const fieldMap = new Map((resource.fields ?? []).map((f) => [f.name, f]));
|
|
96
|
+
const nonSortable = fields.filter((name) => fieldMap.get(name)?.sortable === false);
|
|
103
97
|
if (nonSortable.length) {
|
|
104
98
|
throw new UnprocessableEntityError(`Field(s) not sortable: ${nonSortable.join(', ')}.`);
|
|
105
99
|
}
|