@jskit-ai/crud-core 0.1.27 → 0.1.29
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/package.descriptor.mjs +2 -2
- package/package.json +17 -7
- package/src/client/composables/crudClientSupportHelpers.js +5 -17
- package/src/server/createCrudRepositoryFromResource.js +57 -0
- package/src/server/createCrudServiceFromResource.js +101 -0
- package/src/server/crudModuleConfig.js +13 -21
- package/src/server/fieldAccess.js +316 -0
- package/src/server/listQueryValidators.js +87 -0
- package/src/server/lookupHydration.js +546 -0
- package/src/server/lookupPathSupport.js +45 -0
- package/src/server/lookupProviders.js +43 -0
- package/src/server/repositoryMethods.js +381 -0
- package/src/server/repositorySupport.js +205 -0
- package/src/server/serviceEvents.js +53 -0
- package/src/shared/crudFieldMetaSupport.js +54 -0
- package/src/shared/crudNamespaceSupport.js +31 -0
- package/test/createCrudRepositoryFromResource.test.js +731 -0
- package/test/createCrudServiceFromResource.test.js +263 -0
- package/test/crudFieldMetaSupport.test.js +47 -0
- package/test/fieldAccess.test.js +86 -0
- package/test/listQueryValidators.test.js +162 -0
- package/test/lookupProviders.test.js +103 -0
- package/test/repositorySupport.test.js +282 -1
- package/test/serviceEvents.test.js +28 -0
package/package.descriptor.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export default Object.freeze({
|
|
2
2
|
packageVersion: 1,
|
|
3
3
|
packageId: "@jskit-ai/crud-core",
|
|
4
|
-
version: "0.1.
|
|
4
|
+
version: "0.1.29",
|
|
5
5
|
kind: "runtime",
|
|
6
6
|
description: "Shared CRUD helpers used by CRUD modules.",
|
|
7
7
|
dependsOn: [
|
|
@@ -26,7 +26,7 @@ export default Object.freeze({
|
|
|
26
26
|
mutations: {
|
|
27
27
|
dependencies: {
|
|
28
28
|
runtime: {
|
|
29
|
-
"@jskit-ai/crud-core": "0.1.
|
|
29
|
+
"@jskit-ai/crud-core": "0.1.29"
|
|
30
30
|
},
|
|
31
31
|
dev: {}
|
|
32
32
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/crud-core",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.29",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "node --test"
|
|
@@ -10,15 +10,25 @@
|
|
|
10
10
|
"./client/composables/createCrudClientSupport": "./src/client/composables/createCrudClientSupport.js",
|
|
11
11
|
"./client/composables/crudClientSupportHelpers": "./src/client/composables/crudClientSupportHelpers.js",
|
|
12
12
|
"./client/composables/useCrudRealtimeInvalidation": "./src/client/composables/useCrudRealtimeInvalidation.js",
|
|
13
|
+
"./shared/crudFieldMetaSupport": "./src/shared/crudFieldMetaSupport.js",
|
|
14
|
+
"./shared/crudNamespaceSupport": "./src/shared/crudNamespaceSupport.js",
|
|
13
15
|
"./server/repositorySupport": "./src/server/repositorySupport.js",
|
|
14
|
-
"./server/
|
|
16
|
+
"./server/repositoryMethods": "./src/server/repositoryMethods.js",
|
|
17
|
+
"./server/createCrudRepositoryFromResource": "./src/server/createCrudRepositoryFromResource.js",
|
|
18
|
+
"./server/lookupProviders": "./src/server/lookupProviders.js",
|
|
19
|
+
"./server/serviceEvents": "./src/server/serviceEvents.js",
|
|
20
|
+
"./server/fieldAccess": "./src/server/fieldAccess.js",
|
|
21
|
+
"./server/createCrudServiceFromResource": "./src/server/createCrudServiceFromResource.js",
|
|
22
|
+
"./server/crudModuleConfig": "./src/server/crudModuleConfig.js",
|
|
23
|
+
"./server/listQueryValidators": "./src/server/listQueryValidators.js"
|
|
15
24
|
},
|
|
16
25
|
"dependencies": {
|
|
17
26
|
"@tanstack/vue-query": "^5.90.5",
|
|
18
|
-
"@jskit-ai/kernel": "0.1.
|
|
19
|
-
"@jskit-ai/realtime": "0.1.
|
|
20
|
-
"@jskit-ai/shell-web": "0.1.
|
|
21
|
-
"@jskit-ai/users-core": "0.1.
|
|
22
|
-
"@jskit-ai/users-web": "0.1.
|
|
27
|
+
"@jskit-ai/kernel": "0.1.21",
|
|
28
|
+
"@jskit-ai/realtime": "0.1.20",
|
|
29
|
+
"@jskit-ai/shell-web": "0.1.20",
|
|
30
|
+
"@jskit-ai/users-core": "0.1.30",
|
|
31
|
+
"@jskit-ai/users-web": "0.1.35",
|
|
32
|
+
"typebox": "^1.0.81"
|
|
23
33
|
}
|
|
24
34
|
}
|
|
@@ -1,19 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { normalizeText, normalizeQueryToken } from "@jskit-ai/kernel/shared/support/normalize";
|
|
2
2
|
import { normalizeRouteVisibilityToken } from "@jskit-ai/kernel/shared/support/visibility";
|
|
3
3
|
import { formatDateTime } from "@jskit-ai/kernel/shared/support";
|
|
4
|
+
import {
|
|
5
|
+
requireCrudNamespace,
|
|
6
|
+
resolveCrudRecordChangedEvent
|
|
7
|
+
} from "../../shared/crudNamespaceSupport.js";
|
|
4
8
|
|
|
5
9
|
const DEFAULT_CRUD_OWNERSHIP_FILTER = "workspace";
|
|
6
10
|
const ROUTE_PARAM_NAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]*$/;
|
|
7
11
|
|
|
8
|
-
function requireCrudNamespace(namespace, { context = "resolveCrudClientConfig" } = {}) {
|
|
9
|
-
const normalizedNamespace = normalizeLowerText(namespace);
|
|
10
|
-
if (!normalizedNamespace) {
|
|
11
|
-
throw new TypeError(`${context} requires a non-empty namespace.`);
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
return normalizedNamespace;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
12
|
function normalizeRelativePath(value, { context = "resolveCrudClientConfig" } = {}) {
|
|
18
13
|
const raw = normalizeText(value);
|
|
19
14
|
if (!raw) {
|
|
@@ -77,13 +72,6 @@ function crudScopeQueryKey(namespace = "") {
|
|
|
77
72
|
return Object.freeze(["crud", normalizeQueryToken(namespace)]);
|
|
78
73
|
}
|
|
79
74
|
|
|
80
|
-
function resolveCrudRecordChangedEvent(namespace = "") {
|
|
81
|
-
const normalizedNamespace = requireCrudNamespace(namespace, {
|
|
82
|
-
context: "resolveCrudRecordChangedEvent"
|
|
83
|
-
});
|
|
84
|
-
return `${normalizedNamespace.replace(/-/g, "_")}.record.changed`;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
75
|
async function invalidateCrudQueries(queryClient, namespace = "") {
|
|
88
76
|
if (!queryClient || typeof queryClient.invalidateQueries !== "function") {
|
|
89
77
|
throw new TypeError("invalidateCrudQueries requires queryClient.invalidateQueries().");
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCrudRepositoryRuntime,
|
|
3
|
+
crudRepositoryList,
|
|
4
|
+
crudRepositoryFindById,
|
|
5
|
+
crudRepositoryListByIds,
|
|
6
|
+
crudRepositoryCreate,
|
|
7
|
+
crudRepositoryUpdateById,
|
|
8
|
+
crudRepositoryDeleteById
|
|
9
|
+
} from "./repositoryMethods.js";
|
|
10
|
+
|
|
11
|
+
function createCrudRepositoryFromResource(resource = {}, { context = "crudRepository", list = {} } = {}) {
|
|
12
|
+
const runtime = createCrudRepositoryRuntime(resource, {
|
|
13
|
+
context,
|
|
14
|
+
list
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
return function createRepository(knex, options = {}) {
|
|
18
|
+
if (typeof knex !== "function") {
|
|
19
|
+
throw new TypeError("crudRepository requires knex.");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function listRecords(query = {}, callOptions = {}) {
|
|
23
|
+
return crudRepositoryList(runtime, knex, query, options, callOptions);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function findById(recordId, callOptions = {}) {
|
|
27
|
+
return crudRepositoryFindById(runtime, knex, recordId, options, callOptions);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function listByIds(ids = [], callOptions = {}) {
|
|
31
|
+
return crudRepositoryListByIds(runtime, knex, ids, options, callOptions);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function create(payload = {}, callOptions = {}) {
|
|
35
|
+
return crudRepositoryCreate(runtime, knex, payload, options, callOptions);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function updateById(recordId, patch = {}, callOptions = {}) {
|
|
39
|
+
return crudRepositoryUpdateById(runtime, knex, recordId, patch, options, callOptions);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function deleteById(recordId, callOptions = {}) {
|
|
43
|
+
return crudRepositoryDeleteById(runtime, knex, recordId, options, callOptions);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return Object.freeze({
|
|
47
|
+
list: listRecords,
|
|
48
|
+
findById,
|
|
49
|
+
listByIds,
|
|
50
|
+
create,
|
|
51
|
+
updateById,
|
|
52
|
+
deleteById
|
|
53
|
+
});
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export { createCrudRepositoryFromResource };
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
2
|
+
import { requireCrudNamespace } from "../shared/crudNamespaceSupport.js";
|
|
3
|
+
import { createCrudFieldAccessRuntime } from "./fieldAccess.js";
|
|
4
|
+
import { createCrudServiceEvents } from "./serviceEvents.js";
|
|
5
|
+
|
|
6
|
+
function createCrudServiceFromResource(resource = {}, { context = "crudService" } = {}) {
|
|
7
|
+
const namespace = requireCrudNamespace(resource?.resource, { context: `${context} resource.resource` });
|
|
8
|
+
const baseServiceEvents = createCrudServiceEvents(resource, { context });
|
|
9
|
+
const fieldAccessRuntime = createCrudFieldAccessRuntime(resource, { context });
|
|
10
|
+
|
|
11
|
+
function createBaseService({ repository, fieldAccess = {} } = {}) {
|
|
12
|
+
if (!repository) {
|
|
13
|
+
throw new Error(`${context} requires repository.`);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function listRecords(query = {}, options = {}) {
|
|
17
|
+
const result = await repository.list(query, options);
|
|
18
|
+
return fieldAccessRuntime.filterReadableListResult(result, fieldAccess, {
|
|
19
|
+
action: "list",
|
|
20
|
+
query,
|
|
21
|
+
options,
|
|
22
|
+
context: options?.context
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function getRecord(recordId, options = {}) {
|
|
27
|
+
const record = await repository.findById(recordId, options);
|
|
28
|
+
if (!record) {
|
|
29
|
+
throw new AppError(404, "Record not found.");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return fieldAccessRuntime.filterReadableRecord(record, fieldAccess, {
|
|
33
|
+
action: "view",
|
|
34
|
+
recordId,
|
|
35
|
+
options,
|
|
36
|
+
context: options?.context
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function createRecord(payload = {}, options = {}) {
|
|
41
|
+
const writablePayload = await fieldAccessRuntime.enforceWritablePayload(payload, fieldAccess, {
|
|
42
|
+
action: "create",
|
|
43
|
+
payload,
|
|
44
|
+
options,
|
|
45
|
+
context: options?.context
|
|
46
|
+
});
|
|
47
|
+
const record = await repository.create(writablePayload, options);
|
|
48
|
+
if (!record) {
|
|
49
|
+
throw new Error(`${namespace}Service could not load the created record.`);
|
|
50
|
+
}
|
|
51
|
+
return fieldAccessRuntime.filterReadableRecord(record, fieldAccess, {
|
|
52
|
+
action: "create",
|
|
53
|
+
options,
|
|
54
|
+
context: options?.context
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function updateRecord(recordId, payload = {}, options = {}) {
|
|
59
|
+
const writablePayload = await fieldAccessRuntime.enforceWritablePayload(payload, fieldAccess, {
|
|
60
|
+
action: "update",
|
|
61
|
+
recordId,
|
|
62
|
+
payload,
|
|
63
|
+
options,
|
|
64
|
+
context: options?.context
|
|
65
|
+
});
|
|
66
|
+
const record = await repository.updateById(recordId, writablePayload, options);
|
|
67
|
+
if (!record) {
|
|
68
|
+
throw new AppError(404, "Record not found.");
|
|
69
|
+
}
|
|
70
|
+
return fieldAccessRuntime.filterReadableRecord(record, fieldAccess, {
|
|
71
|
+
action: "update",
|
|
72
|
+
recordId,
|
|
73
|
+
options,
|
|
74
|
+
context: options?.context
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function deleteRecord(recordId, options = {}) {
|
|
79
|
+
const deleted = await repository.deleteById(recordId, options);
|
|
80
|
+
if (!deleted) {
|
|
81
|
+
throw new AppError(404, "Record not found.");
|
|
82
|
+
}
|
|
83
|
+
return deleted;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return Object.freeze({
|
|
87
|
+
listRecords,
|
|
88
|
+
getRecord,
|
|
89
|
+
createRecord,
|
|
90
|
+
updateRecord,
|
|
91
|
+
deleteRecord
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return Object.freeze({
|
|
96
|
+
createBaseService,
|
|
97
|
+
baseServiceEvents
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export { createCrudServiceFromResource };
|
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
2
2
|
import { normalizeSurfaceId } from "@jskit-ai/kernel/shared/surface/registry";
|
|
3
|
+
import {
|
|
4
|
+
normalizeCrudNamespace,
|
|
5
|
+
requireCrudNamespace
|
|
6
|
+
} from "../shared/crudNamespaceSupport.js";
|
|
3
7
|
import {
|
|
4
8
|
resolveApiBasePath
|
|
5
9
|
} from "@jskit-ai/users-core/shared/support/usersApiPaths";
|
|
6
10
|
import {
|
|
7
11
|
USERS_ROUTE_VISIBILITY_LEVELS,
|
|
8
|
-
|
|
12
|
+
checkRouteVisibility,
|
|
9
13
|
isWorkspaceVisibility
|
|
10
14
|
} from "@jskit-ai/users-core/shared/support/usersVisibility";
|
|
11
15
|
|
|
@@ -25,16 +29,13 @@ function asRecord(value) {
|
|
|
25
29
|
return value;
|
|
26
30
|
}
|
|
27
31
|
|
|
28
|
-
function normalizeCrudNamespace(value) {
|
|
29
|
-
return normalizeText(value)
|
|
30
|
-
.toLowerCase()
|
|
31
|
-
.replace(/[^a-z0-9-]+/g, "-")
|
|
32
|
-
.replace(/-+/g, "-")
|
|
33
|
-
.replace(/^-+|-+$/g, "");
|
|
34
|
-
}
|
|
35
|
-
|
|
36
32
|
function normalizeCrudOwnershipFilter(value, { fallback = DEFAULT_OWNERSHIP_FILTER } = {}) {
|
|
37
|
-
|
|
33
|
+
const normalizedValue = normalizeText(value).toLowerCase();
|
|
34
|
+
const normalizedFallback = normalizeText(fallback).toLowerCase();
|
|
35
|
+
const resolved = normalizedValue || normalizedFallback;
|
|
36
|
+
return checkRouteVisibility(resolved, {
|
|
37
|
+
context: "normalizeCrudOwnershipFilter ownershipFilter"
|
|
38
|
+
});
|
|
38
39
|
}
|
|
39
40
|
|
|
40
41
|
function normalizeCrudRequestedOwnershipFilter(value, { fallback = CRUD_REQUESTED_OWNERSHIP_FILTER_AUTO } = {}) {
|
|
@@ -51,15 +52,6 @@ function normalizeCrudRequestedOwnershipFilter(value, { fallback = CRUD_REQUESTE
|
|
|
51
52
|
return CRUD_REQUESTED_OWNERSHIP_FILTER_AUTO;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
|
-
function requireCrudNamespace(namespace, { context = "CRUD config" } = {}) {
|
|
55
|
-
const normalizedNamespace = normalizeCrudNamespace(namespace);
|
|
56
|
-
if (!normalizedNamespace) {
|
|
57
|
-
throw new TypeError(`${context} requires a non-empty namespace.`);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return normalizedNamespace;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
55
|
function resolveCrudNamespacePath(namespace = "") {
|
|
64
56
|
const normalizedNamespace = requireCrudNamespace(namespace, {
|
|
65
57
|
context: "resolveCrudNamespacePath"
|
|
@@ -208,8 +200,8 @@ function resolveCrudSurfacePolicy(
|
|
|
208
200
|
const ownershipFilter =
|
|
209
201
|
requestedOwnershipFilter === CRUD_REQUESTED_OWNERSHIP_FILTER_AUTO
|
|
210
202
|
? resolveOwnershipFilterFromSurfaceDefinition(surfaceDefinition)
|
|
211
|
-
:
|
|
212
|
-
|
|
203
|
+
: checkRouteVisibility(requestedOwnershipFilter, {
|
|
204
|
+
context: `${context} ownershipFilter`
|
|
213
205
|
});
|
|
214
206
|
|
|
215
207
|
if (isWorkspaceVisibility(ownershipFilter) && surfaceDefinition.requiresWorkspace !== true) {
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
2
|
+
import { normalizeObject, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
|
|
3
|
+
import { normalizeObjectInput } from "@jskit-ai/kernel/shared/validators/inputNormalization";
|
|
4
|
+
|
|
5
|
+
function isSchemaNullable(schema = {}) {
|
|
6
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const schemaType = schema.type;
|
|
11
|
+
if (typeof schemaType === "string" && normalizeText(schemaType).toLowerCase() === "null") {
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
if (Array.isArray(schemaType)) {
|
|
15
|
+
const hasNullType = schemaType.some((entry) => normalizeText(entry).toLowerCase() === "null");
|
|
16
|
+
if (hasNullType) {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const variants = [];
|
|
22
|
+
if (Array.isArray(schema.anyOf)) {
|
|
23
|
+
variants.push(...schema.anyOf);
|
|
24
|
+
}
|
|
25
|
+
if (Array.isArray(schema.oneOf)) {
|
|
26
|
+
variants.push(...schema.oneOf);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return variants.some((entry) => isSchemaNullable(entry));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeFieldSet(value, { context = "crudFieldAccess", label = "field list" } = {}) {
|
|
33
|
+
if (value == null || value === "*") {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const rawValues = value instanceof Set
|
|
38
|
+
? [...value]
|
|
39
|
+
: Array.isArray(value)
|
|
40
|
+
? value
|
|
41
|
+
: null;
|
|
42
|
+
if (!rawValues) {
|
|
43
|
+
throw new TypeError(`${context} ${label} must be an array, set, or "*".`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const normalizedKeys = rawValues
|
|
47
|
+
.map((entry) => normalizeText(entry))
|
|
48
|
+
.filter(Boolean);
|
|
49
|
+
|
|
50
|
+
return new Set(normalizedKeys);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function resolveFieldSet(resolver, input = {}, { context = "crudFieldAccess", label = "field list" } = {}) {
|
|
54
|
+
const resolvedResolver = resolver;
|
|
55
|
+
if (resolvedResolver == null) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const resolvedValue = typeof resolvedResolver === "function"
|
|
60
|
+
? await resolvedResolver(input)
|
|
61
|
+
: resolvedResolver;
|
|
62
|
+
|
|
63
|
+
return normalizeFieldSet(resolvedValue, { context, label });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function resolveWriteMode(fieldAccess = {}, { context = "crudFieldAccess" } = {}) {
|
|
67
|
+
const writeMode = normalizeText(fieldAccess?.writeMode || "throw").toLowerCase();
|
|
68
|
+
if (writeMode === "throw" || writeMode === "strip") {
|
|
69
|
+
return writeMode;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
throw new TypeError(`${context} fieldAccess.writeMode must be "throw" or "strip".`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function buildOutputFieldRules(resource = {}) {
|
|
76
|
+
const viewOutputSchema = resource?.operations?.view?.outputValidator?.schema;
|
|
77
|
+
if (!viewOutputSchema || typeof viewOutputSchema !== "object" || Array.isArray(viewOutputSchema)) {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const outputProperties = normalizeObject(viewOutputSchema.properties);
|
|
82
|
+
const requiredFields = new Set(
|
|
83
|
+
(Array.isArray(viewOutputSchema.required) ? viewOutputSchema.required : [])
|
|
84
|
+
.map((entry) => normalizeText(entry))
|
|
85
|
+
.filter(Boolean)
|
|
86
|
+
);
|
|
87
|
+
const fieldRules = new Map();
|
|
88
|
+
|
|
89
|
+
for (const [fieldKey, fieldSchemaRaw] of Object.entries(outputProperties)) {
|
|
90
|
+
const normalizedFieldKey = normalizeText(fieldKey);
|
|
91
|
+
if (!normalizedFieldKey) {
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const fieldSchema = normalizeObject(fieldSchemaRaw);
|
|
96
|
+
fieldRules.set(
|
|
97
|
+
normalizedFieldKey,
|
|
98
|
+
Object.freeze({
|
|
99
|
+
required: requiredFields.has(normalizedFieldKey),
|
|
100
|
+
nullable: isSchemaNullable(fieldSchema),
|
|
101
|
+
hasDefault: Object.hasOwn(fieldSchema, "default"),
|
|
102
|
+
defaultValue: fieldSchema.default
|
|
103
|
+
})
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return Object.freeze({
|
|
108
|
+
fieldRules: new Map(fieldRules)
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function resolveRoleFromFieldAccessInput(input = {}) {
|
|
113
|
+
const role = normalizeText(input?.context?.auth?.role).toLowerCase();
|
|
114
|
+
return role || "default";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function resolveActionFromFieldAccessInput(input = {}) {
|
|
118
|
+
const action = normalizeText(input?.action).toLowerCase();
|
|
119
|
+
return action || "*";
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function resolveOperationPolicyValue(operationPolicy, input = {}, action = "*") {
|
|
123
|
+
if (typeof operationPolicy === "function") {
|
|
124
|
+
return operationPolicy(input);
|
|
125
|
+
}
|
|
126
|
+
if (
|
|
127
|
+
operationPolicy == null ||
|
|
128
|
+
operationPolicy === "*" ||
|
|
129
|
+
Array.isArray(operationPolicy) ||
|
|
130
|
+
operationPolicy instanceof Set
|
|
131
|
+
) {
|
|
132
|
+
return operationPolicy;
|
|
133
|
+
}
|
|
134
|
+
if (typeof operationPolicy !== "object" || Array.isArray(operationPolicy)) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (Object.hasOwn(operationPolicy, action)) {
|
|
139
|
+
return operationPolicy[action];
|
|
140
|
+
}
|
|
141
|
+
if (Object.hasOwn(operationPolicy, "*")) {
|
|
142
|
+
return operationPolicy["*"];
|
|
143
|
+
}
|
|
144
|
+
if (Object.hasOwn(operationPolicy, "all")) {
|
|
145
|
+
return operationPolicy.all;
|
|
146
|
+
}
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function resolveRoleMatrixPolicy(matrix = {}, operation = "readable", input = {}) {
|
|
151
|
+
const sourceMatrix = normalizeObject(matrix);
|
|
152
|
+
const role = resolveRoleFromFieldAccessInput(input);
|
|
153
|
+
const action = resolveActionFromFieldAccessInput(input);
|
|
154
|
+
|
|
155
|
+
const rolePolicy = normalizeObject(sourceMatrix[role]);
|
|
156
|
+
const roleValue = resolveOperationPolicyValue(rolePolicy[operation], input, action);
|
|
157
|
+
if (roleValue != null) {
|
|
158
|
+
return roleValue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const defaultPolicy = normalizeObject(sourceMatrix.default);
|
|
162
|
+
return resolveOperationPolicyValue(defaultPolicy[operation], input, action);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function createFieldAccessForRoleMatrix(matrix = {}, { context = "crudFieldAccess" } = {}) {
|
|
166
|
+
const sourceMatrix = normalizeObject(matrix);
|
|
167
|
+
const writeMode = resolveWriteMode(
|
|
168
|
+
{ writeMode: sourceMatrix.writeMode },
|
|
169
|
+
{ context: `${context} createFieldAccessForRoleMatrix` }
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
return Object.freeze({
|
|
173
|
+
readable(input = {}) {
|
|
174
|
+
return resolveRoleMatrixPolicy(sourceMatrix, "readable", input);
|
|
175
|
+
},
|
|
176
|
+
writable(input = {}) {
|
|
177
|
+
return resolveRoleMatrixPolicy(sourceMatrix, "writable", input);
|
|
178
|
+
},
|
|
179
|
+
writeMode
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function applyReadableFieldPolicyToRecord(record, allowedFields, outputRules = null, { context = "crudFieldAccess" } = {}) {
|
|
184
|
+
if (!record || typeof record !== "object" || Array.isArray(record) || !allowedFields) {
|
|
185
|
+
return record;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const nextRecord = { ...record };
|
|
189
|
+
const fieldRules = outputRules?.fieldRules instanceof Map ? outputRules.fieldRules : null;
|
|
190
|
+
|
|
191
|
+
for (const fieldKey of Object.keys(nextRecord)) {
|
|
192
|
+
if (allowedFields.has(fieldKey)) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const fieldRule = fieldRules?.get(fieldKey);
|
|
197
|
+
if (!fieldRule || fieldRule.required !== true) {
|
|
198
|
+
delete nextRecord[fieldKey];
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (fieldRule.nullable) {
|
|
203
|
+
nextRecord[fieldKey] = null;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (fieldRule.hasDefault) {
|
|
208
|
+
nextRecord[fieldKey] = fieldRule.defaultValue;
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
throw new Error(
|
|
213
|
+
`${context} cannot redact required non-nullable field "${fieldKey}" without schema.default.`
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return nextRecord;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function createCrudFieldAccessRuntime(resource = {}, { context = "crudFieldAccess" } = {}) {
|
|
221
|
+
const outputRules = buildOutputFieldRules(resource);
|
|
222
|
+
|
|
223
|
+
async function resolveReadableAllowedFields(fieldAccess = {}, input = {}) {
|
|
224
|
+
const allowedFields = await resolveFieldSet(
|
|
225
|
+
fieldAccess?.readable,
|
|
226
|
+
input,
|
|
227
|
+
{
|
|
228
|
+
context,
|
|
229
|
+
label: "fieldAccess.readable"
|
|
230
|
+
}
|
|
231
|
+
);
|
|
232
|
+
if (!allowedFields) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
if (!outputRules) {
|
|
236
|
+
throw new TypeError(`${context} requires resource.operations.view.outputValidator.schema for fieldAccess.readable.`);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return allowedFields;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function enforceWritablePayload(payload = {}, fieldAccess = {}, input = {}) {
|
|
243
|
+
const allowedFields = await resolveFieldSet(
|
|
244
|
+
fieldAccess?.writable,
|
|
245
|
+
input,
|
|
246
|
+
{
|
|
247
|
+
context,
|
|
248
|
+
label: "fieldAccess.writable"
|
|
249
|
+
}
|
|
250
|
+
);
|
|
251
|
+
if (!allowedFields) {
|
|
252
|
+
return payload;
|
|
253
|
+
}
|
|
254
|
+
const sourcePayload = normalizeObjectInput(payload);
|
|
255
|
+
|
|
256
|
+
const writeMode = resolveWriteMode(fieldAccess, { context });
|
|
257
|
+
const filteredPayload = {};
|
|
258
|
+
const forbiddenFields = [];
|
|
259
|
+
for (const [fieldKey, fieldValue] of Object.entries(sourcePayload)) {
|
|
260
|
+
if (allowedFields.has(fieldKey)) {
|
|
261
|
+
filteredPayload[fieldKey] = fieldValue;
|
|
262
|
+
} else {
|
|
263
|
+
forbiddenFields.push(fieldKey);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (forbiddenFields.length > 0 && writeMode === "throw") {
|
|
268
|
+
throw new AppError(403, `Write access denied for fields: ${forbiddenFields.join(", ")}.`);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return filteredPayload;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function filterReadableRecord(record = null, fieldAccess = {}, input = {}) {
|
|
275
|
+
const allowedFields = await resolveReadableAllowedFields(fieldAccess, input);
|
|
276
|
+
if (!allowedFields) {
|
|
277
|
+
return record;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return applyReadableFieldPolicyToRecord(record, allowedFields, outputRules, {
|
|
281
|
+
context
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function filterReadableListResult(listResult = {}, fieldAccess = {}, input = {}) {
|
|
286
|
+
const allowedFields = await resolveReadableAllowedFields(fieldAccess, input);
|
|
287
|
+
if (!allowedFields) {
|
|
288
|
+
return listResult;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const sourceList = normalizeObject(listResult);
|
|
292
|
+
const sourceItems = Array.isArray(sourceList.items) ? sourceList.items : [];
|
|
293
|
+
if (sourceItems.length < 1) {
|
|
294
|
+
return sourceList;
|
|
295
|
+
}
|
|
296
|
+
const filteredItems = sourceItems.map((record) =>
|
|
297
|
+
applyReadableFieldPolicyToRecord(record, allowedFields, outputRules, {
|
|
298
|
+
context
|
|
299
|
+
})
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
return {
|
|
303
|
+
...sourceList,
|
|
304
|
+
items: filteredItems
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return Object.freeze({
|
|
309
|
+
enforceWritablePayload,
|
|
310
|
+
filterReadableRecord,
|
|
311
|
+
filterReadableListResult
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export { createCrudFieldAccessRuntime };
|
|
316
|
+
export { createFieldAccessForRoleMatrix };
|