@aooth/arbac-moost 0.1.67 → 0.1.69

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.
@@ -1,4 +1,4 @@
1
- import { r as AoothArbacClaims, t as ArbacUserProvider } from "../user.provider-DDwcuUsU.mjs";
1
+ import { r as AoothArbacClaims, t as ArbacUserProvider } from "../user.provider-C5BrFNxg.mjs";
2
2
  import { TAtscriptAnnotatedType, TAtscriptTypeObject, TMetadataMap, TValidatorOptions, Validator } from "@atscript/typescript/utils";
3
3
  import { AoothUserCredentials } from "@aooth/user/atscript-db/model.as";
4
4
  //#region src/atscript/auto-provider.d.ts
package/dist/index.d.mts CHANGED
@@ -1,13 +1,22 @@
1
- import { a as ArbacDbScope, c as enforceControlsPolicy, d as NavRelationKey, f as NavTarget, i as conjoinArbacDbScopes, l as extractUsedControlValues, m as ProjectionOf, n as ArbacUserProviderToken, o as AsArbacDbController, p as OwnFieldKey, r as AoothArbacClaims, s as applyAllowedFieldsAndSet, t as ArbacUserProvider, u as ControlsOf } from "./user.provider-DDwcuUsU.mjs";
1
+ import { a as ArbacDbScope, c as enforceControlsPolicy, d as NavRelationKey, f as NavTarget, i as conjoinArbacDbScopes, l as extractUsedControlValues, m as ProjectionOf, n as ArbacUserProviderToken, o as AsArbacDbController, p as OwnFieldKey, r as AoothArbacClaims, s as applyAllowedFieldsAndSet, t as ArbacUserProvider, u as ControlsOf } from "./user.provider-C5BrFNxg.mjs";
2
2
  import { Arbac, Arbac as Arbac$1, TArbacCompiledRule, TArbacEvalResult, TArbacRole, TArbacRoleForResource, TArbacRule, arbacPatternToRegex } from "@aooth/arbac-core";
3
3
  import { TAuthGuardDef } from "@moostjs/event-http";
4
4
  import { EventContext } from "@wooksjs/event-core";
5
5
  import { Mate, TMateParamMeta, TMoostMetadata } from "moost";
6
6
  import { ControlGate, TProjection } from "@aooth/arbac";
7
+ import { TMetaResponse } from "@atscript/db";
7
8
  import { AsDbReadableController } from "@atscript/moost-db";
8
9
  import { TAtscriptAnnotatedType } from "@atscript/typescript/utils";
9
- import { TMetaResponse } from "@atscript/db";
10
10
  //#region src/arbac.composables.d.ts
11
+ /**
12
+ * The scopes cached for the current event (`undefined` before the authorize
13
+ * interceptor / `setScopes` ran). A direct slot read — no controller
14
+ * metadata resolution — for per-field hot paths like `hasField`; use
15
+ * {@link useArbac} for `evaluate` / `setScopes`.
16
+ *
17
+ * `TScope` is a caller-side type witness (see `evaluate` below).
18
+ */
19
+ declare function getArbacScopes<TScope extends object>(ctx?: EventContext): TScope[] | undefined;
11
20
  interface ArbacBindings {
12
21
  getScopes: <TScope extends object>() => TScope[] | undefined;
13
22
  setScopes: <TScope extends object>(scope: TScope[] | undefined) => void;
@@ -156,7 +165,10 @@ declare function collectWithGrantNames(scopes: ArbacDbScope[]): ReadonlySet<stri
156
165
  * (or per `/meta` overlay) and threaded through the pruning walk.
157
166
  */
158
167
  interface MetaVisibility {
159
- /** Constrained projection union (never the empty/universe projection). */
168
+ /**
169
+ * Projection union for this level's own fields. `{}` (the universe) means
170
+ * unrestricted — possible when only a `with` sub-scope restricts anything.
171
+ */
160
172
  allowed: TProjection;
161
173
  /**
162
174
  * Identifier fields reads ALWAYS return regardless of projection (the read
@@ -166,6 +178,25 @@ interface MetaVisibility {
166
178
  alwaysVisible: ReadonlySet<string>;
167
179
  /** Relation names granted via `with.<name>` (see {@link collectWithGrantNames}). */
168
180
  withGrants: ReadonlySet<string>;
181
+ /**
182
+ * Visibility of a `with`-granted relation's JOINED fields, built from the
183
+ * union of its `with.<name>` sub-scopes (with the related table's own
184
+ * identifiers always visible). A path `rel.x.y` is checked as `x.y`
185
+ * against it, recursively for nested relations. The scope-driven builders
186
+ * ({@link buildScopeVisibility}) always supply it; a hand-built visibility
187
+ * without it lets granted paths through unchecked (legacy behavior).
188
+ */
189
+ relation?: (name: string) => MetaVisibility | undefined;
190
+ /**
191
+ * Precompiled `isFieldAllowed(path, allowed)`. Supplied by
192
+ * {@link buildScopeVisibility}; a hand-built visibility falls back to
193
+ * `isFieldAllowed`.
194
+ */
195
+ isAllowed?: (path: string) => boolean;
196
+ /** The scopes this level was built from (set by {@link buildScopeVisibility}). */
197
+ scopes?: ArbacDbScope[];
198
+ /** The table this level describes, when known (set by {@link buildScopeVisibility}). */
199
+ table?: VisibilityTableSource;
169
200
  /**
170
201
  * Fields the principal's WRITE scopes allow (union of `allowedFields`, or
171
202
  * `"all"` for an unrestricted write grant). A field that is writable but not
@@ -184,10 +215,12 @@ interface MetaVisibility {
184
215
  */
185
216
  declare function collectWritableFields(scopes: ArbacDbScope[], unrestricted: boolean): ReadonlySet<string> | "all" | undefined;
186
217
  /**
187
- * Whether a flattened dot-path field exists for this principal. A path under
188
- * a `with`-granted relation passes unconditionally — the sub-scope governs
189
- * the joined content, and rejecting the path here would break the very `$with`
190
- * expansion the grant permits.
218
+ * Whether a flattened dot-path field exists for this principal. A
219
+ * `with`-granted relation itself is always visible (the grant implies it);
220
+ * a path UNDER it (`rel.x.y`) is checked as `x.y` against the relation's
221
+ * sub-scope visibility ({@link MetaVisibility.relation}) — so a field the
222
+ * sub-scope hides is as unknown in a `$with` sub-query as a top-level hidden
223
+ * column is in the main query (no filter/sort value oracle).
191
224
  */
192
225
  declare function isMetaFieldVisible(path: string, vis: MetaVisibility): boolean;
193
226
  /**
@@ -198,8 +231,9 @@ declare function isMetaFieldVisible(path: string, vis: MetaVisibility): boolean;
198
231
  *
199
232
  * - `fields` — the flat capability map (sortable/filterable flags).
200
233
  * - `type` — the serialized annotated type (what dynamic clients build
201
- * tables/forms from); nested object props prune by dot-path, relation props
202
- * survive whole when the relation itself is visible.
234
+ * tables/forms from); nested object props prune by dot-path. A relation
235
+ * prop visible through the projection survives whole; a `with`-granted
236
+ * one is pruned by its sub-scope (recursively), matching `hasField`.
203
237
  * - `relations` — entries neither projected nor `with`-granted.
204
238
  * - `versionColumn` — dropped when the OCC column itself is hidden.
205
239
  *
@@ -207,19 +241,59 @@ declare function isMetaFieldVisible(path: string, vis: MetaVisibility): boolean;
207
241
  * (`applyMetaOverlay` contract); every pruned branch is a fresh object.
208
242
  */
209
243
  declare function pruneMetaByVisibility(meta: TMetaResponse, vis: MetaVisibility): TMetaResponse;
244
+ /**
245
+ * The slice of an atscript-db readable (table / view) the visibility walk
246
+ * reads — a moost-db controller's `this.readable` satisfies it: identifiers
247
+ * (always visible), nav relations (to reach the joined table) and the
248
+ * flattened schema (to split `$select` parents by scope).
249
+ */
250
+ interface VisibilityTableSource {
251
+ primaryKeys: readonly string[];
252
+ preferredId: readonly string[];
253
+ relations?: ReadonlyMap<string, unknown>;
254
+ /**
255
+ * The target table of nav relation `navField` (atscript-db's
256
+ * `readable.relatedTable`); absent / `undefined` → the joined table's
257
+ * identifiers are not exempted.
258
+ */
259
+ relatedTable?(navField: string): VisibilityTableSource | undefined;
260
+ /** Flattened schema (dot-paths) — used to split `$select` parents by scope. */
261
+ flatMap?: ReadonlyMap<string, unknown>;
262
+ /** Navigation field paths — their joined descendants are not own columns. */
263
+ navFields?: ReadonlySet<string>;
264
+ }
265
+ /**
266
+ * Build the {@link MetaVisibility} a set of read scopes implies — the single
267
+ * structure behind `hasField`, `/meta` pruning, `$select` value stripping
268
+ * and the `$with` overlay, so they cannot drift. Own fields: the projection
269
+ * union (`{}` when unrestricted), precompiled into `isAllowed`. A
270
+ * `with`-granted relation: a lazily built (memoized) child visibility over
271
+ * the union of its `with.<name>` sub-scopes — the same sub-scopes joined rows
272
+ * are stripped with — whose `alwaysVisible` is the related table's own PK /
273
+ * `preferredId` (reached through `table.relatedTable`; without it, no
274
+ * related identifier is exempt).
275
+ *
276
+ * @param alwaysVisible - identifiers exempt from the projection; defaults to
277
+ * the table's PK + `preferredId`
278
+ */
279
+ declare function buildScopeVisibility(scopes: ArbacDbScope[], table: VisibilityTableSource | undefined, alwaysVisible?: ReadonlySet<string>): MetaVisibility;
210
280
  /**
211
281
  * Shared body of both ARBAC controllers' `hasField` overrides (BUG-3 twin of
212
- * the `/meta` pruning): a field outside the read-scope projection union must
213
- * be indistinguishable from a field that does not exist. Returns `true` when
214
- * the scopes impose no projection restriction.
282
+ * the `/meta` pruning): a field outside the read-scope projection union —
283
+ * or, for a `rel.x` path under a `with`-granted relation, outside that
284
+ * relation's sub-scope union — must be indistinguishable from a field that
285
+ * does not exist. Returns `true` when there are no scopes (unscoped grant).
286
+ *
287
+ * @param source - the controller's `this.readable`; an identifier set (the
288
+ * 0.1.67 signature) still works, without related-table identifiers
215
289
  */
216
- declare function isScopedFieldVisible(scopes: ArbacDbScope[], path: string, alwaysVisible: ReadonlySet<string>): boolean;
290
+ declare function isScopedFieldVisible(scopes: ArbacDbScope[], path: string, source: VisibilityTableSource | ReadonlySet<string>): boolean;
217
291
  /**
218
292
  * PK + `preferredId` for a controller's table/readable — the
219
- * {@link MetaVisibility.alwaysVisible} set both `applyMetaOverlay` and
220
- * `hasField` pass into the visibility checks.
293
+ * {@link MetaVisibility.alwaysVisible} set. Kept for compatibility: the
294
+ * controllers now pass `this.readable` and it is derived there.
221
295
  */
222
- declare function metaAlwaysVisibleFields(controller: object, source: {
296
+ declare function metaAlwaysVisibleFields(_controller: object, source: {
223
297
  primaryKeys: readonly string[];
224
298
  preferredId: readonly string[];
225
299
  }): ReadonlySet<string>;
@@ -239,11 +313,14 @@ declare function metaAlwaysVisibleFields(controller: object, source: {
239
313
  * universe, no pruning (unchanged behavior for unscoped roles). No read op
240
314
  * allowed → the projection union is empty → no pruning: `crud` already
241
315
  * advertises no read surface, and write-only principals still need `type`
242
- * for their insert/update forms. `alwaysVisible` (PK + preferredId) is never
243
- * pruned — reads always return those fields (projection widening / id
244
- * addressing).
316
+ * for their insert/update forms. PK + `preferredId` are never pruned —
317
+ * reads always return those fields (projection widening / id addressing).
318
+ *
319
+ * @param source - the controller's `this.readable` (identifiers and related
320
+ * tables are derived from it); an identifier set (the 0.1.67 signature)
321
+ * still works, without related-table identifiers
245
322
  */
246
- declare function applyArbacMetaOverlay(meta: TMetaResponse, alwaysVisible: ReadonlySet<string>): Promise<TMetaResponse>;
323
+ declare function applyArbacMetaOverlay(meta: TMetaResponse, source: VisibilityTableSource | ReadonlySet<string>): Promise<TMetaResponse>;
247
324
  /**
248
325
  * Test-friendly internal helper — exported for unit tests and helper
249
326
  * composition; regular consumers should not call this directly.
@@ -272,4 +349,4 @@ declare function collectMethodNames(instance: object): string[];
272
349
  */
273
350
  declare class MoostArbac<TUserAttrs extends object, TScope extends object> extends Arbac$1<TUserAttrs, TScope> {}
274
351
  //#endregion
275
- export { type AoothArbacClaims, Arbac, ArbacAction, ArbacAuthorize, ArbacDbScope, ArbacResource, ArbacUserProvider, ArbacUserProviderToken, AsArbacDbController, AsArbacDbReadableController, type ControlGate, type ControlsOf, MetaVisibility, MoostArbac, type NavRelationKey, type NavTarget, type OwnFieldKey, type ProjectionOf, type TArbacCompiledRule, type TArbacEvalResult, TArbacMeta, type TArbacRole, type TArbacRoleForResource, type TArbacRule, applyAllowedFieldsAndSet, applyArbacMetaOverlay, arbacAuthorizeInterceptor, arbacPatternToRegex, collectMethodNames, collectWithGrantNames, collectWritableFields, conjoinArbacDbScopes, enforceControlsPolicy, extractUsedControlValues, getArbacMate, isMetaFieldVisible, isScopedFieldVisible, metaAlwaysVisibleFields, pruneMetaByVisibility, unionScopeProjection, useArbac };
352
+ export { type AoothArbacClaims, Arbac, ArbacAction, ArbacAuthorize, ArbacDbScope, ArbacResource, ArbacUserProvider, ArbacUserProviderToken, AsArbacDbController, AsArbacDbReadableController, type ControlGate, type ControlsOf, type MetaVisibility, MoostArbac, type NavRelationKey, type NavTarget, type OwnFieldKey, type ProjectionOf, type TArbacCompiledRule, type TArbacEvalResult, TArbacMeta, type TArbacRole, type TArbacRoleForResource, type TArbacRule, type VisibilityTableSource, applyAllowedFieldsAndSet, applyArbacMetaOverlay, arbacAuthorizeInterceptor, arbacPatternToRegex, buildScopeVisibility, collectMethodNames, collectWithGrantNames, collectWritableFields, conjoinArbacDbScopes, enforceControlsPolicy, extractUsedControlValues, getArbacMate, getArbacScopes, isMetaFieldVisible, isScopedFieldVisible, metaAlwaysVisibleFields, pruneMetaByVisibility, unionScopeProjection, useArbac };
package/dist/index.mjs CHANGED
@@ -3,7 +3,8 @@ import { Arbac, Arbac as Arbac$1, arbacPatternToRegex } from "@aooth/arbac-core"
3
3
  import { Authenticate, HttpError } from "@moostjs/event-http";
4
4
  import { current, key } from "@wooksjs/event-core";
5
5
  import { Inherit, Injectable, TInterceptorPriority, defineBeforeInterceptor, getConstructor, getMoostMate, useControllerContext, useLogger } from "moost";
6
- import { conjoinScopeFilters, intersectControlsPolicy, isFieldAllowed, mergeScopeFilters, restrictProjection, unionControlsPolicy, unionProjections } from "@aooth/arbac";
6
+ import { DENY_FILTER, conjoinScopeFilters, expandExcludeToLeaves, getProjectionMode, intersectControlsPolicy, intersectProjections, isFieldAllowed, mergeScopeFilters, restrictProjection, unionControlsPolicy, unionProjections } from "@aooth/arbac";
7
+ import { findAncestorInSet } from "@atscript/db";
7
8
  import { AsDbController, AsDbReadableController } from "@atscript/moost-db";
8
9
  import { groupByFields } from "@uniqu/core";
9
10
  //#region src/attenuation.ts
@@ -15,17 +16,29 @@ import { groupByFields } from "@uniqu/core";
15
16
  *
16
17
  * Each side is first UNIONed with the existing additive helpers (today's
17
18
  * machinery), then the two RESULTS are CONJOINED with the dedicated combiners
18
- * ({@link conjoinScopeFilters} `$and`, {@link restrictProjection} field ∩,
19
+ * ({@link conjoinScopeFilters} `$and`, {@link intersectProjections} field ∩,
19
20
  * {@link intersectControlsPolicy} deny-wins, `with` recursion) — never the
20
21
  * additive union helpers, which would silently widen.
21
22
  *
22
23
  * Returned as a single-element list so every downstream scope-application
23
24
  * site (which UNIONs the cached scope list per facet) sees the identity of a
24
25
  * one-element union — i.e. the conjunction — with no change to those sites.
26
+ *
27
+ * The projection conjunction is path-wise and never wider than either side
28
+ * (`{a:1}` ∩ `{"a.b":1}` → `{"a.b":1}`). A nested exclusion under an
29
+ * included parent (`{a:1}` ∩ `{"a.c":0}`) is subtracted exactly via
30
+ * `childrenOf` (the table schema); without it the parent is dropped (fail
31
+ * closed). When NO field survives (`{a:1}` ∩ `{a:0}`, disjoint whitelists),
32
+ * the composite keeps the user's projection and gets a match-nothing filter —
33
+ * an empty field set must never read as the unrestricted `{}`. `with`
34
+ * sub-scopes are conjoined without a schema.
25
35
  */
26
- function conjoinArbacDbScopes(userScopes, credScopes) {
27
- const filter = conjoinScopeFilters(mergeScopeFilters(userScopes.map((s) => s.filter ?? {})), mergeScopeFilters(credScopes.map((s) => s.filter ?? {})));
28
- const projection = restrictProjection(unionProjections(...userScopes.map((s) => s.projection ?? {})), unionProjections(...credScopes.map((s) => s.projection ?? {})));
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));
29
42
  const controls = intersectControlsPolicy(unionControlsPolicy(userScopes), unionControlsPolicy(credScopes));
30
43
  const allowedFields = intersectAllowedFields(userScopes, credScopes);
31
44
  const set = combineSet(userScopes, credScopes);
@@ -98,6 +111,34 @@ function conjoinWith(userScopes, credScopes) {
98
111
  return out;
99
112
  }
100
113
  //#endregion
114
+ //#region src/db/field-children.ts
115
+ const fieldChildrenCache = /* @__PURE__ */ new WeakMap();
116
+ /**
117
+ * The `TProjectionChildren` schema lookup for a table (its own `flatMap` paths —
118
+ * navigation descendants excluded, they are joined rows, not columns);
119
+ * `undefined` when the table exposes no flattened schema.
120
+ */
121
+ 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());
126
+ const byParent = /* @__PURE__ */ new Map();
127
+ for (const path of table.flatMap.keys()) {
128
+ const dot = path.lastIndexOf(".");
129
+ if (dot === -1) continue;
130
+ 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]);
135
+ }
136
+ lookup = (path) => byParent.get(path) ?? [];
137
+ fieldChildrenCache.set(table, lookup);
138
+ }
139
+ return lookup;
140
+ }
141
+ //#endregion
101
142
  //#region src/moost-arbac.ts
102
143
  let MoostArbac = class MoostArbac extends Arbac$1 {};
103
144
  MoostArbac = __decorate([Injectable()], MoostArbac);
@@ -112,6 +153,18 @@ MoostArbac = __decorate([Injectable()], MoostArbac);
112
153
  */
113
154
  const arbacScopesKey = key("arbac.scopes");
114
155
  /**
156
+ * The scopes cached for the current event (`undefined` before the authorize
157
+ * interceptor / `setScopes` ran). A direct slot read — no controller
158
+ * metadata resolution — for per-field hot paths like `hasField`; use
159
+ * {@link useArbac} for `evaluate` / `setScopes`.
160
+ *
161
+ * `TScope` is a caller-side type witness (see `evaluate` below).
162
+ */
163
+ function getArbacScopes(ctx) {
164
+ const c = ctx ?? current();
165
+ return c.has(arbacScopesKey) ? c.get(arbacScopesKey) : void 0;
166
+ }
167
+ /**
115
168
  * Composable for ARBAC utilities within Moost handlers and interceptors.
116
169
  *
117
170
  * Exposes scope read/write, lazy `evaluate`, and the resolved
@@ -129,7 +182,7 @@ const arbacScopesKey = key("arbac.scopes");
129
182
  const useArbac = (_ctx) => {
130
183
  const ctx = _ctx ?? current();
131
184
  const cc = useControllerContext(ctx);
132
- const getScopes = () => ctx.has(arbacScopesKey) ? ctx.get(arbacScopesKey) : void 0;
185
+ const getScopes = () => getArbacScopes(ctx);
133
186
  const setScopes = (scope) => {
134
187
  ctx.set(arbacScopesKey, scope);
135
188
  };
@@ -161,7 +214,7 @@ const useArbac = (_ctx) => {
161
214
  });
162
215
  if (result.allowed && result.credScopes !== void 0) return {
163
216
  allowed: true,
164
- scopes: conjoinArbacDbScopes(result.scopes ?? [], result.credScopes),
217
+ scopes: conjoinArbacDbScopes(result.scopes ?? [], result.credScopes, fieldChildrenOf(cc.getController()?.readable)),
165
218
  userId
166
219
  };
167
220
  return {
@@ -267,11 +320,7 @@ function collectWithGrantNames(scopes) {
267
320
  /** Exact-or-ancestor membership: `credit.credentials.user` matches a `credit.credentials` grant. */
268
321
  function isPathWritable(path, writable) {
269
322
  if (!writable) return false;
270
- if (writable === "all") return true;
271
- if (writable.has(path)) return true;
272
- let pos = path.length;
273
- while ((pos = path.lastIndexOf(".", pos - 1)) !== -1) if (writable.has(path.slice(0, pos))) return true;
274
- return false;
323
+ return writable === "all" || hasSelfOrAncestor(writable, path);
275
324
  }
276
325
  /**
277
326
  * Union of write-scope `allowedFields`; `"all"` for field-unrestricted write
@@ -292,17 +341,23 @@ function collectWritableFields(scopes, unrestricted) {
292
341
  return out.size > 0 ? out : void 0;
293
342
  }
294
343
  /**
295
- * Whether a flattened dot-path field exists for this principal. A path under
296
- * a `with`-granted relation passes unconditionally — the sub-scope governs
297
- * the joined content, and rejecting the path here would break the very `$with`
298
- * expansion the grant permits.
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).
299
350
  */
300
351
  function isMetaFieldVisible(path, vis) {
301
352
  if (vis.alwaysVisible.has(path)) return true;
302
353
  const dot = path.indexOf(".");
303
354
  const head = dot === -1 ? path : path.slice(0, dot);
304
- if (vis.withGrants.has(head)) return true;
305
- return isFieldAllowed(path, vis.allowed);
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);
306
361
  }
307
362
  /**
308
363
  * Prune a `/meta` envelope down to the fields the principal's read scopes can
@@ -312,8 +367,9 @@ function isMetaFieldVisible(path, vis) {
312
367
  *
313
368
  * - `fields` — the flat capability map (sortable/filterable flags).
314
369
  * - `type` — the serialized annotated type (what dynamic clients build
315
- * tables/forms from); nested object props prune by dot-path, relation props
316
- * survive whole when the relation itself is visible.
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`.
317
373
  * - `relations` — entries neither projected nor `with`-granted.
318
374
  * - `versionColumn` — dropped when the OCC column itself is hidden.
319
375
  *
@@ -342,12 +398,13 @@ function pruneMetaByVisibility(meta, vis) {
342
398
  }
343
399
  /**
344
400
  * Copy-on-prune walk over a serialized type node. `basePath` is the flattened
345
- * dot-path prefix ("" at the root). Top-level relation props (nav props named
346
- * in `meta.relations`) are kept or dropped WHOLE — their internal shape is the
347
- * joined model's business (content scoping happens per-request through the
348
- * `with` sub-scopes), while own-field subtrees prune recursively so an
349
- * include-mode union like `{ "password.hash": 1 }` keeps `password` with only
350
- * `hash` inside.
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.
351
408
  */
352
409
  function pruneSerializedType(node, basePath, vis, relationNames) {
353
410
  const def = node.type;
@@ -355,8 +412,10 @@ function pruneSerializedType(node, basePath, vis, relationNames) {
355
412
  const props = {};
356
413
  for (const [name, prop] of Object.entries(def.props)) {
357
414
  const path = basePath ? `${basePath}.${name}` : name;
358
- if (basePath === "" && relationNames.has(name)) {
359
- if (isMetaFieldVisible(name, vis)) props[name] = prop;
415
+ 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;
360
419
  continue;
361
420
  }
362
421
  if (!isMetaFieldVisible(path, vis)) {
@@ -395,52 +454,137 @@ function pruneSerializedType(node, basePath, vis, relationNames) {
395
454
  };
396
455
  return node;
397
456
  }
398
- const UNRESTRICTED_VISIBILITY = {
399
- allowed: void 0,
400
- withGrants: /* @__PURE__ */ new Set()
401
- };
457
+ const NO_NAMES = /* @__PURE__ */ new Set();
458
+ /** A nav prop inside a joined type: atscript-db stamps `db.rel.to` / `from` / `via`. */
459
+ function isNavProp(prop) {
460
+ const meta = prop.metadata;
461
+ return !!meta && ("db.rel.to" in meta || "db.rel.from" in meta || "db.rel.via" in meta);
462
+ }
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);
471
+ }
472
+ return set;
473
+ }
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);
480
+ }
481
+ return out;
482
+ }
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;
486
+ }
487
+ /**
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.
492
+ */
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));
502
+ }
503
+ return (path) => parents.has(path) || hasSelfOrAncestor(listed, path);
504
+ }
505
+ /**
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).
515
+ *
516
+ * @param alwaysVisible - identifiers exempt from the projection; defaults to
517
+ * the table's PK + `preferredId`
518
+ */
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
+ };
540
+ }
402
541
  const scopeVisibilityCache = /* @__PURE__ */ new WeakMap();
403
- function scopeVisibility(scopes) {
404
- if (scopes.length === 0) return UNRESTRICTED_VISIBILITY;
405
- let vis = scopeVisibilityCache.get(scopes);
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);
549
+ }
550
+ /**
551
+ * The event-cached {@link MetaVisibility} for `scopes` over `source` (a
552
+ * readable, or an `alwaysVisible` identifier set).
553
+ */
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);
406
561
  if (!vis) {
407
- vis = {
408
- allowed: unionScopeProjection(scopes),
409
- withGrants: collectWithGrantNames(scopes)
410
- };
411
- scopeVisibilityCache.set(scopes, vis);
562
+ vis = isIdentifierSet(source) ? buildScopeVisibility(scopes, void 0, source) : buildScopeVisibility(scopes, source === NO_TABLE ? void 0 : source);
563
+ perSource.set(source, vis);
412
564
  }
413
565
  return vis;
414
566
  }
415
567
  /**
416
568
  * Shared body of both ARBAC controllers' `hasField` overrides (BUG-3 twin of
417
- * the `/meta` pruning): a field outside the read-scope projection union must
418
- * be indistinguishable from a field that does not exist. Returns `true` when
419
- * the scopes impose no projection restriction.
420
- */
421
- function isScopedFieldVisible(scopes, path, alwaysVisible) {
422
- const { allowed, withGrants } = scopeVisibility(scopes);
423
- if (!allowed) return true;
424
- return isMetaFieldVisible(path, {
425
- allowed,
426
- alwaysVisible,
427
- withGrants
428
- });
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).
573
+ *
574
+ * @param source - the controller's `this.readable`; an identifier set (the
575
+ * 0.1.67 signature) still works, without related-table identifiers
576
+ */
577
+ function isScopedFieldVisible(scopes, path, source) {
578
+ if (scopes.length === 0) return true;
579
+ return isMetaFieldVisible(path, scopeVisibility(scopes, source));
429
580
  }
430
- const alwaysVisibleFieldsCache = /* @__PURE__ */ new WeakMap();
431
581
  /**
432
582
  * PK + `preferredId` for a controller's table/readable — the
433
- * {@link MetaVisibility.alwaysVisible} set both `applyMetaOverlay` and
434
- * `hasField` pass into the visibility checks.
583
+ * {@link MetaVisibility.alwaysVisible} set. Kept for compatibility: the
584
+ * controllers now pass `this.readable` and it is derived there.
435
585
  */
436
- function metaAlwaysVisibleFields(controller, source) {
437
- const ctor = controller.constructor;
438
- let set = alwaysVisibleFieldsCache.get(ctor);
439
- if (!set) {
440
- set = /* @__PURE__ */ new Set([...source.primaryKeys, ...source.preferredId]);
441
- alwaysVisibleFieldsCache.set(ctor, set);
442
- }
443
- return set;
586
+ function metaAlwaysVisibleFields(_controller, source) {
587
+ return identifierSet(source);
444
588
  }
445
589
  /**
446
590
  * The write CRUD ops. Everything else in `meta.crud` is row-returning read
@@ -471,11 +615,14 @@ const WRITE_CRUD_OPS = /* @__PURE__ */ new Set([
471
615
  * universe, no pruning (unchanged behavior for unscoped roles). No read op
472
616
  * allowed → the projection union is empty → no pruning: `crud` already
473
617
  * advertises no read surface, and write-only principals still need `type`
474
- * for their insert/update forms. `alwaysVisible` (PK + preferredId) is never
475
- * pruned — reads always return those fields (projection widening / id
476
- * addressing).
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
477
624
  */
478
- async function applyArbacMetaOverlay(meta, alwaysVisible) {
625
+ async function applyArbacMetaOverlay(meta, source) {
479
626
  const arbac = useArbac();
480
627
  const actionToMethodMeta = collectActionMetaByName();
481
628
  const crudKeys = Object.keys(meta.crud);
@@ -508,12 +655,10 @@ async function applyArbacMetaOverlay(meta, alwaysVisible) {
508
655
  if (!scopes || scopes.length === 0) readUnrestricted = true;
509
656
  else readScopes.push(...scopes);
510
657
  }
511
- if (!readUnrestricted) {
512
- const allowed = unionScopeProjection(readScopes);
513
- if (allowed) overlaid = pruneMetaByVisibility(overlaid, {
514
- allowed,
515
- alwaysVisible,
516
- withGrants: collectWithGrantNames(readScopes),
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,
517
662
  writable: collectWritableFields(writeScopes, writeUnrestricted)
518
663
  });
519
664
  }
@@ -568,11 +713,13 @@ function collectMethodNames(instance) {
568
713
  }
569
714
  //#endregion
570
715
  //#region src/db/shared-read-helpers.ts
571
- /** Filter that matches no rows; used when ARBAC denies the request. */
572
- const DENY_FILTER = { $or: [] };
573
- /** Single point of access to the per-event scope cache. */
716
+ /**
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.
720
+ */
574
721
  function readCachedScopes() {
575
- return useArbac().getScopes() ?? [];
722
+ return getArbacScopes() ?? [];
576
723
  }
577
724
  /**
578
725
  * Full body of `transformFilter` for both ARBAC DB controllers: evaluate
@@ -594,16 +741,34 @@ async function transformArbacFilter(filter) {
594
741
  return conjoinScopeFilters(mergeScopeFilters((scopes ?? []).map((s) => s.filter ?? {})), filter) ?? {};
595
742
  }
596
743
  /**
597
- * Union the per-scope `projection` whitelists and restrict the user-supplied
598
- * projection to that union. Returns the original projection untouched when
599
- * no scope declares a projection (so caller sees the unrestricted shape).
600
- * Uses the same `unionScopeProjection` the field-visibility seams (`hasField`
601
- * / `/meta` pruning) use, so value stripping and name hiding cannot drift.
744
+ * Restrict the user-supplied `$select` to the scopes' projection union.
745
+ * Returns the original projection untouched when no scope declares a
746
+ * projection (so caller sees the unrestricted shape). Reads the same cached
747
+ * visibility (`vis.allowed`) `hasField` / `/meta` pruning use, so value
748
+ * stripping and name hiding cannot drift.
749
+ *
750
+ * `projection` is the `$select` as moost-db parsed it: an inclusion is an
751
+ * ARRAY of names, an exclusion an object — both are normalized first. A
752
+ * requested parent (`$select=a`) narrows to what the scope shows: to the
753
+ * whitelisted descendants (`a.b: 1`), or — for a hidden descendant
754
+ * (`a.c: 0`) — to its visible children via `childrenOf` (without a schema
755
+ * the parent is dropped). Never the whole object, and never the empty
756
+ * (universe) projection: when nothing survives, the scope's own projection
757
+ * applies (`restrictProjection`'s ceiling fallback).
758
+ *
759
+ * An exclusion result names nested-object parents by their leaves
760
+ * (`{ a: 0 }` → `{ "a.b": 0, "a.c": 0 }`): flattening adapters invert an
761
+ * exclusion against their leaf columns, so a parent key alone would strip
762
+ * nothing (a scope `{ a: 0 }` would otherwise return all of `a`).
602
763
  */
603
- function applyArbacProjection(projection, scopes) {
604
- const allowed = unionScopeProjection(scopes);
605
- if (!allowed) return projection;
606
- return restrictProjection(projection ?? {}, allowed);
764
+ function applyArbacProjection(projection, scopes, readable) {
765
+ return restrictSelect(projection, scopeVisibility(scopes, readable));
766
+ }
767
+ /** {@link applyArbacProjection} over an already-built visibility level. */
768
+ function restrictSelect(projection, vis) {
769
+ if (Object.keys(vis.allowed).length === 0) return projection;
770
+ const childrenOf = fieldChildrenOf(vis.table);
771
+ return expandExcludeToLeaves(restrictProjection(normalizeSelect(projection) ?? {}, vis.allowed, childrenOf), childrenOf);
607
772
  }
608
773
  /**
609
774
  * Enforce the union of per-scope `controls` gates against the parsed
@@ -625,43 +790,43 @@ function applyArbacControls(controls, scopes) {
625
790
  * passes through unchanged. The outermost check skips the entire walk when no
626
791
  * scope declares `with` at all — this is the common case on every read.
627
792
  */
628
- function applyArbacRelationScopes(controls, scopes) {
793
+ function applyArbacRelationScopes(controls, scopes, readable) {
629
794
  if (scopes.length === 0) return;
795
+ if (!scopes.some((s) => s.with)) return;
796
+ applyRelationLevel(controls, scopeVisibility(scopes, readable));
797
+ }
798
+ /**
799
+ * One `$with` level of {@link applyArbacRelationScopes}: each granted
800
+ * relation's child visibility already carries its sub-scopes and target table.
801
+ */
802
+ function applyRelationLevel(controls, vis) {
630
803
  const withArr = controls.$with;
631
804
  if (!Array.isArray(withArr) || withArr.length === 0) return;
632
- if (!scopes.some((s) => s.with)) return;
633
805
  for (const raw of withArr) {
634
806
  if (!raw || typeof raw !== "object") continue;
635
807
  const entry = raw;
636
808
  if (typeof entry.name !== "string") continue;
637
- const subScopes = collectSubScopes(scopes, entry.name);
638
- if (subScopes.length === 0) continue;
809
+ const child = vis.relation?.(entry.name);
810
+ const subScopes = child?.scopes ?? [];
811
+ if (!child || subScopes.length === 0) continue;
639
812
  const conjoined = conjoinScopeFilters(mergeScopeFilters(subScopes.map((s) => s.filter ?? {})), entry.filter);
640
813
  if (conjoined) entry.filter = conjoined;
641
814
  const entryControls = entry.controls ?? {};
642
- const restricted = applyArbacProjection(normalizeSelect(entryControls.$select), subScopes);
815
+ const restricted = restrictSelect(entryControls.$select, child);
643
816
  if (restricted !== void 0) {
644
817
  entryControls.$select = restricted;
645
818
  entry.controls = entryControls;
646
819
  }
647
820
  enforceControlsPolicy(unionControlsPolicy(subScopes), entryControls);
648
- applyArbacRelationScopes(entryControls, subScopes);
821
+ if (subScopes.some((s) => s.with)) applyRelationLevel(entryControls, child);
649
822
  }
650
823
  }
651
- /** Pick `scopes[i].with?.[name]`, dropping undefined. */
652
- function collectSubScopes(scopes, name) {
653
- const out = [];
654
- for (const s of scopes) {
655
- const sub = s.with?.[name];
656
- if (sub) out.push(sub);
657
- }
658
- return out;
659
- }
660
824
  /**
661
825
  * `$select` may be an array of field names (uniquery inclusion list) or a
662
- * `TProjection` object. `applyArbacProjection` / `restrictProjection` operate
663
- * on the object form, so normalize the array to `{ field: 1 }` first. Returns
664
- * undefined if the input is empty (caller treats as "no projection set").
826
+ * `TProjection` object. `restrictProjection` operates on the object form
827
+ * (it would read `["a"]` as an exclusion keyed `"0"`), so normalize the array
828
+ * to `{ field: 1 }` first. Returns undefined if the input is empty (caller
829
+ * treats as "no projection set").
665
830
  */
666
831
  function normalizeSelect(value) {
667
832
  if (value === void 0 || value === null) return void 0;
@@ -679,7 +844,7 @@ let AsArbacDbController = class AsArbacDbController extends AsDbController {
679
844
  return transformArbacFilter(filter);
680
845
  }
681
846
  transformProjection(projection) {
682
- return applyArbacProjection(projection, readCachedScopes());
847
+ return applyArbacProjection(projection, readCachedScopes(), this.readable);
683
848
  }
684
849
  /**
685
850
  * Translate `@uniqu/url` parser failures into HTTP 400 instead of letting them
@@ -730,21 +895,24 @@ let AsArbacDbController = class AsArbacDbController extends AsDbController {
730
895
  if (baseErr) return baseErr;
731
896
  const scopes = readCachedScopes();
732
897
  applyArbacControls(controls, scopes);
733
- applyArbacRelationScopes(controls, scopes);
898
+ applyArbacRelationScopes(controls, scopes, this.readable);
734
899
  }
735
900
  applyMetaOverlay(meta) {
736
- return applyArbacMetaOverlay(meta, metaAlwaysVisibleFields(this, this.readable));
901
+ return applyArbacMetaOverlay(meta, this.readable);
737
902
  }
738
903
  /**
739
904
  * Scope-aware field visibility (BUG-3 twin of the `/meta` pruning above): a
740
905
  * field outside the read-scope projection union answers `false`, which
741
906
  * moost-db's visibility hook turns into the same `Unknown field "x"` 400 a
742
907
  * nonexistent field gets at every gated query position — see the docs'
743
- * "Column-scope security floor". Identifiers stay visible; paths under a
744
- * `with`-granted relation pass through to the sub-scope's own enforcement.
908
+ * "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.
745
913
  */
746
914
  hasField(path) {
747
- return super.hasField(path) && isScopedFieldVisible(readCachedScopes(), path, metaAlwaysVisibleFields(this, this.readable));
915
+ return super.hasField(path) && isScopedFieldVisible(readCachedScopes(), path, this.readable);
748
916
  }
749
917
  async onWrite(action, data) {
750
918
  const scopes = readCachedScopes();
@@ -759,7 +927,7 @@ let AsArbacDbController = class AsArbacDbController extends AsDbController {
759
927
  const scopeFilter = mergeScopeFilters(scopes.map((s) => s.filter ?? {}));
760
928
  if (!scopeFilter) return;
761
929
  const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];
762
- const idFilters = ids.map((id) => this.readable.resolveIdFilter(id));
930
+ const idFilters = ids.map((id) => this.readable.resolveIdFilter(id, this._idOpts));
763
931
  if (idFilters.some((f) => !f)) throw new HttpError(404, "Not found");
764
932
  const idFilter = idFilters.length === 1 ? idFilters[0] : { $or: idFilters };
765
933
  if (await this.readable.count({ filter: { $and: [idFilter, scopeFilter] } }) < ids.length) throw new HttpError(404, "Not found");
@@ -908,14 +1076,14 @@ let AsArbacDbReadableController = class AsArbacDbReadableController extends AsDb
908
1076
  return transformArbacFilter(filter);
909
1077
  }
910
1078
  transformProjection(projection) {
911
- return applyArbacProjection(projection, readCachedScopes());
1079
+ return applyArbacProjection(projection, readCachedScopes(), this.readable);
912
1080
  }
913
1081
  validateControls(controls, type) {
914
1082
  const baseErr = super.validateControls(controls, type);
915
1083
  if (baseErr) return baseErr;
916
1084
  const scopes = readCachedScopes();
917
1085
  applyArbacControls(controls, scopes);
918
- applyArbacRelationScopes(controls, scopes);
1086
+ applyArbacRelationScopes(controls, scopes, this.readable);
919
1087
  }
920
1088
  /**
921
1089
  * Same ARBAC `/meta` overlay as the writable controller (actions/crud
@@ -923,16 +1091,16 @@ let AsArbacDbReadableController = class AsArbacDbReadableController extends AsDb
923
1091
  * union) — view-style metas leak hidden field names identically.
924
1092
  */
925
1093
  applyMetaOverlay(meta) {
926
- return applyArbacMetaOverlay(meta, metaAlwaysVisibleFields(this, this.readable));
1094
+ return applyArbacMetaOverlay(meta, this.readable);
927
1095
  }
928
1096
  /**
929
1097
  * Scope-aware field visibility — same contract as
930
1098
  * {@link AsArbacDbController.hasField}.
931
1099
  */
932
1100
  hasField(path) {
933
- return super.hasField(path) && isScopedFieldVisible(readCachedScopes(), path, metaAlwaysVisibleFields(this, this.readable));
1101
+ return super.hasField(path) && isScopedFieldVisible(readCachedScopes(), path, this.readable);
934
1102
  }
935
1103
  };
936
1104
  AsArbacDbReadableController = __decorate([Inherit()], AsArbacDbReadableController);
937
1105
  //#endregion
938
- export { Arbac, ArbacAction, ArbacAuthorize, ArbacResource, ArbacUserProvider, ArbacUserProviderToken, AsArbacDbController, AsArbacDbReadableController, MoostArbac, applyAllowedFieldsAndSet, applyArbacMetaOverlay, arbacAuthorizeInterceptor, arbacPatternToRegex, collectMethodNames, collectWithGrantNames, collectWritableFields, conjoinArbacDbScopes, enforceControlsPolicy, extractUsedControlValues, getArbacMate, isMetaFieldVisible, isScopedFieldVisible, metaAlwaysVisibleFields, pruneMetaByVisibility, unionScopeProjection, useArbac };
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 };
@@ -1,8 +1,8 @@
1
1
  import { TClassConstructor } from "moost";
2
- import { ControlGate, TProjection, TScopeFilter } from "@aooth/arbac";
2
+ import { ControlGate, TProjection, TProjectionChildren, TScopeFilter } from "@aooth/arbac";
3
+ import { TMetaResponse } from "@atscript/db";
3
4
  import { AsDbController } from "@atscript/moost-db";
4
5
  import { NavPropsOf, OwnPropsOf, TAtscriptAnnotatedType } from "@atscript/typescript/utils";
5
- import { TMetaResponse } from "@atscript/db";
6
6
  //#region src/db/scope-types.d.ts
7
7
  /**
8
8
  * Unwrap a `NavPropsOf<T>[K]` value to its target model type:
@@ -167,8 +167,11 @@ declare class AsArbacDbController<T extends TAtscriptAnnotatedType = TAtscriptAn
167
167
  * field outside the read-scope projection union answers `false`, which
168
168
  * moost-db's visibility hook turns into the same `Unknown field "x"` 400 a
169
169
  * nonexistent field gets at every gated query position — see the docs'
170
- * "Column-scope security floor". Identifiers stay visible; paths under a
171
- * `with`-granted relation pass through to the sub-scope's own enforcement.
170
+ * "Column-scope security floor". Identifiers stay visible. A `rel.x` path
171
+ * under a `with`-granted relation (`$with=rel(x>1)`, `$with=rel($sort=x)`,
172
+ * `$with=rel($select=x)`) is checked as `x` against the union of the
173
+ * `with.rel` sub-scopes, recursively; the related table's own PK /
174
+ * `preferredId` stay visible.
172
175
  */
173
176
  protected hasField(path: string): boolean;
174
177
  protected onWrite(action: "insert" | "insertMany" | "replace" | "replaceMany" | "update" | "updateMany", data: unknown): Promise<unknown>;
@@ -256,15 +259,24 @@ interface AoothArbacClaims {
256
259
  *
257
260
  * Each side is first UNIONed with the existing additive helpers (today's
258
261
  * machinery), then the two RESULTS are CONJOINED with the dedicated combiners
259
- * ({@link conjoinScopeFilters} `$and`, {@link restrictProjection} field ∩,
262
+ * ({@link conjoinScopeFilters} `$and`, {@link intersectProjections} field ∩,
260
263
  * {@link intersectControlsPolicy} deny-wins, `with` recursion) — never the
261
264
  * additive union helpers, which would silently widen.
262
265
  *
263
266
  * Returned as a single-element list so every downstream scope-application
264
267
  * site (which UNIONs the cached scope list per facet) sees the identity of a
265
268
  * one-element union — i.e. the conjunction — with no change to those sites.
269
+ *
270
+ * The projection conjunction is path-wise and never wider than either side
271
+ * (`{a:1}` ∩ `{"a.b":1}` → `{"a.b":1}`). A nested exclusion under an
272
+ * included parent (`{a:1}` ∩ `{"a.c":0}`) is subtracted exactly via
273
+ * `childrenOf` (the table schema); without it the parent is dropped (fail
274
+ * closed). When NO field survives (`{a:1}` ∩ `{a:0}`, disjoint whitelists),
275
+ * the composite keeps the user's projection and gets a match-nothing filter —
276
+ * an empty field set must never read as the unrestricted `{}`. `with`
277
+ * sub-scopes are conjoined without a schema.
266
278
  */
267
- declare function conjoinArbacDbScopes(userScopes: ArbacDbScope[], credScopes: ArbacDbScope[]): ArbacDbScope[];
279
+ declare function conjoinArbacDbScopes(userScopes: ArbacDbScope[], credScopes: ArbacDbScope[], childrenOf?: TProjectionChildren): ArbacDbScope[];
268
280
  //#endregion
269
281
  //#region src/user.provider.d.ts
270
282
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aooth/arbac-moost",
3
- "version": "0.1.67",
3
+ "version": "0.1.69",
4
4
  "description": "Moost RBAC integration for aoothjs (migrated from @moostjs/arbac)",
5
5
  "keywords": [
6
6
  "abac",
@@ -60,17 +60,17 @@
60
60
  "access": "public"
61
61
  },
62
62
  "dependencies": {
63
- "@uniqu/core": "^0.1.10",
64
- "@aooth/arbac": "0.1.67",
65
- "@aooth/user": "0.1.67",
66
- "@aooth/arbac-core": "0.1.67"
63
+ "@uniqu/core": "^0.1.11",
64
+ "@aooth/arbac": "0.1.69",
65
+ "@aooth/user": "0.1.69",
66
+ "@aooth/arbac-core": "0.1.69"
67
67
  },
68
68
  "devDependencies": {
69
69
  "@atscript/core": "^0.1.93",
70
- "@atscript/db": "^0.1.133",
71
- "@atscript/db-sql-tools": "^0.1.133",
72
- "@atscript/db-sqlite": "^0.1.133",
73
- "@atscript/moost-db": "^0.1.133",
70
+ "@atscript/db": "^0.1.136",
71
+ "@atscript/db-sql-tools": "^0.1.136",
72
+ "@atscript/db-sqlite": "^0.1.136",
73
+ "@atscript/moost-db": "^0.1.136",
74
74
  "@atscript/typescript": "^0.1.93",
75
75
  "@moostjs/event-http": "^0.6.37",
76
76
  "@types/better-sqlite3": "^7.6.13",
@@ -78,8 +78,8 @@
78
78
  "unplugin-atscript": "^0.1.93"
79
79
  },
80
80
  "peerDependencies": {
81
- "@atscript/db": "^0.1.133",
82
- "@atscript/moost-db": "^0.1.133",
81
+ "@atscript/db": "^0.1.136",
82
+ "@atscript/moost-db": "^0.1.136",
83
83
  "@atscript/typescript": "^0.1.93",
84
84
  "@moostjs/event-http": "^0.6.37",
85
85
  "@wooksjs/event-core": "^0.7.23",