@aooth/arbac-moost 0.1.73 → 0.1.75
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/dist/index.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { i as __decorate, n as ArbacUserProvider, r as ArbacUserProviderToken, t as __decorateMetadata } from "./decorateMetadata-CU_mqYGT.mjs";
|
|
2
2
|
import { Arbac, Arbac as Arbac$1, arbacPatternToRegex } from "@aooth/arbac-core";
|
|
3
|
+
import { DB_SCOPE_KEYS, DENY_FILTER, ScopeFieldConfigError, applyScopeFieldFilters, conjoinScopeFilters, conjoinScopeFilters as conjoinScopeFilters$1, conjoinScopes, effectiveScope, expandExcludeToLeaves, getProjectionMode, intersectEnabled, isFieldAllowed, needsInheritedConjunction, normalizeScopes, restrictProjection, stableKey, unionOutcomes } from "@aooth/arbac";
|
|
3
4
|
import { Authenticate, HttpError } from "@moostjs/event-http";
|
|
4
5
|
import { cached, cachedBy, current, eventTypeKey, key, tryGetCurrent } from "@wooksjs/event-core";
|
|
5
6
|
import { Inherit, Injectable, TInterceptorPriority, defineBeforeInterceptor, getConstructor, getMoostMate, useControllerContext, useLogger } from "moost";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { AsDbController, AsDbReadableController, AsJsonValueHelpController, AsValueHelpController, DB_CRUD_HANDLERS, VALUE_HELP_CRUD_HANDLERS, discoverRowLevelActions, getDbEndpoint, resolveTerminalRef } from "@atscript/moost-db";
|
|
7
|
+
import { computeInsights, containsRelationFilter, findAncestorInSet, getPath, hasRelationOp, isPlainObject, isRelationOp, translateQueryTree } from "@atscript/db";
|
|
8
|
+
import { AsDbController, AsDbReadableController, AsJsonValueHelpController, AsValueHelpController, DB_CRUD_HANDLERS, VALUE_HELP_CRUD_HANDLERS, badRequest, discoverRowLevelActions, getControllerFormType, getDbEndpoint, hasActionDelegations, resolveTerminalRef } from "@atscript/moost-db";
|
|
9
9
|
import { groupByFields, walkFilter } from "@uniqu/core";
|
|
10
10
|
//#region src/db/helpers.ts
|
|
11
11
|
/** `map.get(key)`, created with `create()` and stored on a miss. */
|
|
@@ -110,6 +110,10 @@ function isMetaFieldVisible(path, vis) {
|
|
|
110
110
|
}
|
|
111
111
|
return allowed(path);
|
|
112
112
|
}
|
|
113
|
+
/** The level of relation `name` when it is visible at `vis` (see {@link isMetaFieldVisible}). */
|
|
114
|
+
function visibleRelation(name, vis) {
|
|
115
|
+
return isMetaFieldVisible(name, vis) ? vis.relation?.(name) : void 0;
|
|
116
|
+
}
|
|
113
117
|
const NO_NAMES$1 = /* @__PURE__ */ new Set();
|
|
114
118
|
const identifierSetCache = /* @__PURE__ */ new WeakMap();
|
|
115
119
|
/** PK + `preferredId` of a table — the identifiers reads always return. */
|
|
@@ -294,11 +298,28 @@ const registry = /* @__PURE__ */ new WeakMap();
|
|
|
294
298
|
*/
|
|
295
299
|
function registerArbacDbTarget(controller) {
|
|
296
300
|
const c = controller;
|
|
297
|
-
const target = c.readable?.type ?? c.readable;
|
|
301
|
+
const target = valueHelpTargets.get(controller)?.source.type ?? c.readable?.type ?? c.readable;
|
|
298
302
|
if (!target) return true;
|
|
299
303
|
getOrCreate(getOrCreate(registry, c.app ?? NO_APP, () => /* @__PURE__ */ new WeakMap()), target, () => /* @__PURE__ */ new Map()).set(getConstructor(controller), controller);
|
|
300
304
|
return true;
|
|
301
305
|
}
|
|
306
|
+
/** Value-help controllers have no `readable`: their visibility source and read handler method. */
|
|
307
|
+
const valueHelpTargets = /* @__PURE__ */ new WeakMap();
|
|
308
|
+
/**
|
|
309
|
+
* Register an ARBAC value-help controller as an owner of its bound type's
|
|
310
|
+
* ARBAC resource, so a `ui.valueHelp` binding (or `ref`) from another
|
|
311
|
+
* controller's `/meta` is kept for a caller who can read the dictionary
|
|
312
|
+
* through it (its `runQuery` grant, evaluated against `source`).
|
|
313
|
+
*
|
|
314
|
+
* @returns `true` (so it can initialize a field)
|
|
315
|
+
*/
|
|
316
|
+
function registerArbacValueHelpTarget(controller, source) {
|
|
317
|
+
valueHelpTargets.set(controller, {
|
|
318
|
+
source,
|
|
319
|
+
method: "runQuery"
|
|
320
|
+
});
|
|
321
|
+
return registerArbacDbTarget(controller);
|
|
322
|
+
}
|
|
302
323
|
function registeredControllers(target) {
|
|
303
324
|
let app = NO_APP;
|
|
304
325
|
try {
|
|
@@ -342,13 +363,14 @@ function evaluateHandlerOutcomes(ids) {
|
|
|
342
363
|
async function evaluateTypeRead(type) {
|
|
343
364
|
const controllers = registeredControllers(type);
|
|
344
365
|
if (controllers.length === 0) return null;
|
|
366
|
+
const tableOf = (c) => valueHelpTargets.get(c)?.source ?? c.readable;
|
|
345
367
|
const scopes = await evaluateHandlers(controllers.map((c) => ({
|
|
346
|
-
...resolveHandlerArbacIds(c, "query"),
|
|
347
|
-
table: c
|
|
368
|
+
...resolveHandlerArbacIds(c, valueHelpTargets.get(c)?.method ?? "query"),
|
|
369
|
+
table: tableOf(c)
|
|
348
370
|
})));
|
|
349
371
|
return scopes ? {
|
|
350
372
|
scopes,
|
|
351
|
-
table: controllers[0]
|
|
373
|
+
table: tableOf(controllers[0])
|
|
352
374
|
} : null;
|
|
353
375
|
}
|
|
354
376
|
/**
|
|
@@ -358,7 +380,9 @@ async function evaluateTypeRead(type) {
|
|
|
358
380
|
const MAX_WITH_DEPTH = 16;
|
|
359
381
|
/**
|
|
360
382
|
* Relation names a parsed `$with` control reaches, per level. A dotted
|
|
361
|
-
* name (`a.b`) nests like `a($with=b)`.
|
|
383
|
+
* name (`a.b`) nests like `a($with=b)`. The relational predicates of each
|
|
384
|
+
* entry's sub-filter (`$with=a(b=$some(…))`) join the entry's level
|
|
385
|
+
* ({@link relationFilterNameTree}).
|
|
362
386
|
*/
|
|
363
387
|
function withNameTree(withArr, into = /* @__PURE__ */ new Map(), depth = 0) {
|
|
364
388
|
if (!Array.isArray(withArr) || depth >= MAX_WITH_DEPTH) return into;
|
|
@@ -370,7 +394,29 @@ function withNameTree(withArr, into = /* @__PURE__ */ new Map(), depth = 0) {
|
|
|
370
394
|
if (depth + segments.length > MAX_WITH_DEPTH) continue;
|
|
371
395
|
let level = into;
|
|
372
396
|
for (const segment of segments) level = getOrCreate(level, segment, () => /* @__PURE__ */ new Map());
|
|
373
|
-
if (entry && typeof entry === "object")
|
|
397
|
+
if (entry && typeof entry === "object") {
|
|
398
|
+
withNameTree(entry.controls?.$with ?? entry.$with, level, depth + segments.length);
|
|
399
|
+
relationFilterNameTree(entry.filter, level, depth + segments.length);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return into;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Relation names the relational predicates of a filter reach, per level
|
|
406
|
+
* (`ticket=$some(team=$some(…))` → `ticket → { team }`), through
|
|
407
|
+
* `$and` / `$or` / `$not` and nested predicate operands. Only reads
|
|
408
|
+
* `filter` (moost-db hands `prepareRequest` a deep-frozen copy).
|
|
409
|
+
*
|
|
410
|
+
* @since 0.1.74
|
|
411
|
+
*/
|
|
412
|
+
function relationFilterNameTree(filter, into = /* @__PURE__ */ new Map(), depth = 0) {
|
|
413
|
+
if (!filter || typeof filter !== "object" || depth >= MAX_WITH_DEPTH) return into;
|
|
414
|
+
for (const [k, value] of Object.entries(filter)) if (k === "$and" || k === "$or") {
|
|
415
|
+
if (Array.isArray(value)) for (const c of value) relationFilterNameTree(c, into, depth);
|
|
416
|
+
} else if (k === "$not") relationFilterNameTree(value, into, depth);
|
|
417
|
+
else if (!k.startsWith("$") && hasRelationOp(value)) {
|
|
418
|
+
const level = getOrCreate(into, k, () => /* @__PURE__ */ new Map());
|
|
419
|
+
for (const [op, operand] of Object.entries(value)) if (isRelationOp(op)) relationFilterNameTree(operand, level, depth + 1);
|
|
374
420
|
}
|
|
375
421
|
return into;
|
|
376
422
|
}
|
|
@@ -412,19 +458,35 @@ async function resolveRelationTree(scopes, table, names, opts = {}) {
|
|
|
412
458
|
const requestRelationsKey = key("arbac.db.relations");
|
|
413
459
|
/**
|
|
414
460
|
* Per-request `$with` policy for the ARBAC DB controllers' `prepareRequest`:
|
|
415
|
-
* resolves every relation `controls.$with` names (recursively) and
|
|
416
|
-
*
|
|
417
|
-
* `
|
|
461
|
+
* resolves every relation `controls.$with` names (recursively) and — since
|
|
462
|
+
* 0.1.74 — every relation the client `filter`'s relational predicates name
|
|
463
|
+
* (`ticket=$some(…)`, also inside `$with` sub-filters), and stores the
|
|
464
|
+
* resolution the request's visibility reads.
|
|
465
|
+
*
|
|
466
|
+
* @returns the relation names resolved, per level
|
|
418
467
|
*/
|
|
419
|
-
async function resolveRequestRelations(scopes, controls, readable) {
|
|
468
|
+
async function resolveRequestRelations(scopes, controls, readable, filter) {
|
|
420
469
|
const names = withNameTree(controls?.$with);
|
|
421
|
-
|
|
422
|
-
|
|
470
|
+
const listSiblings = names.size > 0;
|
|
471
|
+
relationFilterNameTree(filter, names);
|
|
472
|
+
if (names.size === 0) return names;
|
|
473
|
+
const relations = await resolveRelationTree(scopes, readable, names, { listSiblings });
|
|
474
|
+
current().set(requestRelationsKey, {
|
|
475
|
+
readable,
|
|
476
|
+
relations
|
|
477
|
+
});
|
|
478
|
+
return names;
|
|
423
479
|
}
|
|
424
|
-
/**
|
|
425
|
-
|
|
480
|
+
/**
|
|
481
|
+
* The resolution {@link resolveRequestRelations} stored for the current
|
|
482
|
+
* event, if any — only for the readable it was resolved for: another
|
|
483
|
+
* controller evaluated in the same event (a `@DbActionsFrom` source) never
|
|
484
|
+
* sees the delegating request's `$with` resolution.
|
|
485
|
+
*/
|
|
486
|
+
function requestRelations(readable) {
|
|
426
487
|
const ctx = current();
|
|
427
|
-
|
|
488
|
+
const stored = ctx.has(requestRelationsKey) ? ctx.get(requestRelationsKey) : void 0;
|
|
489
|
+
return stored && stored.readable === readable ? stored.relations : void 0;
|
|
428
490
|
}
|
|
429
491
|
//#endregion
|
|
430
492
|
//#region src/db/write-refs.ts
|
|
@@ -741,6 +803,20 @@ function arbacIdsFromMeta(cMeta, mMeta, controller, methodName) {
|
|
|
741
803
|
action: mMeta?.arbacActionId || mMeta?.atscript_db_action?.name || cMeta?.arbacActionId || mMeta?.id || methodName
|
|
742
804
|
};
|
|
743
805
|
}
|
|
806
|
+
const scopeOwners = /* @__PURE__ */ new WeakMap();
|
|
807
|
+
/** Record that `scopes` were evaluated for `resource` / `action` (see {@link scopesOwnedBy}). */
|
|
808
|
+
function tagScopesOwner(scopes, resource, action) {
|
|
809
|
+
scopeOwners.set(scopes, `${resource}\u0000${action}`);
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* `true` when the cached `scopes` were set for `resource` / `action`. An
|
|
813
|
+
* event slot read can fall through to a parent context (moost-db evaluates a
|
|
814
|
+
* `@DbActionsFrom` source in a child of the delegating event), so scopes
|
|
815
|
+
* cached for another handler are never taken as this handler's grant.
|
|
816
|
+
*/
|
|
817
|
+
function scopesOwnedBy(scopes, resource, action) {
|
|
818
|
+
return scopeOwners.get(scopes) === `${resource}\u0000${action}`;
|
|
819
|
+
}
|
|
744
820
|
/** The 403 every ARBAC deny answers. */
|
|
745
821
|
function insufficientPrivileges(resource, action) {
|
|
746
822
|
return new HttpError(403, `Insufficient privileges for action "${action}" on resource "${resource}"`);
|
|
@@ -899,6 +975,7 @@ const useArbac = (_ctx) => {
|
|
|
899
975
|
const cc = useControllerContext(ctx);
|
|
900
976
|
const getScopes = () => getArbacScopes(ctx);
|
|
901
977
|
const setScopes = (scope) => {
|
|
978
|
+
if (scope) tagScopesOwner(scope, resource, action);
|
|
902
979
|
ctx.set(arbacScopesKey, scope);
|
|
903
980
|
};
|
|
904
981
|
const cMeta = cc.getControllerMeta();
|
|
@@ -1056,7 +1133,7 @@ function pruneMetaByVisibility(meta, vis, refTargets) {
|
|
|
1056
1133
|
...meta,
|
|
1057
1134
|
fields,
|
|
1058
1135
|
relations,
|
|
1059
|
-
type: pruneSerializedType(meta.type, {
|
|
1136
|
+
type: pruneSerializedType(inlineTypeRefs(meta.type), {
|
|
1060
1137
|
basePath: "",
|
|
1061
1138
|
vis,
|
|
1062
1139
|
relationNames,
|
|
@@ -1066,6 +1143,153 @@ function pruneMetaByVisibility(meta, vis, refTargets) {
|
|
|
1066
1143
|
if (out.versionColumn !== void 0 && !isMetaFieldVisible(out.versionColumn, vis)) delete out.versionColumn;
|
|
1067
1144
|
return out;
|
|
1068
1145
|
}
|
|
1146
|
+
/**
|
|
1147
|
+
* Replace every `$ref` entry with a copy of the body serialized under its `id`
|
|
1148
|
+
* (the first node carrying that id), so each use is pruned by ITS OWN path's
|
|
1149
|
+
* visibility. A shared body pruned once would resolve, on the client, to the
|
|
1150
|
+
* first use's shape at every other use: hidden fields leak through a later
|
|
1151
|
+
* `$ref`, and a hidden first use leaves later `$ref`s dangling. An `own`
|
|
1152
|
+
* entry keeps its own metadata / optional / `ref`. Unknown ids and recursive
|
|
1153
|
+
* back-references stay `$ref`s (a recursive one points at an ancestor that is
|
|
1154
|
+
* itself retained). Never mutates the (cached) input.
|
|
1155
|
+
*/
|
|
1156
|
+
function inlineTypeRefs(root) {
|
|
1157
|
+
return getOrCreate(inlinedCache, root, () => {
|
|
1158
|
+
const bodies = /* @__PURE__ */ new Map();
|
|
1159
|
+
collectBodies(root, bodies);
|
|
1160
|
+
if (bodies.size === 0) return root;
|
|
1161
|
+
return inlineNode(root, {
|
|
1162
|
+
bodies,
|
|
1163
|
+
budget: 2e4,
|
|
1164
|
+
n: 0
|
|
1165
|
+
}, /* @__PURE__ */ new Map(), false) ?? root;
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
const inlinedCache = /* @__PURE__ */ new WeakMap();
|
|
1169
|
+
function childNodes(node) {
|
|
1170
|
+
const def = node.type;
|
|
1171
|
+
const out = [];
|
|
1172
|
+
if (def.kind === "object") {
|
|
1173
|
+
out.push(...Object.values(def.props));
|
|
1174
|
+
for (const p of def.propsPatterns ?? []) out.push(p.def);
|
|
1175
|
+
} else if (def.kind === "array") out.push(def.of);
|
|
1176
|
+
else if (Array.isArray(def.items)) out.push(...def.items);
|
|
1177
|
+
const refType = node.ref?.type;
|
|
1178
|
+
if (refType && refType.type) out.push(refType);
|
|
1179
|
+
return out;
|
|
1180
|
+
}
|
|
1181
|
+
function collectBodies(node, bodies) {
|
|
1182
|
+
if (node.type.kind === "$ref") return;
|
|
1183
|
+
if (node.id !== void 0 && !bodies.has(node.id)) bodies.set(node.id, node);
|
|
1184
|
+
for (const child of childNodes(node)) collectBodies(child, bodies);
|
|
1185
|
+
}
|
|
1186
|
+
function inlineNode(node, ctx, stack, inlining) {
|
|
1187
|
+
const def = node.type;
|
|
1188
|
+
if (def.kind === "$ref") {
|
|
1189
|
+
const ancestor = stack.get(def.id);
|
|
1190
|
+
if (ancestor !== void 0) return {
|
|
1191
|
+
...node,
|
|
1192
|
+
type: {
|
|
1193
|
+
...def,
|
|
1194
|
+
id: ancestor
|
|
1195
|
+
}
|
|
1196
|
+
};
|
|
1197
|
+
const body = ctx.bodies.get(def.id);
|
|
1198
|
+
if (!body) return node;
|
|
1199
|
+
const inlined = inlineNode(body, ctx, stack, true);
|
|
1200
|
+
if (!inlined) return void 0;
|
|
1201
|
+
if (!def.own) return inlined;
|
|
1202
|
+
const { ref: _ref, optional: _optional, ...rest } = inlined;
|
|
1203
|
+
return {
|
|
1204
|
+
...rest,
|
|
1205
|
+
metadata: node.metadata,
|
|
1206
|
+
...node.optional === void 0 ? {} : { optional: node.optional },
|
|
1207
|
+
...node.ref ? { ref: node.ref } : {}
|
|
1208
|
+
};
|
|
1209
|
+
}
|
|
1210
|
+
if (inlining && --ctx.budget < 0) return void 0;
|
|
1211
|
+
let nextStack = stack;
|
|
1212
|
+
let out = node;
|
|
1213
|
+
if (node.id !== void 0) {
|
|
1214
|
+
const copyId = `${node.id}#${++ctx.n}`;
|
|
1215
|
+
nextStack = new Map(stack).set(node.id, copyId);
|
|
1216
|
+
out = {
|
|
1217
|
+
...node,
|
|
1218
|
+
id: copyId
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
const d = def;
|
|
1222
|
+
if (d.kind === "object") {
|
|
1223
|
+
const props = {};
|
|
1224
|
+
for (const [k, v] of Object.entries(d.props)) {
|
|
1225
|
+
const r = inlineNode(v, ctx, nextStack, inlining);
|
|
1226
|
+
if (r) props[k] = r;
|
|
1227
|
+
}
|
|
1228
|
+
const propsPatterns = [];
|
|
1229
|
+
for (const p of d.propsPatterns ?? []) {
|
|
1230
|
+
const r = inlineNode(p.def, ctx, nextStack, inlining);
|
|
1231
|
+
if (r) propsPatterns.push({
|
|
1232
|
+
...p,
|
|
1233
|
+
def: r
|
|
1234
|
+
});
|
|
1235
|
+
}
|
|
1236
|
+
out = {
|
|
1237
|
+
...out,
|
|
1238
|
+
type: {
|
|
1239
|
+
...def,
|
|
1240
|
+
props,
|
|
1241
|
+
propsPatterns
|
|
1242
|
+
}
|
|
1243
|
+
};
|
|
1244
|
+
} else if (d.kind === "array") {
|
|
1245
|
+
const of = inlineNode(d.of, ctx, nextStack, inlining) ?? emptyStub();
|
|
1246
|
+
out = {
|
|
1247
|
+
...out,
|
|
1248
|
+
type: {
|
|
1249
|
+
...def,
|
|
1250
|
+
of
|
|
1251
|
+
}
|
|
1252
|
+
};
|
|
1253
|
+
} else if (Array.isArray(d.items)) {
|
|
1254
|
+
const items = d.items.map((i) => inlineNode(i, ctx, nextStack, inlining) ?? emptyStub());
|
|
1255
|
+
out = {
|
|
1256
|
+
...out,
|
|
1257
|
+
type: {
|
|
1258
|
+
...def,
|
|
1259
|
+
items
|
|
1260
|
+
}
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
const refType = out.ref?.type;
|
|
1264
|
+
if (out.ref && refType && refType.type) {
|
|
1265
|
+
const r = inlineNode(refType, ctx, nextStack, inlining);
|
|
1266
|
+
if (r) out = {
|
|
1267
|
+
...out,
|
|
1268
|
+
ref: {
|
|
1269
|
+
...out.ref,
|
|
1270
|
+
type: r
|
|
1271
|
+
}
|
|
1272
|
+
};
|
|
1273
|
+
else {
|
|
1274
|
+
const { ref: _r, ...noRef } = out;
|
|
1275
|
+
out = noRef;
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
return out;
|
|
1279
|
+
}
|
|
1280
|
+
/** A body-less placeholder for a positional child dropped by the inline budget. */
|
|
1281
|
+
function emptyStub() {
|
|
1282
|
+
return {
|
|
1283
|
+
$v: 2,
|
|
1284
|
+
type: {
|
|
1285
|
+
kind: "object",
|
|
1286
|
+
props: {},
|
|
1287
|
+
propsPatterns: [],
|
|
1288
|
+
tags: []
|
|
1289
|
+
},
|
|
1290
|
+
metadata: {}
|
|
1291
|
+
};
|
|
1292
|
+
}
|
|
1069
1293
|
function pruneSerializedType(node, level) {
|
|
1070
1294
|
const { basePath, vis, relationNames, refTargets } = level;
|
|
1071
1295
|
const def = node.type;
|
|
@@ -1092,20 +1316,21 @@ function pruneSerializedType(node, level) {
|
|
|
1092
1316
|
basePath: path
|
|
1093
1317
|
};
|
|
1094
1318
|
if (!isMetaFieldVisible(path, vis)) {
|
|
1095
|
-
if (isPathWritable(path, vis)) props[name] = stampWriteOnly(prop);
|
|
1096
|
-
else if (hasWritableDescendant(path, vis.writable)) props[name] = stampWriteOnly(pruneSerializedType(prop, child));
|
|
1319
|
+
if (isPathWritable(path, vis)) props[name] = withVisibleTargets(stampWriteOnly(prop), path, vis, refTargets, level.inBranch);
|
|
1320
|
+
else if (hasWritableDescendant(path, vis.writable)) props[name] = withVisibleTargets(stampWriteOnly(pruneSerializedType(prop, child)), path, vis, refTargets, level.inBranch);
|
|
1097
1321
|
continue;
|
|
1098
1322
|
}
|
|
1099
|
-
|
|
1100
|
-
props[name] = pruned.ref && !isRefVisible(path, vis, refTargets) ? withoutRef(pruned) : pruned;
|
|
1323
|
+
props[name] = withVisibleTargets(pruneSerializedType(prop, child), path, vis, refTargets, level.inBranch);
|
|
1101
1324
|
}
|
|
1102
|
-
|
|
1325
|
+
const pruned = {
|
|
1103
1326
|
...node,
|
|
1104
1327
|
type: {
|
|
1105
1328
|
...def,
|
|
1106
1329
|
props
|
|
1107
1330
|
}
|
|
1108
1331
|
};
|
|
1332
|
+
if (basePath !== "" || !def.propsPatterns?.length || !vis.table?.flatMap || !refTargets) return pruned;
|
|
1333
|
+
return gateChildren(pruned, level.inBranch ? void 0 : vis.table?.type, vis, refTargets, false);
|
|
1109
1334
|
}
|
|
1110
1335
|
if (def.kind === "array") return {
|
|
1111
1336
|
...node,
|
|
@@ -1118,7 +1343,10 @@ function pruneSerializedType(node, level) {
|
|
|
1118
1343
|
...node,
|
|
1119
1344
|
type: {
|
|
1120
1345
|
...def,
|
|
1121
|
-
items: def.items.map((item) => pruneSerializedType(item,
|
|
1346
|
+
items: def.items.map((item) => pruneSerializedType(item, {
|
|
1347
|
+
...level,
|
|
1348
|
+
inBranch: true
|
|
1349
|
+
}))
|
|
1122
1350
|
}
|
|
1123
1351
|
};
|
|
1124
1352
|
return node;
|
|
@@ -1143,36 +1371,202 @@ function withoutRef(node) {
|
|
|
1143
1371
|
const { ref: _ref, ...rest } = node;
|
|
1144
1372
|
return rest;
|
|
1145
1373
|
}
|
|
1374
|
+
/** The `/meta` annotation key of a dictionary value-help binding (`@ui.valueHelp`, since 0.1.75); a string constant — arbac-moost does not import `@atscript/ui`. */
|
|
1375
|
+
const UI_VALUE_HELP = "ui.valueHelp";
|
|
1376
|
+
function withoutBinding(node) {
|
|
1377
|
+
const { [UI_VALUE_HELP]: _binding, ...metadata } = node.metadata;
|
|
1378
|
+
return {
|
|
1379
|
+
...node,
|
|
1380
|
+
metadata
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
const bindingCache = /* @__PURE__ */ new WeakMap();
|
|
1384
|
+
/**
|
|
1385
|
+
* The runtime `ui.valueHelp` binding of a flat-map entry — the dictionary and
|
|
1386
|
+
* the fields it names there: the committed `field` plus every field its
|
|
1387
|
+
* static filter reads. `undefined` = no binding; `null` = a binding that
|
|
1388
|
+
* cannot be resolved (callers fail closed and strip it).
|
|
1389
|
+
*/
|
|
1390
|
+
function bindingOf(entry) {
|
|
1391
|
+
const raw = entry?.metadata?.get?.(UI_VALUE_HELP);
|
|
1392
|
+
if (!raw) return void 0;
|
|
1393
|
+
let binding = bindingCache.get(entry);
|
|
1394
|
+
if (binding === void 0) {
|
|
1395
|
+
binding = resolveBinding(raw);
|
|
1396
|
+
bindingCache.set(entry, binding);
|
|
1397
|
+
}
|
|
1398
|
+
return binding;
|
|
1399
|
+
}
|
|
1400
|
+
function resolveBinding(raw) {
|
|
1401
|
+
try {
|
|
1402
|
+
const t = raw.target;
|
|
1403
|
+
const type = typeof t === "function" ? t() : t?.type();
|
|
1404
|
+
if (!type) return null;
|
|
1405
|
+
const fields = typeof raw.field === "string" ? [raw.field] : [];
|
|
1406
|
+
if (raw.filter) translateQueryTree(raw.filter, (ref) => {
|
|
1407
|
+
fields.push(ref.field);
|
|
1408
|
+
return ref.field;
|
|
1409
|
+
});
|
|
1410
|
+
return {
|
|
1411
|
+
type,
|
|
1412
|
+
fields
|
|
1413
|
+
};
|
|
1414
|
+
} catch {
|
|
1415
|
+
return null;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
/** Every table a flat-map entry points at: its `ref` target and its value-help binding. */
|
|
1419
|
+
function targetsOf(entry) {
|
|
1420
|
+
return [refTargetOf(entry), bindingOf(entry)].filter((t) => !!t);
|
|
1421
|
+
}
|
|
1146
1422
|
/** The field a runtime prop's `ref` finally points at (the same terminal `/meta` serializes). */
|
|
1147
1423
|
function refTargetOf(entry) {
|
|
1148
1424
|
const def = entry;
|
|
1149
1425
|
if (!def?.ref) return void 0;
|
|
1150
1426
|
const terminal = resolveTerminalRef(def);
|
|
1151
|
-
if (terminal) return
|
|
1427
|
+
if (terminal) return {
|
|
1428
|
+
type: terminal.type,
|
|
1429
|
+
fields: terminal.field ? [terminal.field] : []
|
|
1430
|
+
};
|
|
1152
1431
|
try {
|
|
1432
|
+
const { type, field } = def.ref;
|
|
1153
1433
|
return {
|
|
1154
|
-
type:
|
|
1155
|
-
field:
|
|
1434
|
+
type: type(),
|
|
1435
|
+
fields: field ? [field] : []
|
|
1156
1436
|
};
|
|
1157
1437
|
} catch {
|
|
1158
1438
|
return;
|
|
1159
1439
|
}
|
|
1160
1440
|
}
|
|
1441
|
+
/** A table-served type: any `db.*` annotation (`db.table`, `db.view`, `db.http.path`, …). */
|
|
1442
|
+
function isDbServed(type) {
|
|
1443
|
+
const meta = type.metadata;
|
|
1444
|
+
if (!meta || typeof meta.keys !== "function") return false;
|
|
1445
|
+
for (const key of meta.keys()) if (key.startsWith("db.")) return true;
|
|
1446
|
+
return false;
|
|
1447
|
+
}
|
|
1161
1448
|
/**
|
|
1162
|
-
* A
|
|
1163
|
-
* a self-
|
|
1164
|
-
*
|
|
1165
|
-
*
|
|
1166
|
-
*
|
|
1449
|
+
* A target survives when the caller can read its table AND see every field it
|
|
1450
|
+
* names: a self-target against this level's visibility, a foreign one against
|
|
1451
|
+
* the caller's visibility on that table (`refTargets`; unreadable → hidden). A
|
|
1452
|
+
* target naming no field (a prop typed as a table interface, `@db.json snap?:
|
|
1453
|
+
* Dict`) still needs the table readable, unless the type is not table-served.
|
|
1167
1454
|
*/
|
|
1168
|
-
function
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1455
|
+
function isTargetVisible(target, vis, refTargets) {
|
|
1456
|
+
if (target.fields.length === 0 && !isDbServed(target.type)) return true;
|
|
1457
|
+
const targetVis = target.type === vis.table?.type ? vis : refTargets.get(target.type);
|
|
1458
|
+
return !!targetVis && target.fields.every((f) => isMetaFieldVisible(f, targetVis));
|
|
1459
|
+
}
|
|
1460
|
+
/** The binding on the TYPE a `ref` points at (an alias such as `@ui.valueHelp X type Code = string`). */
|
|
1461
|
+
function refTypeBindingOf(entry) {
|
|
1462
|
+
try {
|
|
1463
|
+
const type = entry?.ref?.type?.();
|
|
1464
|
+
return type ? bindingOf(type) : null;
|
|
1465
|
+
} catch {
|
|
1466
|
+
return null;
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
/** Both targets of one node: stripped = hidden. The serialized `ref.type` carries its own metadata. */
|
|
1470
|
+
function gateOwn(node, entry, visible) {
|
|
1471
|
+
let out = node;
|
|
1472
|
+
if (out.ref) {
|
|
1473
|
+
const ref = refTargetOf(entry);
|
|
1474
|
+
if (!ref || !visible(ref)) out = withoutRef(out);
|
|
1475
|
+
}
|
|
1476
|
+
if (out.metadata?.[UI_VALUE_HELP] !== void 0) {
|
|
1477
|
+
const binding = bindingOf(entry);
|
|
1478
|
+
if (!binding || !visible(binding)) out = withoutBinding(out);
|
|
1479
|
+
}
|
|
1480
|
+
const refMeta = out.ref?.type?.metadata;
|
|
1481
|
+
if (out.ref && refMeta?.[UI_VALUE_HELP] !== void 0) {
|
|
1482
|
+
const binding = refTypeBindingOf(entry);
|
|
1483
|
+
if (!binding || !visible(binding)) {
|
|
1484
|
+
const { [UI_VALUE_HELP]: _b, ...metadata } = refMeta;
|
|
1485
|
+
out = {
|
|
1486
|
+
...out,
|
|
1487
|
+
ref: {
|
|
1488
|
+
...out.ref,
|
|
1489
|
+
type: {
|
|
1490
|
+
...out.ref.type,
|
|
1491
|
+
metadata
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
return out;
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* Gate the children of `node` a prop walk does not reach by flat path: array
|
|
1501
|
+
* elements, union / intersection / tuple members and wildcard props — each
|
|
1502
|
+
* paired with its runtime counterpart under `runtime` (a missing one fails
|
|
1503
|
+
* closed). `deep` also descends object props (own-field subtrees only; a nav
|
|
1504
|
+
* prop belongs to its own level).
|
|
1505
|
+
*/
|
|
1506
|
+
function gateChildren(node, runtime, vis, refTargets, deep) {
|
|
1507
|
+
const visible = (t) => isTargetVisible(t, vis, refTargets);
|
|
1508
|
+
const gate = (child, rt) => gateChildren(gateOwn(child, rt, visible), rt, vis, refTargets, true);
|
|
1509
|
+
const rtType = runtime?.type;
|
|
1510
|
+
const def = node.type;
|
|
1511
|
+
if (def.kind === "object") {
|
|
1512
|
+
let next = def;
|
|
1513
|
+
if (deep && def.props) {
|
|
1514
|
+
const props = {};
|
|
1515
|
+
for (const [name, prop] of Object.entries(def.props)) props[name] = isNavProp(prop) ? prop : gate(prop, rtType?.props?.get?.(name));
|
|
1516
|
+
next = {
|
|
1517
|
+
...next,
|
|
1518
|
+
props
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
if (def.propsPatterns?.length) {
|
|
1522
|
+
const patterns = def.propsPatterns.map((p) => {
|
|
1523
|
+
const rt = rtType?.propsPatterns?.find((r) => r.pattern?.source === p.pattern?.source && r.pattern?.flags === p.pattern?.flags)?.def;
|
|
1524
|
+
return {
|
|
1525
|
+
...p,
|
|
1526
|
+
def: gate(p.def, rt)
|
|
1527
|
+
};
|
|
1528
|
+
});
|
|
1529
|
+
next = {
|
|
1530
|
+
...next,
|
|
1531
|
+
propsPatterns: patterns
|
|
1532
|
+
};
|
|
1533
|
+
}
|
|
1534
|
+
return next === def ? node : {
|
|
1535
|
+
...node,
|
|
1536
|
+
type: next
|
|
1537
|
+
};
|
|
1538
|
+
}
|
|
1539
|
+
if (def.kind === "array") return {
|
|
1540
|
+
...node,
|
|
1541
|
+
type: {
|
|
1542
|
+
...def,
|
|
1543
|
+
of: gate(def.of, rtType?.of)
|
|
1544
|
+
}
|
|
1545
|
+
};
|
|
1546
|
+
if (Array.isArray(def.items)) {
|
|
1547
|
+
const items = def.items.map((item, i) => gate(item, rtType?.items?.[i]));
|
|
1548
|
+
return {
|
|
1549
|
+
...node,
|
|
1550
|
+
type: {
|
|
1551
|
+
...def,
|
|
1552
|
+
items
|
|
1553
|
+
}
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
return node;
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Strip a serialized `ref` / `ui.valueHelp` binding at `path` — on the prop
|
|
1560
|
+
* itself, on a `ref` target's own metadata, and on every array element, union
|
|
1561
|
+
* / intersection / tuple member and wildcard prop below it — whose target is
|
|
1562
|
+
* hidden from the caller. Without runtime schema info (`vis.table.flatMap`) or
|
|
1563
|
+
* `refTargets`, all are kept.
|
|
1564
|
+
*/
|
|
1565
|
+
function withVisibleTargets(node, path, vis, refTargets, ambiguous = false) {
|
|
1566
|
+
const flatMap = vis.table?.flatMap;
|
|
1567
|
+
if (!flatMap || !refTargets || !serializedHasTarget(node)) return node;
|
|
1568
|
+
const entry = ambiguous ? void 0 : flatMap.get(path);
|
|
1569
|
+
return gateChildren(gateOwn(node, entry, (t) => isTargetVisible(t, vis, refTargets)), entry, vis, refTargets, false);
|
|
1176
1570
|
}
|
|
1177
1571
|
const NO_NAMES = /* @__PURE__ */ new Set();
|
|
1178
1572
|
/** A nav prop inside a joined type: atscript-db stamps `db.rel.to` / `from` / `via`. */
|
|
@@ -1222,22 +1616,21 @@ function crudHandlers(op, methods) {
|
|
|
1222
1616
|
* `crud` already advertises no read surface, and write-only principals
|
|
1223
1617
|
* still need `type` for their insert/update forms. PK + `preferredId` are
|
|
1224
1618
|
* never pruned.
|
|
1225
|
-
*
|
|
1226
|
-
*
|
|
1227
|
-
*
|
|
1228
|
-
*
|
|
1619
|
+
*
|
|
1620
|
+
* The search surface (`searchIndexes`, `searchable`, `vectorSearchable`,
|
|
1621
|
+
* `geoSearchable`) is narrowed by moost-db itself (≥ 0.1.147, under the
|
|
1622
|
+
* overridden `hasField`), after this overlay.
|
|
1229
1623
|
*
|
|
1230
1624
|
* @param source - the controller's `this.readable` (identifiers, relations
|
|
1231
1625
|
* and schema rules are derived from it); an identifier set (the 0.1.67
|
|
1232
1626
|
* signature) still works, without them
|
|
1233
|
-
* @param
|
|
1234
|
-
* it on the current controller when it has one
|
|
1627
|
+
* @param _indexes - @deprecated ignored since 0.1.74 (moost-db prunes the search surface)
|
|
1235
1628
|
*/
|
|
1236
|
-
async function applyArbacMetaOverlay(meta, source,
|
|
1629
|
+
async function applyArbacMetaOverlay(meta, source, _indexes) {
|
|
1237
1630
|
const instance = useControllerContext().getController();
|
|
1238
1631
|
const { methods } = controllerMethodIndex(instance);
|
|
1239
1632
|
const crudKeys = Object.keys(meta.crud);
|
|
1240
|
-
const [allowedActions, crudScopes] = await Promise.all([grantedActions(instance, meta.actions.map((entry) => entry.name)), Promise.all(crudKeys.map((op) => evaluateHandlers(
|
|
1633
|
+
const [allowedActions, crudScopes] = await Promise.all([grantedActions(instance, meta.actions.map((entry) => entry.name)), Promise.all(crudKeys.map((op) => evaluateHandlers(crudHandlerIds(instance, op, methods))))]);
|
|
1241
1634
|
const allowed = new Set(allowedActions);
|
|
1242
1635
|
const actions = meta.actions.filter((entry) => allowed.has(entry.name));
|
|
1243
1636
|
const crud = {};
|
|
@@ -1255,23 +1648,203 @@ async function applyArbacMetaOverlay(meta, source, indexes) {
|
|
|
1255
1648
|
crud
|
|
1256
1649
|
};
|
|
1257
1650
|
if (readScopes.length === 0) return overlaid;
|
|
1651
|
+
const groupBy = effectiveScope(readScopes).controls.$groupBy;
|
|
1258
1652
|
const writable = collectWritableFields(writeScopes, false);
|
|
1259
1653
|
const nestedWrites = effectiveScope(writeScopes).nestedWrites;
|
|
1260
1654
|
if (isIdentifierSet(source)) {
|
|
1261
1655
|
const vis = buildScopeVisibility(readScopes, void 0, source);
|
|
1262
|
-
return Object.keys(vis.allowed).length > 0 || vis.withGrants.size > 0 ? pruneMetaByVisibility(overlaid, {
|
|
1656
|
+
return gateGroupable(Object.keys(vis.allowed).length > 0 || vis.withGrants.size > 0 ? pruneMetaByVisibility(overlaid, {
|
|
1263
1657
|
...vis,
|
|
1264
1658
|
writable,
|
|
1265
1659
|
nestedWrites
|
|
1266
|
-
}) : overlaid;
|
|
1660
|
+
}) : overlaid, groupBy);
|
|
1267
1661
|
}
|
|
1268
1662
|
const vis = buildScopeVisibility(readScopes, source, { relations: await resolveRelationTree(readScopes, source, metaRelationNames(meta), { listSiblings: false }) });
|
|
1269
1663
|
const refTargets = await resolveRefTargets(vis, meta);
|
|
1270
|
-
return
|
|
1664
|
+
return gateGroupable(pruneMetaByVisibility(overlaid, {
|
|
1271
1665
|
...vis,
|
|
1272
1666
|
writable,
|
|
1273
1667
|
nestedWrites
|
|
1274
|
-
}, refTargets),
|
|
1668
|
+
}, refTargets), groupBy);
|
|
1669
|
+
}
|
|
1670
|
+
/**
|
|
1671
|
+
* The `/meta/form/:name` overlay: a form is a plain annotated type (no table),
|
|
1672
|
+
* so only its cross-table pointers are projected. Every prop's `ref` and
|
|
1673
|
+
* `ui.valueHelp` binding is stripped when the caller cannot read the target
|
|
1674
|
+
* table, or cannot see a field the pointer names there (the same rule as the
|
|
1675
|
+
* `/meta` type); an unresolvable binding is stripped too. Never mutates
|
|
1676
|
+
* `serialized` (the base controller caches it); returns it itself when
|
|
1677
|
+
* nothing is stripped.
|
|
1678
|
+
*
|
|
1679
|
+
* @param serialized - the form's serialized schema as moost-db served it
|
|
1680
|
+
* @param formType - the runtime form type the schema was serialized from
|
|
1681
|
+
*/
|
|
1682
|
+
async function applyArbacFormOverlay(serialized, formType) {
|
|
1683
|
+
if (!formType) return serialized;
|
|
1684
|
+
const types = /* @__PURE__ */ new Set();
|
|
1685
|
+
collectFormTargets(formType, serialized, types, 0);
|
|
1686
|
+
if (types.size === 0 && !hasUnresolvedBinding(formType, serialized, 0)) return serialized;
|
|
1687
|
+
const readable = /* @__PURE__ */ new Map();
|
|
1688
|
+
await Promise.all([...types].map(async (type) => {
|
|
1689
|
+
const read = await evaluateTypeRead(type);
|
|
1690
|
+
readable.set(type, read ? buildScopeVisibility(read.scopes, read.table) : null);
|
|
1691
|
+
}));
|
|
1692
|
+
return stripFormTargets(formType, serialized, readable, 0);
|
|
1693
|
+
}
|
|
1694
|
+
const formTargetsOf = (entry, node) => {
|
|
1695
|
+
const out = [];
|
|
1696
|
+
if (node.ref) {
|
|
1697
|
+
const ref = refTargetOf(entry);
|
|
1698
|
+
out.push(ref ?? null);
|
|
1699
|
+
}
|
|
1700
|
+
if (node.metadata?.[UI_VALUE_HELP] !== void 0) {
|
|
1701
|
+
const binding = bindingOf(entry);
|
|
1702
|
+
if (binding !== void 0) out.push(binding);
|
|
1703
|
+
}
|
|
1704
|
+
if (node.ref && node.ref.type?.metadata?.[UI_VALUE_HELP] !== void 0) out.push(refTypeBindingOf(entry) ?? null);
|
|
1705
|
+
return out;
|
|
1706
|
+
};
|
|
1707
|
+
/** Pairs the runtime and serialized trees over EVERY child container (props, of, items). */
|
|
1708
|
+
function formChildren(runtime, node) {
|
|
1709
|
+
const rt = runtime.type;
|
|
1710
|
+
const def = node.type;
|
|
1711
|
+
const out = [];
|
|
1712
|
+
if (rt.props && def.props) {
|
|
1713
|
+
for (const [name, child] of rt.props) if (def.props[name]) out.push([
|
|
1714
|
+
child,
|
|
1715
|
+
def.props[name],
|
|
1716
|
+
{ prop: name }
|
|
1717
|
+
]);
|
|
1718
|
+
}
|
|
1719
|
+
if (rt.of && !Array.isArray(rt.of) && def.of && !Array.isArray(def.of)) out.push([
|
|
1720
|
+
rt.of,
|
|
1721
|
+
def.of,
|
|
1722
|
+
{ of: true }
|
|
1723
|
+
]);
|
|
1724
|
+
if (Array.isArray(rt.items) && Array.isArray(def.items)) rt.items.forEach((child, i) => def.items[i] && out.push([
|
|
1725
|
+
child,
|
|
1726
|
+
def.items[i],
|
|
1727
|
+
{ item: i }
|
|
1728
|
+
]));
|
|
1729
|
+
def.propsPatterns?.forEach((p, i) => {
|
|
1730
|
+
const match = rt.propsPatterns?.find((r) => r.pattern?.source === p.pattern?.source && r.pattern?.flags === p.pattern?.flags);
|
|
1731
|
+
if (match) out.push([
|
|
1732
|
+
match.def,
|
|
1733
|
+
p.def,
|
|
1734
|
+
{ pattern: i }
|
|
1735
|
+
]);
|
|
1736
|
+
});
|
|
1737
|
+
return out;
|
|
1738
|
+
}
|
|
1739
|
+
/** Nested-relation walk bound — serialized nav bodies are finite, this only guards pathological input. */
|
|
1740
|
+
const MAX_NAV_DEPTH = 8;
|
|
1741
|
+
/** The form walk bound; past it every `ref` / binding is stripped (fail closed). */
|
|
1742
|
+
const MAX_FORM_DEPTH = MAX_NAV_DEPTH * 2;
|
|
1743
|
+
function serializedChildren(node) {
|
|
1744
|
+
const def = node.type;
|
|
1745
|
+
return [
|
|
1746
|
+
...Object.values(def.props ?? {}),
|
|
1747
|
+
...def.of ? [def.of] : [],
|
|
1748
|
+
...def.items ?? [],
|
|
1749
|
+
...(def.propsPatterns ?? []).map((p) => p.def)
|
|
1750
|
+
];
|
|
1751
|
+
}
|
|
1752
|
+
function serializedHasTarget(node) {
|
|
1753
|
+
return !!node.ref || node.metadata?.[UI_VALUE_HELP] !== void 0 || serializedChildren(node).some(serializedHasTarget);
|
|
1754
|
+
}
|
|
1755
|
+
function stripAllTargets(node) {
|
|
1756
|
+
if (!serializedHasTarget(node)) return node;
|
|
1757
|
+
let out = node;
|
|
1758
|
+
if (out.ref) out = withoutRef(out);
|
|
1759
|
+
if (out.metadata?.[UI_VALUE_HELP] !== void 0) out = withoutBinding(out);
|
|
1760
|
+
const def = out.type;
|
|
1761
|
+
const next = { ...def };
|
|
1762
|
+
if (def.props) next.props = Object.fromEntries(Object.entries(def.props).map(([k, v]) => [k, stripAllTargets(v)]));
|
|
1763
|
+
if (def.of) next.of = stripAllTargets(def.of);
|
|
1764
|
+
if (def.items) next.items = def.items.map((i) => stripAllTargets(i));
|
|
1765
|
+
if (def.propsPatterns) next.propsPatterns = def.propsPatterns.map((p) => ({
|
|
1766
|
+
...p,
|
|
1767
|
+
def: stripAllTargets(p.def)
|
|
1768
|
+
}));
|
|
1769
|
+
return {
|
|
1770
|
+
...out,
|
|
1771
|
+
type: next
|
|
1772
|
+
};
|
|
1773
|
+
}
|
|
1774
|
+
function collectFormTargets(runtime, node, into, depth) {
|
|
1775
|
+
if (depth >= MAX_FORM_DEPTH) return;
|
|
1776
|
+
for (const t of formTargetsOf(runtime, node)) if (t) into.add(t.type);
|
|
1777
|
+
for (const [child, childNode] of formChildren(runtime, node)) collectFormTargets(child, childNode, into, depth + 1);
|
|
1778
|
+
}
|
|
1779
|
+
function hasUnresolvedBinding(runtime, node, depth) {
|
|
1780
|
+
if (depth >= MAX_FORM_DEPTH) return serializedHasTarget(node);
|
|
1781
|
+
if (formTargetsOf(runtime, node).includes(null)) return true;
|
|
1782
|
+
return formChildren(runtime, node).some(([c, n]) => hasUnresolvedBinding(c, n, depth + 1));
|
|
1783
|
+
}
|
|
1784
|
+
function stripFormTargets(runtime, node, readable, depth) {
|
|
1785
|
+
if (depth >= MAX_FORM_DEPTH) return stripAllTargets(node);
|
|
1786
|
+
const visible = (t) => {
|
|
1787
|
+
if (!t) return false;
|
|
1788
|
+
const targetVis = readable.get(t.type);
|
|
1789
|
+
return !!targetVis && t.fields.every((f) => isMetaFieldVisible(f, targetVis));
|
|
1790
|
+
};
|
|
1791
|
+
const out = gateOwn(node, runtime, (t) => visible(t));
|
|
1792
|
+
const children = formChildren(runtime, node);
|
|
1793
|
+
let changed;
|
|
1794
|
+
for (const [child, childNode, key] of children) {
|
|
1795
|
+
const next = stripFormTargets(child, childNode, readable, depth + 1);
|
|
1796
|
+
if (next === childNode) continue;
|
|
1797
|
+
const base = changed ?? out.type;
|
|
1798
|
+
if ("prop" in key) changed = {
|
|
1799
|
+
...base,
|
|
1800
|
+
props: {
|
|
1801
|
+
...base.props,
|
|
1802
|
+
[key.prop]: next
|
|
1803
|
+
}
|
|
1804
|
+
};
|
|
1805
|
+
else if ("pattern" in key) changed = {
|
|
1806
|
+
...base,
|
|
1807
|
+
propsPatterns: base.propsPatterns.map((o, i) => i === key.pattern ? {
|
|
1808
|
+
...o,
|
|
1809
|
+
def: next
|
|
1810
|
+
} : o)
|
|
1811
|
+
};
|
|
1812
|
+
else if ("item" in key) changed = {
|
|
1813
|
+
...base,
|
|
1814
|
+
items: base.items.map((o, i) => i === key.item ? next : o)
|
|
1815
|
+
};
|
|
1816
|
+
else changed = {
|
|
1817
|
+
...base,
|
|
1818
|
+
of: next
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
return changed ? {
|
|
1822
|
+
...out,
|
|
1823
|
+
type: changed
|
|
1824
|
+
} : out;
|
|
1825
|
+
}
|
|
1826
|
+
/**
|
|
1827
|
+
* `fields[*].groupable` follows the `controls.$groupBy` policy, mirroring the
|
|
1828
|
+
* 403 `applyArbacControls` raises: `false` drops the flag everywhere, a
|
|
1829
|
+
* whitelist keeps it only on the listed paths. Returns `meta` itself when
|
|
1830
|
+
* nothing is stripped.
|
|
1831
|
+
*
|
|
1832
|
+
* @since 0.1.75
|
|
1833
|
+
*/
|
|
1834
|
+
function gateGroupable(meta, gate) {
|
|
1835
|
+
if (gate === void 0 || gate === true) return meta;
|
|
1836
|
+
const stripped = (path, field) => field.groupable && (gate === false || !gate.includes(path));
|
|
1837
|
+
const entries = Object.entries(meta.fields);
|
|
1838
|
+
if (!entries.some(([path, field]) => stripped(path, field))) return meta;
|
|
1839
|
+
const fields = {};
|
|
1840
|
+
for (const [path, field] of entries) if (stripped(path, field)) {
|
|
1841
|
+
const { groupable: _groupable, ...rest } = field;
|
|
1842
|
+
fields[path] = rest;
|
|
1843
|
+
} else fields[path] = field;
|
|
1844
|
+
return {
|
|
1845
|
+
...meta,
|
|
1846
|
+
fields
|
|
1847
|
+
};
|
|
1275
1848
|
}
|
|
1276
1849
|
const metaNamesCache = /* @__PURE__ */ new WeakMap();
|
|
1277
1850
|
/** The relation names `/meta`'s serialized type reaches: root relations, then nav props of each nav body. */
|
|
@@ -1280,7 +1853,7 @@ function metaRelationNames(meta) {
|
|
|
1280
1853
|
}
|
|
1281
1854
|
function buildMetaRelationNames(meta) {
|
|
1282
1855
|
const tree = /* @__PURE__ */ new Map();
|
|
1283
|
-
const rootProps = objectProps(meta.type);
|
|
1856
|
+
const rootProps = objectProps(inlineTypeRefs(meta.type));
|
|
1284
1857
|
for (const r of meta.relations) {
|
|
1285
1858
|
const nested = /* @__PURE__ */ new Map();
|
|
1286
1859
|
tree.set(r.name, nested);
|
|
@@ -1289,8 +1862,6 @@ function buildMetaRelationNames(meta) {
|
|
|
1289
1862
|
}
|
|
1290
1863
|
return tree;
|
|
1291
1864
|
}
|
|
1292
|
-
/** Nested-relation walk bound — serialized nav bodies are finite, this only guards pathological input. */
|
|
1293
|
-
const MAX_NAV_DEPTH = 8;
|
|
1294
1865
|
function collectNavNames(node, into, depth) {
|
|
1295
1866
|
if (depth >= MAX_NAV_DEPTH) return;
|
|
1296
1867
|
const props = objectProps(node);
|
|
@@ -1319,12 +1890,12 @@ async function resolveRefTargets(root, meta) {
|
|
|
1319
1890
|
if (!props || depth >= MAX_NAV_DEPTH) return;
|
|
1320
1891
|
for (const name of /* @__PURE__ */ new Set([...vis.relationNames ?? NO_NAMES, ...vis.withGrants])) {
|
|
1321
1892
|
const prop = props[name];
|
|
1322
|
-
if (!prop
|
|
1323
|
-
const sub =
|
|
1893
|
+
if (!prop) continue;
|
|
1894
|
+
const sub = visibleRelation(name, vis);
|
|
1324
1895
|
if (sub) visit(sub, prop, depth + 1);
|
|
1325
1896
|
}
|
|
1326
1897
|
};
|
|
1327
|
-
visit(root, meta.type, 0);
|
|
1898
|
+
visit(root, inlineTypeRefs(meta.type), 0);
|
|
1328
1899
|
const out = /* @__PURE__ */ new Map();
|
|
1329
1900
|
await Promise.all([...types].map(async (type) => {
|
|
1330
1901
|
const read = await evaluateTypeRead(type);
|
|
@@ -1341,66 +1912,12 @@ function foreignRefTypes(table) {
|
|
|
1341
1912
|
const out = /* @__PURE__ */ new Set();
|
|
1342
1913
|
for (const [path, entry] of flatMap) {
|
|
1343
1914
|
if (hasSelfOrAncestor(nav, path)) continue;
|
|
1344
|
-
const target
|
|
1345
|
-
if (target && target.type !== table.type) out.add(target.type);
|
|
1915
|
+
for (const target of targetsOf(entry)) if (target.type !== table.type) out.add(target.type);
|
|
1346
1916
|
}
|
|
1347
1917
|
return out;
|
|
1348
1918
|
});
|
|
1349
1919
|
}
|
|
1350
1920
|
const NO_TYPES = /* @__PURE__ */ new Set();
|
|
1351
|
-
/** Controls a text / vector search needs, dropped from `crud.query` / `crud.pages` when unusable. */
|
|
1352
|
-
const TEXT_SEARCH_CONTROLS = ["search", "fuzzy"];
|
|
1353
|
-
const VECTOR_CONTROLS = ["vector", "threshold"];
|
|
1354
|
-
/**
|
|
1355
|
-
* Drop `searchIndexes` reading a hidden field, recompute the search /
|
|
1356
|
-
* vector / geo flags over what is left (native search, or the visible
|
|
1357
|
-
* `@db.column.searchable` fallback), and remove the controls that became
|
|
1358
|
-
* unusable from the `query` / `pages` control lists (`crud.geo` goes with
|
|
1359
|
-
* the last visible geo index).
|
|
1360
|
-
*/
|
|
1361
|
-
function pruneSearchSurface(meta, original, vis, indexes) {
|
|
1362
|
-
if (!indexes) return meta;
|
|
1363
|
-
const shown = indexes.filter((e) => e.fields.every((f) => isMetaFieldVisible(f, vis)));
|
|
1364
|
-
const shownOf = (type) => shown.some((e) => e.type === type);
|
|
1365
|
-
const hasText = indexes.some((e) => e.type === "text");
|
|
1366
|
-
const native = vis.table?.isSearchable?.() ?? hasText;
|
|
1367
|
-
const fallback = searchFallbackVisible(meta, vis.table);
|
|
1368
|
-
const searchable = original.searchable && (native && (!hasText || shownOf("text")) || fallback);
|
|
1369
|
-
const vectorSearchable = original.vectorSearchable && shownOf("vector");
|
|
1370
|
-
const geoSearchable = original.geoSearchable === true ? shownOf("geo") : original.geoSearchable;
|
|
1371
|
-
const out = {
|
|
1372
|
-
...meta,
|
|
1373
|
-
searchIndexes: original.searchIndexes.filter((info) => shown.some((e) => e.name === info.name && e.type === (info.type ?? "text"))),
|
|
1374
|
-
searchable,
|
|
1375
|
-
vectorSearchable
|
|
1376
|
-
};
|
|
1377
|
-
if (geoSearchable !== void 0) out.geoSearchable = geoSearchable;
|
|
1378
|
-
const drop = /* @__PURE__ */ new Set();
|
|
1379
|
-
if (original.searchable && !searchable && !vectorSearchable) for (const c of TEXT_SEARCH_CONTROLS) drop.add(c);
|
|
1380
|
-
if (original.vectorSearchable && !vectorSearchable) for (const c of VECTOR_CONTROLS) drop.add(c);
|
|
1381
|
-
if (hasText && !shownOf("text")) drop.add("index");
|
|
1382
|
-
if (drop.size > 0 || original.geoSearchable && !geoSearchable) {
|
|
1383
|
-
const crud = { ...meta.crud };
|
|
1384
|
-
for (const op of ["query", "pages"]) {
|
|
1385
|
-
const list = crud[op];
|
|
1386
|
-
if (list && drop.size > 0) crud[op] = list.filter((c) => !drop.has(c));
|
|
1387
|
-
}
|
|
1388
|
-
if (original.geoSearchable && !geoSearchable) delete crud.geo;
|
|
1389
|
-
out.crud = crud;
|
|
1390
|
-
}
|
|
1391
|
-
return out;
|
|
1392
|
-
}
|
|
1393
|
-
/** A `@db.column.searchable` field is still listed (visible) in the pruned `fields`. */
|
|
1394
|
-
function searchFallbackVisible(meta, table) {
|
|
1395
|
-
if (!table?.flatMap) return false;
|
|
1396
|
-
for (const path of Object.keys(meta.fields)) if (table.flatMap.get(path)?.metadata?.has?.("db.column.searchable") && !meta.fields[path].writeOnly) return true;
|
|
1397
|
-
return false;
|
|
1398
|
-
}
|
|
1399
|
-
/** The current controller's `indexFieldPaths()` (moost-db 0.1.143 DB readables), if it has one. */
|
|
1400
|
-
function controllerIndexes(instance) {
|
|
1401
|
-
const c = instance;
|
|
1402
|
-
return typeof c.indexFieldPaths === "function" ? c.indexFieldPaths() : void 0;
|
|
1403
|
-
}
|
|
1404
1921
|
const methodIndexCache = /* @__PURE__ */ new WeakMap();
|
|
1405
1922
|
/**
|
|
1406
1923
|
* THE ARBAC ids an action is authorized as — used by the `/meta` overlay,
|
|
@@ -1432,14 +1949,20 @@ async function grantedActions(instance, names) {
|
|
|
1432
1949
|
function controllerArbacResource(instance) {
|
|
1433
1950
|
return arbacIdsFromMeta(getMoostMate().read(instance), void 0, instance, "").resource;
|
|
1434
1951
|
}
|
|
1435
|
-
/**
|
|
1952
|
+
/**
|
|
1953
|
+
* A controller class's methods and `@DbAction` name → methods map (memoized
|
|
1954
|
+
* per class). The method metadata is read from `instance` itself — never from
|
|
1955
|
+
* the event's current controller, which differs whenever another
|
|
1956
|
+
* controller's actions are evaluated in this event (a view's
|
|
1957
|
+
* `@DbActionsFrom` delegation).
|
|
1958
|
+
*/
|
|
1436
1959
|
function controllerMethodIndex(instance) {
|
|
1437
1960
|
return getOrCreate(methodIndexCache, getConstructor(instance), () => {
|
|
1438
|
-
const
|
|
1961
|
+
const mate = getMoostMate();
|
|
1439
1962
|
const methods = new Set(collectMethodNames(instance));
|
|
1440
1963
|
const actionMethods = /* @__PURE__ */ new Map();
|
|
1441
1964
|
for (const methodName of methods) {
|
|
1442
|
-
const name =
|
|
1965
|
+
const name = mate.read(instance, methodName)?.atscript_db_action?.name;
|
|
1443
1966
|
if (name) getOrCreate(actionMethods, name, () => []).push(methodName);
|
|
1444
1967
|
}
|
|
1445
1968
|
return {
|
|
@@ -1448,6 +1971,18 @@ function controllerMethodIndex(instance) {
|
|
|
1448
1971
|
};
|
|
1449
1972
|
});
|
|
1450
1973
|
}
|
|
1974
|
+
/** The ARBAC ids of the handler method(s) serving crud op `op` on `instance` (`useArbac()` precedence). */
|
|
1975
|
+
function crudHandlerIds(instance, op, methods = controllerMethodIndex(instance).methods) {
|
|
1976
|
+
return crudHandlers(op, methods).map((m) => resolveHandlerArbacIds(instance, m));
|
|
1977
|
+
}
|
|
1978
|
+
/**
|
|
1979
|
+
* The ARBAC ids of `instance`'s READ handler — the `query` crud op's
|
|
1980
|
+
* handler method(s), exactly what the `/meta` overlay evaluates for
|
|
1981
|
+
* `crud.query`.
|
|
1982
|
+
*/
|
|
1983
|
+
function readHandlerIds(instance) {
|
|
1984
|
+
return crudHandlerIds(instance, "query");
|
|
1985
|
+
}
|
|
1451
1986
|
/**
|
|
1452
1987
|
* Test-friendly internal helper — exported for unit tests and helper
|
|
1453
1988
|
* composition; regular consumers should not call this directly.
|
|
@@ -1474,6 +2009,124 @@ function collectMethodNames(instance) {
|
|
|
1474
2009
|
return [...names];
|
|
1475
2010
|
}
|
|
1476
2011
|
//#endregion
|
|
2012
|
+
//#region src/db/shared-read-helpers.ts
|
|
2013
|
+
/**
|
|
2014
|
+
* Restrict the user-supplied `$select` to the scopes' projection union.
|
|
2015
|
+
* Returns the original projection untouched when no scope declares a
|
|
2016
|
+
* projection (so caller sees the unrestricted shape). Reads the same cached
|
|
2017
|
+
* visibility (`vis.allowed`) `hasField` / `/meta` pruning use, so value
|
|
2018
|
+
* stripping and name hiding cannot drift.
|
|
2019
|
+
*
|
|
2020
|
+
* `projection` is the `$select` as moost-db parsed it: an inclusion is an
|
|
2021
|
+
* ARRAY of names, an exclusion an object — both are normalized first. A
|
|
2022
|
+
* requested parent (`$select=a`) narrows to what the scope shows: to the
|
|
2023
|
+
* whitelisted descendants (`a.b: 1`), or — for a hidden descendant
|
|
2024
|
+
* (`a.c: 0`) — to its visible children via `childrenOf` (without a schema
|
|
2025
|
+
* the parent is dropped). Never the whole object, and never the empty
|
|
2026
|
+
* (universe) projection: when nothing survives, the scope's own projection
|
|
2027
|
+
* applies (`restrictProjection`'s ceiling fallback).
|
|
2028
|
+
*
|
|
2029
|
+
* An exclusion result names nested-object parents by their leaves
|
|
2030
|
+
* (`{ a: 0 }` → `{ "a.b": 0, "a.c": 0 }`): flattening adapters invert an
|
|
2031
|
+
* exclusion against their leaf columns, so a parent key alone would strip
|
|
2032
|
+
* nothing (a scope `{ a: 0 }` would otherwise return all of `a`).
|
|
2033
|
+
*
|
|
2034
|
+
* @since 0.1.72
|
|
2035
|
+
*/
|
|
2036
|
+
function applyArbacProjection(projection, scopes, readable) {
|
|
2037
|
+
return restrictSelect(projection, visibilityFor(scopes, readable));
|
|
2038
|
+
}
|
|
2039
|
+
/** {@link applyArbacProjection} over an already-built visibility level. */
|
|
2040
|
+
function restrictSelect(projection, vis) {
|
|
2041
|
+
if (Object.keys(vis.allowed).length === 0) return projection;
|
|
2042
|
+
const childrenOf = fieldChildrenOf(vis.table);
|
|
2043
|
+
return expandExcludeToLeaves(restrictProjection(normalizeSelect(projection) ?? {}, vis.allowed, childrenOf), childrenOf);
|
|
2044
|
+
}
|
|
2045
|
+
/**
|
|
2046
|
+
* Enforce the union of per-scope `controls` gates against the parsed
|
|
2047
|
+
* Uniquery controls. Throws `HttpError(403)` on the first violation.
|
|
2048
|
+
*
|
|
2049
|
+
* @since 0.1.72
|
|
2050
|
+
*/
|
|
2051
|
+
function applyArbacControls(controls, scopes) {
|
|
2052
|
+
enforceControlsPolicy(effectiveScope(scopes).controls, controls);
|
|
2053
|
+
}
|
|
2054
|
+
/**
|
|
2055
|
+
* The per-role `controls.$with` gates over every relation a read reaches —
|
|
2056
|
+
* its `$with` entries and its client relational predicates alike (see
|
|
2057
|
+
* `prepareArbacRequest`) — each level against that level's scopes.
|
|
2058
|
+
* Overlaps `validateControls`' gate for the `$with` entries on purpose: one
|
|
2059
|
+
* rule for both. Throws `HttpError(403)` on the first violation; a hidden
|
|
2060
|
+
* relation's nested names are left to moost-db (`Unknown field`).
|
|
2061
|
+
*
|
|
2062
|
+
* @since 0.1.74
|
|
2063
|
+
*/
|
|
2064
|
+
function enforceRelationGates(names, vis) {
|
|
2065
|
+
if (!vis?.scopes || names.size === 0) return;
|
|
2066
|
+
enforceControlsPolicy(effectiveScope(vis.scopes).controls, { $with: [...names.keys()] });
|
|
2067
|
+
for (const [name, nested] of names) if (nested.size > 0) enforceRelationGates(nested, visibleRelation(name, vis));
|
|
2068
|
+
}
|
|
2069
|
+
/**
|
|
2070
|
+
* Walk the user-supplied `$with` items in `controls` and overlay each joined
|
|
2071
|
+
* relation with the policy of its rows (the `$with` inherit-target policy):
|
|
2072
|
+
* a relation some scope declares `with.<name>` for gets the union of those
|
|
2073
|
+
* sub-scopes (parent authority — silent roles contribute nothing); any other
|
|
2074
|
+
* gets the caller's OWN read scopes on the related table, as resolved in
|
|
2075
|
+
* `prepareRequest` (for the request's own scopes). Filter conjoined, `$select`
|
|
2076
|
+
* restricted, per-relation `controls` gates enforced, recursively.
|
|
2077
|
+
*
|
|
2078
|
+
* Mutates `controls` in place (matches the `validateControls` contract).
|
|
2079
|
+
* Throws `HttpError(403)` if a per-relation control policy is violated. A
|
|
2080
|
+
* hidden relation is skipped: moost-db (≥ 0.1.143) rejects it at every
|
|
2081
|
+
* level through `hasField`, byte-for-byte like a nonexistent one.
|
|
2082
|
+
*
|
|
2083
|
+
* @since 0.1.72
|
|
2084
|
+
*/
|
|
2085
|
+
function applyArbacRelationScopes(controls, scopes, readable) {
|
|
2086
|
+
const withArr = controls.$with;
|
|
2087
|
+
if (!Array.isArray(withArr) || withArr.length === 0) return;
|
|
2088
|
+
applyRelationLevel(controls, visibilityFor(scopes, readable));
|
|
2089
|
+
}
|
|
2090
|
+
/** One `$with` level of {@link applyArbacRelationScopes}. */
|
|
2091
|
+
function applyRelationLevel(controls, vis) {
|
|
2092
|
+
const withArr = controls.$with;
|
|
2093
|
+
if (!Array.isArray(withArr) || withArr.length === 0) return;
|
|
2094
|
+
for (const raw of withArr) {
|
|
2095
|
+
if (!raw || typeof raw !== "object") continue;
|
|
2096
|
+
const entry = raw;
|
|
2097
|
+
if (typeof entry.name !== "string" || entry.name.includes(".")) continue;
|
|
2098
|
+
const level = (!vis.relationNames || vis.relationNames.has(entry.name)) && isMetaFieldVisible(entry.name, vis) ? vis.relation?.(entry.name) : void 0;
|
|
2099
|
+
if (!level) continue;
|
|
2100
|
+
const sub = effectiveScope(level.scopes ?? []);
|
|
2101
|
+
const conjoined = conjoinScopeFilters$1(sub.filter, entry.filter);
|
|
2102
|
+
if (conjoined) entry.filter = conjoined;
|
|
2103
|
+
const entryControls = entry.controls ?? {};
|
|
2104
|
+
const restricted = restrictSelect(entryControls.$select, level);
|
|
2105
|
+
if (restricted !== void 0) {
|
|
2106
|
+
entryControls.$select = restricted;
|
|
2107
|
+
entry.controls = entryControls;
|
|
2108
|
+
}
|
|
2109
|
+
enforceControlsPolicy(sub.controls, entryControls);
|
|
2110
|
+
applyRelationLevel(entryControls, level);
|
|
2111
|
+
}
|
|
2112
|
+
}
|
|
2113
|
+
/**
|
|
2114
|
+
* `$select` may be an array of field names (uniquery inclusion list) or a
|
|
2115
|
+
* `TProjection` object. `restrictProjection` operates on the object form
|
|
2116
|
+
* (it would read `["a"]` as an exclusion keyed `"0"`), so normalize the array
|
|
2117
|
+
* to `{ field: 1 }` first. Returns undefined if the input is empty (caller
|
|
2118
|
+
* treats as "no projection set").
|
|
2119
|
+
*/
|
|
2120
|
+
function normalizeSelect(value) {
|
|
2121
|
+
if (value === void 0 || value === null) return void 0;
|
|
2122
|
+
if (Array.isArray(value)) {
|
|
2123
|
+
const out = {};
|
|
2124
|
+
for (const item of value) if (typeof item === "string") out[item] = 1;
|
|
2125
|
+
return Object.keys(out).length > 0 ? out : void 0;
|
|
2126
|
+
}
|
|
2127
|
+
if (typeof value === "object") return value;
|
|
2128
|
+
}
|
|
2129
|
+
//#endregion
|
|
1477
2130
|
//#region src/db/request-scopes.ts
|
|
1478
2131
|
/** Endpoints whose `prepareRequest` carries parsed Uniquery controls. */
|
|
1479
2132
|
const READ_ENDPOINTS = /* @__PURE__ */ new Set([
|
|
@@ -1483,6 +2136,17 @@ const READ_ENDPOINTS = /* @__PURE__ */ new Set([
|
|
|
1483
2136
|
"one"
|
|
1484
2137
|
]);
|
|
1485
2138
|
const requestScopesKey = key("arbac.db.scopes");
|
|
2139
|
+
/**
|
|
2140
|
+
* `true` while the current request is an insert in ignore mode
|
|
2141
|
+
* (`?$onConflict=ignore`): conflicting rows are skipped, so the post-write
|
|
2142
|
+
* WITH CHECK never sees them and the write guard must refuse what it cannot
|
|
2143
|
+
* check up front.
|
|
2144
|
+
*
|
|
2145
|
+
* @since 0.1.75
|
|
2146
|
+
*/
|
|
2147
|
+
function isInsertIgnoreRequest() {
|
|
2148
|
+
return resolved()?.insertIgnore === true;
|
|
2149
|
+
}
|
|
1486
2150
|
/** Store the event's resolved scopes (for the handler `ids` resolves to). */
|
|
1487
2151
|
function storeResolved(ctx, ids, source, scopes) {
|
|
1488
2152
|
ctx.set(requestScopesKey, {
|
|
@@ -1519,7 +2183,8 @@ function cachedRequestScopes() {
|
|
|
1519
2183
|
async function resolveRequestScopes() {
|
|
1520
2184
|
const ctx = current();
|
|
1521
2185
|
const arbac = useArbac(ctx);
|
|
1522
|
-
const
|
|
2186
|
+
const cachedScopes = arbac.getScopes();
|
|
2187
|
+
const fromInterceptor = cachedScopes && scopesOwnedBy(cachedScopes, arbac.resource, arbac.action) ? cachedScopes : void 0;
|
|
1523
2188
|
const prior = resolved();
|
|
1524
2189
|
if (prior && prior.resource === arbac.resource && prior.action === arbac.action && prior.source === fromInterceptor) return prior.scopes;
|
|
1525
2190
|
const scopes = normalizeScopes(fromInterceptor !== void 0 ? {
|
|
@@ -1551,7 +2216,7 @@ function requireRequestScopes() {
|
|
|
1551
2216
|
*/
|
|
1552
2217
|
function visibilityFor(scopes, source) {
|
|
1553
2218
|
const request = resolved();
|
|
1554
|
-
if (source && !isIdentifierSet(source) && request?.scopes === scopes) return getOrCreate(request.visibility, source, () => buildScopeVisibility(scopes, source, { relations: requestRelations() }));
|
|
2219
|
+
if (source && !isIdentifierSet(source) && request?.scopes === scopes) return getOrCreate(request.visibility, source, () => buildScopeVisibility(scopes, source, { relations: requestRelations(source) }));
|
|
1555
2220
|
return scopeVisibility(scopes, source);
|
|
1556
2221
|
}
|
|
1557
2222
|
/**
|
|
@@ -1585,7 +2250,18 @@ function isScopedFieldVisible(scopes, path, source) {
|
|
|
1585
2250
|
* action id / row is read): resolve the request's scopes
|
|
1586
2251
|
* ({@link resolveRequestScopes} — 403 on denial, `arbacPublic` does not
|
|
1587
2252
|
* bypass) and, on read endpoints, the `$with` relation policy for the
|
|
1588
|
-
* requested relations.
|
|
2253
|
+
* requested relations — the `$with` entries and, since 0.1.74, the
|
|
2254
|
+
* relations the client filter's relational predicates name (`ctx.filter`):
|
|
2255
|
+
* a predicate on a relation is allowed exactly when `$with` of it is (same
|
|
2256
|
+
* visibility, same `controls.$with` gates).
|
|
2257
|
+
*
|
|
2258
|
+
* Delegated endpoints (no grant of their own): `"availableActions"`
|
|
2259
|
+
* (`GET /meta/actions…`) — some row-level action grant (or, on a
|
|
2260
|
+
* `@DbActionsFrom` controller, none: its own part lists nothing);
|
|
2261
|
+
* `"delegatedAction"` (`POST /delegated-actions/:name`, since 0.1.74) — the
|
|
2262
|
+
* controller's READ grant, since the route only resolves which of the
|
|
2263
|
+
* caller's readable rows match the query; the action itself is authorized
|
|
2264
|
+
* by its source controller on every batch.
|
|
1589
2265
|
*
|
|
1590
2266
|
* @since 0.1.72
|
|
1591
2267
|
*/
|
|
@@ -1594,8 +2270,13 @@ async function prepareArbacRequest(ctx, readable) {
|
|
|
1594
2270
|
await authorizeAvailableActions();
|
|
1595
2271
|
return;
|
|
1596
2272
|
}
|
|
2273
|
+
if (ctx.endpoint === "delegatedAction") {
|
|
2274
|
+
await authorizeDelegatedQueryTarget();
|
|
2275
|
+
return;
|
|
2276
|
+
}
|
|
1597
2277
|
const scopes = await resolveRequestScopes();
|
|
1598
|
-
if (ctx.
|
|
2278
|
+
if (ctx.endpoint === "insert" && ctx.onConflict === "ignore") resolved().insertIgnore = true;
|
|
2279
|
+
if (READ_ENDPOINTS.has(ctx.endpoint)) enforceRelationGates(await resolveRequestRelations(scopes, ctx.controls, readable, ctx.filter), visibilityFor(scopes, readable));
|
|
1599
2280
|
}
|
|
1600
2281
|
/**
|
|
1601
2282
|
* `authorizeForm` for the ARBAC DB controllers: a form is served iff the
|
|
@@ -1621,14 +2302,42 @@ function actionsIds(names) {
|
|
|
1621
2302
|
* overlay stays unrestricted — each action's rows come from
|
|
1622
2303
|
* `actionRowScope` ({@link arbacActionRowScope}) — while field visibility
|
|
1623
2304
|
* is never wider than the widest granted action.
|
|
2305
|
+
*
|
|
2306
|
+
* A `@DbActionsFrom` controller without an own row-level grant is served
|
|
2307
|
+
* too (since 0.1.74): its own part lists nothing (a match-nothing row
|
|
2308
|
+
* overlay), and each delegated action is answered by its source controller,
|
|
2309
|
+
* under the caller's grants THERE.
|
|
1624
2310
|
*/
|
|
1625
2311
|
async function authorizeAvailableActions() {
|
|
1626
2312
|
const ctx = current();
|
|
1627
2313
|
const instance = useControllerContext(ctx).getController();
|
|
1628
|
-
const
|
|
2314
|
+
const ctor = getConstructor(instance);
|
|
2315
|
+
const granted = await evaluateHandlers(actionsIds(discoverRowLevelActions(ctor, instance.app, instance.logger).map((e) => e.info.name)));
|
|
2316
|
+
const arbac = useArbac(ctx);
|
|
2317
|
+
if (granted) storeResolved(ctx, arbac, void 0, granted.map(withoutRowPolicy));
|
|
2318
|
+
else if (hasActionDelegations(ctor)) storeResolved(ctx, arbac, void 0, NO_OWN_ACTIONS);
|
|
2319
|
+
else throw insufficientPrivileges(arbac.resource, arbac.action);
|
|
2320
|
+
}
|
|
2321
|
+
/** The own scopes of a delegating controller's `GET /meta/actions` without an own grant. */
|
|
2322
|
+
const NO_OWN_ACTIONS = [{
|
|
2323
|
+
filter: DENY_FILTER,
|
|
2324
|
+
check: DENY_FILTER
|
|
2325
|
+
}];
|
|
2326
|
+
/**
|
|
2327
|
+
* `POST /delegated-actions/:name` (endpoint `"delegatedAction"`): the route
|
|
2328
|
+
* resolves which rows of THIS controller match the query — a read, so it
|
|
2329
|
+
* is served under the caller's read grant (its `query` handler; none →
|
|
2330
|
+
* 403), stored as the request's scopes (`transformFilter`, `hasField` and
|
|
2331
|
+
* moost-db's default `queryTargetScope` read them). It grants nothing on
|
|
2332
|
+
* the action: the source controller's route re-authorizes every batch.
|
|
2333
|
+
*/
|
|
2334
|
+
async function authorizeDelegatedQueryTarget() {
|
|
2335
|
+
const ctx = current();
|
|
1629
2336
|
const arbac = useArbac(ctx);
|
|
1630
|
-
|
|
1631
|
-
|
|
2337
|
+
const ids = readHandlerIds(useControllerContext(ctx).getController());
|
|
2338
|
+
const scopes = ids.length > 0 ? await evaluateHandlers(ids) : void 0;
|
|
2339
|
+
if (!scopes) throw insufficientPrivileges(ids[0]?.resource ?? arbac.resource, ids[0]?.action ?? "query");
|
|
2340
|
+
storeResolved(ctx, arbac, void 0, scopes);
|
|
1632
2341
|
}
|
|
1633
2342
|
/** A scope without its row policy (`filter` / `check`). */
|
|
1634
2343
|
function withoutRowPolicy(scope) {
|
|
@@ -1653,12 +2362,17 @@ const actionScopes = cached(() => /* @__PURE__ */ new Map());
|
|
|
1653
2362
|
* `@DbAction` `name` may run on — the filter of the caller's grant on the
|
|
1654
2363
|
* action ({@link actionArbacIds}: every handler method of the action, or a
|
|
1655
2364
|
* class-level entry's fallback ids), including custom-field row filters and
|
|
1656
|
-
* credential attenuation.
|
|
1657
|
-
*
|
|
1658
|
-
*
|
|
2365
|
+
* credential attenuation. Several grants union (`$or`); attenuation is
|
|
2366
|
+
* already intersected into each. No grant → a match-nothing filter (fail
|
|
2367
|
+
* closed); an unrestricted grant → `undefined`. When the current request IS
|
|
2368
|
+
* that action (its resolved scopes are the same evaluation), the row overlay
|
|
1659
2369
|
* already applies it → `undefined`. Equal filters are returned as ONE
|
|
1660
2370
|
* object per request.
|
|
1661
2371
|
*
|
|
2372
|
+
* Candidate-free on purpose (evaluated once per action per event) — see
|
|
2373
|
+
* `AsArbacDbController.actionRowScope` to bound an action by its candidate
|
|
2374
|
+
* rows.
|
|
2375
|
+
*
|
|
1662
2376
|
* @since 0.1.72
|
|
1663
2377
|
*/
|
|
1664
2378
|
async function arbacActionRowScope(name) {
|
|
@@ -1681,110 +2395,26 @@ async function arbacActionRowScope(name) {
|
|
|
1681
2395
|
* @since 0.1.72
|
|
1682
2396
|
*/
|
|
1683
2397
|
function arbacRowFilter(filter) {
|
|
1684
|
-
return conjoinScopeFilters(effectiveScope(requireRequestScopes()).filter, filter) ?? {};
|
|
1685
|
-
}
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
*
|
|
1690
|
-
*
|
|
1691
|
-
*
|
|
1692
|
-
*
|
|
1693
|
-
*
|
|
1694
|
-
*
|
|
1695
|
-
*
|
|
1696
|
-
* ARRAY of names, an exclusion an object — both are normalized first. A
|
|
1697
|
-
* requested parent (`$select=a`) narrows to what the scope shows: to the
|
|
1698
|
-
* whitelisted descendants (`a.b: 1`), or — for a hidden descendant
|
|
1699
|
-
* (`a.c: 0`) — to its visible children via `childrenOf` (without a schema
|
|
1700
|
-
* the parent is dropped). Never the whole object, and never the empty
|
|
1701
|
-
* (universe) projection: when nothing survives, the scope's own projection
|
|
1702
|
-
* applies (`restrictProjection`'s ceiling fallback).
|
|
1703
|
-
*
|
|
1704
|
-
* An exclusion result names nested-object parents by their leaves
|
|
1705
|
-
* (`{ a: 0 }` → `{ "a.b": 0, "a.c": 0 }`): flattening adapters invert an
|
|
1706
|
-
* exclusion against their leaf columns, so a parent key alone would strip
|
|
1707
|
-
* nothing (a scope `{ a: 0 }` would otherwise return all of `a`).
|
|
1708
|
-
*
|
|
1709
|
-
* @since 0.1.72
|
|
1710
|
-
*/
|
|
1711
|
-
function applyArbacProjection(projection, scopes, readable) {
|
|
1712
|
-
return restrictSelect(projection, visibilityFor(scopes, readable));
|
|
1713
|
-
}
|
|
1714
|
-
/** {@link applyArbacProjection} over an already-built visibility level. */
|
|
1715
|
-
function restrictSelect(projection, vis) {
|
|
1716
|
-
if (Object.keys(vis.allowed).length === 0) return projection;
|
|
1717
|
-
const childrenOf = fieldChildrenOf(vis.table);
|
|
1718
|
-
return expandExcludeToLeaves(restrictProjection(normalizeSelect(projection) ?? {}, vis.allowed, childrenOf), childrenOf);
|
|
1719
|
-
}
|
|
1720
|
-
/**
|
|
1721
|
-
* Enforce the union of per-scope `controls` gates against the parsed
|
|
1722
|
-
* Uniquery controls. Throws `HttpError(403)` on the first violation.
|
|
2398
|
+
return conjoinScopeFilters$1(effectiveScope(requireRequestScopes()).filter, filter) ?? {};
|
|
2399
|
+
}
|
|
2400
|
+
/**
|
|
2401
|
+
* Body of the ARBAC DB controllers' `transformRelationFilter` (since 0.1.74):
|
|
2402
|
+
* the operand of a CLIENT relational predicate at `path` (`ticket`,
|
|
2403
|
+
* `ticket.team`, `tickets.issues` inside `$with=tickets(…)`) CONJOINED
|
|
2404
|
+
* with the row filter of the related rows the caller may see there — the
|
|
2405
|
+
* same policy `$with` of that relation applies: a declared `with.<rel>`
|
|
2406
|
+
* sub-scope, else the caller's own read grant on the related table. So
|
|
2407
|
+
* `$some` matches, and `$none` excludes, only on rows the caller could
|
|
2408
|
+
* load. A relation the request did not resolve answers like an unknown
|
|
2409
|
+
* field (400 — the request gate rejects it first).
|
|
1723
2410
|
*
|
|
1724
|
-
* @since 0.1.
|
|
2411
|
+
* @since 0.1.74
|
|
1725
2412
|
*/
|
|
1726
|
-
function
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
* relation with the policy of its rows (the `$with` inherit-target policy):
|
|
1732
|
-
* a relation some scope declares `with.<name>` for gets the union of those
|
|
1733
|
-
* sub-scopes (parent authority — silent roles contribute nothing); any other
|
|
1734
|
-
* gets the caller's OWN read scopes on the related table, as resolved in
|
|
1735
|
-
* `prepareRequest` (for the request's own scopes). Filter conjoined, `$select`
|
|
1736
|
-
* restricted, per-relation `controls` gates enforced, recursively.
|
|
1737
|
-
*
|
|
1738
|
-
* Mutates `controls` in place (matches the `validateControls` contract).
|
|
1739
|
-
* Throws `HttpError(403)` if a per-relation control policy is violated. A
|
|
1740
|
-
* hidden relation is skipped: moost-db (≥ 0.1.143) rejects it at every
|
|
1741
|
-
* level through `hasField`, byte-for-byte like a nonexistent one.
|
|
1742
|
-
*
|
|
1743
|
-
* @since 0.1.72
|
|
1744
|
-
*/
|
|
1745
|
-
function applyArbacRelationScopes(controls, scopes, readable) {
|
|
1746
|
-
const withArr = controls.$with;
|
|
1747
|
-
if (!Array.isArray(withArr) || withArr.length === 0) return;
|
|
1748
|
-
applyRelationLevel(controls, visibilityFor(scopes, readable));
|
|
1749
|
-
}
|
|
1750
|
-
/** One `$with` level of {@link applyArbacRelationScopes}. */
|
|
1751
|
-
function applyRelationLevel(controls, vis) {
|
|
1752
|
-
const withArr = controls.$with;
|
|
1753
|
-
if (!Array.isArray(withArr) || withArr.length === 0) return;
|
|
1754
|
-
for (const raw of withArr) {
|
|
1755
|
-
if (!raw || typeof raw !== "object") continue;
|
|
1756
|
-
const entry = raw;
|
|
1757
|
-
if (typeof entry.name !== "string" || entry.name.includes(".")) continue;
|
|
1758
|
-
const level = (!vis.relationNames || vis.relationNames.has(entry.name)) && isMetaFieldVisible(entry.name, vis) ? vis.relation?.(entry.name) : void 0;
|
|
1759
|
-
if (!level) continue;
|
|
1760
|
-
const sub = effectiveScope(level.scopes ?? []);
|
|
1761
|
-
const conjoined = conjoinScopeFilters(sub.filter, entry.filter);
|
|
1762
|
-
if (conjoined) entry.filter = conjoined;
|
|
1763
|
-
const entryControls = entry.controls ?? {};
|
|
1764
|
-
const restricted = restrictSelect(entryControls.$select, level);
|
|
1765
|
-
if (restricted !== void 0) {
|
|
1766
|
-
entryControls.$select = restricted;
|
|
1767
|
-
entry.controls = entryControls;
|
|
1768
|
-
}
|
|
1769
|
-
enforceControlsPolicy(sub.controls, entryControls);
|
|
1770
|
-
applyRelationLevel(entryControls, level);
|
|
1771
|
-
}
|
|
1772
|
-
}
|
|
1773
|
-
/**
|
|
1774
|
-
* `$select` may be an array of field names (uniquery inclusion list) or a
|
|
1775
|
-
* `TProjection` object. `restrictProjection` operates on the object form
|
|
1776
|
-
* (it would read `["a"]` as an exclusion keyed `"0"`), so normalize the array
|
|
1777
|
-
* to `{ field: 1 }` first. Returns undefined if the input is empty (caller
|
|
1778
|
-
* treats as "no projection set").
|
|
1779
|
-
*/
|
|
1780
|
-
function normalizeSelect(value) {
|
|
1781
|
-
if (value === void 0 || value === null) return void 0;
|
|
1782
|
-
if (Array.isArray(value)) {
|
|
1783
|
-
const out = {};
|
|
1784
|
-
for (const item of value) if (typeof item === "string") out[item] = 1;
|
|
1785
|
-
return Object.keys(out).length > 0 ? out : void 0;
|
|
1786
|
-
}
|
|
1787
|
-
if (typeof value === "object") return value;
|
|
2413
|
+
function arbacRelationFilter(path, filter, readable) {
|
|
2414
|
+
let level = visibilityFor(requireRequestScopes(), readable);
|
|
2415
|
+
for (const name of path.split(".")) level = level && visibleRelation(name, level);
|
|
2416
|
+
if (!level?.scopes) throw badRequest(path, `Unknown field "${path}"`);
|
|
2417
|
+
return conjoinScopeFilters$1(effectiveScope(level.scopes).filter, filter) ?? filter;
|
|
1788
2418
|
}
|
|
1789
2419
|
//#endregion
|
|
1790
2420
|
//#region src/db/write-fields.ts
|
|
@@ -1914,16 +2544,44 @@ var UnsupportedCheckError = class extends Error {};
|
|
|
1914
2544
|
* BEFORE the write. Deliberately a small subset of the filter language
|
|
1915
2545
|
* (`$eq`/`$ne`/`$in`/`$nin`/ordering/`$exists` + `$and`/`$or`/`$not`) with the
|
|
1916
2546
|
* database's null model (`$eq: null` matches null or missing, `$exists`
|
|
1917
|
-
* means "holds a non-null value"); any other operator (e.g. `$regex`)
|
|
1918
|
-
* {
|
|
2547
|
+
* means "holds a non-null value"); any other operator (e.g. `$regex`) and
|
|
2548
|
+
* a relational predicate (`{ ticket: { $some: … } }` — it needs the related
|
|
2549
|
+
* rows) throw {@link UnsupportedCheckError} — callers fail closed.
|
|
1919
2550
|
*/
|
|
1920
2551
|
function compileScopeCheck(filter) {
|
|
1921
2552
|
return walkFilter(filter, checkVisitor) ?? (() => true);
|
|
1922
2553
|
}
|
|
1923
|
-
/**
|
|
2554
|
+
/**
|
|
2555
|
+
* The field paths a filter references (dotted, as written). A relational
|
|
2556
|
+
* predicate throws {@link UnsupportedCheckError} (its fields live on the
|
|
2557
|
+
* related table).
|
|
2558
|
+
*/
|
|
1924
2559
|
function checkFields(filter) {
|
|
2560
|
+
if (containsRelationFilter(filter)) throw new UnsupportedCheckError("relational predicate");
|
|
1925
2561
|
return new Set(computeInsights(filter).keys());
|
|
1926
2562
|
}
|
|
2563
|
+
/**
|
|
2564
|
+
* The in-memory WITH CHECK verdict for rows about to be inserted: `false` when
|
|
2565
|
+
* a row the evaluator can decide fails the check, `true` when every row passes,
|
|
2566
|
+
* `undefined` when some row cannot be decided up front — the check is not
|
|
2567
|
+
* modelled (relational, `$regex`, …) or reads a field the row omits (a database
|
|
2568
|
+
* default or generated value).
|
|
2569
|
+
*/
|
|
2570
|
+
function insertRowsVerdict(rows, check) {
|
|
2571
|
+
let predicate;
|
|
2572
|
+
let fields;
|
|
2573
|
+
try {
|
|
2574
|
+
predicate = compileScopeCheck(check);
|
|
2575
|
+
fields = checkFields(check);
|
|
2576
|
+
} catch (error) {
|
|
2577
|
+
if (error instanceof UnsupportedCheckError) return void 0;
|
|
2578
|
+
throw error;
|
|
2579
|
+
}
|
|
2580
|
+
let verdict = true;
|
|
2581
|
+
for (const row of rows) if ([...fields].some((f) => getPath(row, f) === void 0)) verdict = void 0;
|
|
2582
|
+
else if (!predicate(row)) return false;
|
|
2583
|
+
return verdict;
|
|
2584
|
+
}
|
|
1927
2585
|
function same(a, b) {
|
|
1928
2586
|
if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
|
|
1929
2587
|
return a === b;
|
|
@@ -1938,6 +2596,9 @@ const checkVisitor = {
|
|
|
1938
2596
|
and: (children) => (row) => children.every((c) => c(row)),
|
|
1939
2597
|
or: (children) => (row) => children.some((c) => c(row)),
|
|
1940
2598
|
not: (child) => (row) => !child(row),
|
|
2599
|
+
relation(field, op) {
|
|
2600
|
+
throw new UnsupportedCheckError(`${field}: ${op}`);
|
|
2601
|
+
},
|
|
1941
2602
|
comparison(field, op, value) {
|
|
1942
2603
|
const read = (row) => getPath(row, field);
|
|
1943
2604
|
const compare = (test) => (row) => {
|
|
@@ -2069,9 +2730,14 @@ async function assertWrittenRowsInCheck(ctx, check) {
|
|
|
2069
2730
|
* - `checkRefs` — every enforced FK the write sets must reference rows the
|
|
2070
2731
|
* caller can read on the target table (→ 403);
|
|
2071
2732
|
* - WITH CHECK when the adapter cannot roll back — validated here, before
|
|
2072
|
-
* the write (see {@link checkArbacWrite} for the transactional path)
|
|
2073
|
-
|
|
2074
|
-
|
|
2733
|
+
* the write (see {@link checkArbacWrite} for the transactional path);
|
|
2734
|
+
* - WITH CHECK of an insert, even on a transactional adapter, evaluated on
|
|
2735
|
+
* the submitted rows before the write when the in-memory evaluator can
|
|
2736
|
+
* model it (a forbidden row → 403, never a duplicate-key 409; since
|
|
2737
|
+
* 0.1.75) — and, with `ignoreConflicts` (`?$onConflict=ignore`), refused
|
|
2738
|
+
* whenever it cannot, since the post-write check never sees skipped rows.
|
|
2739
|
+
*/
|
|
2740
|
+
async function guardArbacWrite(ctx, scopes, table, refs, ignoreConflicts = false) {
|
|
2075
2741
|
const eff = effectiveScope(scopes);
|
|
2076
2742
|
if (eff.filter && hasPreImage(ctx.action)) await assertIdFiltersInScope(table, ctx.rows.map((_row, i) => {
|
|
2077
2743
|
const filter = ctx.filterFor(i);
|
|
@@ -2079,7 +2745,12 @@ async function guardArbacWrite(ctx, scopes, table, refs) {
|
|
|
2079
2745
|
return filter;
|
|
2080
2746
|
}), eff.filter);
|
|
2081
2747
|
await assertRefsInScope(ctx, scopes, refs);
|
|
2082
|
-
if (eff.check
|
|
2748
|
+
if (!eff.check) return;
|
|
2749
|
+
if (!table.getAdapter().isInTransaction()) await assertCheckBeforeWrite(ctx, eff.check);
|
|
2750
|
+
else if (!hasPreImage(ctx.action)) {
|
|
2751
|
+
const verdict = insertRowsVerdict(ctx.rows, eff.check);
|
|
2752
|
+
if (verdict === false || verdict === void 0 && ignoreConflicts) throw new HttpError(403, OUT_OF_SCOPE);
|
|
2753
|
+
}
|
|
2083
2754
|
}
|
|
2084
2755
|
/**
|
|
2085
2756
|
* WITH CHECK after the write, inside its transaction: every written row must
|
|
@@ -2115,9 +2786,23 @@ let AsArbacDbController = class AsArbacDbController extends AsDbController {
|
|
|
2115
2786
|
prepareRequest(ctx) {
|
|
2116
2787
|
return prepareArbacRequest(ctx, this.readable);
|
|
2117
2788
|
}
|
|
2789
|
+
/**
|
|
2790
|
+
* The request filter ∧ the union of the scopes' `filter`s. Computed
|
|
2791
|
+
* synchronously, but typed with moost-db's async-capable contract
|
|
2792
|
+
* (`FilterExpr | Promise<FilterExpr>`) so a subclass may override it
|
|
2793
|
+
* `async` — `await super.transformFilter(filter)` — and moost-db awaits it.
|
|
2794
|
+
*/
|
|
2118
2795
|
transformFilter(filter) {
|
|
2119
2796
|
return arbacRowFilter(filter);
|
|
2120
2797
|
}
|
|
2798
|
+
/** A client relational predicate's operand ∧ the related rows the caller may see ({@link arbacRelationFilter}). Since 0.1.74. */
|
|
2799
|
+
transformRelationFilter(path, filter) {
|
|
2800
|
+
return arbacRelationFilter(path, filter, this.readable);
|
|
2801
|
+
}
|
|
2802
|
+
/**
|
|
2803
|
+
* `$select` restricted to the scopes' projection union. Synchronous, typed
|
|
2804
|
+
* async-capable like {@link transformFilter}.
|
|
2805
|
+
*/
|
|
2121
2806
|
transformProjection(projection) {
|
|
2122
2807
|
return applyArbacProjection(projection, requireRequestScopes(), this.readable);
|
|
2123
2808
|
}
|
|
@@ -2172,6 +2857,14 @@ let AsArbacDbController = class AsArbacDbController extends AsDbController {
|
|
|
2172
2857
|
return applyArbacMetaOverlay(meta, this.readable);
|
|
2173
2858
|
}
|
|
2174
2859
|
/**
|
|
2860
|
+
* `GET /meta/form/:name` under the `/meta` overlay's pointer rule: a form
|
|
2861
|
+
* prop's `ref` / `ui.valueHelp` binding to a table the caller cannot read
|
|
2862
|
+
* (or whose named fields they cannot see) is stripped.
|
|
2863
|
+
*/
|
|
2864
|
+
async metaForm(name) {
|
|
2865
|
+
return applyArbacFormOverlay(await super.metaForm(name), getControllerFormType(this.constructor, name));
|
|
2866
|
+
}
|
|
2867
|
+
/**
|
|
2175
2868
|
* `GET /meta/form/:name` serves a form only when the caller may run at
|
|
2176
2869
|
* least one of the actions taking it as input (else the unknown-form 404).
|
|
2177
2870
|
*/
|
|
@@ -2180,10 +2873,22 @@ let AsArbacDbController = class AsArbacDbController extends AsDbController {
|
|
|
2180
2873
|
}
|
|
2181
2874
|
/**
|
|
2182
2875
|
* The rows each `@DbAction` may run on: the caller's grant on that action
|
|
2183
|
-
* (
|
|
2184
|
-
* in `$actions` and
|
|
2876
|
+
* ({@link arbacActionRowScope} — several grants union, attenuation
|
|
2877
|
+
* intersects). Enforced by the action gate and reflected in `$actions` and
|
|
2878
|
+
* `GET /meta/actions/:id`. Since 0.1.72.
|
|
2879
|
+
*
|
|
2880
|
+
* The grant does not depend on `ctx` (the candidate rows, since 0.1.74).
|
|
2881
|
+
* To bound an action further by the candidates, override and AND your
|
|
2882
|
+
* filter onto `super`'s — it must never replace it:
|
|
2883
|
+
*
|
|
2884
|
+
* ```ts
|
|
2885
|
+
* protected async actionRowScope(name: string, ctx: TDbActionScopeContext) {
|
|
2886
|
+
* const grant = await super.actionRowScope(name, ctx);
|
|
2887
|
+
* return conjoinScopeFilters(grant, await myCandidateScope(name, ctx));
|
|
2888
|
+
* }
|
|
2889
|
+
* ```
|
|
2185
2890
|
*/
|
|
2186
|
-
actionRowScope(name) {
|
|
2891
|
+
actionRowScope(name, _ctx) {
|
|
2187
2892
|
return arbacActionRowScope(name);
|
|
2188
2893
|
}
|
|
2189
2894
|
/**
|
|
@@ -2233,7 +2938,7 @@ let AsArbacDbController = class AsArbacDbController extends AsDbController {
|
|
|
2233
2938
|
}
|
|
2234
2939
|
/** In-transaction stage — USING, `checkRefs`, and WITH CHECK on non-transactional adapters. */
|
|
2235
2940
|
guardWrite(ctx) {
|
|
2236
|
-
return guardArbacWrite(ctx, requireRequestScopes(), this.guardedTable, this.readable);
|
|
2941
|
+
return guardArbacWrite(ctx, requireRequestScopes(), this.guardedTable, this.readable, isInsertIgnoreRequest());
|
|
2237
2942
|
}
|
|
2238
2943
|
/** USING for deletes: the exact row the delete targets must match the scope filter (else 404). */
|
|
2239
2944
|
guardRemove(ctx) {
|
|
@@ -2366,6 +3071,10 @@ let AsArbacDbReadableController = class AsArbacDbReadableController extends AsDb
|
|
|
2366
3071
|
transformFilter(filter) {
|
|
2367
3072
|
return arbacRowFilter(filter);
|
|
2368
3073
|
}
|
|
3074
|
+
/** Same contract as {@link AsArbacDbController.transformRelationFilter}. Since 0.1.74. */
|
|
3075
|
+
transformRelationFilter(path, filter) {
|
|
3076
|
+
return arbacRelationFilter(path, filter, this.readable);
|
|
3077
|
+
}
|
|
2369
3078
|
/** Same contract as {@link AsArbacDbController.transformProjection}. */
|
|
2370
3079
|
transformProjection(projection) {
|
|
2371
3080
|
return applyArbacProjection(projection, requireRequestScopes(), this.readable);
|
|
@@ -2382,12 +3091,20 @@ let AsArbacDbReadableController = class AsArbacDbReadableController extends AsDb
|
|
|
2382
3091
|
applyMetaOverlay(meta) {
|
|
2383
3092
|
return applyArbacMetaOverlay(meta, this.readable);
|
|
2384
3093
|
}
|
|
3094
|
+
/**
|
|
3095
|
+
* `GET /meta/form/:name` under the `/meta` overlay's pointer rule: a form
|
|
3096
|
+
* prop's `ref` / `ui.valueHelp` binding to a table the caller cannot read
|
|
3097
|
+
* (or whose named fields they cannot see) is stripped.
|
|
3098
|
+
*/
|
|
3099
|
+
async metaForm(name) {
|
|
3100
|
+
return applyArbacFormOverlay(await super.metaForm(name), getControllerFormType(this.constructor, name));
|
|
3101
|
+
}
|
|
2385
3102
|
/** Same contract as {@link AsArbacDbController.authorizeForm}. */
|
|
2386
3103
|
authorizeForm(_name, actionNames) {
|
|
2387
3104
|
return authorizeArbacForm(actionNames);
|
|
2388
3105
|
}
|
|
2389
3106
|
/** Same contract as {@link AsArbacDbController.actionRowScope}. */
|
|
2390
|
-
actionRowScope(name) {
|
|
3107
|
+
actionRowScope(name, _ctx) {
|
|
2391
3108
|
return arbacActionRowScope(name);
|
|
2392
3109
|
}
|
|
2393
3110
|
/** Same contract as {@link AsArbacDbController.allowedActions}. */
|
|
@@ -2406,7 +3123,7 @@ let AsArbacDbReadableController = class AsArbacDbReadableController extends AsDb
|
|
|
2406
3123
|
AsArbacDbReadableController = __decorate([Inherit()], AsArbacDbReadableController);
|
|
2407
3124
|
//#endregion
|
|
2408
3125
|
//#region src/db/as-arbac-value-help-controller.ts
|
|
2409
|
-
function valueHelpHost(
|
|
3126
|
+
function valueHelpHost(boundType, primaryKey, searchableFields) {
|
|
2410
3127
|
const ids = primaryKey ? [primaryKey] : [];
|
|
2411
3128
|
return {
|
|
2412
3129
|
primaryKey,
|
|
@@ -2414,7 +3131,8 @@ function valueHelpHost(fields, primaryKey, searchableFields) {
|
|
|
2414
3131
|
source: {
|
|
2415
3132
|
primaryKeys: ids,
|
|
2416
3133
|
preferredId: ids,
|
|
2417
|
-
|
|
3134
|
+
type: boundType,
|
|
3135
|
+
flatMap: boundType.type.props ?? /* @__PURE__ */ new Map()
|
|
2418
3136
|
}
|
|
2419
3137
|
};
|
|
2420
3138
|
}
|
|
@@ -2461,12 +3179,15 @@ async function applyValueHelpMetaOverlay(host, meta) {
|
|
|
2461
3179
|
}
|
|
2462
3180
|
let AsArbacValueHelpController = class AsArbacValueHelpController extends AsValueHelpController {
|
|
2463
3181
|
_arbacHost;
|
|
3182
|
+
/** Makes this controller the binding / `ref` policy source for its bound type's ARBAC resource. */
|
|
3183
|
+
_arbacValueHelpTarget = registerArbacValueHelpTarget(this, this.arbacHost.source);
|
|
2464
3184
|
get arbacHost() {
|
|
2465
|
-
return this._arbacHost ??= valueHelpHost(this.
|
|
3185
|
+
return this._arbacHost ??= valueHelpHost(this.boundType, this.primaryKey, this.searchableFields);
|
|
2466
3186
|
}
|
|
2467
3187
|
async prepareRequest(_ctx) {
|
|
2468
3188
|
await resolveRequestScopes();
|
|
2469
3189
|
}
|
|
3190
|
+
/** Same contract as {@link AsArbacDbController.transformFilter}. */
|
|
2470
3191
|
transformFilter(filter) {
|
|
2471
3192
|
return arbacRowFilter(filter);
|
|
2472
3193
|
}
|
|
@@ -2501,35 +3222,38 @@ __decorate([
|
|
|
2501
3222
|
ArbacAction("query"),
|
|
2502
3223
|
__decorateMetadata("design:type", Function),
|
|
2503
3224
|
__decorateMetadata("design:paramtypes", [String]),
|
|
2504
|
-
__decorateMetadata("design:returntype",
|
|
3225
|
+
__decorateMetadata("design:returntype", typeof ReturnType === "undefined" ? Object : ReturnType)
|
|
2505
3226
|
], AsArbacValueHelpController.prototype, "runQuery", null);
|
|
2506
3227
|
__decorate([
|
|
2507
3228
|
ArbacAction("pages"),
|
|
2508
3229
|
__decorateMetadata("design:type", Function),
|
|
2509
3230
|
__decorateMetadata("design:paramtypes", [String]),
|
|
2510
|
-
__decorateMetadata("design:returntype",
|
|
3231
|
+
__decorateMetadata("design:returntype", typeof ReturnType === "undefined" ? Object : ReturnType)
|
|
2511
3232
|
], AsArbacValueHelpController.prototype, "runPages", null);
|
|
2512
3233
|
__decorate([
|
|
2513
3234
|
ArbacAction("getOne"),
|
|
2514
3235
|
__decorateMetadata("design:type", Function),
|
|
2515
3236
|
__decorateMetadata("design:paramtypes", [String]),
|
|
2516
|
-
__decorateMetadata("design:returntype",
|
|
3237
|
+
__decorateMetadata("design:returntype", typeof ReturnType === "undefined" ? Object : ReturnType)
|
|
2517
3238
|
], AsArbacValueHelpController.prototype, "runGetOne", null);
|
|
2518
3239
|
__decorate([
|
|
2519
3240
|
ArbacAction("getOneComposite"),
|
|
2520
3241
|
__decorateMetadata("design:type", Function),
|
|
2521
3242
|
__decorateMetadata("design:paramtypes", [typeof Record === "undefined" ? Object : Record]),
|
|
2522
|
-
__decorateMetadata("design:returntype",
|
|
3243
|
+
__decorateMetadata("design:returntype", typeof ReturnType === "undefined" ? Object : ReturnType)
|
|
2523
3244
|
], AsArbacValueHelpController.prototype, "runGetOneComposite", null);
|
|
2524
3245
|
AsArbacValueHelpController = __decorate([Inherit()], AsArbacValueHelpController);
|
|
2525
3246
|
let AsArbacJsonValueHelpController = class AsArbacJsonValueHelpController extends AsJsonValueHelpController {
|
|
2526
3247
|
_arbacHost;
|
|
3248
|
+
/** Makes this controller the binding / `ref` policy source for its bound type's ARBAC resource. */
|
|
3249
|
+
_arbacValueHelpTarget = registerArbacValueHelpTarget(this, this.arbacHost.source);
|
|
2527
3250
|
get arbacHost() {
|
|
2528
|
-
return this._arbacHost ??= valueHelpHost(this.
|
|
3251
|
+
return this._arbacHost ??= valueHelpHost(this.boundType, this.primaryKey, this.searchableFields);
|
|
2529
3252
|
}
|
|
2530
3253
|
async prepareRequest(_ctx) {
|
|
2531
3254
|
await resolveRequestScopes();
|
|
2532
3255
|
}
|
|
3256
|
+
/** Same contract as {@link AsArbacDbController.transformFilter}. */
|
|
2533
3257
|
transformFilter(filter) {
|
|
2534
3258
|
return arbacRowFilter(filter);
|
|
2535
3259
|
}
|
|
@@ -2564,25 +3288,25 @@ __decorate([
|
|
|
2564
3288
|
ArbacAction("query"),
|
|
2565
3289
|
__decorateMetadata("design:type", Function),
|
|
2566
3290
|
__decorateMetadata("design:paramtypes", [String]),
|
|
2567
|
-
__decorateMetadata("design:returntype",
|
|
3291
|
+
__decorateMetadata("design:returntype", typeof ReturnType === "undefined" ? Object : ReturnType)
|
|
2568
3292
|
], AsArbacJsonValueHelpController.prototype, "runQuery", null);
|
|
2569
3293
|
__decorate([
|
|
2570
3294
|
ArbacAction("pages"),
|
|
2571
3295
|
__decorateMetadata("design:type", Function),
|
|
2572
3296
|
__decorateMetadata("design:paramtypes", [String]),
|
|
2573
|
-
__decorateMetadata("design:returntype",
|
|
3297
|
+
__decorateMetadata("design:returntype", typeof ReturnType === "undefined" ? Object : ReturnType)
|
|
2574
3298
|
], AsArbacJsonValueHelpController.prototype, "runPages", null);
|
|
2575
3299
|
__decorate([
|
|
2576
3300
|
ArbacAction("getOne"),
|
|
2577
3301
|
__decorateMetadata("design:type", Function),
|
|
2578
3302
|
__decorateMetadata("design:paramtypes", [String]),
|
|
2579
|
-
__decorateMetadata("design:returntype",
|
|
3303
|
+
__decorateMetadata("design:returntype", typeof ReturnType === "undefined" ? Object : ReturnType)
|
|
2580
3304
|
], AsArbacJsonValueHelpController.prototype, "runGetOne", null);
|
|
2581
3305
|
__decorate([
|
|
2582
3306
|
ArbacAction("getOneComposite"),
|
|
2583
3307
|
__decorateMetadata("design:type", Function),
|
|
2584
3308
|
__decorateMetadata("design:paramtypes", [typeof Record === "undefined" ? Object : Record]),
|
|
2585
|
-
__decorateMetadata("design:returntype",
|
|
3309
|
+
__decorateMetadata("design:returntype", typeof ReturnType === "undefined" ? Object : ReturnType)
|
|
2586
3310
|
], AsArbacJsonValueHelpController.prototype, "runGetOneComposite", null);
|
|
2587
3311
|
AsArbacJsonValueHelpController = __decorate([Inherit()], AsArbacJsonValueHelpController);
|
|
2588
3312
|
//#endregion
|
|
@@ -2609,7 +3333,7 @@ async function useArbacDbScope() {
|
|
|
2609
3333
|
const visibleOn = (table) => (path) => isScopedFieldVisible(scopes, path, table);
|
|
2610
3334
|
return {
|
|
2611
3335
|
scopes,
|
|
2612
|
-
filter: (extra) => conjoinScopeFilters(eff.filter, extra) ?? {},
|
|
3336
|
+
filter: (extra) => conjoinScopeFilters$1(eff.filter, extra) ?? {},
|
|
2613
3337
|
set: () => ({ ...eff.set }),
|
|
2614
3338
|
check: () => eff.check ?? {},
|
|
2615
3339
|
async assertRowsInScope(table, ids) {
|
|
@@ -2632,12 +3356,12 @@ async function useArbacDbScope() {
|
|
|
2632
3356
|
currentAll: async () => rows.map(() => null),
|
|
2633
3357
|
filterFor: () => null
|
|
2634
3358
|
}, scopes, table),
|
|
2635
|
-
writeOptions: (table) => ({
|
|
3359
|
+
writeOptions: (table, opts) => ({
|
|
2636
3360
|
isFieldVisible: visibleOn(table),
|
|
2637
3361
|
guard: async (ctx) => {
|
|
2638
3362
|
const rows = ctx;
|
|
2639
3363
|
assertNestedWritesAllowed(rows.rows, scopes, navFieldsOf(table));
|
|
2640
|
-
await guardArbacWrite(rows, scopes, table, table);
|
|
3364
|
+
await guardArbacWrite(rows, scopes, table, table, opts?.onConflict === "ignore");
|
|
2641
3365
|
},
|
|
2642
3366
|
check: (ctx) => checkArbacWrite(ctx, scopes)
|
|
2643
3367
|
}),
|
|
@@ -2649,4 +3373,4 @@ async function useArbacDbScope() {
|
|
|
2649
3373
|
};
|
|
2650
3374
|
}
|
|
2651
3375
|
//#endregion
|
|
2652
|
-
export { ARBAC_DELEGATED_AUTH, Arbac, ArbacAction, ArbacAuthorize, ArbacResource, ArbacUserProvider, ArbacUserProviderToken, AsArbacDbController, AsArbacDbReadableController, AsArbacJsonValueHelpController, AsArbacValueHelpController, MoostArbac, applyAllowedFieldsAndSet, applyArbacControls, applyArbacMetaOverlay, applyArbacProjection, applyArbacRelationScopes, arbacActionRowScope, arbacAllowedActions, arbacAuthorizeInterceptor, arbacPatternToRegex, arbacRowFilter, authorizeArbacForm, buildScopeVisibility, cachedRequestScopes, checkArbacWrite, collectMethodNames, collectWithGrantNames, collectWritableFields, conjoinArbacDbScopes, enforceControlsPolicy, extractUsedControlValues, getArbacMate, getArbacScopes, guardArbacRemove, guardArbacWrite, isMetaFieldVisible, isScopedFieldVisible, metaAlwaysVisibleFields, prepareArbacRequest, pruneMetaByVisibility, registerArbacDbTarget, requestFieldVisible, requireRequestScopes, resolveHandlerArbacIds, resolveRequestScopes, unionScopeProjection, useArbac, useArbacDbScope };
|
|
3376
|
+
export { ARBAC_DELEGATED_AUTH, Arbac, ArbacAction, ArbacAuthorize, ArbacResource, ArbacUserProvider, ArbacUserProviderToken, AsArbacDbController, AsArbacDbReadableController, AsArbacJsonValueHelpController, AsArbacValueHelpController, MoostArbac, applyAllowedFieldsAndSet, applyArbacControls, applyArbacMetaOverlay, applyArbacProjection, applyArbacRelationScopes, arbacActionRowScope, arbacAllowedActions, arbacAuthorizeInterceptor, arbacPatternToRegex, arbacRelationFilter, arbacRowFilter, authorizeArbacForm, buildScopeVisibility, cachedRequestScopes, checkArbacWrite, collectMethodNames, collectWithGrantNames, collectWritableFields, conjoinArbacDbScopes, conjoinScopeFilters, enforceControlsPolicy, extractUsedControlValues, getArbacMate, getArbacScopes, guardArbacRemove, guardArbacWrite, isMetaFieldVisible, isScopedFieldVisible, metaAlwaysVisibleFields, prepareArbacRequest, pruneMetaByVisibility, registerArbacDbTarget, requestFieldVisible, requireRequestScopes, resolveHandlerArbacIds, resolveRequestScopes, unionScopeProjection, useArbac, useArbacDbScope };
|