@aooth/arbac-moost 0.1.71 → 0.1.73

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,12 +1,592 @@
1
- import { n as ArbacUserProviderToken, r as __decorate, t as ArbacUserProvider } from "./user.provider-CYiwr7y3.mjs";
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
3
  import { Authenticate, HttpError } from "@moostjs/event-http";
4
- import { current, key } from "@wooksjs/event-core";
4
+ import { cached, cachedBy, current, eventTypeKey, key, tryGetCurrent } from "@wooksjs/event-core";
5
5
  import { Inherit, Injectable, TInterceptorPriority, defineBeforeInterceptor, getConstructor, getMoostMate, useControllerContext, useLogger } from "moost";
6
- import { DENY_FILTER, conjoinScopeFilters, expandExcludeToLeaves, getProjectionMode, intersectControlsPolicy, intersectProjections, isFieldAllowed, mergeScopeFilters, restrictProjection, unionControlsPolicy, unionProjections } from "@aooth/arbac";
7
- import { findAncestorInSet } from "@atscript/db";
8
- import { AsDbController, AsDbReadableController } from "@atscript/moost-db";
9
- import { groupByFields } from "@uniqu/core";
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";
9
+ import { groupByFields, walkFilter } from "@uniqu/core";
10
+ //#region src/db/helpers.ts
11
+ /** `map.get(key)`, created with `create()` and stored on a miss. */
12
+ function getOrCreate(map, key, create) {
13
+ let value = map.get(key);
14
+ if (value === void 0) {
15
+ value = create();
16
+ map.set(key, value);
17
+ }
18
+ return value;
19
+ }
20
+ /** `path` or one of its dotted ancestors is in `set`. */
21
+ function hasSelfOrAncestor(set, path) {
22
+ return set.has(path) || findAncestorInSet(path, set) !== void 0;
23
+ }
24
+ /** A patch (`update` / `updateMany`) — only the keys it carries are written. */
25
+ function isPatchAction(action) {
26
+ return action === "update" || action === "updateMany";
27
+ }
28
+ /** An update / replace — a write whose target row has a pre-image to scope (USING). */
29
+ function hasPreImage(action) {
30
+ return action !== "insert" && action !== "insertMany";
31
+ }
32
+ /**
33
+ * A value written as-is to one column: a primitive, `null`, or a class
34
+ * instance (`Date`, a Mongo `ObjectId`, a `Buffer`, …) — never a plain object
35
+ * or an array (a nested patch / an operator shape).
36
+ */
37
+ function isScalar(v) {
38
+ if (v === null) return true;
39
+ if (typeof v === "function") return false;
40
+ return typeof v !== "object" || !Array.isArray(v) && !isPlainObject(v);
41
+ }
42
+ const navFieldsCache = /* @__PURE__ */ new WeakMap();
43
+ /** The table's navigation fields (`navFields`, else its relation names), cached per table. */
44
+ function navFieldsOf(table) {
45
+ return table.navFields ?? getOrCreate(navFieldsCache, table, () => new Set(table.relations?.keys()));
46
+ }
47
+ //#endregion
48
+ //#region src/db/visibility.ts
49
+ /**
50
+ * Union the per-scope `projection` whitelists into the single access-control
51
+ * projection used for FIELD-EXISTENCE decisions (`hasField` parity + `/meta`
52
+ * pruning). Returns `undefined` when the union imposes no restriction — no
53
+ * scopes at all, or any scope without a `projection` (a universal grant makes
54
+ * `unionProjections` collapse to the universe `{}`).
55
+ */
56
+ function unionScopeProjection(scopes) {
57
+ const allowed = effectiveScope(scopes).projection;
58
+ return Object.keys(allowed).length === 0 ? void 0 : allowed;
59
+ }
60
+ /**
61
+ * Relation names any scope explicitly declares via `with.<name>` — those
62
+ * relations are governed by the declared sub-scopes alone (parent authority),
63
+ * and the declaration implies the relation EXISTS for this principal even
64
+ * when the projection union does not name it (see {@link ArbacDbScope.with}).
65
+ */
66
+ function collectWithGrantNames(scopes) {
67
+ return effectiveScope(scopes).withNames;
68
+ }
69
+ /**
70
+ * Union of write-scope `allowedFields`; `"all"` for field-unrestricted write
71
+ * access. The whitelist exists only when at least one scope carries an
72
+ * `allowedFields` array — scoped-but-unlisted writes are field-unrestricted.
73
+ */
74
+ function collectWritableFields(scopes, unrestricted) {
75
+ if (unrestricted) return "all";
76
+ if (scopes.length === 0) return void 0;
77
+ const fields = effectiveScope(scopes).allowedFields;
78
+ if (!fields) return "all";
79
+ return fields.size > 0 ? fields : void 0;
80
+ }
81
+ /**
82
+ * Whether a flattened dot-path field exists for this principal.
83
+ *
84
+ * - A relation declared via `with.<name>` is visible; a path under it
85
+ * (`rel.x.y`) is checked as `x.y` against its sub-scope visibility.
86
+ * - An undeclared relation of the table (see {@link MetaVisibility.relationNames})
87
+ * is visible only when the projection allows its name and the caller's own
88
+ * grant on the related table resolved ({@link MetaVisibility.relation});
89
+ * paths under it are checked against that grant.
90
+ * - Anything else follows the projection union (and the derived-column rule).
91
+ *
92
+ * So a field a joined table hides is as unknown in a `$with` sub-query as a
93
+ * top-level hidden column is in the main query (no filter/sort value oracle).
94
+ */
95
+ function isMetaFieldVisible(path, vis) {
96
+ if (vis.alwaysVisible.has(path)) return true;
97
+ const dot = path.indexOf(".");
98
+ const head = dot === -1 ? path : path.slice(0, dot);
99
+ if (vis.withGrants.has(head)) {
100
+ if (dot === -1) return true;
101
+ const sub = vis.relation?.(head);
102
+ return sub ? isMetaFieldVisible(path.slice(dot + 1), sub) : true;
103
+ }
104
+ const allowed = (p) => vis.isAllowed ? vis.isAllowed(p) : isFieldAllowed(p, vis.allowed);
105
+ if (vis.relationNames?.has(head)) {
106
+ if (!allowed(head)) return false;
107
+ const sub = vis.relation?.(head);
108
+ if (!sub) return false;
109
+ return dot === -1 || isMetaFieldVisible(path.slice(dot + 1), sub);
110
+ }
111
+ return allowed(path);
112
+ }
113
+ const NO_NAMES$1 = /* @__PURE__ */ new Set();
114
+ const identifierSetCache = /* @__PURE__ */ new WeakMap();
115
+ /** PK + `preferredId` of a table — the identifiers reads always return. */
116
+ function identifierSet(table) {
117
+ if (!table) return NO_NAMES$1;
118
+ return getOrCreate(identifierSetCache, table, () => /* @__PURE__ */ new Set([...table.primaryKeys, ...table.preferredId]));
119
+ }
120
+ /**
121
+ * PK + `preferredId` for a controller's table/readable — the
122
+ * {@link MetaVisibility.alwaysVisible} set. Kept for compatibility: the
123
+ * controllers now pass `this.readable` and it is derived there.
124
+ */
125
+ function metaAlwaysVisibleFields(_controller, source) {
126
+ return identifierSet(source);
127
+ }
128
+ const NO_POLICY = {
129
+ json: NO_NAMES$1,
130
+ derived: /* @__PURE__ */ new Map(),
131
+ relations: void 0
132
+ };
133
+ const tablePolicyCache = /* @__PURE__ */ new WeakMap();
134
+ function tablePolicy(table) {
135
+ if (!table) return NO_POLICY;
136
+ return getOrCreate(tablePolicyCache, table, () => {
137
+ const derived = /* @__PURE__ */ new Map();
138
+ for (const fd of table.fieldDescriptors ?? []) if (fd.derived?.sourcePath) derived.set(fd.path, fd.derived.sourcePath);
139
+ return {
140
+ json: table.jsonParents ?? NO_NAMES$1,
141
+ derived,
142
+ relations: table.relations ? new Set(table.relations.keys()) : void 0
143
+ };
144
+ });
145
+ }
146
+ /**
147
+ * Precompiled {@link isFieldAllowed} for one projection: `hasField` asks it
148
+ * for every referenced path, so the mode and key sets are computed once.
149
+ * Inclusion: the path, an ancestor, or a descendant is listed. Exclusion: no
150
+ * listed key is the path or an ancestor.
151
+ */
152
+ function compileProjection(projection) {
153
+ const keys = Object.keys(projection);
154
+ if (keys.length === 0) return () => true;
155
+ const listed = new Set(keys);
156
+ if (getProjectionMode(projection) === "exclude") return (path) => !hasSelfOrAncestor(listed, path);
157
+ const parents = /* @__PURE__ */ new Set();
158
+ for (const key of keys) {
159
+ let pos = key.length;
160
+ while ((pos = key.lastIndexOf(".", pos - 1)) !== -1) parents.add(key.slice(0, pos));
161
+ }
162
+ return (path) => parents.has(path) || hasSelfOrAncestor(listed, path);
163
+ }
164
+ /**
165
+ * The projection union made executable and consistent for the table:
166
+ *
167
+ * - **atomic JSON** — a scope key inside an atomic JSON column (relational
168
+ * adapters) is folded onto the column: an excluded leaf hides the whole
169
+ * column (`{ "settings.apiKey": 0 }` → `{ settings: 0 }`), a whitelisted
170
+ * leaf alone does not reveal it (the key is dropped — fail closed);
171
+ * - **derived columns** — a `@db.column.derived` field whose source the
172
+ * projection hides is excluded too (or dropped from a whitelist).
173
+ *
174
+ * An include projection the rewrite emptied would read as the universe: it
175
+ * falls back to the table's identifiers (reads return them anyway), else
176
+ * keeps the original (unexecutable → fails closed rather than widening).
177
+ */
178
+ function normalizeAllowed(allowed, policy, identifiers) {
179
+ const keys = Object.keys(allowed);
180
+ if (keys.length === 0) return allowed;
181
+ const exclude = getProjectionMode(allowed) === "exclude";
182
+ let out;
183
+ if (policy.json.size > 0) for (const key of keys) {
184
+ const column = findAncestorInSet(key, policy.json);
185
+ if (column === void 0) continue;
186
+ out ??= { ...allowed };
187
+ delete out[key];
188
+ if (exclude) out[column] = 0;
189
+ }
190
+ if (policy.derived.size > 0) {
191
+ const visible = compileProjection(out ?? allowed);
192
+ for (const [path, source] of policy.derived) {
193
+ if (identifiers.has(source) || visible(source)) continue;
194
+ if (exclude) {
195
+ if (!visible(path)) continue;
196
+ out ??= { ...allowed };
197
+ out[path] = 0;
198
+ } else if ((out ?? allowed)[path] !== void 0) {
199
+ out ??= { ...allowed };
200
+ delete out[path];
201
+ }
202
+ }
203
+ }
204
+ if (!out || exclude || Object.keys(out).length > 0) return out ?? allowed;
205
+ if (identifiers.size === 0) return allowed;
206
+ return Object.fromEntries([...identifiers].map((id) => [id, 1]));
207
+ }
208
+ /**
209
+ * The declared relations governed by their `with` sub-scopes alone. A
210
+ * sub-scope a credential conjunction marked `INHERITED_CONJUNCTION` (declared
211
+ * by one side only) is not: such a relation follows the undeclared-relation
212
+ * rule — visible only once resolved (conjoined with the caller's own grant
213
+ * on the related table).
214
+ */
215
+ function parentAuthorityGrants(eff) {
216
+ const names = eff.withNames;
217
+ for (const name of names) if (eff.withScopes(name).some(needsInheritedConjunction)) return new Set([...names].filter((n) => !eff.withScopes(n).some(needsInheritedConjunction)));
218
+ return names;
219
+ }
220
+ /**
221
+ * Build the {@link MetaVisibility} a set of read scopes implies — the single
222
+ * structure behind `hasField`, `/meta` pruning, `$select` value stripping
223
+ * and the `$with` overlay, so they cannot drift. Own fields: the projection
224
+ * union (`{}` when unrestricted), normalized for atomic JSON columns and
225
+ * derived-column sources, precompiled into `isAllowed`. Relations: an
226
+ * undeclared one → its node in `relations` (none → hidden); a declared
227
+ * `with.<name>` → the union of its sub-scopes (lazily built when `relations`
228
+ * has no node for it). The related table's own PK / `preferredId` stay
229
+ * visible.
230
+ *
231
+ * @param opts - {@link ScopeVisibilityOptions}; a bare identifier set is the
232
+ * 0.1.71 `alwaysVisible` argument
233
+ */
234
+ function buildScopeVisibility(scopes, table, opts = {}) {
235
+ const { alwaysVisible = identifierSet(table), relations } = opts instanceof Set ? { alwaysVisible: opts } : opts;
236
+ const eff = effectiveScope(scopes);
237
+ const withGrants = parentAuthorityGrants(eff);
238
+ const policy = tablePolicy(table);
239
+ const allowed = normalizeAllowed(unionScopeProjection(scopes) ?? {}, policy, alwaysVisible);
240
+ const base = compileProjection(allowed);
241
+ const isAllowed = policy.derived.size === 0 ? base : (path) => {
242
+ if (!base(path)) return false;
243
+ const source = policy.derived.get(path);
244
+ return source === void 0 || alwaysVisible.has(source) || base(source);
245
+ };
246
+ const declared = /* @__PURE__ */ new Map();
247
+ return {
248
+ allowed,
249
+ alwaysVisible,
250
+ withGrants,
251
+ relationNames: policy.relations,
252
+ scopes,
253
+ table,
254
+ isAllowed,
255
+ relation(name) {
256
+ const resolved = relations?.get(name);
257
+ if (resolved || !withGrants.has(name)) return resolved ?? void 0;
258
+ return getOrCreate(declared, name, () => buildScopeVisibility(eff.withScopes(name), table?.relatedTable?.(name)));
259
+ }
260
+ };
261
+ }
262
+ const scopeVisibilityCache = /* @__PURE__ */ new WeakMap();
263
+ const NO_TABLE = {
264
+ primaryKeys: [],
265
+ preferredId: []
266
+ };
267
+ /** `source` is an identifier set (the 0.1.67 signatures), not a readable. */
268
+ function isIdentifierSet(source) {
269
+ return !("primaryKeys" in source);
270
+ }
271
+ /**
272
+ * The memoized {@link MetaVisibility} for `scopes` over `source` (a
273
+ * readable, or an `alwaysVisible` identifier set), without relation
274
+ * resolution (undeclared relations hidden).
275
+ */
276
+ function scopeVisibility(scopes, source = NO_TABLE) {
277
+ return getOrCreate(getOrCreate(scopeVisibilityCache, scopes, () => /* @__PURE__ */ new WeakMap()), source, () => isIdentifierSet(source) ? buildScopeVisibility(scopes, void 0, source) : buildScopeVisibility(scopes, source === NO_TABLE ? void 0 : source));
278
+ }
279
+ //#endregion
280
+ //#region src/db/relation-policy.ts
281
+ /** Registry bucket for controllers without an `app`. */
282
+ const NO_APP = {};
283
+ const registry = /* @__PURE__ */ new WeakMap();
284
+ /**
285
+ * Register an ARBAC DB controller as an owner of its readable's ARBAC
286
+ * resource, so a `$with` from another controller can evaluate the caller's
287
+ * own grant on the joined table. Called once per controller instance (a
288
+ * field initializer of the ARBAC DB controllers). Scoped per moost app and
289
+ * keyed by the readable's annotated type; a newer instance of the same class
290
+ * replaces the older one.
291
+ *
292
+ * @returns `true` (so it can initialize a field)
293
+ * @since 0.1.72
294
+ */
295
+ function registerArbacDbTarget(controller) {
296
+ const c = controller;
297
+ const target = c.readable?.type ?? c.readable;
298
+ if (!target) return true;
299
+ getOrCreate(getOrCreate(registry, c.app ?? NO_APP, () => /* @__PURE__ */ new WeakMap()), target, () => /* @__PURE__ */ new Map()).set(getConstructor(controller), controller);
300
+ return true;
301
+ }
302
+ function registeredControllers(target) {
303
+ let app = NO_APP;
304
+ try {
305
+ app = useControllerContext().getController()?.app ?? NO_APP;
306
+ } catch {}
307
+ const perTarget = registry.get(app)?.get(target);
308
+ return perTarget ? [...perTarget.values()] : [];
309
+ }
310
+ /**
311
+ * The ARBAC resource + action a controller's handler is authorized as — the
312
+ * precedence `useArbac()` applies while that handler serves a request.
313
+ * Resource: method `@ArbacResource` → class `@ArbacResource` → controller id
314
+ * → class name. Action: method `@ArbacAction` → `@DbAction` name → class
315
+ * `@ArbacAction` → method `@Id` → method name.
316
+ *
317
+ * @since 0.1.72
318
+ */
319
+ function resolveHandlerArbacIds(instance, methodName) {
320
+ const mate = getMoostMate();
321
+ return arbacIdsFromMeta(mate.read(instance), mate.read(instance, methodName), instance, methodName);
322
+ }
323
+ /**
324
+ * Evaluate several handlers as ONE surface (memoized per event): the scopes
325
+ * of every allowing handler concatenated (`unionOutcomes`); `undefined` when
326
+ * none allows.
327
+ */
328
+ async function evaluateHandlers(ids) {
329
+ return unionOutcomes(await evaluateHandlerOutcomes(ids));
330
+ }
331
+ /** The raw per-handler outcomes behind {@link evaluateHandlers} (memoized per event). */
332
+ function evaluateHandlerOutcomes(ids) {
333
+ return Promise.all(ids.map((id) => evaluateArbacCached(id.resource, id.action, id.table)));
334
+ }
335
+ /**
336
+ * The caller's read scopes on the readable of annotated type `type` (a
337
+ * `$with` / `ref` target) through its registered ARBAC DB controllers (their
338
+ * `query` handler, each evaluated against ITS table's schema), plus a
339
+ * registered controller's readable — `null` when none is registered or none
340
+ * allows.
341
+ */
342
+ async function evaluateTypeRead(type) {
343
+ const controllers = registeredControllers(type);
344
+ if (controllers.length === 0) return null;
345
+ const scopes = await evaluateHandlers(controllers.map((c) => ({
346
+ ...resolveHandlerArbacIds(c, "query"),
347
+ table: c.readable
348
+ })));
349
+ return scopes ? {
350
+ scopes,
351
+ table: controllers[0].readable
352
+ } : null;
353
+ }
354
+ /**
355
+ * Deepest `$with` nesting resolved: the request controls the depth, and each
356
+ * level may evaluate grants. A deeper relation stays unresolved (hidden).
357
+ */
358
+ const MAX_WITH_DEPTH = 16;
359
+ /**
360
+ * Relation names a parsed `$with` control reaches, per level. A dotted
361
+ * name (`a.b`) nests like `a($with=b)`.
362
+ */
363
+ function withNameTree(withArr, into = /* @__PURE__ */ new Map(), depth = 0) {
364
+ if (!Array.isArray(withArr) || depth >= MAX_WITH_DEPTH) return into;
365
+ for (const raw of withArr) {
366
+ const entry = raw;
367
+ const name = typeof raw === "string" ? raw : entry?.name;
368
+ if (typeof name !== "string" || !name) continue;
369
+ const segments = name.split(".");
370
+ if (depth + segments.length > MAX_WITH_DEPTH) continue;
371
+ let level = into;
372
+ 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);
374
+ }
375
+ return into;
376
+ }
377
+ /**
378
+ * Resolve every relation in `names` against `scopes` over `table` into the
379
+ * visibility its joined rows obey: a declared `with.<name>` → its
380
+ * sub-scopes; otherwise the caller's own read grant on the related table
381
+ * (`null` = hidden). Recurses into nested names — the decision is made here
382
+ * once; `MetaVisibility.relation()` only reads it.
383
+ *
384
+ * When a level names a hidden or nonexistent relation, the level's OTHER
385
+ * relations are resolved too (one level deep — `listSiblings: false` skips
386
+ * it), so the `Unknown relation` 400 can list the relations the caller may
387
+ * use — never the hidden ones.
388
+ */
389
+ async function resolveRelationTree(scopes, table, names, opts = {}) {
390
+ const out = /* @__PURE__ */ new Map();
391
+ const resolveOne = async (name, nested) => {
392
+ const target = table?.relatedTable?.(name);
393
+ const declared = effectiveScope(scopes).withScopes(name);
394
+ const inherits = declared.length === 0 || declared.some(needsInheritedConjunction);
395
+ const read = inherits && target ? await evaluateTypeRead(target.type ?? target) : null;
396
+ let nodeScopes = inherits ? read?.scopes : declared;
397
+ if (nodeScopes && declared.length > 0 && inherits) nodeScopes = [conjoinForTable(declared, nodeScopes, target, await currentScopeFields())];
398
+ if (!nodeScopes) {
399
+ out.set(name, null);
400
+ return;
401
+ }
402
+ const relations = nested.size > 0 ? await resolveRelationTree(nodeScopes, target, nested, opts) : void 0;
403
+ out.set(name, buildScopeVisibility(nodeScopes, target, { relations }));
404
+ };
405
+ await Promise.all([...names].map(([name, nested]) => resolveOne(name, nested)));
406
+ if (opts.listSiblings !== false && [...out.values()].includes(null) && table?.relations) {
407
+ const siblings = [...table.relations.keys()].filter((name) => !out.has(name));
408
+ await Promise.all(siblings.map((name) => resolveOne(name, /* @__PURE__ */ new Map())));
409
+ }
410
+ return out;
411
+ }
412
+ const requestRelationsKey = key("arbac.db.relations");
413
+ /**
414
+ * 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.
418
+ */
419
+ async function resolveRequestRelations(scopes, controls, readable) {
420
+ const names = withNameTree(controls?.$with);
421
+ if (names.size === 0) return;
422
+ current().set(requestRelationsKey, await resolveRelationTree(scopes, readable, names));
423
+ }
424
+ /** The resolution {@link resolveRequestRelations} stored for the current event, if any. */
425
+ function requestRelations() {
426
+ const ctx = current();
427
+ return ctx.has(requestRelationsKey) ? ctx.get(requestRelationsKey) : void 0;
428
+ }
429
+ //#endregion
430
+ //#region src/db/write-refs.ts
431
+ /**
432
+ * The FK a `checkRefs` entry designates: one of its local fields, or the TO
433
+ * relation it backs (`readable.foreignKeyOf`). A name that is neither is a
434
+ * configuration error.
435
+ */
436
+ function refForeignKeyOf(table, name) {
437
+ const fk = [...table.foreignKeys?.values() ?? []].find((f) => f.fields.includes(name)) ?? table.foreignKeyOf?.(name);
438
+ if (fk) return fk;
439
+ throw new Error(`checkRefs: "${name}" is not a foreign key field or TO relation of this table`);
440
+ }
441
+ /**
442
+ * The FKs whose target check applies to a write under `scopes` — the union
443
+ * rule: write scopes grant additively (more roles = more access), so a scope
444
+ * WITHOUT the flag for an FK grants unconstrained writes of it; the check is
445
+ * enforced only for the FKs EVERY scope enables (intersection).
446
+ *
447
+ * @example
448
+ * ```ts
449
+ * // role "member": { filter: { tenant }, checkRefs: ["projectId"] }
450
+ * // role "auditor": { filter: { tenant }, checkRefs: true }
451
+ * // member + auditor → projectId checked (both enable it); assigneeId not
452
+ * // role "importer": { filter: { tenant } } // no checkRefs
453
+ * // member + importer → nothing checked (importer grants unconstrained FK writes)
454
+ * ```
455
+ */
456
+ function enforcedRefs(scopes, table) {
457
+ const all = table.foreignKeys;
458
+ if (!all || all.size === 0 || scopes.length === 0) return [];
459
+ const enforced = intersectEnabled(scopes.map((s) => s.checkRefs === true ? true : new Set((s.checkRefs ?? []).map((name) => refForeignKeyOf(table, name)))));
460
+ return enforced === true ? [...all.values()] : [...enforced];
461
+ }
462
+ /**
463
+ * Canonical `checkRefs` of a conjunction (credential attenuation): the FKs
464
+ * EITHER side enforces — each side resolved with {@link enforcedRefs} —
465
+ * named by their first local field. `true` when that is every FK of the
466
+ * table; `undefined` when none. Without the table's schema the names are
467
+ * combined as written (per side: the entries every scope lists, `true`
468
+ * matching any).
469
+ */
470
+ function conjoinCheckRefs(userScopes, credScopes, table) {
471
+ if (!table?.foreignKeys) {
472
+ const u = effectiveScope(userScopes).checkRefs;
473
+ const c = effectiveScope(credScopes).checkRefs;
474
+ if (u === true || c === true) return true;
475
+ const names = /* @__PURE__ */ new Set([...u, ...c]);
476
+ return names.size > 0 ? [...names].toSorted() : void 0;
477
+ }
478
+ const all = /* @__PURE__ */ new Set([...enforcedRefs(userScopes, table), ...enforcedRefs(credScopes, table)]);
479
+ if (all.size === 0) return void 0;
480
+ if (all.size === table.foreignKeys.size) return true;
481
+ return [...all].map((fk) => fk.fields[0]).toSorted();
482
+ }
483
+ /** A row value at `path`: a literal dotted key first, else the nested path. */
484
+ function readPath(row, path) {
485
+ return path in row ? row[path] : getPath(row, path);
486
+ }
487
+ function outOfScope(fk) {
488
+ return new HttpError(403, `Referenced row "${fk.fields.join(", ")}" is outside your scope`);
489
+ }
490
+ /** A patch row touches `fk` in part only — its tuple is completed from the pre-image. */
491
+ function needsPreImage(row, fk) {
492
+ const touched = fk.fields.filter((f) => readPath(row, f) !== void 0).length;
493
+ return touched > 0 && touched < fk.fields.length;
494
+ }
495
+ /**
496
+ * The distinct FK tuples the write SETS: insert / replace rows carry them in
497
+ * full; an update patch only when it touches the FK (a composite FK touched
498
+ * in part is completed from the pre-image). A null / absent part → no
499
+ * reference, skipped. A plain object / array value (an operator shape) →
500
+ * 403 (fail closed); scalars and class instances (a Mongo `ObjectId`, a
501
+ * `Date`) are FK values.
502
+ */
503
+ function referencedTuples(ctx, fk, pre) {
504
+ const isPatch = isPatchAction(ctx.action);
505
+ const tuples = /* @__PURE__ */ new Map();
506
+ for (let i = 0; i < ctx.rows.length; i++) {
507
+ const row = ctx.rows[i];
508
+ if (isPatch && !fk.fields.some((f) => readPath(row, f) !== void 0)) continue;
509
+ const source = pre[i] ? {
510
+ ...pre[i],
511
+ ...row
512
+ } : row;
513
+ const tuple = fk.fields.map((f) => readPath(source, f));
514
+ if (tuple.some((v) => v === null || v === void 0)) continue;
515
+ if (!tuple.every(isScalar)) throw outOfScope(fk);
516
+ tuples.set(stableKey(tuple), tuple);
517
+ }
518
+ return [...tuples.values()];
519
+ }
520
+ /** The target-row filter of `tuples` over `fk.targetFields`. */
521
+ function targetFilter(fk, tuples) {
522
+ if (fk.targetFields.length === 1) return { [fk.targetFields[0]]: { $in: tuples.map((t) => t[0]) } };
523
+ return { $or: tuples.map((t) => Object.fromEntries(fk.targetFields.map((f, j) => [f, t[j]]))) };
524
+ }
525
+ async function countInScope(table, ids, scope) {
526
+ return table.count({
527
+ filter: scope ? { $and: [ids, scope] } : ids,
528
+ controls: {}
529
+ });
530
+ }
531
+ /**
532
+ * One target type's check: every referenced row must be readable by the
533
+ * caller there — ONE count over all of its FKs' references; on a shortfall
534
+ * (or two FKs naming one row through different keys) each FK is re-counted
535
+ * exactly so the error names the offending FK.
536
+ */
537
+ async function assertTargetRefs(type, refs) {
538
+ const target = await evaluateTypeRead(type);
539
+ const table = target?.table;
540
+ if (!target || typeof table?.count !== "function") throw outOfScope(refs[0].fk);
541
+ const scope = effectiveScope(target.scopes).filter;
542
+ if (refs.length > 1) {
543
+ const keys = /* @__PURE__ */ new Set();
544
+ for (const { fk, tuples } of refs) for (const t of tuples) keys.add(`${fk.targetFields.join(",")}:${stableKey(t)}`);
545
+ if (await countInScope(table, { $or: refs.map(({ fk, tuples }) => targetFilter(fk, tuples)) }, scope) === keys.size) return;
546
+ }
547
+ for (const { fk, tuples } of refs) if (await countInScope(table, targetFilter(fk, tuples), scope) !== tuples.length) throw outOfScope(fk);
548
+ }
549
+ /**
550
+ * `checkRefs` enforcement for a write (runs in `guardWrite`, inside the
551
+ * write's transaction): every FK {@link enforcedRefs} returns that the write
552
+ * sets must reference rows the caller can read on the target table (its own
553
+ * `query` grant there) — one count per target table — else 403
554
+ * `Referenced row "<field>" is outside your scope`. No registered ARBAC
555
+ * controller on the target, or no read grant there → 403 as well.
556
+ *
557
+ * @since 0.1.72
558
+ */
559
+ async function assertRefsInScope(ctx, scopes, table) {
560
+ const fks = enforcedRefs(scopes, table);
561
+ if (fks.length === 0) return;
562
+ const pre = isPatchAction(ctx.action) && ctx.rows.some((row) => fks.some((fk) => needsPreImage(row, fk))) ? await ctx.currentAll() : [];
563
+ const byType = /* @__PURE__ */ new Map();
564
+ for (const fk of fks) {
565
+ const tuples = referencedTuples(ctx, fk, pre);
566
+ if (tuples.length === 0) continue;
567
+ const type = fk.targetTypeRef?.();
568
+ if (!type) throw outOfScope(fk);
569
+ getOrCreate(byType, type, () => []).push({
570
+ fk,
571
+ tuples
572
+ });
573
+ }
574
+ for (const [type, refs] of byType) await assertTargetRefs(type, refs);
575
+ }
576
+ /**
577
+ * Start the target-table evaluations {@link assertRefsInScope} will need, so
578
+ * the in-transaction guard finds them memoized (fire-and-forget: a failure
579
+ * resurfaces where the guard awaits it).
580
+ */
581
+ function prefetchRefTargets(scopes, table) {
582
+ const types = /* @__PURE__ */ new Set();
583
+ for (const fk of enforcedRefs(scopes, table)) {
584
+ const type = fk.targetTypeRef?.();
585
+ if (type) types.add(type);
586
+ }
587
+ for (const type of types) evaluateTypeRead(type).catch(() => {});
588
+ }
589
+ //#endregion
10
590
  //#region src/attenuation.ts
11
591
  /**
12
592
  * Conjoin the full-authority scopes (`userScopes`, the ceiling) and the
@@ -14,11 +594,12 @@ import { groupByFields } from "@uniqu/core";
14
594
  * `ArbacDbScope`. A row/field/control is admitted only if BOTH passes admit
15
595
  * it — the normative restrict-only clip that closes the attr-widen hole.
16
596
  *
17
- * Each side is first UNIONed with the existing additive helpers (today's
18
- * machinery), then the two RESULTS are CONJOINED with the dedicated combiners
19
- * ({@link conjoinScopeFilters} `$and`, {@link intersectProjections} field ∩,
20
- * {@link intersectControlsPolicy} deny-wins, `with` recursion) — never the
21
- * additive union helpers, which would silently widen.
597
+ * Each side is first UNIONed, then the two results are CONJOINED facet by
598
+ * facet with `conjoinScopes` from `@aooth/arbac` (`$and` filters and WITH
599
+ * CHECK filters, path-wise projection ∩, deny-wins controls, `allowedFields`
600
+ * / `nestedWrites` intersection, `set` combined with the USER winning a key
601
+ * conflict, recursive `with`) — never the additive union helpers, which
602
+ * would silently widen.
22
603
  *
23
604
  * Returned as a single-element list so every downstream scope-application
24
605
  * site (which UNIONs the cached scope list per facet) sees the identity of a
@@ -31,118 +612,252 @@ import { groupByFields } from "@uniqu/core";
31
612
  * closed). When NO field survives (`{a:1}` ∩ `{a:0}`, disjoint whitelists),
32
613
  * the composite keeps the user's projection and gets a match-nothing filter —
33
614
  * an empty field set must never read as the unrestricted `{}`. `with`
34
- * sub-scopes are conjoined without a schema.
35
- */
36
- function conjoinArbacDbScopes(userScopes, credScopes, childrenOf) {
37
- const userProjection = unionProjections(...userScopes.map((s) => s.projection ?? {}));
38
- const intersected = intersectProjections(userProjection, unionProjections(...credScopes.map((s) => s.projection ?? {})), childrenOf);
39
- const projection = intersected ?? userProjection;
40
- const credFilter = mergeScopeFilters(credScopes.map((s) => s.filter ?? {}));
41
- const filter = conjoinScopeFilters(mergeScopeFilters(userScopes.map((s) => s.filter ?? {})), intersected ? credFilter : conjoinScopeFilters(credFilter, DENY_FILTER));
42
- const controls = intersectControlsPolicy(unionControlsPolicy(userScopes), unionControlsPolicy(credScopes));
43
- const allowedFields = intersectAllowedFields(userScopes, credScopes);
44
- const set = combineSet(userScopes, credScopes);
45
- const withMap = conjoinWith(userScopes, credScopes);
46
- const s = {};
47
- if (filter && Object.keys(filter).length > 0) s.filter = filter;
48
- if (Object.keys(projection).length > 0) s.projection = projection;
49
- if (Object.keys(controls).length > 0) s.controls = controls;
50
- if (allowedFields) s.allowedFields = allowedFields;
51
- if (set) s.set = set;
52
- if (withMap) s.with = withMap;
53
- return [s];
54
- }
55
- /**
56
- * Intersect the two sides' `allowedFields` write-whitelists (the credential
57
- * may write FEWER fields). A side with no whitelist is unrestricted (all
58
- * fields), so the other side wins; both restricting → set intersection.
59
- */
60
- function intersectAllowedFields(userScopes, credScopes) {
61
- const u = unionAllowedFields(userScopes);
62
- const c = unionAllowedFields(credScopes);
63
- if (u === void 0) return c;
64
- if (c === void 0) return u;
65
- const cset = new Set(c);
66
- return [...new Set(u)].filter((f) => cset.has(f)).toSorted();
67
- }
68
- function unionAllowedFields(scopes) {
69
- let set;
70
- for (const s of scopes) if (Array.isArray(s.allowedFields)) {
71
- set ??= /* @__PURE__ */ new Set();
72
- for (const f of s.allowedFields) set.add(f);
73
- }
74
- return set ? [...set] : void 0;
75
- }
76
- /**
77
- * Combine the two sides' `set` overlays. `set` forces field values on writes;
78
- * both sides' forced constraints apply, and on a key conflict the USER's value
79
- * wins so the credential can only ADD constraints, never override the owner's.
80
- */
81
- function combineSet(userScopes, credScopes) {
82
- const u = unionSet(userScopes);
83
- const c = unionSet(credScopes);
84
- if (!u && !c) return void 0;
85
- return {
86
- ...c,
87
- ...u
88
- };
89
- }
90
- function unionSet(scopes) {
91
- let out;
92
- for (const s of scopes) if (s.set) {
93
- out ??= {};
94
- Object.assign(out, s.set);
95
- }
96
- return out;
97
- }
98
- /**
99
- * Recurse the conjunction into joined-resource sub-scopes. A relation declared
100
- * on only one side is conjoined against the other side's silence (unrestricted
101
- * = identity), so a credential can narrow a relation the user left open but
102
- * never widen one the user restricted.
615
+ * sub-scopes are conjoined without a schema. `checkRefs` enforces what either
616
+ * side enforces (resolved against `refTable`'s foreign keys when given — the
617
+ * evaluated table). A custom (declaration-merged) scope field is conjoined by
618
+ * its `fields` rule; without one this THROWS — never dropped (fail closed).
619
+ *
620
+ * The third argument is the options object, or (legacy form) `childrenOf`.
103
621
  */
104
- function conjoinWith(userScopes, credScopes) {
105
- const relNames = /* @__PURE__ */ new Set();
106
- for (const s of userScopes) if (s.with) for (const k of Object.keys(s.with)) relNames.add(k);
107
- for (const s of credScopes) if (s.with) for (const k of Object.keys(s.with)) relNames.add(k);
108
- if (relNames.size === 0) return void 0;
109
- const out = {};
110
- for (const rel of relNames) out[rel] = conjoinArbacDbScopes(userScopes.map((s) => s.with?.[rel]).filter(Boolean), credScopes.map((s) => s.with?.[rel]).filter(Boolean))[0];
111
- return out;
622
+ function conjoinArbacDbScopes(userScopes, credScopes, opts) {
623
+ const { childrenOf, refTable, fields } = typeof opts === "function" ? { childrenOf: opts } : opts ?? {};
624
+ return [conjoinScopes(userScopes, credScopes, {
625
+ childrenOf,
626
+ checkRefs: refTable?.foreignKeys ? (u, c) => conjoinCheckRefs(u, c, refTable) : void 0,
627
+ fields
628
+ })];
112
629
  }
113
630
  //#endregion
114
631
  //#region src/db/field-children.ts
115
632
  const fieldChildrenCache = /* @__PURE__ */ new WeakMap();
633
+ const NONE = /* @__PURE__ */ new Set();
116
634
  /**
117
635
  * The `TProjectionChildren` schema lookup for a table (its own `flatMap` paths —
118
- * navigation descendants excluded, they are joined rows, not columns);
636
+ * navigation descendants excluded, they are joined rows, not columns; so are
637
+ * the descendants of an atomic JSON column — `readable.jsonParents`);
119
638
  * `undefined` when the table exposes no flattened schema.
120
639
  */
121
640
  function fieldChildrenOf(table) {
122
- if (!table?.flatMap) return void 0;
123
- let lookup = fieldChildrenCache.get(table);
124
- if (!lookup) {
125
- const nav = table.navFields ?? new Set(table.relations?.keys());
641
+ const flatMap = table?.flatMap;
642
+ if (!flatMap) return void 0;
643
+ return getOrCreate(fieldChildrenCache, table, () => {
644
+ const nav = navFieldsOf(table);
645
+ const json = table.jsonParents ?? NONE;
126
646
  const byParent = /* @__PURE__ */ new Map();
127
- for (const path of table.flatMap.keys()) {
647
+ for (const path of flatMap.keys()) {
128
648
  const dot = path.lastIndexOf(".");
129
649
  if (dot === -1) continue;
130
650
  const parent = path.slice(0, dot);
131
- if (nav.has(parent) || findAncestorInSet(parent, nav) !== void 0) continue;
132
- const list = byParent.get(parent);
133
- if (list) list.push(path);
134
- else byParent.set(parent, [path]);
651
+ if (hasSelfOrAncestor(nav, parent) || hasSelfOrAncestor(json, parent)) continue;
652
+ getOrCreate(byParent, parent, () => []).push(path);
135
653
  }
136
- lookup = (path) => byParent.get(path) ?? [];
137
- fieldChildrenCache.set(table, lookup);
138
- }
139
- return lookup;
654
+ return (path) => byParent.get(path) ?? [];
655
+ });
140
656
  }
141
657
  //#endregion
142
658
  //#region src/moost-arbac.ts
143
- let MoostArbac = class MoostArbac extends Arbac$1 {};
659
+ let MoostArbac = class MoostArbac extends Arbac$1 {
660
+ scopeFieldRules = {};
661
+ /**
662
+ * Register the conjunction rule of custom (declaration-merged) scope
663
+ * fields — used wherever two scope lists are conjoined into one composite
664
+ * scope (credential attenuation, and the `with` sub-scopes it builds). A
665
+ * custom field present in a conjunction without a rule fails the request
666
+ * with a generic 500 (never dropped: a dropped field would read as
667
+ * unrestricted). A rule's optional `rowFilter` turns the field into a ROW
668
+ * restriction every framework row path enforces (folded into each
669
+ * evaluated scope's `filter`). Each field is registered once; a built-in
670
+ * `ArbacDbScope` key is rejected.
671
+ *
672
+ * @example
673
+ * ```ts
674
+ * arbac.registerScopeFields({
675
+ * teams: {
676
+ * conjoin: (user, cred) => intersectTeams(user, cred),
677
+ * rowFilter: (teams) => ({ teamId: { $in: teams } }),
678
+ * },
679
+ * });
680
+ * ```
681
+ * @since 0.1.72
682
+ */
683
+ registerScopeFields(rules) {
684
+ const entries = Object.entries(rules);
685
+ for (const [name, rule] of entries) {
686
+ if (DB_SCOPE_KEYS.includes(name)) throw new Error(`registerScopeFields: "${name}" is a built-in scope key`);
687
+ if (typeof rule?.conjoin !== "function") throw new TypeError(`registerScopeFields: "${name}" needs a conjoin(a, b) function`);
688
+ if (rule.rowFilter !== void 0 && typeof rule.rowFilter !== "function") throw new TypeError(`registerScopeFields: "${name}".rowFilter must be a function`);
689
+ const existing = this.scopeFieldRules[name];
690
+ if (existing && existing !== rule) throw new Error(`registerScopeFields: scope field "${name}" is already registered`);
691
+ }
692
+ this.scopeFieldRules = {
693
+ ...this.scopeFieldRules,
694
+ ...Object.fromEntries(entries)
695
+ };
696
+ return this;
697
+ }
698
+ /**
699
+ * The engine's evaluation with every custom field's `rowFilter` folded
700
+ * into each evaluated scope's `filter` (both passes of an attenuated
701
+ * evaluation, each with its own scopes) — so every consumer, direct
702
+ * engine calls included, sees the row restriction. A non-built-in scope
703
+ * key without a registered rule is warned about once.
704
+ */
705
+ async evaluate(res, user) {
706
+ const result = await super.evaluate(res, user);
707
+ const fold = (list) => list && applyScopeFieldFilters(list, this.scopeFieldRules, { onUnknownField: this.warnUnknownField });
708
+ const scopes = fold(result.scopes);
709
+ const credScopes = fold(result.credScopes);
710
+ if (scopes === result.scopes && credScopes === result.credScopes) return result;
711
+ const out = {
712
+ ...result,
713
+ scopes
714
+ };
715
+ if (credScopes !== void 0) out.credScopes = credScopes;
716
+ return out;
717
+ }
718
+ warnedFields = /* @__PURE__ */ new Set();
719
+ warnUnknownField = (field) => {
720
+ if (this.warnedFields.has(field)) return;
721
+ this.warnedFields.add(field);
722
+ console.warn(`[arbac] Scope field "${field}" has no rule registered with registerScopeFields: it reaches only your own code, and an attenuated request carrying it fails with 500.`);
723
+ };
724
+ /** The registered custom scope field rules. @since 0.1.72 */
725
+ getScopeFields() {
726
+ return this.scopeFieldRules;
727
+ }
728
+ };
144
729
  MoostArbac = __decorate([Injectable()], MoostArbac);
145
730
  //#endregion
731
+ //#region src/arbac.evaluate.ts
732
+ /**
733
+ * The ARBAC resource + action a handler is authorized as. Resource: method
734
+ * `@ArbacResource` → class `@ArbacResource` → controller id → class name.
735
+ * Action: method `@ArbacAction` → `@DbAction` name → class `@ArbacAction` →
736
+ * method `@Id` → method name.
737
+ */
738
+ function arbacIdsFromMeta(cMeta, mMeta, controller, methodName) {
739
+ return {
740
+ resource: mMeta?.arbacResourceId || cMeta?.arbacResourceId || cMeta?.id || getConstructor(controller).name,
741
+ action: mMeta?.arbacActionId || mMeta?.atscript_db_action?.name || cMeta?.arbacActionId || mMeta?.id || methodName
742
+ };
743
+ }
744
+ /** The 403 every ARBAC deny answers. */
745
+ function insufficientPrivileges(resource, action) {
746
+ return new HttpError(403, `Insufficient privileges for action "${action}" on resource "${resource}"`);
747
+ }
748
+ async function loadPrincipal(user) {
749
+ const userId = await user.getUserId();
750
+ const [roles, att] = await Promise.all([user.getRoles(userId), user.getAttenuation?.()]);
751
+ let attrs;
752
+ return {
753
+ userId,
754
+ roles,
755
+ attenuate: att && (att.roles !== void 0 || att.attrs !== void 0) ? {
756
+ roles: att.roles,
757
+ attrs: att.attrs,
758
+ ...att.allowUnheldRoles === true && { allowUnheldRoles: true }
759
+ } : void 0,
760
+ attrs: (id) => attrs ??= Promise.resolve(user.getAttrs(id))
761
+ };
762
+ }
763
+ const principalKey = key("arbac.principal");
764
+ function isHttpEvent(ctx) {
765
+ return ctx.has(eventTypeKey) && ctx.get(eventTypeKey) === "http";
766
+ }
767
+ const evalMemo = cachedBy((_table) => /* @__PURE__ */ new Map());
768
+ /**
769
+ * Evaluate `target` for the current principal. Attenuated + allowed: the
770
+ * ceiling pass and the narrowed pass are conjoined into ONE composite scope
771
+ * (every downstream site unions the list per facet — a one-element union is
772
+ * the identity). `table` defaults to the current controller's `readable`.
773
+ */
774
+ function evaluateArbac(ctx, target, memoPrincipal = false) {
775
+ const cc = useControllerContext(ctx);
776
+ const table = target.table ?? cc.getController()?.readable;
777
+ const pending = (async () => {
778
+ const [user, arbac] = await Promise.all([cc.instantiate(ArbacUserProviderToken), cc.instantiate(MoostArbac)]);
779
+ let principal;
780
+ if (!memoPrincipal || !isHttpEvent(ctx)) principal = loadPrincipal(user);
781
+ else if (ctx.has(principalKey)) principal = ctx.get(principalKey);
782
+ else ctx.set(principalKey, principal = loadPrincipal(user));
783
+ const p = await principal;
784
+ const result = await arbac.evaluate({
785
+ resource: target.resource,
786
+ action: target.action
787
+ }, {
788
+ id: p.userId,
789
+ roles: p.roles,
790
+ attrs: p.attrs,
791
+ attenuate: p.attenuate
792
+ });
793
+ if (result.allowed && result.credScopes !== void 0) return {
794
+ allowed: true,
795
+ scopes: [conjoinForTable(result.scopes ?? [], result.credScopes, table, arbac.getScopeFields())],
796
+ userId: p.userId
797
+ };
798
+ return {
799
+ allowed: result.allowed,
800
+ scopes: result.scopes,
801
+ userId: p.userId
802
+ };
803
+ })();
804
+ evalMemo(table, ctx).set(`${target.resource}\u0000${target.action}`, pending);
805
+ return pending;
806
+ }
807
+ const loggedConfigErrors = /* @__PURE__ */ new Set();
808
+ /**
809
+ * Run `fn`; a {@link ScopeFieldConfigError} (a server misconfiguration)
810
+ * becomes a generic 500 — the details are logged server-side once per
811
+ * distinct error, never sent to the client. Fail closed either way.
812
+ */
813
+ function asServerError(fn) {
814
+ try {
815
+ return fn();
816
+ } catch (error) {
817
+ if (!(error instanceof ScopeFieldConfigError)) throw error;
818
+ if (!loggedConfigErrors.has(error.message)) {
819
+ loggedConfigErrors.add(error.message);
820
+ useLogger("arbac").error(error.message);
821
+ }
822
+ throw new HttpError(500, "Internal Server Error");
823
+ }
824
+ }
825
+ /**
826
+ * THE conjunction of two scope lists over `table` (credential attenuation,
827
+ * and a `$with` sub-scope ∧ the caller's grant on the related table): the
828
+ * table's schema subtracts nested exclusions exactly, its foreign keys
829
+ * resolve `checkRefs`, `fields` conjoin custom scope fields — and an
830
+ * unregistered custom field is a generic 500 ({@link asServerError}).
831
+ */
832
+ function conjoinForTable(a, b, table, fields) {
833
+ return asServerError(() => conjoinArbacDbScopes(a, b, {
834
+ childrenOf: fieldChildrenOf(table),
835
+ refTable: table,
836
+ fields
837
+ })[0]);
838
+ }
839
+ const scopeFieldsSlot = cached(async (ctx) => (await useControllerContext(ctx).instantiate(MoostArbac)).getScopeFields());
840
+ /** The current app's custom scope field rules — resolved once per event. */
841
+ function currentScopeFields() {
842
+ return current().get(scopeFieldsSlot);
843
+ }
844
+ /**
845
+ * {@link evaluateArbac} memoized for the current HTTP event (with the
846
+ * per-event principal memo) — `/meta`, `$with` resolution and `checkRefs`
847
+ * evaluate the same pairs repeatedly, and an evaluation the authorize
848
+ * interceptor already ran is reused. Other events (a WF event spans steps)
849
+ * evaluate afresh. `table` omitted → the current controller's readable.
850
+ */
851
+ function evaluateArbacCached(resource, action, table) {
852
+ const ctx = current();
853
+ const normalized = table ?? useControllerContext(ctx).getController()?.readable;
854
+ return (isHttpEvent(ctx) ? evalMemo(normalized, ctx).get(`${resource}\u0000${action}`) : void 0) ?? evaluateArbac(ctx, {
855
+ resource,
856
+ action,
857
+ table: normalized
858
+ }, true);
859
+ }
860
+ //#endregion
146
861
  //#region src/arbac.composables.ts
147
862
  /**
148
863
  * Writable slot holding the evaluated scopes for the current event.
@@ -188,40 +903,18 @@ const useArbac = (_ctx) => {
188
903
  };
189
904
  const cMeta = cc.getControllerMeta();
190
905
  const mMeta = cc.getMethodMeta();
191
- const resource = mMeta?.arbacResourceId || cMeta?.arbacResourceId || cMeta?.id || getConstructor(cc.getController()).name;
192
- const action = mMeta?.arbacActionId || mMeta?.atscript_db_action?.name || cMeta?.arbacActionId || mMeta?.id || (cc.getMethod() ?? "");
906
+ const { resource, action } = arbacIdsFromMeta(cMeta, mMeta, cc.getController(), cc.getMethod() ?? "");
193
907
  const isPublic = mMeta?.arbacPublic || cMeta?.arbacPublic || false;
194
908
  const evaluate = async (opts) => {
195
909
  const effectiveResource = opts?.resource || resource;
196
910
  const effectiveAction = opts?.action || action;
197
911
  if (!effectiveResource) throw new Error("useArbac().evaluate(): `resource` is required — could not be resolved from controller/method metadata. Pass it explicitly.");
198
912
  if (!effectiveAction) throw new Error("useArbac().evaluate(): `action` is required — could not be resolved from controller/method metadata. Pass it explicitly.");
199
- const [user, arbac] = await Promise.all([cc.instantiate(ArbacUserProviderToken), cc.instantiate(MoostArbac)]);
200
- const userId = await user.getUserId();
201
- const att = user.getAttenuation ? await user.getAttenuation() : void 0;
202
- const attenuate = att && (att.roles !== void 0 || att.attrs !== void 0) ? {
203
- roles: att.roles,
204
- attrs: att.attrs
205
- } : void 0;
206
- const result = await arbac.evaluate({
913
+ return evaluateArbac(ctx, {
207
914
  resource: effectiveResource,
208
- action: effectiveAction
209
- }, {
210
- id: userId,
211
- roles: await user.getRoles(userId),
212
- attrs: (id) => user.getAttrs(id),
213
- attenuate
915
+ action: effectiveAction,
916
+ table: opts?.table
214
917
  });
215
- if (result.allowed && result.credScopes !== void 0) return {
216
- allowed: true,
217
- scopes: conjoinArbacDbScopes(result.scopes ?? [], result.credScopes, fieldChildrenOf(cc.getController()?.readable)),
218
- userId
219
- };
220
- return {
221
- allowed: result.allowed,
222
- scopes: result.scopes,
223
- userId
224
- };
225
918
  };
226
919
  const evaluateOrThrow = async (opts) => {
227
920
  const result = await evaluate(opts);
@@ -252,6 +945,18 @@ const useArbac = (_ctx) => {
252
945
  function getArbacMate() {
253
946
  return getMoostMate();
254
947
  }
948
+ /**
949
+ * Marks a controller whose `prepareRequest` authorizes moost-db's delegated
950
+ * handlers (the ones `getDbEndpoint` tags, e.g. `GET meta/actions/:id`) with
951
+ * ARBAC: a method keyed by this symbol answers whether `method` is such a
952
+ * handler. The authorize interceptor then skips its own evaluation for that
953
+ * handler (the route has no grant of its own). `AsArbacDbController` /
954
+ * `AsArbacDbReadableController` carry it; a controller without it keeps the
955
+ * interceptor's normal (fail-closed) evaluation.
956
+ *
957
+ * @since 0.1.72
958
+ */
959
+ const ARBAC_DELEGATED_AUTH = Symbol.for("aooth.arbac.delegatedAuth");
255
960
  //#endregion
256
961
  //#region src/arbac.decorator.ts
257
962
  /**
@@ -268,11 +973,12 @@ const arbacAuthorizeInterceptor = Object.assign(defineBeforeInterceptor(async ()
268
973
  const ctx = current();
269
974
  const { setScopes, evaluate, resource, action, isPublic } = useArbac(ctx);
270
975
  if (!action || !resource || isPublic) return;
976
+ if (delegatesToArbacPrepareRequest(ctx)) return;
271
977
  const logger = useLogger("arbac", ctx);
272
978
  try {
273
979
  const { allowed, scopes, userId } = await evaluate();
274
980
  logger.debug(`[${userId}] ${allowed ? "Authorized" : "Blocked"} "${resource}" : "${action}"`);
275
- if (!allowed) throw new HttpError(403, `Insufficient privileges for action "${action}" on resource "${resource}"`);
981
+ if (!allowed) throw insufficientPrivileges(resource, action);
276
982
  setScopes(scopes);
277
983
  } catch (error) {
278
984
  if (error instanceof HttpError) throw error;
@@ -280,6 +986,13 @@ const arbacAuthorizeInterceptor = Object.assign(defineBeforeInterceptor(async ()
280
986
  throw new HttpError(401, error instanceof Error ? error.message : String(error));
281
987
  }
282
988
  }, TInterceptorPriority.GUARD), { __authTransports: {} });
989
+ function delegatesToArbacPrepareRequest(ctx) {
990
+ const cc = useControllerContext(ctx);
991
+ const controller = cc.getController();
992
+ const method = cc.getMethod();
993
+ const check = controller?.[ARBAC_DELEGATED_AUTH];
994
+ return typeof method === "string" && typeof check === "function" && check.call(controller, method);
995
+ }
283
996
  /** Wrapped via `Authenticate` so `@moostjs/swagger` picks up the auth-guard metadata. */
284
997
  const ArbacAuthorize = () => Authenticate(arbacAuthorizeInterceptor);
285
998
  /**
@@ -295,69 +1008,19 @@ const ArbacAction = (name) => getArbacMate().decorate("arbacActionId", name);
295
1008
  //#endregion
296
1009
  //#region src/db/meta-projection.ts
297
1010
  /**
298
- * Union the per-scope `projection` whitelists into the single access-control
299
- * projection used for FIELD-EXISTENCE decisions (`hasField` parity + `/meta`
300
- * pruning). Returns `undefined` when the union imposes no restriction — no
301
- * scopes at all, or any scope without a `projection` (a universal grant makes
302
- * `unionProjections` collapse to the universe `{}`).
303
- */
304
- function unionScopeProjection(scopes) {
305
- if (scopes.length === 0) return void 0;
306
- const allowed = unionProjections(...scopes.map((s) => s.projection ?? {}));
307
- return Object.keys(allowed).length === 0 ? void 0 : allowed;
308
- }
309
- /**
310
- * Relation names any scope explicitly grants via `with.<name>` — an explicit
311
- * content grant implies the relation EXISTS for this principal even when the
312
- * projection union does not name it (the sub-scope, not the projection, owns
313
- * the joined rows' policy — see {@link ArbacDbScope.with}).
314
- */
315
- function collectWithGrantNames(scopes) {
316
- const out = /* @__PURE__ */ new Set();
317
- for (const s of scopes) if (s.with) for (const name of Object.keys(s.with)) out.add(name);
318
- return out;
319
- }
320
- /** Exact-or-ancestor membership: `credit.credentials.user` matches a `credit.credentials` grant. */
321
- function isPathWritable(path, writable) {
322
- if (!writable) return false;
323
- return writable === "all" || hasSelfOrAncestor(writable, path);
324
- }
325
- /**
326
- * Union of write-scope `allowedFields`; `"all"` for field-unrestricted write
327
- * access. Mirrors `prepareScopeOverlay`: the whitelist exists only when at
328
- * least one scope carries an `allowedFields` array — scoped-but-unlisted
329
- * writes are field-unrestricted.
330
- */
331
- function collectWritableFields(scopes, unrestricted) {
332
- if (unrestricted) return "all";
333
- if (scopes.length === 0) return void 0;
334
- const out = /* @__PURE__ */ new Set();
335
- let sawWhitelist = false;
336
- for (const s of scopes) if (Array.isArray(s.allowedFields)) {
337
- sawWhitelist = true;
338
- for (const f of s.allowedFields) out.add(f);
339
- }
340
- if (!sawWhitelist) return "all";
341
- return out.size > 0 ? out : void 0;
342
- }
343
- /**
344
- * Whether a flattened dot-path field exists for this principal. A
345
- * `with`-granted relation itself is always visible (the grant implies it);
346
- * a path UNDER it (`rel.x.y`) is checked as `x.y` against the relation's
347
- * sub-scope visibility ({@link MetaVisibility.relation}) — so a field the
348
- * sub-scope hides is as unknown in a `$with` sub-query as a top-level hidden
349
- * column is in the main query (no filter/sort value oracle).
1011
+ * Exact-or-ancestor membership in the write whitelist: `credit.credentials.user`
1012
+ * matches a `credit.credentials` grant. A path through a nav relation is
1013
+ * writable only when the relation is opted into nested writes
1014
+ * ({@link MetaVisibility.nestedWrites}); a `@db.column.derived` field never is
1015
+ * (its payload value is dropped).
350
1016
  */
351
- function isMetaFieldVisible(path, vis) {
352
- if (vis.alwaysVisible.has(path)) return true;
1017
+ function isPathWritable(path, vis) {
1018
+ const writable = vis.writable;
1019
+ if (!writable || tablePolicy(vis.table).derived.has(path)) return false;
353
1020
  const dot = path.indexOf(".");
354
1021
  const head = dot === -1 ? path : path.slice(0, dot);
355
- if (vis.withGrants.has(head)) {
356
- if (dot === -1) return true;
357
- const sub = vis.relation?.(head);
358
- return sub ? isMetaFieldVisible(path.slice(dot + 1), sub) : true;
359
- }
360
- return vis.isAllowed ? vis.isAllowed(path) : isFieldAllowed(path, vis.allowed);
1022
+ if ((vis.relationNames?.has(head) || vis.table?.navFields?.has(head)) && !vis.nestedWrites?.has(head)) return false;
1023
+ return writable === "all" || hasSelfOrAncestor(writable, path);
361
1024
  }
362
1025
  /**
363
1026
  * Prune a `/meta` envelope down to the fields the principal's read scopes can
@@ -367,19 +1030,21 @@ function isMetaFieldVisible(path, vis) {
367
1030
  *
368
1031
  * - `fields` — the flat capability map (sortable/filterable flags).
369
1032
  * - `type` — the serialized annotated type (what dynamic clients build
370
- * tables/forms from); nested object props prune by dot-path. A relation
371
- * prop visible through the projection survives whole; a `with`-granted
372
- * one is pruned by its sub-scope (recursively), matching `hasField`.
373
- * - `relations` — entries neither projected nor `with`-granted.
1033
+ * tables/forms from); nested object props prune by dot-path. A visible
1034
+ * relation's nav prop is pruned by that relation's visibility (declared
1035
+ * sub-scope or the caller's own grant on the target), recursively. A
1036
+ * `ref` whose target field is hidden (own table) or whose target table the
1037
+ * caller cannot read (`refTargets`) is stripped.
1038
+ * - `relations` — hidden relations dropped.
374
1039
  * - `versionColumn` — dropped when the OCC column itself is hidden.
375
1040
  *
376
1041
  * NEVER mutates the input — the base controller caches the static envelope
377
1042
  * (`applyMetaOverlay` contract); every pruned branch is a fresh object.
378
1043
  */
379
- function pruneMetaByVisibility(meta, vis) {
1044
+ function pruneMetaByVisibility(meta, vis, refTargets) {
380
1045
  const fields = {};
381
1046
  for (const [path, fieldMeta] of Object.entries(meta.fields)) if (isMetaFieldVisible(path, vis)) fields[path] = fieldMeta;
382
- else if (isPathWritable(path, vis.writable)) fields[path] = {
1047
+ else if (isPathWritable(path, vis)) fields[path] = {
383
1048
  ...fieldMeta,
384
1049
  writeOnly: true,
385
1050
  filterable: false,
@@ -391,44 +1056,48 @@ function pruneMetaByVisibility(meta, vis) {
391
1056
  ...meta,
392
1057
  fields,
393
1058
  relations,
394
- type: pruneSerializedType(meta.type, "", vis, relationNames)
1059
+ type: pruneSerializedType(meta.type, {
1060
+ basePath: "",
1061
+ vis,
1062
+ relationNames,
1063
+ refTargets
1064
+ })
395
1065
  };
396
1066
  if (out.versionColumn !== void 0 && !isMetaFieldVisible(out.versionColumn, vis)) delete out.versionColumn;
397
1067
  return out;
398
1068
  }
399
- /**
400
- * Copy-on-prune walk over a serialized type node. `basePath` is the flattened
401
- * dot-path prefix ("" at the root). Relation props (nav props named in
402
- * `meta.relations` at the root, or carrying a `db.rel.*` annotation inside a
403
- * joined type) are dropped when invisible; a `with`-granted one is pruned by
404
- * its sub-scope visibility (the same one `hasField` checks `rel.x` paths
405
- * against), any other visible one survives whole. Own-field subtrees prune
406
- * recursively so an include-mode union like `{ "password.hash": 1 }` keeps
407
- * `password` with only `hash` inside.
408
- */
409
- function pruneSerializedType(node, basePath, vis, relationNames) {
1069
+ function pruneSerializedType(node, level) {
1070
+ const { basePath, vis, relationNames, refTargets } = level;
410
1071
  const def = node.type;
411
1072
  if (def.kind === "object") {
412
1073
  const props = {};
413
1074
  for (const [name, prop] of Object.entries(def.props)) {
414
1075
  const path = basePath ? `${basePath}.${name}` : name;
415
1076
  if (basePath === "" && (relationNames.has(name) || isNavProp(prop))) {
416
- if (!isMetaFieldVisible(name, vis)) continue;
417
- const sub = vis.withGrants.has(name) ? vis.relation?.(name) : void 0;
418
- props[name] = sub ? pruneSerializedType(prop, "", sub, NO_NAMES) : prop;
1077
+ if (!isMetaFieldVisible(name, vis)) {
1078
+ if (vis.nestedWrites?.has(name) && isPathWritable(name, vis)) props[name] = stampWriteOnly(prop);
1079
+ continue;
1080
+ }
1081
+ const sub = vis.relation?.(name);
1082
+ props[name] = sub ? pruneSerializedType(prop, {
1083
+ basePath: "",
1084
+ vis: sub,
1085
+ relationNames: sub.relationNames ?? NO_NAMES,
1086
+ refTargets
1087
+ }) : prop;
419
1088
  continue;
420
1089
  }
1090
+ const child = {
1091
+ ...level,
1092
+ basePath: path
1093
+ };
421
1094
  if (!isMetaFieldVisible(path, vis)) {
422
- if (isPathWritable(path, vis.writable)) props[name] = {
423
- ...prop,
424
- metadata: {
425
- ...prop.metadata,
426
- "db.writeOnly": true
427
- }
428
- };
1095
+ if (isPathWritable(path, vis)) props[name] = stampWriteOnly(prop);
1096
+ else if (hasWritableDescendant(path, vis.writable)) props[name] = stampWriteOnly(pruneSerializedType(prop, child));
429
1097
  continue;
430
1098
  }
431
- props[name] = pruneSerializedType(prop, path, vis, relationNames);
1099
+ const pruned = pruneSerializedType(prop, child);
1100
+ props[name] = pruned.ref && !isRefVisible(path, vis, refTargets) ? withoutRef(pruned) : pruned;
432
1101
  }
433
1102
  return {
434
1103
  ...node,
@@ -442,304 +1111,580 @@ function pruneSerializedType(node, basePath, vis, relationNames) {
442
1111
  ...node,
443
1112
  type: {
444
1113
  ...def,
445
- of: pruneSerializedType(def.of, basePath, vis, relationNames)
1114
+ of: pruneSerializedType(def.of, level)
446
1115
  }
447
1116
  };
448
1117
  if (def.kind === "union" || def.kind === "intersection" || def.kind === "tuple") return {
449
1118
  ...node,
450
1119
  type: {
451
1120
  ...def,
452
- items: def.items.map((item) => pruneSerializedType(item, basePath, vis, relationNames))
1121
+ items: def.items.map((item) => pruneSerializedType(item, level))
453
1122
  }
454
1123
  };
455
1124
  return node;
456
1125
  }
1126
+ function stampWriteOnly(node) {
1127
+ return {
1128
+ ...node,
1129
+ metadata: {
1130
+ ...node.metadata,
1131
+ "db.writeOnly": true
1132
+ }
1133
+ };
1134
+ }
1135
+ /** A strict descendant of `path` is in the write whitelist. */
1136
+ function hasWritableDescendant(path, writable) {
1137
+ if (!writable || writable === "all") return false;
1138
+ const prefix = `${path}.`;
1139
+ for (const f of writable) if (f.startsWith(prefix)) return true;
1140
+ return false;
1141
+ }
1142
+ function withoutRef(node) {
1143
+ const { ref: _ref, ...rest } = node;
1144
+ return rest;
1145
+ }
1146
+ /** The field a runtime prop's `ref` finally points at (the same terminal `/meta` serializes). */
1147
+ function refTargetOf(entry) {
1148
+ const def = entry;
1149
+ if (!def?.ref) return void 0;
1150
+ const terminal = resolveTerminalRef(def);
1151
+ if (terminal) return terminal;
1152
+ try {
1153
+ return {
1154
+ type: def.ref.type(),
1155
+ field: def.ref.field
1156
+ };
1157
+ } catch {
1158
+ return;
1159
+ }
1160
+ }
1161
+ /**
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.
1167
+ */
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);
1176
+ }
457
1177
  const NO_NAMES = /* @__PURE__ */ new Set();
458
1178
  /** A nav prop inside a joined type: atscript-db stamps `db.rel.to` / `from` / `via`. */
459
1179
  function isNavProp(prop) {
460
1180
  const meta = prop.metadata;
461
1181
  return !!meta && ("db.rel.to" in meta || "db.rel.from" in meta || "db.rel.via" in meta);
462
1182
  }
463
- const identifierSetCache = /* @__PURE__ */ new WeakMap();
464
- /** PK + `preferredId` of a table — the identifiers reads always return. */
465
- function identifierSet(table) {
466
- if (!table) return NO_NAMES;
467
- let set = identifierSetCache.get(table);
468
- if (!set) {
469
- set = /* @__PURE__ */ new Set([...table.primaryKeys, ...table.preferredId]);
470
- identifierSetCache.set(table, set);
1183
+ /**
1184
+ * The write CRUD ops. Everything else in `meta.crud` is row-returning read
1185
+ * surface whose scopes govern field VISIBILITY in `/meta` — classified by
1186
+ * complement so a read op added upstream defaults to READ and the pruning
1187
+ * fails closed (a principal whose only read grant is the new op still gets
1188
+ * a pruned envelope rather than the full field map).
1189
+ */
1190
+ const WRITE_CRUD_OPS = /* @__PURE__ */ new Set([
1191
+ "insert",
1192
+ "update",
1193
+ "replace",
1194
+ "remove"
1195
+ ]);
1196
+ /**
1197
+ * The handler methods serving a `/meta` crud op on this controller (a DB or
1198
+ * value-help controller — moost-db's handler maps); an op is allowed when
1199
+ * ANY of them is. An op no map lists is its own method name.
1200
+ */
1201
+ function crudHandlers(op, methods) {
1202
+ const found = [...DB_CRUD_HANDLERS[op] ?? [], ...VALUE_HELP_CRUD_HANDLERS[op] ?? []].filter((m) => methods.has(m));
1203
+ return found.length > 0 ? found : [op];
1204
+ }
1205
+ /**
1206
+ * The full ARBAC `/meta` overlay, shared by the ARBAC DB and value-help
1207
+ * controllers.
1208
+ *
1209
+ * 1. `actions` and `crud` are filtered by evaluating each one exactly as its
1210
+ * handler is authorized (`useArbac` precedence): a crud op through its
1211
+ * handler method(s) — `one` via `getOne` / `getOneComposite`, `remove`
1212
+ * via `remove` / `removeComposite` (allowed when any is) — and an action
1213
+ * through its method (`@ArbacAction` → `@DbAction` name → …).
1214
+ * 2. The FIELD surface (`fields`, serialized `type`, `relations`,
1215
+ * `versionColumn`) is pruned by the read scopes' visibility: the
1216
+ * projection union, derived-column sources, atomic JSON columns, and the
1217
+ * `$with` relation policy (declared `with` sub-scopes, else the caller's
1218
+ * own grant on the related table — no grant → relation and nav type
1219
+ * dropped). `ref`s to hidden targets are stripped. An allowed read op
1220
+ * without scopes is an unscoped grant (no field restriction; relations
1221
+ * still follow the relation policy). No read op allowed → no pruning:
1222
+ * `crud` already advertises no read surface, and write-only principals
1223
+ * still need `type` for their insert/update forms. PK + `preferredId` are
1224
+ * 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).
1229
+ *
1230
+ * @param source - the controller's `this.readable` (identifiers, relations
1231
+ * and schema rules are derived from it); an identifier set (the 0.1.67
1232
+ * 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
1235
+ */
1236
+ async function applyArbacMetaOverlay(meta, source, indexes) {
1237
+ const instance = useControllerContext().getController();
1238
+ const { methods } = controllerMethodIndex(instance);
1239
+ 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)))))]);
1241
+ const allowed = new Set(allowedActions);
1242
+ const actions = meta.actions.filter((entry) => allowed.has(entry.name));
1243
+ const crud = {};
1244
+ const readScopes = [];
1245
+ const writeScopes = [];
1246
+ for (let i = 0; i < crudKeys.length; i++) {
1247
+ const scopes = crudScopes[i];
1248
+ if (!scopes) continue;
1249
+ crud[crudKeys[i]] = meta.crud[crudKeys[i]];
1250
+ (WRITE_CRUD_OPS.has(crudKeys[i]) ? writeScopes : readScopes).push(...scopes);
1251
+ }
1252
+ const overlaid = {
1253
+ ...meta,
1254
+ actions,
1255
+ crud
1256
+ };
1257
+ if (readScopes.length === 0) return overlaid;
1258
+ const writable = collectWritableFields(writeScopes, false);
1259
+ const nestedWrites = effectiveScope(writeScopes).nestedWrites;
1260
+ if (isIdentifierSet(source)) {
1261
+ const vis = buildScopeVisibility(readScopes, void 0, source);
1262
+ return Object.keys(vis.allowed).length > 0 || vis.withGrants.size > 0 ? pruneMetaByVisibility(overlaid, {
1263
+ ...vis,
1264
+ writable,
1265
+ nestedWrites
1266
+ }) : overlaid;
471
1267
  }
472
- return set;
1268
+ const vis = buildScopeVisibility(readScopes, source, { relations: await resolveRelationTree(readScopes, source, metaRelationNames(meta), { listSiblings: false }) });
1269
+ const refTargets = await resolveRefTargets(vis, meta);
1270
+ return pruneSearchSurface(pruneMetaByVisibility(overlaid, {
1271
+ ...vis,
1272
+ writable,
1273
+ nestedWrites
1274
+ }, refTargets), meta, vis, indexes ?? controllerIndexes(instance));
473
1275
  }
474
- /** Pick `scopes[i].with?.[name]`, dropping undefined (silence wins when empty). */
475
- function collectSubScopes(scopes, name) {
476
- const out = [];
477
- for (const s of scopes) {
478
- const sub = s.with?.[name];
479
- if (sub) out.push(sub);
1276
+ const metaNamesCache = /* @__PURE__ */ new WeakMap();
1277
+ /** The relation names `/meta`'s serialized type reaches: root relations, then nav props of each nav body. */
1278
+ function metaRelationNames(meta) {
1279
+ return getOrCreate(metaNamesCache, meta, () => buildMetaRelationNames(meta));
1280
+ }
1281
+ function buildMetaRelationNames(meta) {
1282
+ const tree = /* @__PURE__ */ new Map();
1283
+ const rootProps = objectProps(meta.type);
1284
+ for (const r of meta.relations) {
1285
+ const nested = /* @__PURE__ */ new Map();
1286
+ tree.set(r.name, nested);
1287
+ const prop = rootProps?.[r.name];
1288
+ if (prop) collectNavNames(prop, nested, 0);
1289
+ }
1290
+ return tree;
1291
+ }
1292
+ /** Nested-relation walk bound — serialized nav bodies are finite, this only guards pathological input. */
1293
+ const MAX_NAV_DEPTH = 8;
1294
+ function collectNavNames(node, into, depth) {
1295
+ if (depth >= MAX_NAV_DEPTH) return;
1296
+ const props = objectProps(node);
1297
+ if (!props) return;
1298
+ for (const [name, prop] of Object.entries(props)) {
1299
+ if (!isNavProp(prop)) continue;
1300
+ collectNavNames(prop, getOrCreate(into, name, () => /* @__PURE__ */ new Map()), depth + 1);
480
1301
  }
1302
+ }
1303
+ /** The props of an object node (through arrays). */
1304
+ function objectProps(node) {
1305
+ const def = node.type;
1306
+ if (def.kind === "object") return def.props;
1307
+ if (def.kind === "array") return objectProps(def.of);
1308
+ }
1309
+ /**
1310
+ * Foreign `ref` targets of every level the pruned `/meta` shows (the root
1311
+ * table and each visible relation's table): the caller's visibility on each
1312
+ * target table, or `null` when they cannot read it.
1313
+ */
1314
+ async function resolveRefTargets(root, meta) {
1315
+ const types = /* @__PURE__ */ new Set();
1316
+ const visit = (vis, node, depth) => {
1317
+ if (vis.table) for (const type of foreignRefTypes(vis.table)) types.add(type);
1318
+ const props = objectProps(node);
1319
+ if (!props || depth >= MAX_NAV_DEPTH) return;
1320
+ for (const name of /* @__PURE__ */ new Set([...vis.relationNames ?? NO_NAMES, ...vis.withGrants])) {
1321
+ const prop = props[name];
1322
+ if (!prop || !isMetaFieldVisible(name, vis)) continue;
1323
+ const sub = vis.relation?.(name);
1324
+ if (sub) visit(sub, prop, depth + 1);
1325
+ }
1326
+ };
1327
+ visit(root, meta.type, 0);
1328
+ const out = /* @__PURE__ */ new Map();
1329
+ await Promise.all([...types].map(async (type) => {
1330
+ const read = await evaluateTypeRead(type);
1331
+ out.set(type, read ? buildScopeVisibility(read.scopes, read.table) : null);
1332
+ }));
481
1333
  return out;
482
1334
  }
483
- /** `path` or one of its ancestors is in `set`. */
484
- function hasSelfOrAncestor(set, path) {
485
- return set.has(path) || findAncestorInSet(path, set) !== void 0;
1335
+ const refTypesCache = /* @__PURE__ */ new WeakMap();
1336
+ function foreignRefTypes(table) {
1337
+ const flatMap = table.flatMap;
1338
+ if (!flatMap) return NO_TYPES;
1339
+ return getOrCreate(refTypesCache, table, () => {
1340
+ const nav = navFieldsOf(table);
1341
+ const out = /* @__PURE__ */ new Set();
1342
+ for (const [path, entry] of flatMap) {
1343
+ if (hasSelfOrAncestor(nav, path)) continue;
1344
+ const target = refTargetOf(entry);
1345
+ if (target && target.type !== table.type) out.add(target.type);
1346
+ }
1347
+ return out;
1348
+ });
486
1349
  }
1350
+ 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"];
487
1354
  /**
488
- * Precompiled {@link isFieldAllowed} for one projection: `hasField` asks it
489
- * for every referenced path, so the mode and key sets are computed once.
490
- * Inclusion: the path, an ancestor, or a descendant is listed. Exclusion: no
491
- * listed key is the path or an ancestor.
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).
492
1360
  */
493
- function compileProjection(projection) {
494
- const keys = Object.keys(projection);
495
- if (keys.length === 0) return () => true;
496
- const listed = new Set(keys);
497
- if (getProjectionMode(projection) === "exclude") return (path) => !hasSelfOrAncestor(listed, path);
498
- const parents = /* @__PURE__ */ new Set();
499
- for (const key of keys) {
500
- let pos = key.length;
501
- while ((pos = key.lastIndexOf(".", pos - 1)) !== -1) parents.add(key.slice(0, pos));
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;
502
1390
  }
503
- return (path) => parents.has(path) || hasSelfOrAncestor(listed, path);
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;
504
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
+ const methodIndexCache = /* @__PURE__ */ new WeakMap();
505
1405
  /**
506
- * Build the {@link MetaVisibility} a set of read scopes implies — the single
507
- * structure behind `hasField`, `/meta` pruning, `$select` value stripping
508
- * and the `$with` overlay, so they cannot drift. Own fields: the projection
509
- * union (`{}` when unrestricted), precompiled into `isAllowed`. A
510
- * `with`-granted relation: a lazily built (memoized) child visibility over
511
- * the union of its `with.<name>` sub-scopes — the same sub-scopes joined rows
512
- * are stripped with — whose `alwaysVisible` is the related table's own PK /
513
- * `preferredId` (reached through `table.relatedTable`; without it, no
514
- * related identifier is exempt).
1406
+ * THE ARBAC ids an action is authorized as — used by the `/meta` overlay,
1407
+ * `authorizeForm`, `GET meta/actions` and `actionRowScope` alike: every
1408
+ * method declaring the `@DbAction` (allowed when ANY is — a union), each
1409
+ * with `useArbac()` precedence; a class-level `@DbActions` entry (no handler
1410
+ * method) → `{ resource: defaultResource, action: name }`. `defaultResource`
1411
+ * defaults to the controller's class-level resource.
515
1412
  *
516
- * @param alwaysVisible - identifiers exempt from the projection; defaults to
517
- * the table's PK + `preferredId`
1413
+ * @since 0.1.72
518
1414
  */
519
- function buildScopeVisibility(scopes, table, alwaysVisible = identifierSet(table)) {
520
- const withGrants = collectWithGrantNames(scopes);
521
- const allowed = unionScopeProjection(scopes) ?? {};
522
- const children = /* @__PURE__ */ new Map();
523
- return {
524
- allowed,
525
- alwaysVisible,
526
- withGrants,
527
- scopes,
528
- table,
529
- isAllowed: compileProjection(allowed),
530
- relation(name) {
531
- if (!withGrants.has(name)) return void 0;
532
- let child = children.get(name);
533
- if (!child) {
534
- child = buildScopeVisibility(collectSubScopes(scopes, name), table?.relatedTable?.(name));
535
- children.set(name, child);
536
- }
537
- return child;
538
- }
539
- };
1415
+ function actionArbacIds(instance, name, defaultResource = controllerArbacResource(instance)) {
1416
+ const methods = controllerMethodIndex(instance).actionMethods.get(name);
1417
+ return methods?.length ? methods.map((m) => resolveHandlerArbacIds(instance, m)) : [{
1418
+ resource: defaultResource,
1419
+ action: name
1420
+ }];
540
1421
  }
541
- const scopeVisibilityCache = /* @__PURE__ */ new WeakMap();
542
- const NO_TABLE = {
543
- primaryKeys: [],
544
- preferredId: []
545
- };
546
- /** `source` is an identifier set (the 0.1.67 signatures), not a readable. */
547
- function isIdentifierSet(source) {
548
- return !("primaryKeys" in source);
1422
+ /**
1423
+ * The action `names` the caller holds a grant on — each evaluated as
1424
+ * {@link actionArbacIds} names it (memoized per event). THE action rule of
1425
+ * the `/meta` overlay, `allowedActions` (`$actions`, `GET meta/actions`).
1426
+ */
1427
+ async function grantedActions(instance, names) {
1428
+ const granted = await Promise.all(names.map((name) => evaluateHandlers(actionArbacIds(instance, name))));
1429
+ return names.filter((_, i) => granted[i] !== void 0);
1430
+ }
1431
+ /** The controller's class-level ARBAC resource (`@ArbacResource` → controller id → class name). */
1432
+ function controllerArbacResource(instance) {
1433
+ return arbacIdsFromMeta(getMoostMate().read(instance), void 0, instance, "").resource;
1434
+ }
1435
+ /** A controller class's methods and `@DbAction` name → methods map (memoized per class). */
1436
+ function controllerMethodIndex(instance) {
1437
+ return getOrCreate(methodIndexCache, getConstructor(instance), () => {
1438
+ const cc = useControllerContext();
1439
+ const methods = new Set(collectMethodNames(instance));
1440
+ const actionMethods = /* @__PURE__ */ new Map();
1441
+ for (const methodName of methods) {
1442
+ const name = cc.getMethodMeta(methodName)?.atscript_db_action?.name;
1443
+ if (name) getOrCreate(actionMethods, name, () => []).push(methodName);
1444
+ }
1445
+ return {
1446
+ actionMethods,
1447
+ methods
1448
+ };
1449
+ });
549
1450
  }
550
1451
  /**
551
- * The event-cached {@link MetaVisibility} for `scopes` over `source` (a
552
- * readable, or an `alwaysVisible` identifier set).
1452
+ * Test-friendly internal helper — exported for unit tests and helper
1453
+ * composition; regular consumers should not call this directly.
1454
+ *
1455
+ * Method names of an instance, walking the full prototype chain via property
1456
+ * DESCRIPTORS. Deliberately NOT moost's `getInstanceOwnMethods`: that helper
1457
+ * evaluates `instance[name]` for every property to test "is it a function",
1458
+ * which fires accessors — and moost-db's inherited `.table` getter THROWS for
1459
+ * view-bound controllers, turning every `/meta` request into a 500. Accessor
1460
+ * properties are skipped entirely (a getter-valued property is not a method
1461
+ * and can never carry `@DbAction` metadata).
553
1462
  */
554
- function scopeVisibility(scopes, source = NO_TABLE) {
555
- let perSource = scopeVisibilityCache.get(scopes);
556
- if (!perSource) {
557
- perSource = /* @__PURE__ */ new WeakMap();
558
- scopeVisibilityCache.set(scopes, perSource);
559
- }
560
- let vis = perSource.get(source);
561
- if (!vis) {
562
- vis = isIdentifierSet(source) ? buildScopeVisibility(scopes, void 0, source) : buildScopeVisibility(scopes, source === NO_TABLE ? void 0 : source);
563
- perSource.set(source, vis);
1463
+ function collectMethodNames(instance) {
1464
+ const names = /* @__PURE__ */ new Set();
1465
+ let obj = instance;
1466
+ while (obj && obj !== Object.prototype) {
1467
+ for (const name of Object.getOwnPropertyNames(obj)) {
1468
+ if (name === "constructor") continue;
1469
+ const desc = Object.getOwnPropertyDescriptor(obj, name);
1470
+ if (desc && typeof desc.value === "function") names.add(name);
1471
+ }
1472
+ obj = Object.getPrototypeOf(obj);
564
1473
  }
565
- return vis;
1474
+ return [...names];
1475
+ }
1476
+ //#endregion
1477
+ //#region src/db/request-scopes.ts
1478
+ /** Endpoints whose `prepareRequest` carries parsed Uniquery controls. */
1479
+ const READ_ENDPOINTS = /* @__PURE__ */ new Set([
1480
+ "query",
1481
+ "pages",
1482
+ "geo",
1483
+ "one"
1484
+ ]);
1485
+ const requestScopesKey = key("arbac.db.scopes");
1486
+ /** Store the event's resolved scopes (for the handler `ids` resolves to). */
1487
+ function storeResolved(ctx, ids, source, scopes) {
1488
+ ctx.set(requestScopesKey, {
1489
+ resource: ids.resource,
1490
+ action: ids.action,
1491
+ source,
1492
+ scopes,
1493
+ visibility: /* @__PURE__ */ new Map()
1494
+ });
1495
+ }
1496
+ function resolved() {
1497
+ const ctx = tryGetCurrent();
1498
+ return ctx?.has(requestScopesKey) ? ctx.get(requestScopesKey) : void 0;
1499
+ }
1500
+ /**
1501
+ * The scopes resolved for the current event, or `undefined` before
1502
+ * {@link resolveRequestScopes} ran.
1503
+ *
1504
+ * @since 0.1.72
1505
+ */
1506
+ function cachedRequestScopes() {
1507
+ return resolved()?.scopes;
1508
+ }
1509
+ /**
1510
+ * Resolve the current event's scopes once: the ones the authorize
1511
+ * interceptor cached, else an ARBAC evaluation of the handler's
1512
+ * resource/action. The empty-scope rule (`normalizeScopes` from
1513
+ * `@aooth/arbac`): an allowed grant without scopes is unrestricted (`[{}]`),
1514
+ * a scope predicate returning nothing contributes nothing, and an allowed
1515
+ * evaluation left with NO scope is a deny. A deny throws 403.
1516
+ *
1517
+ * @since 0.1.72
1518
+ */
1519
+ async function resolveRequestScopes() {
1520
+ const ctx = current();
1521
+ const arbac = useArbac(ctx);
1522
+ const fromInterceptor = arbac.getScopes();
1523
+ const prior = resolved();
1524
+ if (prior && prior.resource === arbac.resource && prior.action === arbac.action && prior.source === fromInterceptor) return prior.scopes;
1525
+ const scopes = normalizeScopes(fromInterceptor !== void 0 ? {
1526
+ allowed: true,
1527
+ scopes: fromInterceptor
1528
+ } : await evaluateArbacCached(arbac.resource, arbac.action));
1529
+ if (!scopes) throw insufficientPrivileges(arbac.resource, arbac.action);
1530
+ if (fromInterceptor === void 0) arbac.setScopes(scopes);
1531
+ storeResolved(ctx, arbac, fromInterceptor ?? scopes, scopes);
1532
+ return scopes;
566
1533
  }
567
1534
  /**
568
- * Shared body of both ARBAC controllers' `hasField` overrides (BUG-3 twin of
569
- * the `/meta` pruning): a field outside the read-scope projection union —
570
- * or, for a `rel.x` path under a `with`-granted relation, outside that
571
- * relation's sub-scope union — must be indistinguishable from a field that
572
- * does not exist. Returns `true` when there are no scopes (unscoped grant).
1535
+ * The resolved scopes, synchronously — for the hooks that run after the
1536
+ * entry point (`validateControls`, `transformFilter`, the write guards …).
1537
+ * Unresolved scopes are a 403, never "unrestricted".
1538
+ *
1539
+ * @since 0.1.72
1540
+ */
1541
+ function requireRequestScopes() {
1542
+ const scopes = cachedRequestScopes();
1543
+ if (scopes) return scopes;
1544
+ const { resource, action } = useArbac();
1545
+ throw insufficientPrivileges(resource, action);
1546
+ }
1547
+ /**
1548
+ * The visibility `scopes` imply over `source`: the current request's scopes
1549
+ * over a readable carry the request's `$with` resolution; any other pair is
1550
+ * built without one (undeclared relations hidden).
1551
+ */
1552
+ function visibilityFor(scopes, source) {
1553
+ const request = resolved();
1554
+ if (source && !isIdentifierSet(source) && request?.scopes === scopes) return getOrCreate(request.visibility, source, () => buildScopeVisibility(scopes, source, { relations: requestRelations() }));
1555
+ return scopeVisibility(scopes, source);
1556
+ }
1557
+ /**
1558
+ * Shared body of the ARBAC controllers' `hasField` overrides: a field
1559
+ * outside the read-scope visibility — hidden by the projection union, a
1560
+ * derived field whose source is hidden, a path inside a hidden atomic JSON
1561
+ * column, a hidden relation, or a `rel.x` path the relation's policy hides —
1562
+ * is indistinguishable from a field that does not exist. Unresolved scopes
1563
+ * hide every field.
1564
+ *
1565
+ * @since 0.1.72
1566
+ */
1567
+ function requestFieldVisible(path, source) {
1568
+ const scopes = cachedRequestScopes();
1569
+ return scopes !== void 0 && isMetaFieldVisible(path, visibilityFor(scopes, source));
1570
+ }
1571
+ /**
1572
+ * Whether `path` is visible to `scopes` over `source` (see
1573
+ * {@link requestFieldVisible}, which reads the request's scopes).
573
1574
  *
574
1575
  * @param source - the controller's `this.readable`; an identifier set (the
575
- * 0.1.67 signature) still works, without related-table identifiers
1576
+ * 0.1.67 signature) still works, without relation / schema rules
576
1577
  */
577
1578
  function isScopedFieldVisible(scopes, path, source) {
578
- if (scopes.length === 0) return true;
579
- return isMetaFieldVisible(path, scopeVisibility(scopes, source));
1579
+ if (scopes.length === 0 && isIdentifierSet(source)) return true;
1580
+ return isMetaFieldVisible(path, visibilityFor(scopes, source));
580
1581
  }
581
1582
  /**
582
- * PK + `preferredId` for a controller's table/readable — the
583
- * {@link MetaVisibility.alwaysVisible} set. Kept for compatibility: the
584
- * controllers now pass `this.readable` and it is derived there.
1583
+ * Body of the ARBAC DB / value-help controllers' `prepareRequest` — every
1584
+ * endpoint, `@DbAction` handlers included (`endpoint: "action"`, before any
1585
+ * action id / row is read): resolve the request's scopes
1586
+ * ({@link resolveRequestScopes} — 403 on denial, `arbacPublic` does not
1587
+ * bypass) and, on read endpoints, the `$with` relation policy for the
1588
+ * requested relations.
1589
+ *
1590
+ * @since 0.1.72
585
1591
  */
586
- function metaAlwaysVisibleFields(_controller, source) {
587
- return identifierSet(source);
1592
+ async function prepareArbacRequest(ctx, readable) {
1593
+ if (ctx.endpoint === "availableActions") {
1594
+ await authorizeAvailableActions();
1595
+ return;
1596
+ }
1597
+ const scopes = await resolveRequestScopes();
1598
+ if (ctx.controls && READ_ENDPOINTS.has(ctx.endpoint)) await resolveRequestRelations(scopes, ctx.controls, readable);
588
1599
  }
589
1600
  /**
590
- * The write CRUD ops. Everything else in `meta.crud` is row-returning read
591
- * surface whose scopes govern field VISIBILITY in `/meta` — classified by
592
- * complement so a read op added upstream defaults to READ and the pruning
593
- * fails closed (a principal whose only read grant is the new op still gets
594
- * a pruned envelope rather than the full field map).
1601
+ * `authorizeForm` for the ARBAC DB controllers: a form is served iff the
1602
+ * caller may run at least one of the actions that take it as input — each
1603
+ * evaluated as the resource/action its handler is authorized as.
1604
+ *
1605
+ * @since 0.1.72
595
1606
  */
596
- const WRITE_CRUD_OPS = /* @__PURE__ */ new Set([
597
- "insert",
598
- "update",
599
- "replace",
600
- "remove"
601
- ]);
1607
+ async function authorizeArbacForm(actionNames) {
1608
+ return await evaluateHandlers(actionsIds(actionNames)) !== void 0;
1609
+ }
1610
+ /** The ARBAC ids of `names` on the current controller ({@link actionArbacIds}). */
1611
+ function actionsIds(names) {
1612
+ const instance = useControllerContext().getController();
1613
+ return names.flatMap((name) => actionArbacIds(instance, name));
1614
+ }
602
1615
  /**
603
- * The full ARBAC `/meta` overlay, shared by `AsArbacDbController` and
604
- * `AsArbacDbReadableController`: filter `actions` + `crud` by per-action
605
- * evaluation, then prune the FIELD surface (`fields`, serialized `type`,
606
- * `relations`, `versionColumn`) by the read scopes' projection union.
607
- *
608
- * Field pruning (BUG-3): a scope projection must remove fields from the META
609
- * entirely — `transformProjection` already strips their VALUES from every
610
- * read, but the envelope still advertised the full field map, so a scoped UI
611
- * offered columns that could never populate, and secret-bearing column NAMES
612
- * leaked. The pruning union comes from the read-op evaluations already
613
- * computed for the `crud` overlay (same `unionProjections` the read path
614
- * applies). An allowed read op WITHOUT scopes is an unscoped grant —
615
- * universe, no pruning (unchanged behavior for unscoped roles). No read op
616
- * allowed → the projection union is empty → no pruning: `crud` already
617
- * advertises no read surface, and write-only principals still need `type`
618
- * for their insert/update forms. PK + `preferredId` are never pruned —
619
- * reads always return those fields (projection widening / id addressing).
620
- *
621
- * @param source - the controller's `this.readable` (identifiers and related
622
- * tables are derived from it); an identifier set (the 0.1.67 signature)
623
- * still works, without related-table identifiers
1616
+ * `GET /meta/actions/:id` (endpoint `"availableActions"`): served iff the
1617
+ * caller may run at least ONE row-level action of the controller (else 403).
1618
+ * The route has no grant of its own and needs no read grant. Its request
1619
+ * scopes are the granted row-level actions' scopes WITHOUT their row parts
1620
+ * (`filter` / `check`, which carry any custom-field row filter): the row
1621
+ * overlay stays unrestricted — each action's rows come from
1622
+ * `actionRowScope` ({@link arbacActionRowScope}) — while field visibility
1623
+ * is never wider than the widest granted action.
624
1624
  */
625
- async function applyArbacMetaOverlay(meta, source) {
626
- const arbac = useArbac();
627
- const actionToMethodMeta = collectActionMetaByName();
628
- const crudKeys = Object.keys(meta.crud);
629
- const [actionResults, crudResults] = await Promise.all([Promise.all(meta.actions.map((entry) => {
630
- const methodMeta = actionToMethodMeta.get(entry.name);
631
- const arbacAction = methodMeta?.arbacActionId ?? methodMeta?.id ?? entry.name;
632
- return arbac.evaluate({ action: arbacAction });
633
- })), Promise.all(crudKeys.map((key) => arbac.evaluate({ action: key })))]);
634
- const filteredActions = [];
635
- for (let i = 0; i < meta.actions.length; i++) if (actionResults[i].allowed) filteredActions.push(meta.actions[i]);
636
- const filteredCrud = {};
637
- for (let i = 0; i < crudKeys.length; i++) if (crudResults[i].allowed) filteredCrud[crudKeys[i]] = meta.crud[crudKeys[i]];
638
- let overlaid = {
639
- ...meta,
640
- actions: filteredActions,
641
- crud: filteredCrud
642
- };
643
- let readUnrestricted = false;
644
- let writeUnrestricted = false;
645
- const readScopes = [];
646
- const writeScopes = [];
647
- for (let i = 0; i < crudKeys.length; i++) {
648
- if (!crudResults[i].allowed) continue;
649
- const scopes = crudResults[i].scopes;
650
- if (WRITE_CRUD_OPS.has(crudKeys[i])) {
651
- if (!scopes || scopes.length === 0) writeUnrestricted = true;
652
- else writeScopes.push(...scopes);
653
- continue;
654
- }
655
- if (!scopes || scopes.length === 0) readUnrestricted = true;
656
- else readScopes.push(...scopes);
657
- }
658
- if (!readUnrestricted && readScopes.length > 0) {
659
- const vis = isIdentifierSet(source) ? buildScopeVisibility(readScopes, void 0, source) : buildScopeVisibility(readScopes, source);
660
- if (Object.keys(vis.allowed).length > 0 || vis.withGrants.size > 0) overlaid = pruneMetaByVisibility(overlaid, {
661
- ...vis,
662
- writable: collectWritableFields(writeScopes, writeUnrestricted)
663
- });
664
- }
665
- return overlaid;
666
- }
667
- const actionMetaByClassCache = /* @__PURE__ */ new WeakMap();
668
- function collectActionMetaByName() {
669
- const cc = useControllerContext();
670
- const instance = cc.getController();
671
- const ctor = getConstructor(instance);
672
- const cached = actionMetaByClassCache.get(ctor);
673
- if (cached) return cached;
674
- const map = /* @__PURE__ */ new Map();
675
- const ctrlMeta = cc.getControllerMeta();
676
- for (const entry of ctrlMeta?.atscript_db_actions ?? []) map.set(entry.name, {});
677
- for (const methodName of collectMethodNames(instance)) {
678
- const m = cc.getMethodMeta(methodName);
679
- if (!m) continue;
680
- const actionMeta = m.atscript_db_action;
681
- if (actionMeta?.name) map.set(actionMeta.name, {
682
- arbacActionId: m.arbacActionId,
683
- id: m.id
684
- });
685
- }
686
- actionMetaByClassCache.set(ctor, map);
687
- return map;
1625
+ async function authorizeAvailableActions() {
1626
+ const ctx = current();
1627
+ const instance = useControllerContext(ctx).getController();
1628
+ const granted = await evaluateHandlers(actionsIds(discoverRowLevelActions(getConstructor(instance), instance.app, instance.logger).map((e) => e.info.name)));
1629
+ const arbac = useArbac(ctx);
1630
+ if (!granted) throw insufficientPrivileges(arbac.resource, arbac.action);
1631
+ storeResolved(ctx, arbac, void 0, granted.map(withoutRowPolicy));
1632
+ }
1633
+ /** A scope without its row policy (`filter` / `check`). */
1634
+ function withoutRowPolicy(scope) {
1635
+ if (scope.filter === void 0 && scope.check === void 0) return scope;
1636
+ const { filter: _filter, check: _check, ...rest } = scope;
1637
+ return rest;
688
1638
  }
689
1639
  /**
690
- * Test-friendly internal helper — exported for unit tests and helper
691
- * composition; regular consumers should not call this directly.
1640
+ * Body of the ARBAC DB controllers' `allowedActions`: the action `names` the
1641
+ * caller holds a grant on — each evaluated as {@link actionArbacIds} names
1642
+ * it, exactly the `/meta` overlay's action rule (memoized per event, so the
1643
+ * evaluations `prepareRequest` / the overlay already ran are reused).
692
1644
  *
693
- * Method names of an instance, walking the full prototype chain via property
694
- * DESCRIPTORS. Deliberately NOT moost's `getInstanceOwnMethods`: that helper
695
- * evaluates `instance[name]` for every property to test "is it a function",
696
- * which fires accessors — and moost-db's inherited `.table` getter THROWS for
697
- * view-bound controllers, turning every `/meta` request into a 500. Accessor
698
- * properties are skipped entirely (a getter-valued property is not a method
699
- * and can never carry `@DbAction` metadata).
1645
+ * @since 0.1.72
700
1646
  */
701
- function collectMethodNames(instance) {
702
- const names = /* @__PURE__ */ new Set();
703
- let obj = instance;
704
- while (obj && obj !== Object.prototype) {
705
- for (const name of Object.getOwnPropertyNames(obj)) {
706
- if (name === "constructor") continue;
707
- const desc = Object.getOwnPropertyDescriptor(obj, name);
708
- if (desc && typeof desc.value === "function") names.add(name);
709
- }
710
- obj = Object.getPrototypeOf(obj);
711
- }
712
- return [...names];
1647
+ function arbacAllowedActions(names) {
1648
+ return grantedActions(useControllerContext().getController(), names);
713
1649
  }
714
- //#endregion
715
- //#region src/db/shared-read-helpers.ts
1650
+ const actionScopes = cached(() => /* @__PURE__ */ new Map());
716
1651
  /**
717
- * Single point of access to the per-event scope cache. Reads the slot
718
- * directly — `hasField` calls this per referenced path, and `useArbac()`
719
- * would resolve controller metadata on every call.
1652
+ * Body of the ARBAC DB controllers' `actionRowScope`: the rows the
1653
+ * `@DbAction` `name` may run on — the filter of the caller's grant on the
1654
+ * action ({@link actionArbacIds}: every handler method of the action, or a
1655
+ * 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
1659
+ * already applies it → `undefined`. Equal filters are returned as ONE
1660
+ * object per request.
1661
+ *
1662
+ * @since 0.1.72
720
1663
  */
721
- function readCachedScopes() {
722
- return getArbacScopes() ?? [];
1664
+ async function arbacActionRowScope(name) {
1665
+ const ctx = current();
1666
+ const ids = actionsIds([name]);
1667
+ const outcomes = await evaluateHandlerOutcomes(ids);
1668
+ const scopes = ids.length === 1 ? normalizeScopes(outcomes[0]) : unionOutcomes(outcomes);
1669
+ if (!scopes) return DENY_FILTER;
1670
+ const prior = resolved();
1671
+ if (prior && ids.length === 1 && ids[0].resource === prior.resource && ids[0].action === prior.action && outcomes[0].scopes !== void 0 && (outcomes[0].scopes === prior.source || outcomes[0].scopes === prior.scopes)) return;
1672
+ const filter = effectiveScope(scopes).filter;
1673
+ if (!filter || Object.keys(filter).length === 0) return void 0;
1674
+ return getOrCreate(ctx.get(actionScopes), stableKey(filter), () => filter);
723
1675
  }
724
1676
  /**
725
- * Full body of `transformFilter` for both ARBAC DB controllers: evaluate
726
- * ARBAC once, cache the scopes for the per-event hooks that follow
727
- * (`transformProjection`, `validateControls`), and merge the user filter
728
- * with the union of scope filters.
1677
+ * Body of the ARBAC controllers' `transformFilter` (and so of the row
1678
+ * overlay `/one`, `DELETE` and `@DbAction` ids use): the user filter
1679
+ * CONJOINED with the union of the request's scope filters.
729
1680
  *
730
- * Combining is delegated to `conjoinScopeFilters`, which owns the
731
- * `$and`-never-spread invariant (a user filter constraining the same field as
732
- * the scope would otherwise replace it and widen access) and treats an empty
733
- * side as the identity. Returns a match-nothing filter on denial so the
734
- * downstream pipeline returns an empty result set without leaking rows.
1681
+ * @since 0.1.72
735
1682
  */
736
- async function transformArbacFilter(filter) {
737
- const arbac = useArbac();
738
- const { allowed, scopes } = await arbac.evaluate();
739
- if (!allowed) return DENY_FILTER;
740
- arbac.setScopes(scopes);
741
- return conjoinScopeFilters(mergeScopeFilters((scopes ?? []).map((s) => s.filter ?? {})), filter) ?? {};
1683
+ function arbacRowFilter(filter) {
1684
+ return conjoinScopeFilters(effectiveScope(requireRequestScopes()).filter, filter) ?? {};
742
1685
  }
1686
+ //#endregion
1687
+ //#region src/db/shared-read-helpers.ts
743
1688
  /**
744
1689
  * Restrict the user-supplied `$select` to the scopes' projection union.
745
1690
  * Returns the original projection untouched when no scope declares a
@@ -760,9 +1705,11 @@ async function transformArbacFilter(filter) {
760
1705
  * (`{ a: 0 }` → `{ "a.b": 0, "a.c": 0 }`): flattening adapters invert an
761
1706
  * exclusion against their leaf columns, so a parent key alone would strip
762
1707
  * nothing (a scope `{ a: 0 }` would otherwise return all of `a`).
1708
+ *
1709
+ * @since 0.1.72
763
1710
  */
764
1711
  function applyArbacProjection(projection, scopes, readable) {
765
- return restrictSelect(projection, scopeVisibility(scopes, readable));
1712
+ return restrictSelect(projection, visibilityFor(scopes, readable));
766
1713
  }
767
1714
  /** {@link applyArbacProjection} over an already-built visibility level. */
768
1715
  function restrictSelect(projection, vis) {
@@ -773,52 +1720,54 @@ function restrictSelect(projection, vis) {
773
1720
  /**
774
1721
  * Enforce the union of per-scope `controls` gates against the parsed
775
1722
  * Uniquery controls. Throws `HttpError(403)` on the first violation.
1723
+ *
1724
+ * @since 0.1.72
776
1725
  */
777
1726
  function applyArbacControls(controls, scopes) {
778
- if (scopes.length === 0) return;
779
- enforceControlsPolicy(unionControlsPolicy(scopes), controls);
1727
+ enforceControlsPolicy(effectiveScope(scopes).controls, controls);
780
1728
  }
781
1729
  /**
782
- * Walk the user-supplied `$with` items in `controls` and inject per-relation
783
- * filter/projection/controls/nested-$with from the role scopes' `with` field.
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.
784
1737
  *
785
1738
  * Mutates `controls` in place (matches the `validateControls` contract).
786
- * Throws `HttpError(403)` if a per-relation control policy is violated
787
- * (delegates to `enforceControlsPolicy` like top-level `applyArbacControls`).
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.
788
1742
  *
789
- * Silence wins: if no role declares `with.<name>` for a relation, that entry
790
- * passes through unchanged. The outermost check skips the entire walk when no
791
- * scope declares `with` at all — this is the common case on every read.
1743
+ * @since 0.1.72
792
1744
  */
793
1745
  function applyArbacRelationScopes(controls, scopes, readable) {
794
- if (scopes.length === 0) return;
795
- if (!scopes.some((s) => s.with)) return;
796
- applyRelationLevel(controls, scopeVisibility(scopes, readable));
1746
+ const withArr = controls.$with;
1747
+ if (!Array.isArray(withArr) || withArr.length === 0) return;
1748
+ applyRelationLevel(controls, visibilityFor(scopes, readable));
797
1749
  }
798
- /**
799
- * One `$with` level of {@link applyArbacRelationScopes}: each granted
800
- * relation's child visibility already carries its sub-scopes and target table.
801
- */
1750
+ /** One `$with` level of {@link applyArbacRelationScopes}. */
802
1751
  function applyRelationLevel(controls, vis) {
803
1752
  const withArr = controls.$with;
804
1753
  if (!Array.isArray(withArr) || withArr.length === 0) return;
805
1754
  for (const raw of withArr) {
806
1755
  if (!raw || typeof raw !== "object") continue;
807
1756
  const entry = raw;
808
- if (typeof entry.name !== "string") continue;
809
- const child = vis.relation?.(entry.name);
810
- const subScopes = child?.scopes ?? [];
811
- if (!child || subScopes.length === 0) continue;
812
- const conjoined = conjoinScopeFilters(mergeScopeFilters(subScopes.map((s) => s.filter ?? {})), entry.filter);
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);
813
1762
  if (conjoined) entry.filter = conjoined;
814
1763
  const entryControls = entry.controls ?? {};
815
- const restricted = restrictSelect(entryControls.$select, child);
1764
+ const restricted = restrictSelect(entryControls.$select, level);
816
1765
  if (restricted !== void 0) {
817
1766
  entryControls.$select = restricted;
818
1767
  entry.controls = entryControls;
819
1768
  }
820
- enforceControlsPolicy(unionControlsPolicy(subScopes), entryControls);
821
- if (subScopes.some((s) => s.with)) applyRelationLevel(entryControls, child);
1769
+ enforceControlsPolicy(sub.controls, entryControls);
1770
+ applyRelationLevel(entryControls, level);
822
1771
  }
823
1772
  }
824
1773
  /**
@@ -838,13 +1787,339 @@ function normalizeSelect(value) {
838
1787
  if (typeof value === "object") return value;
839
1788
  }
840
1789
  //#endregion
1790
+ //#region src/db/write-fields.ts
1791
+ /**
1792
+ * Test-friendly internal helper — exported for unit tests and helper
1793
+ * composition; regular consumers should not call this directly.
1794
+ *
1795
+ * Apply the union of `allowedFields` whitelists (with `preserveFields`
1796
+ * always preserved) and overlay each scope's `set` overrides. Returns a
1797
+ * copy; the original `data` is not mutated.
1798
+ *
1799
+ * Path-aware: a top-level entry (`profile`) keeps the whole subtree; a
1800
+ * dotted entry (`profile.name`) keeps only that branch of a nested object
1801
+ * payload (`profile: { name, tenant }` → `profile: { name }`) — a non-object
1802
+ * value under a partially whitelisted key is dropped, never kept whole. A
1803
+ * dotted `set` key sets the nested path, merging into the payload's nested
1804
+ * object.
1805
+ *
1806
+ * Patches (`isMergePatch` given): a nested object is written as a whole
1807
+ * unless its path is a `@db.patch.strategy 'merge'` block, so a partially
1808
+ * whitelisted REPLACE block is dropped (a partial block would clear the
1809
+ * non-whitelisted leaves), and a dotted `set` under a replace block the patch
1810
+ * does not carry is skipped (adding the block would clear its other leaves).
1811
+ */
1812
+ function applyAllowedFieldsAndSet(data, scopes, preserveFields = [], isMergePatch) {
1813
+ if (scopes.length === 0) return data;
1814
+ return applyPreparedOverlay(data, prepareScopeOverlay(scopes, preserveFields, isMergePatch));
1815
+ }
1816
+ function prepareScopeOverlay(scopes, preserveFields, isMergePatch) {
1817
+ const { allowedFields, set } = effectiveScope(scopes);
1818
+ let tree = null;
1819
+ if (allowedFields) {
1820
+ tree = newNode();
1821
+ for (const f of allowedFields) addPath(tree, f);
1822
+ for (const f of preserveFields) addPath(tree, f);
1823
+ }
1824
+ const setOverrides = set ? Object.entries(set).map(([k, v]) => [k.split("."), v]) : null;
1825
+ return {
1826
+ tree,
1827
+ setOverrides,
1828
+ isMergePatch
1829
+ };
1830
+ }
1831
+ function newNode() {
1832
+ return {
1833
+ all: false,
1834
+ children: /* @__PURE__ */ new Map()
1835
+ };
1836
+ }
1837
+ function addPath(tree, path) {
1838
+ let node = tree;
1839
+ for (const seg of path.split(".")) {
1840
+ if (node.all) return;
1841
+ let next = node.children.get(seg);
1842
+ if (!next) {
1843
+ next = newNode();
1844
+ node.children.set(seg, next);
1845
+ }
1846
+ node = next;
1847
+ }
1848
+ node.all = true;
1849
+ }
1850
+ /** `true` when `path` (dotted) is whitelisted by itself or an ancestor. */
1851
+ function coversPath(tree, path) {
1852
+ let node = tree;
1853
+ for (const seg of path.split(".")) {
1854
+ node = node.children.get(seg);
1855
+ if (!node) return false;
1856
+ if (node.all) return true;
1857
+ }
1858
+ return false;
1859
+ }
1860
+ function pruneByTree(obj, tree, prefix, isMergePatch) {
1861
+ const out = {};
1862
+ for (const [k, v] of Object.entries(obj)) {
1863
+ if (k.includes(".")) {
1864
+ if (coversPath(tree, k)) out[k] = v;
1865
+ continue;
1866
+ }
1867
+ const node = tree.children.get(k);
1868
+ if (!node) continue;
1869
+ if (node.all) {
1870
+ out[k] = v;
1871
+ continue;
1872
+ }
1873
+ const path = prefix + k;
1874
+ if (!isPlainObject(v) || isMergePatch && !isMergePatch(path)) continue;
1875
+ const pruned = pruneByTree(v, node, `${path}.`, isMergePatch);
1876
+ if (Object.keys(pruned).length > 0) out[k] = pruned;
1877
+ }
1878
+ return out;
1879
+ }
1880
+ /**
1881
+ * Set `path` in `target` (copy-on-write for nested objects). On a patch, a
1882
+ * missing intermediate replace block is not created — returns without
1883
+ * setting (the stored block stays as it is).
1884
+ */
1885
+ function setPath$1(target, path, value, isMergePatch, prefix = "") {
1886
+ if (path.length === 1) {
1887
+ target[path[0]] = value;
1888
+ return;
1889
+ }
1890
+ delete target[path.join(".")];
1891
+ const [head, ...rest] = path;
1892
+ const existing = target[head];
1893
+ const objectPath = prefix + head;
1894
+ if (!isPlainObject(existing) && isMergePatch && !isMergePatch(objectPath)) return;
1895
+ const child = isPlainObject(existing) ? { ...existing } : {};
1896
+ target[head] = child;
1897
+ setPath$1(child, rest, value, isMergePatch, `${objectPath}.`);
1898
+ }
1899
+ function applyPreparedOverlay(data, prepared) {
1900
+ if (Array.isArray(data)) return data.map((row) => applyPreparedOverlay(row, prepared));
1901
+ if (!data || typeof data !== "object") return data;
1902
+ const source = data;
1903
+ const merged = prepared.tree ? pruneByTree(source, prepared.tree, "", prepared.isMergePatch) : { ...source };
1904
+ if (prepared.setOverrides) for (const [path, value] of prepared.setOverrides) setPath$1(merged, path, value, prepared.isMergePatch);
1905
+ return merged;
1906
+ }
1907
+ //#endregion
1908
+ //#region src/db/write-check.ts
1909
+ /** Thrown by {@link compileScopeCheck} for an operator the evaluator does not model. */
1910
+ var UnsupportedCheckError = class extends Error {};
1911
+ /**
1912
+ * Compile a scope `check` filter into an in-memory predicate — used only when
1913
+ * the adapter cannot roll back (`!transactional`) and the check must run
1914
+ * BEFORE the write. Deliberately a small subset of the filter language
1915
+ * (`$eq`/`$ne`/`$in`/`$nin`/ordering/`$exists` + `$and`/`$or`/`$not`) with the
1916
+ * 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.
1919
+ */
1920
+ function compileScopeCheck(filter) {
1921
+ return walkFilter(filter, checkVisitor) ?? (() => true);
1922
+ }
1923
+ /** The field paths a filter references (dotted, as written). */
1924
+ function checkFields(filter) {
1925
+ return new Set(computeInsights(filter).keys());
1926
+ }
1927
+ function same(a, b) {
1928
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
1929
+ return a === b;
1930
+ }
1931
+ function eq(v, value) {
1932
+ return value === null ? v == null : same(v, value);
1933
+ }
1934
+ function ordinal(v) {
1935
+ return v instanceof Date ? v.getTime() : v;
1936
+ }
1937
+ const checkVisitor = {
1938
+ and: (children) => (row) => children.every((c) => c(row)),
1939
+ or: (children) => (row) => children.some((c) => c(row)),
1940
+ not: (child) => (row) => !child(row),
1941
+ comparison(field, op, value) {
1942
+ const read = (row) => getPath(row, field);
1943
+ const compare = (test) => (row) => {
1944
+ const v = read(row);
1945
+ return v != null && test(ordinal(v), ordinal(value));
1946
+ };
1947
+ switch (op) {
1948
+ case "$eq": return (row) => eq(read(row), value);
1949
+ case "$ne": return (row) => !eq(read(row), value);
1950
+ case "$in": return (row) => Array.isArray(value) && value.some((x) => same(read(row), x));
1951
+ case "$nin": return (row) => !(Array.isArray(value) && value.some((x) => same(read(row), x)));
1952
+ case "$exists": return (row) => read(row) != null === Boolean(value);
1953
+ case "$gt": return compare((a, b) => a > b);
1954
+ case "$gte": return compare((a, b) => a >= b);
1955
+ case "$lt": return compare((a, b) => a < b);
1956
+ case "$lte": return compare((a, b) => a <= b);
1957
+ default: throw new UnsupportedCheckError(`${field}: ${op}`);
1958
+ }
1959
+ }
1960
+ };
1961
+ /**
1962
+ * The post-image of an update patch for the fields a check references, or
1963
+ * `undefined` when it cannot be known without the database (fail closed):
1964
+ * every referenced path must be untouched by the patch or set by a plain
1965
+ * scalar at exactly that path — a nested object / array / operator value
1966
+ * (`$inc`, `$insert`, …) over or under a referenced path is not modelled.
1967
+ */
1968
+ function patchPostImage(current, patch, fields) {
1969
+ const out = structuredCloneRow(current);
1970
+ for (const field of fields) for (const [key, value] of Object.entries(patch)) if (key === field) {
1971
+ if (!isComparable(value)) return void 0;
1972
+ setPath(out, field, value);
1973
+ } else if (field.startsWith(`${key}.`) || key.startsWith(`${field}.`)) return;
1974
+ return out;
1975
+ }
1976
+ /**
1977
+ * A value the in-memory evaluator compares faithfully: a primitive, `null`
1978
+ * or a `Date` — other class instances (e.g. a Mongo `ObjectId`) compare by
1979
+ * identity here, so a SET of one over a checked field is not modelled.
1980
+ */
1981
+ function isComparable(v) {
1982
+ return v === null || v instanceof Date || typeof v !== "object" && typeof v !== "function";
1983
+ }
1984
+ function structuredCloneRow(row) {
1985
+ const out = {};
1986
+ for (const [k, v] of Object.entries(row)) out[k] = isPlainObject(v) ? structuredCloneRow(v) : v;
1987
+ return out;
1988
+ }
1989
+ function setPath(target, path, value) {
1990
+ const segs = path.split(".");
1991
+ let node = target;
1992
+ for (let i = 0; i < segs.length - 1; i++) {
1993
+ const next = node[segs[i]];
1994
+ if (!next || typeof next !== "object" || Array.isArray(next)) node[segs[i]] = {};
1995
+ node = node[segs[i]];
1996
+ }
1997
+ node[segs.at(-1)] = value;
1998
+ }
1999
+ //#endregion
2000
+ //#region src/db/write-policy.ts
2001
+ const OUT_OF_SCOPE = "Row outside your write scope";
2002
+ /**
2003
+ * Nested writes through nav props are denied unless a write scope opts the
2004
+ * relation in via `nestedWrites` (an unrestricted scope does not). A dotted
2005
+ * key whose head is a nav prop counts as that relation.
2006
+ */
2007
+ function assertNestedWritesAllowed(data, scopes, navFields) {
2008
+ if (navFields.size === 0) return;
2009
+ const rows = Array.isArray(data) ? data : [data];
2010
+ for (const row of rows) {
2011
+ if (!row || typeof row !== "object") continue;
2012
+ for (const key of Object.keys(row)) {
2013
+ const dot = key.indexOf(".");
2014
+ const rel = dot === -1 ? key : key.slice(0, dot);
2015
+ if (navFields.has(rel) && !effectiveScope(scopes).nestedWrites.has(rel)) throw new HttpError(403, `Nested writes through "${rel}" are not allowed`);
2016
+ }
2017
+ }
2018
+ }
2019
+ /**
2020
+ * Every exact id filter (one row each, duplicates counted once) must match
2021
+ * a row inside `filter` — ONE count; else the same 404 a missing row gets
2022
+ * (no existence leak).
2023
+ */
2024
+ async function assertIdFiltersInScope(table, idFilters, filter) {
2025
+ const unique = /* @__PURE__ */ new Map();
2026
+ for (const f of idFilters) unique.set(stableKey(f), f);
2027
+ if (unique.size === 0) return;
2028
+ const ids = [...unique.values()];
2029
+ const idFilter = ids.length === 1 ? ids[0] : { $or: ids };
2030
+ if (await table.count({
2031
+ filter: filter ? { $and: [idFilter, filter] } : idFilter,
2032
+ controls: {}
2033
+ }) < ids.length) throw new HttpError(404, "Not found");
2034
+ }
2035
+ /**
2036
+ * WITH CHECK before the write (adapters without real transactions): insert /
2037
+ * replace rows are full rows, evaluated as-is; an update patch is evaluated
2038
+ * over its pre-image when every check-referenced field is untouched or a
2039
+ * plain scalar SET. Anything the in-memory evaluator cannot decide (an
2040
+ * unsupported operator, an operator / nested patch over a checked field, a
2041
+ * missing pre-image) is rejected — fail closed.
2042
+ */
2043
+ async function assertCheckBeforeWrite(ctx, check) {
2044
+ let predicate;
2045
+ try {
2046
+ predicate = compileScopeCheck(check);
2047
+ } catch (error) {
2048
+ if (error instanceof UnsupportedCheckError) throw new HttpError(403, OUT_OF_SCOPE);
2049
+ throw error;
2050
+ }
2051
+ const fields = isPatchAction(ctx.action) ? checkFields(check) : void 0;
2052
+ const images = fields ? (await ctx.currentAll()).map((current, i) => current ? patchPostImage(current, ctx.rows[i], fields) : void 0) : ctx.rows;
2053
+ for (const image of images) if (!image || !predicate(image)) throw new HttpError(403, OUT_OF_SCOPE);
2054
+ }
2055
+ /** WITH CHECK after the write (inside the transaction — a throw rolls back). */
2056
+ async function assertWrittenRowsInCheck(ctx, check) {
2057
+ if (ctx.filters.length === 0) return;
2058
+ const written = ctx.filters.length === 1 ? ctx.filters[0] : { $or: [...ctx.filters] };
2059
+ if (await ctx.count({ $and: [written, check] }) !== ctx.filters.length) throw new HttpError(403, OUT_OF_SCOPE);
2060
+ }
2061
+ /**
2062
+ * The in-transaction write guard of the ARBAC DB layer — the body of
2063
+ * `AsArbacDbController.guardWrite` and of `useArbacDbScope().writeOptions()`'s
2064
+ * `guard`:
2065
+ *
2066
+ * - USING — every update / replace target (the exact record filter the
2067
+ * write uses) must match the scope filter — one count, no pre-image read
2068
+ * (missing or out of scope → 404);
2069
+ * - `checkRefs` — every enforced FK the write sets must reference rows the
2070
+ * caller can read on the target table (→ 403);
2071
+ * - 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) {
2075
+ const eff = effectiveScope(scopes);
2076
+ if (eff.filter && hasPreImage(ctx.action)) await assertIdFiltersInScope(table, ctx.rows.map((_row, i) => {
2077
+ const filter = ctx.filterFor(i);
2078
+ if (!filter) throw new HttpError(404, "Not found");
2079
+ return filter;
2080
+ }), eff.filter);
2081
+ await assertRefsInScope(ctx, scopes, refs);
2082
+ if (eff.check && !table.getAdapter().isInTransaction()) await assertCheckBeforeWrite(ctx, eff.check);
2083
+ }
2084
+ /**
2085
+ * WITH CHECK after the write, inside its transaction: every written row must
2086
+ * match the scopes' `check` (default `filter`) — else 403 and the write rolls
2087
+ * back. Non-transactional adapters were checked in {@link guardArbacWrite}.
2088
+ */
2089
+ async function checkArbacWrite(ctx, scopes) {
2090
+ if (!ctx.transactional) return;
2091
+ const check = effectiveScope(scopes).check;
2092
+ if (check) await assertWrittenRowsInCheck(ctx, check);
2093
+ }
2094
+ /** USING for deletes: the exact row the delete targets must match the scope filter (else 404). */
2095
+ async function guardArbacRemove(ctx, scopes, table) {
2096
+ const filter = effectiveScope(scopes).filter;
2097
+ if (!filter) return;
2098
+ if (await table.count({
2099
+ filter: { $and: [ctx.filter, filter] },
2100
+ controls: {}
2101
+ }) === 0) throw new HttpError(404, "Not found");
2102
+ }
2103
+ //#endregion
841
2104
  //#region src/db/as-arbac-db-controller.ts
842
2105
  let AsArbacDbController = class AsArbacDbController extends AsDbController {
2106
+ /** Makes this controller the `$with` policy source for its table's ARBAC resource. */
2107
+ _arbacRelationTarget = registerArbacDbTarget(this);
2108
+ /**
2109
+ * Resolve the request's ARBAC scopes before anything else consults them:
2110
+ * reuse the ones the authorize interceptor cached, else evaluate the
2111
+ * handler's resource/action now — a deny is a 403 (`arbacPublic` never
2112
+ * bypasses a DB controller). On read endpoints the `$with` relation policy
2113
+ * is resolved for the requested relations.
2114
+ */
2115
+ prepareRequest(ctx) {
2116
+ return prepareArbacRequest(ctx, this.readable);
2117
+ }
843
2118
  transformFilter(filter) {
844
- return transformArbacFilter(filter);
2119
+ return arbacRowFilter(filter);
845
2120
  }
846
2121
  transformProjection(projection) {
847
- return applyArbacProjection(projection, readCachedScopes(), this.readable);
2122
+ return applyArbacProjection(projection, requireRequestScopes(), this.readable);
848
2123
  }
849
2124
  /**
850
2125
  * Translate `@uniqu/url` parser failures into HTTP 400 instead of letting them
@@ -882,18 +2157,14 @@ let AsArbacDbController = class AsArbacDbController extends AsDbController {
882
2157
  * controls of a request. Runs after the base validator (which checks the
883
2158
  * controls DTO shape) and BEFORE the query/aggregation pipeline executes.
884
2159
  *
885
- * Reads the scopes the route-level authorize interceptor cached via
886
- * `arbac.setScopes(...)` — validation runs before `transformFilter`, so
887
- * this hook never re-evaluates ARBAC.
888
- *
889
- * On a violation we throw `HttpError(403)`. moost-db's `query` / `pages` /
890
- * `getOne` handlers do NOT wrap `validateParsed` in try/catch, so the
891
- * thrown error bubbles to moost which translates it to a 403 response.
2160
+ * Reads the scopes `prepareRequest` resolved — unresolved scopes are a 403
2161
+ * (never "unrestricted"). A violation throws `HttpError(403)`, which
2162
+ * moost-db's read handlers let bubble.
892
2163
  */
893
2164
  validateControls(controls, type) {
894
2165
  const baseErr = super.validateControls(controls, type);
895
2166
  if (baseErr) return baseErr;
896
- const scopes = readCachedScopes();
2167
+ const scopes = requireRequestScopes();
897
2168
  applyArbacControls(controls, scopes);
898
2169
  applyArbacRelationScopes(controls, scopes, this.readable);
899
2170
  }
@@ -901,50 +2172,104 @@ let AsArbacDbController = class AsArbacDbController extends AsDbController {
901
2172
  return applyArbacMetaOverlay(meta, this.readable);
902
2173
  }
903
2174
  /**
904
- * Scope-aware field visibility (BUG-3 twin of the `/meta` pruning above): a
2175
+ * `GET /meta/form/:name` serves a form only when the caller may run at
2176
+ * least one of the actions taking it as input (else the unknown-form 404).
2177
+ */
2178
+ authorizeForm(_name, actionNames) {
2179
+ return authorizeArbacForm(actionNames);
2180
+ }
2181
+ /**
2182
+ * 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.
2185
+ */
2186
+ actionRowScope(name) {
2187
+ return arbacActionRowScope(name);
2188
+ }
2189
+ /**
2190
+ * The row-level actions `$actions` and `GET /meta/actions/:id` may list:
2191
+ * those the caller holds a grant on (the `/meta` overlay's rule, without
2192
+ * building the overlay). Since 0.1.72.
2193
+ */
2194
+ allowedActions(names) {
2195
+ return arbacAllowedActions(names);
2196
+ }
2197
+ /**
2198
+ * moost-db handlers that delegate their authorization to `prepareRequest`
2199
+ * (`getDbEndpoint` — `GET /meta/actions/:id`): the authorize interceptor
2200
+ * skips them; `prepareRequest` serves them iff the caller may run at least
2201
+ * one row-level action (else 403). Since 0.1.72.
2202
+ */
2203
+ [ARBAC_DELEGATED_AUTH](method) {
2204
+ return getDbEndpoint(this, method) !== void 0;
2205
+ }
2206
+ /**
2207
+ * Scope-aware field visibility (the twin of the `/meta` pruning above): a
905
2208
  * field outside the read-scope projection union answers `false`, which
906
2209
  * moost-db's visibility hook turns into the same `Unknown field "x"` 400 a
907
2210
  * nonexistent field gets at every gated query position — see the docs'
908
2211
  * "Column-scope security floor". Identifiers stay visible. A `rel.x` path
909
- * under a `with`-granted relation (`$with=rel(x>1)`, `$with=rel($sort=x)`,
910
- * `$with=rel($select=x)`) is checked as `x` against the union of the
911
- * `with.rel` sub-scopes, recursively; the related table's own PK /
912
- * `preferredId` stay visible.
2212
+ * (`$with=rel(x>1)`, `$with=rel($sort=x)`, `$with=rel($select=x)`) is
2213
+ * checked as `x` against the relation's policy, recursively; the related
2214
+ * table's own PK / `preferredId` stay visible. Unresolved scopes hide every
2215
+ * field.
913
2216
  */
914
2217
  hasField(path) {
915
- return super.hasField(path) && isScopedFieldVisible(readCachedScopes(), path, this.readable);
916
- }
917
- async onWrite(action, data) {
918
- const scopes = readCachedScopes();
919
- if (action !== "insert" && action !== "insertMany") await this.assertInScope(data, scopes);
920
- return applyAllowedFieldsAndSet(data, scopes, this.identifierFields());
921
- }
922
- async onRemove(id) {
923
- await this.assertInScope(id, readCachedScopes());
924
- return id;
925
- }
926
- async assertInScope(idOrIds, scopes) {
927
- const scopeFilter = mergeScopeFilters(scopes.map((s) => s.filter ?? {}));
928
- if (!scopeFilter) return;
929
- const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
930
- const idFilters = ids.map((id) => this.readable.resolveIdFilter(id, this._idOpts));
931
- if (idFilters.some((f) => !f)) throw new HttpError(404, "Not found");
932
- const idFilter = idFilters.length === 1 ? idFilters[0] : { $or: idFilters };
933
- if (await this.readable.count({ filter: { $and: [idFilter, scopeFilter] } }) < ids.length) throw new HttpError(404, "Not found");
934
- }
935
- identifierFields() {
2218
+ return super.hasField(path) && requestFieldVisible(path, this.readable);
2219
+ }
2220
+ /**
2221
+ * Untrusted-body stage: reject nested writes the scopes do not opt in
2222
+ * (`nestedWrites`), then apply the `allowedFields` whitelist and `set`.
2223
+ */
2224
+ onWrite(action, data) {
2225
+ const scopes = requireRequestScopes();
2226
+ assertNestedWritesAllowed(data, scopes, this.readable.navFields);
2227
+ prefetchRefTargets(scopes, this.readable);
2228
+ return applyAllowedFieldsAndSet(data, scopes, this.preservedWriteFields(), isPatchAction(action) ? (path) => this.isMergeBlock(path) : void 0);
2229
+ }
2230
+ /** `true` when a patch merges into the nested object at `path` (`@db.patch.strategy 'merge'`). */
2231
+ isMergeBlock(path) {
2232
+ return this.readable.flatMap.get(path)?.metadata.get("db.patch.strategy") === "merge";
2233
+ }
2234
+ /** In-transaction stage — USING, `checkRefs`, and WITH CHECK on non-transactional adapters. */
2235
+ guardWrite(ctx) {
2236
+ return guardArbacWrite(ctx, requireRequestScopes(), this.guardedTable, this.readable);
2237
+ }
2238
+ /** USING for deletes: the exact row the delete targets must match the scope filter (else 404). */
2239
+ guardRemove(ctx) {
2240
+ return guardArbacRemove(ctx, requireRequestScopes(), this.guardedTable);
2241
+ }
2242
+ /**
2243
+ * WITH CHECK: after the write, inside its transaction, every written row
2244
+ * must match the scopes' `check` (default `filter`) — else 403 and the
2245
+ * write rolls back. Non-transactional adapters were checked in
2246
+ * {@link guardWrite}.
2247
+ */
2248
+ checkWrite(ctx) {
2249
+ return checkArbacWrite(ctx, requireRequestScopes());
2250
+ }
2251
+ /** `this.table` as the write guards see it (the generic `T` defeats structural matching). */
2252
+ get guardedTable() {
2253
+ return this.table;
2254
+ }
2255
+ preservedWriteFields() {
936
2256
  const ctor = this.constructor;
937
- const cached = identifierFieldsCache.get(ctor);
2257
+ const cached = preservedFieldsCache.get(ctor);
938
2258
  if (cached) return cached;
939
2259
  const out = /* @__PURE__ */ new Set();
940
2260
  for (const ident of this.readable.identifications) for (const f of ident.fields) out.add(f);
2261
+ const version = this.readable.versionColumn;
2262
+ if (version) {
2263
+ out.add(version);
2264
+ out.add("$cas");
2265
+ }
941
2266
  const arr = [...out];
942
- identifierFieldsCache.set(ctor, arr);
2267
+ preservedFieldsCache.set(ctor, arr);
943
2268
  return arr;
944
2269
  }
945
2270
  };
946
2271
  AsArbacDbController = __decorate([Inherit()], AsArbacDbController);
947
- const identifierFieldsCache = /* @__PURE__ */ new WeakMap();
2272
+ const preservedFieldsCache = /* @__PURE__ */ new WeakMap();
948
2273
  /**
949
2274
  * Test-friendly internal helper — exported for unit tests and helper
950
2275
  * composition; regular consumers should not call this directly.
@@ -1011,47 +2336,6 @@ function extractUsedControlValues(key, value, controls) {
1011
2336
  return [];
1012
2337
  }
1013
2338
  /**
1014
- * Test-friendly internal helper — exported for unit tests and helper
1015
- * composition; regular consumers should not call this directly.
1016
- *
1017
- * Apply the union of `allowedFields` whitelists (with `preserveFields`
1018
- * always preserved) and overlay each scope's `set` overrides. Returns a
1019
- * shallow copy; original `data` is not mutated.
1020
- */
1021
- function applyAllowedFieldsAndSet(data, scopes, preserveFields = []) {
1022
- if (scopes.length === 0) return data;
1023
- const prepared = prepareScopeOverlay(scopes, preserveFields);
1024
- if (Array.isArray(data)) return data.map((row) => applyPreparedOverlay(row, prepared));
1025
- return applyPreparedOverlay(data, prepared);
1026
- }
1027
- function prepareScopeOverlay(scopes, preserveFields) {
1028
- let union = null;
1029
- for (const s of scopes) if (Array.isArray(s.allowedFields)) {
1030
- if (!union) union = /* @__PURE__ */ new Set();
1031
- for (const f of s.allowedFields) union.add(f);
1032
- }
1033
- if (union) for (const f of preserveFields) union.add(f);
1034
- let setOverrides = null;
1035
- for (const s of scopes) if (s.set) {
1036
- if (!setOverrides) setOverrides = {};
1037
- Object.assign(setOverrides, s.set);
1038
- }
1039
- return {
1040
- union,
1041
- setOverrides
1042
- };
1043
- }
1044
- function applyPreparedOverlay(data, prepared) {
1045
- if (Array.isArray(data)) return data.map((row) => applyPreparedOverlay(row, prepared));
1046
- if (!data || typeof data !== "object") return data;
1047
- const merged = { ...data };
1048
- if (prepared.union) {
1049
- for (const k of Object.keys(merged)) if (!prepared.union.has(k)) delete merged[k];
1050
- }
1051
- if (prepared.setOverrides) Object.assign(merged, prepared.setOverrides);
1052
- return merged;
1053
- }
1054
- /**
1055
2339
  * Translate a `@uniqu/url` parser `SyntaxError` into `HttpError(400)`. Any
1056
2340
  * other error (including a pre-existing `HttpError`) is returned as-is so the
1057
2341
  * caller's `throw remapUniquUrlSyntaxError(err)` preserves the original
@@ -1072,35 +2356,297 @@ function remapUniquUrlSyntaxError(err) {
1072
2356
  //#endregion
1073
2357
  //#region src/db/as-arbac-db-readable-controller.ts
1074
2358
  let AsArbacDbReadableController = class AsArbacDbReadableController extends AsDbReadableController {
2359
+ /** Makes this controller the `$with` policy source for its table's ARBAC resource. */
2360
+ _arbacRelationTarget = registerArbacDbTarget(this);
2361
+ /** Same contract as {@link AsArbacDbController.prepareRequest}. */
2362
+ prepareRequest(ctx) {
2363
+ return prepareArbacRequest(ctx, this.readable);
2364
+ }
2365
+ /** Same contract as {@link AsArbacDbController.transformFilter}. */
1075
2366
  transformFilter(filter) {
1076
- return transformArbacFilter(filter);
2367
+ return arbacRowFilter(filter);
1077
2368
  }
2369
+ /** Same contract as {@link AsArbacDbController.transformProjection}. */
1078
2370
  transformProjection(projection) {
1079
- return applyArbacProjection(projection, readCachedScopes(), this.readable);
2371
+ return applyArbacProjection(projection, requireRequestScopes(), this.readable);
1080
2372
  }
2373
+ /** Same contract as {@link AsArbacDbController.validateControls}. */
1081
2374
  validateControls(controls, type) {
1082
2375
  const baseErr = super.validateControls(controls, type);
1083
2376
  if (baseErr) return baseErr;
1084
- const scopes = readCachedScopes();
2377
+ const scopes = requireRequestScopes();
1085
2378
  applyArbacControls(controls, scopes);
1086
2379
  applyArbacRelationScopes(controls, scopes, this.readable);
1087
2380
  }
1088
- /**
1089
- * Same ARBAC `/meta` overlay as the writable controller (actions/crud
1090
- * filtering + BUG-3 field-surface pruning by the read scopes' projection
1091
- * union) — view-style metas leak hidden field names identically.
1092
- */
2381
+ /** Same contract as {@link AsArbacDbController.applyMetaOverlay}. */
1093
2382
  applyMetaOverlay(meta) {
1094
2383
  return applyArbacMetaOverlay(meta, this.readable);
1095
2384
  }
1096
- /**
1097
- * Scope-aware field visibility — same contract as
1098
- * {@link AsArbacDbController.hasField}.
1099
- */
2385
+ /** Same contract as {@link AsArbacDbController.authorizeForm}. */
2386
+ authorizeForm(_name, actionNames) {
2387
+ return authorizeArbacForm(actionNames);
2388
+ }
2389
+ /** Same contract as {@link AsArbacDbController.actionRowScope}. */
2390
+ actionRowScope(name) {
2391
+ return arbacActionRowScope(name);
2392
+ }
2393
+ /** Same contract as {@link AsArbacDbController.allowedActions}. */
2394
+ allowedActions(names) {
2395
+ return arbacAllowedActions(names);
2396
+ }
2397
+ /** Same contract as {@link AsArbacDbController.[ARBAC_DELEGATED_AUTH]}. */
2398
+ [ARBAC_DELEGATED_AUTH](method) {
2399
+ return getDbEndpoint(this, method) !== void 0;
2400
+ }
2401
+ /** Same contract as {@link AsArbacDbController.hasField}. */
1100
2402
  hasField(path) {
1101
- return super.hasField(path) && isScopedFieldVisible(readCachedScopes(), path, this.readable);
2403
+ return super.hasField(path) && requestFieldVisible(path, this.readable);
1102
2404
  }
1103
2405
  };
1104
2406
  AsArbacDbReadableController = __decorate([Inherit()], AsArbacDbReadableController);
1105
2407
  //#endregion
1106
- export { Arbac, ArbacAction, ArbacAuthorize, ArbacResource, ArbacUserProvider, ArbacUserProviderToken, AsArbacDbController, AsArbacDbReadableController, MoostArbac, applyAllowedFieldsAndSet, applyArbacMetaOverlay, arbacAuthorizeInterceptor, arbacPatternToRegex, buildScopeVisibility, collectMethodNames, collectWithGrantNames, collectWritableFields, conjoinArbacDbScopes, enforceControlsPolicy, extractUsedControlValues, getArbacMate, getArbacScopes, isMetaFieldVisible, isScopedFieldVisible, metaAlwaysVisibleFields, pruneMetaByVisibility, unionScopeProjection, useArbac };
2408
+ //#region src/db/as-arbac-value-help-controller.ts
2409
+ function valueHelpHost(fields, primaryKey, searchableFields) {
2410
+ const ids = primaryKey ? [primaryKey] : [];
2411
+ return {
2412
+ primaryKey,
2413
+ searchableFields,
2414
+ source: {
2415
+ primaryKeys: ids,
2416
+ preferredId: ids,
2417
+ flatMap: fields
2418
+ }
2419
+ };
2420
+ }
2421
+ function transformValueHelpProjection(host, select) {
2422
+ return widenPrimaryKey(applyArbacProjection(select, requireRequestScopes(), host.source), select, host.primaryKey);
2423
+ }
2424
+ /**
2425
+ * The PK is always visible (`hasField`, `/meta`), so a scope projection
2426
+ * never strips it — mirroring the DB controllers' `preferredId` widening.
2427
+ * An explicit `$select` that leaves the PK out (or excludes it) still wins.
2428
+ */
2429
+ function widenPrimaryKey(projection, requested, pk) {
2430
+ if (!pk || !projection || Array.isArray(projection)) return projection;
2431
+ const mode = getProjectionMode(projection);
2432
+ if (mode === "empty") return projection;
2433
+ if (!requestsField(requested, pk)) return projection;
2434
+ if (mode === "include" && !projection[pk]) return {
2435
+ ...projection,
2436
+ [pk]: 1
2437
+ };
2438
+ if (mode === "exclude" && projection[pk] === 0) {
2439
+ const { [pk]: _dropped, ...rest } = projection;
2440
+ return Object.keys(rest).length > 0 ? rest : void 0;
2441
+ }
2442
+ return projection;
2443
+ }
2444
+ /** Whether a raw value-help `$select` (absent, list, or `{ f: 0 | 1 }` map) asks for `field`. */
2445
+ function requestsField(requested, field) {
2446
+ if (requested === void 0 || requested === null) return true;
2447
+ if (Array.isArray(requested)) return requested.length === 0 || requested.includes(field);
2448
+ if (typeof requested !== "object") return true;
2449
+ const map = requested;
2450
+ const values = Object.values(map);
2451
+ if (values.length === 0) return true;
2452
+ return values.every((v) => v === 0 || v === false) ? map[field] === void 0 : !!map[field];
2453
+ }
2454
+ async function applyValueHelpMetaOverlay(host, meta) {
2455
+ const overlaid = await applyArbacMetaOverlay(meta, host.source);
2456
+ if (!overlaid.searchable || overlaid.fields === meta.fields) return overlaid;
2457
+ return host.searchableFields.some((f) => f in overlaid.fields) ? overlaid : {
2458
+ ...overlaid,
2459
+ searchable: false
2460
+ };
2461
+ }
2462
+ let AsArbacValueHelpController = class AsArbacValueHelpController extends AsValueHelpController {
2463
+ _arbacHost;
2464
+ get arbacHost() {
2465
+ return this._arbacHost ??= valueHelpHost(this.fieldMeta, this.primaryKey, this.searchableFields);
2466
+ }
2467
+ async prepareRequest(_ctx) {
2468
+ await resolveRequestScopes();
2469
+ }
2470
+ transformFilter(filter) {
2471
+ return arbacRowFilter(filter);
2472
+ }
2473
+ transformProjection(select) {
2474
+ return transformValueHelpProjection(this.arbacHost, select);
2475
+ }
2476
+ hasField(path) {
2477
+ return super.hasField(path) && requestFieldVisible(path, this.arbacHost.source);
2478
+ }
2479
+ validateControls(controls, type) {
2480
+ const baseErr = super.validateControls(controls, type);
2481
+ if (baseErr) return baseErr;
2482
+ applyArbacControls(controls, requireRequestScopes());
2483
+ }
2484
+ applyMetaOverlay(meta) {
2485
+ return applyValueHelpMetaOverlay(this.arbacHost, meta);
2486
+ }
2487
+ runQuery(url) {
2488
+ return super.runQuery(url);
2489
+ }
2490
+ runPages(url) {
2491
+ return super.runPages(url);
2492
+ }
2493
+ runGetOne(id) {
2494
+ return super.runGetOne(id);
2495
+ }
2496
+ runGetOneComposite(query) {
2497
+ return super.runGetOneComposite(query);
2498
+ }
2499
+ };
2500
+ __decorate([
2501
+ ArbacAction("query"),
2502
+ __decorateMetadata("design:type", Function),
2503
+ __decorateMetadata("design:paramtypes", [String]),
2504
+ __decorateMetadata("design:returntype", void 0)
2505
+ ], AsArbacValueHelpController.prototype, "runQuery", null);
2506
+ __decorate([
2507
+ ArbacAction("pages"),
2508
+ __decorateMetadata("design:type", Function),
2509
+ __decorateMetadata("design:paramtypes", [String]),
2510
+ __decorateMetadata("design:returntype", void 0)
2511
+ ], AsArbacValueHelpController.prototype, "runPages", null);
2512
+ __decorate([
2513
+ ArbacAction("getOne"),
2514
+ __decorateMetadata("design:type", Function),
2515
+ __decorateMetadata("design:paramtypes", [String]),
2516
+ __decorateMetadata("design:returntype", void 0)
2517
+ ], AsArbacValueHelpController.prototype, "runGetOne", null);
2518
+ __decorate([
2519
+ ArbacAction("getOneComposite"),
2520
+ __decorateMetadata("design:type", Function),
2521
+ __decorateMetadata("design:paramtypes", [typeof Record === "undefined" ? Object : Record]),
2522
+ __decorateMetadata("design:returntype", void 0)
2523
+ ], AsArbacValueHelpController.prototype, "runGetOneComposite", null);
2524
+ AsArbacValueHelpController = __decorate([Inherit()], AsArbacValueHelpController);
2525
+ let AsArbacJsonValueHelpController = class AsArbacJsonValueHelpController extends AsJsonValueHelpController {
2526
+ _arbacHost;
2527
+ get arbacHost() {
2528
+ return this._arbacHost ??= valueHelpHost(this.fieldMeta, this.primaryKey, this.searchableFields);
2529
+ }
2530
+ async prepareRequest(_ctx) {
2531
+ await resolveRequestScopes();
2532
+ }
2533
+ transformFilter(filter) {
2534
+ return arbacRowFilter(filter);
2535
+ }
2536
+ transformProjection(select) {
2537
+ return transformValueHelpProjection(this.arbacHost, select);
2538
+ }
2539
+ hasField(path) {
2540
+ return super.hasField(path) && requestFieldVisible(path, this.arbacHost.source);
2541
+ }
2542
+ validateControls(controls, type) {
2543
+ const baseErr = super.validateControls(controls, type);
2544
+ if (baseErr) return baseErr;
2545
+ applyArbacControls(controls, requireRequestScopes());
2546
+ }
2547
+ applyMetaOverlay(meta) {
2548
+ return applyValueHelpMetaOverlay(this.arbacHost, meta);
2549
+ }
2550
+ runQuery(url) {
2551
+ return super.runQuery(url);
2552
+ }
2553
+ runPages(url) {
2554
+ return super.runPages(url);
2555
+ }
2556
+ runGetOne(id) {
2557
+ return super.runGetOne(id);
2558
+ }
2559
+ runGetOneComposite(query) {
2560
+ return super.runGetOneComposite(query);
2561
+ }
2562
+ };
2563
+ __decorate([
2564
+ ArbacAction("query"),
2565
+ __decorateMetadata("design:type", Function),
2566
+ __decorateMetadata("design:paramtypes", [String]),
2567
+ __decorateMetadata("design:returntype", void 0)
2568
+ ], AsArbacJsonValueHelpController.prototype, "runQuery", null);
2569
+ __decorate([
2570
+ ArbacAction("pages"),
2571
+ __decorateMetadata("design:type", Function),
2572
+ __decorateMetadata("design:paramtypes", [String]),
2573
+ __decorateMetadata("design:returntype", void 0)
2574
+ ], AsArbacJsonValueHelpController.prototype, "runPages", null);
2575
+ __decorate([
2576
+ ArbacAction("getOne"),
2577
+ __decorateMetadata("design:type", Function),
2578
+ __decorateMetadata("design:paramtypes", [String]),
2579
+ __decorateMetadata("design:returntype", void 0)
2580
+ ], AsArbacJsonValueHelpController.prototype, "runGetOne", null);
2581
+ __decorate([
2582
+ ArbacAction("getOneComposite"),
2583
+ __decorateMetadata("design:type", Function),
2584
+ __decorateMetadata("design:paramtypes", [typeof Record === "undefined" ? Object : Record]),
2585
+ __decorateMetadata("design:returntype", void 0)
2586
+ ], AsArbacJsonValueHelpController.prototype, "runGetOneComposite", null);
2587
+ AsArbacJsonValueHelpController = __decorate([Inherit()], AsArbacJsonValueHelpController);
2588
+ //#endregion
2589
+ //#region src/db/use-arbac-db-scope.ts
2590
+ /**
2591
+ * The current event's merged ARBAC DB scope, for custom routes and
2592
+ * `@DbAction` handlers that query the table directly. Resolves the event's
2593
+ * scopes once (the ones the authorize interceptor cached, else an ARBAC
2594
+ * evaluation of the current resource/action) — a deny, or an allowed
2595
+ * evaluation with no scope left, throws 403.
2596
+ *
2597
+ * @example
2598
+ * ```ts
2599
+ * const scope = await useArbacDbScope<typeof Task>();
2600
+ * await scope.assertRowsInScope(this.table, [id]);
2601
+ * await this.table.updateMany(scope.filter({ id }), { ...patch, ...scope.set() });
2602
+ * // A handler-side insert with the CRUD endpoints' write enforcement.
2603
+ * await this.table.insertOne({ ...row, ...scope.set() }, scope.writeOptions(this.table));
2604
+ * ```
2605
+ */
2606
+ async function useArbacDbScope() {
2607
+ const scopes = await resolveRequestScopes();
2608
+ const eff = effectiveScope(scopes);
2609
+ const visibleOn = (table) => (path) => isScopedFieldVisible(scopes, path, table);
2610
+ return {
2611
+ scopes,
2612
+ filter: (extra) => conjoinScopeFilters(eff.filter, extra) ?? {},
2613
+ set: () => ({ ...eff.set }),
2614
+ check: () => eff.check ?? {},
2615
+ async assertRowsInScope(table, ids) {
2616
+ const isFieldVisible = visibleOn(table);
2617
+ const scope = eff.filter;
2618
+ await assertIdFiltersInScope(table, await Promise.all(ids.map(async (id) => {
2619
+ const f = await table.resolveRowFilter(id, {
2620
+ isFieldVisible,
2621
+ scope
2622
+ });
2623
+ if (!f) throw new HttpError(404, "Not found");
2624
+ return f;
2625
+ })), eff.filter);
2626
+ },
2627
+ assertRefsInScope: (table, rows) => assertRefsInScope({
2628
+ action: "insertMany",
2629
+ rows: [...rows],
2630
+ expectedVersions: [],
2631
+ current: async () => null,
2632
+ currentAll: async () => rows.map(() => null),
2633
+ filterFor: () => null
2634
+ }, scopes, table),
2635
+ writeOptions: (table) => ({
2636
+ isFieldVisible: visibleOn(table),
2637
+ guard: async (ctx) => {
2638
+ const rows = ctx;
2639
+ assertNestedWritesAllowed(rows.rows, scopes, navFieldsOf(table));
2640
+ await guardArbacWrite(rows, scopes, table, table);
2641
+ },
2642
+ check: (ctx) => checkArbacWrite(ctx, scopes)
2643
+ }),
2644
+ removeOptions: (table) => ({
2645
+ isFieldVisible: visibleOn(table),
2646
+ ...eff.filter ? { scope: eff.filter } : {},
2647
+ guard: (ctx) => guardArbacRemove(ctx, scopes, table)
2648
+ })
2649
+ };
2650
+ }
2651
+ //#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 };