@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 { DB_SCOPE_KEYS, DENY_FILTER, ScopeFieldConfigError, applyScopeFieldFilters, conjoinScopeFilters, conjoinScopes, effectiveScope, expandExcludeToLeaves, getProjectionMode, intersectEnabled, isFieldAllowed, needsInheritedConjunction, normalizeScopes, restrictProjection, stableKey, unionOutcomes } from "@aooth/arbac";
7
- import { computeInsights, findAncestorInSet, getPath, isPlainObject } from "@atscript/db";
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.readable
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].readable
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") withNameTree(entry.controls?.$with ?? entry.$with, level, depth + segments.length);
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 stores
416
- * the resolution the request's visibility (`hasField`, `validateControls`,
417
- * `transformProjection`) reads.
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
- if (names.size === 0) return;
422
- current().set(requestRelationsKey, await resolveRelationTree(scopes, readable, names));
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
- /** The resolution {@link resolveRequestRelations} stored for the current event, if any. */
425
- function requestRelations() {
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
- return ctx.has(requestRelationsKey) ? ctx.get(requestRelationsKey) : void 0;
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
- const pruned = pruneSerializedType(prop, child);
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
- return {
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, level))
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 terminal;
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: def.ref.type(),
1155
- field: def.ref.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 serialized `ref` at `path` survives when its target field is visible:
1163
- * a self-reference (e.g. a derived column's source) against this level's
1164
- * visibility; a foreign one against the caller's visibility on the target
1165
- * table (`refTargets`, stripped when the caller cannot read it). Without
1166
- * runtime schema info (`vis.table.flatMap`) or `refTargets`, refs are kept.
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 isRefVisible(path, vis, refTargets) {
1169
- const table = vis.table;
1170
- if (!table?.flatMap || !refTargets) return true;
1171
- const target = refTargetOf(table.flatMap.get(path));
1172
- if (!target || !target.field) return true;
1173
- if (target.type === table.type) return isMetaFieldVisible(target.field, vis);
1174
- const targetVis = refTargets.get(target.type);
1175
- return !!targetVis && isMetaFieldVisible(target.field, targetVis);
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
- * 3. The search surface: `searchIndexes` reading a hidden field are dropped,
1226
- * `searchable` / `vectorSearchable` / `geoSearchable` recomputed, and the
1227
- * `query` / `pages` control lists lose the controls that became unusable
1228
- * (`crud.geo` goes when no geo index is left).
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 indexes - the controller's `indexFieldPaths()`; defaults to calling
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, indexes) {
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(crudHandlers(op, methods).map((m) => resolveHandlerArbacIds(instance, m)))))]);
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 pruneSearchSurface(pruneMetaByVisibility(overlaid, {
1664
+ return gateGroupable(pruneMetaByVisibility(overlaid, {
1271
1665
  ...vis,
1272
1666
  writable,
1273
1667
  nestedWrites
1274
- }, refTargets), meta, vis, indexes ?? controllerIndexes(instance));
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 || !isMetaFieldVisible(name, vis)) continue;
1323
- const sub = vis.relation?.(name);
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 = refTargetOf(entry);
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
- /** A controller class's methods and `@DbAction` name → methods map (memoized per class). */
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 cc = useControllerContext();
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 = cc.getMethodMeta(methodName)?.atscript_db_action?.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 fromInterceptor = arbac.getScopes();
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.controls && READ_ENDPOINTS.has(ctx.endpoint)) await resolveRequestRelations(scopes, ctx.controls, readable);
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 granted = await evaluateHandlers(actionsIds(discoverRowLevelActions(getConstructor(instance), instance.app, instance.logger).map((e) => e.info.name)));
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
- if (!granted) throw insufficientPrivileges(arbac.resource, arbac.action);
1631
- storeResolved(ctx, arbac, void 0, granted.map(withoutRowPolicy));
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. No grant → a match-nothing filter (fail closed);
1657
- * an unrestricted grant → `undefined`. When the current request IS that
1658
- * action (its resolved scopes are the same evaluation), the row overlay
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
- //#endregion
1687
- //#region src/db/shared-read-helpers.ts
1688
- /**
1689
- * Restrict the user-supplied `$select` to the scopes' projection union.
1690
- * Returns the original projection untouched when no scope declares a
1691
- * projection (so caller sees the unrestricted shape). Reads the same cached
1692
- * visibility (`vis.allowed`) `hasField` / `/meta` pruning use, so value
1693
- * stripping and name hiding cannot drift.
1694
- *
1695
- * `projection` is the `$select` as moost-db parsed it: an inclusion is an
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.72
2411
+ * @since 0.1.74
1725
2412
  */
1726
- function applyArbacControls(controls, scopes) {
1727
- enforceControlsPolicy(effectiveScope(scopes).controls, controls);
1728
- }
1729
- /**
1730
- * Walk the user-supplied `$with` items in `controls` and overlay each joined
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`) throws
1918
- * {@link UnsupportedCheckError} — callers fail closed.
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
- /** The field paths a filter references (dotted, as written). */
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
- async function guardArbacWrite(ctx, scopes, table, refs) {
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 && !table.getAdapter().isInTransaction()) await assertCheckBeforeWrite(ctx, 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
- * (see `arbacActionRowScope`). Enforced by the action gate and reflected
2184
- * in `$actions` and `GET /meta/actions/:id`. Since 0.1.72.
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(fields, primaryKey, searchableFields) {
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
- flatMap: fields
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.fieldMeta, this.primaryKey, this.searchableFields);
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", void 0)
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", void 0)
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", void 0)
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", void 0)
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.fieldMeta, this.primaryKey, this.searchableFields);
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", void 0)
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", void 0)
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", void 0)
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", void 0)
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 };