@aooth/arbac-moost 0.1.21 → 0.1.23

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-CKPMp3G8.mjs";
1
+ import { r as AoothArbacClaims, t as ArbacUserProvider } from "../user.provider-BFHjF2vD.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
 
package/dist/index.d.mts CHANGED
@@ -1,4 +1,4 @@
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-CKPMp3G8.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-BFHjF2vD.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";
@@ -6,6 +6,7 @@ import { Mate, TMateParamMeta, TMoostMetadata } from "moost";
6
6
  import { ControlGate, TProjection } from "@aooth/arbac";
7
7
  import { AsDbReadableController } from "@atscript/moost-db";
8
8
  import { TAtscriptAnnotatedType } from "@atscript/typescript/utils";
9
+ import { TMetaResponse } from "@atscript/db";
9
10
 
10
11
  //#region src/arbac.composables.d.ts
11
12
  interface ArbacBindings {
@@ -122,7 +123,115 @@ declare class AsArbacDbReadableController<T extends TAtscriptAnnotatedType = TAt
122
123
  protected transformFilter(filter: Record<string, unknown> | undefined): Promise<Record<string, unknown>>;
123
124
  protected transformProjection(projection?: TProjection): TProjection | undefined | Promise<TProjection | undefined>;
124
125
  protected validateControls(controls: Record<string, unknown>, type: "query" | "pages" | "getOne"): string | undefined;
126
+ /**
127
+ * Same ARBAC `/meta` overlay as the writable controller (actions/crud
128
+ * filtering + BUG-3 field-surface pruning by the read scopes' projection
129
+ * union) — view-style metas leak hidden field names identically.
130
+ */
131
+ protected applyMetaOverlay(meta: TMetaResponse): Promise<TMetaResponse>;
132
+ /**
133
+ * Scope-aware field-existence check — same contract as
134
+ * {@link AsArbacDbController.hasField} (BUG-3): a field outside the
135
+ * read-scope projection union answers `false`, so `validateInsights` rejects
136
+ * `$select` / filter / sort references to it with the identical
137
+ * `Unknown field "x"` 400 a nonexistent field gets.
138
+ */
139
+ protected hasField(path: string): boolean;
140
+ }
141
+ //#endregion
142
+ //#region src/db/meta-projection.d.ts
143
+ /**
144
+ * Union the per-scope `projection` whitelists into the single access-control
145
+ * projection used for FIELD-EXISTENCE decisions (`hasField` parity + `/meta`
146
+ * pruning). Returns `undefined` when the union imposes no restriction — no
147
+ * scopes at all, or any scope without a `projection` (a universal grant makes
148
+ * `unionProjections` collapse to the universe `{}`).
149
+ */
150
+ declare function unionScopeProjection(scopes: ArbacDbScope[]): TProjection | undefined;
151
+ /**
152
+ * Relation names any scope explicitly grants via `with.<name>` — an explicit
153
+ * content grant implies the relation EXISTS for this principal even when the
154
+ * projection union does not name it (the sub-scope, not the projection, owns
155
+ * the joined rows' policy — see {@link ArbacDbScope.with}).
156
+ */
157
+ declare function collectWithGrantNames(scopes: ArbacDbScope[]): ReadonlySet<string>;
158
+ /**
159
+ * The principal's field-visibility verdict set, assembled once per request
160
+ * (or per `/meta` overlay) and threaded through the pruning walk.
161
+ */
162
+ interface MetaVisibility {
163
+ /** Constrained projection union (never the empty/universe projection). */
164
+ allowed: TProjection;
165
+ /**
166
+ * Identifier fields reads ALWAYS return regardless of projection (the read
167
+ * path widens `preferredId` back in, and PK addressing requires the key) —
168
+ * hiding them would advertise less than reads deliver and break `/one/:id`.
169
+ */
170
+ alwaysVisible: ReadonlySet<string>;
171
+ /** Relation names granted via `with.<name>` (see {@link collectWithGrantNames}). */
172
+ withGrants: ReadonlySet<string>;
125
173
  }
174
+ /**
175
+ * Whether a flattened dot-path field exists for this principal. A path under
176
+ * a `with`-granted relation passes unconditionally — the sub-scope governs
177
+ * the joined content, and rejecting the path here would break the very `$with`
178
+ * expansion the grant permits.
179
+ */
180
+ declare function isMetaFieldVisible(path: string, vis: MetaVisibility): boolean;
181
+ /**
182
+ * Prune a `/meta` envelope down to the fields the principal's read scopes can
183
+ * ever surface, so a scoped UI cannot even OFFER an out-of-scope column (the
184
+ * designed contract: the projection removes fields from the META entirely,
185
+ * not just from row payloads). Pruned facets:
186
+ *
187
+ * - `fields` — the flat capability map (sortable/filterable flags).
188
+ * - `type` — the serialized annotated type (what dynamic clients build
189
+ * tables/forms from); nested object props prune by dot-path, relation props
190
+ * survive whole when the relation itself is visible.
191
+ * - `relations` — entries neither projected nor `with`-granted.
192
+ * - `versionColumn` — dropped when the OCC column itself is hidden.
193
+ *
194
+ * NEVER mutates the input — the base controller caches the static envelope
195
+ * (`applyMetaOverlay` contract); every pruned branch is a fresh object.
196
+ */
197
+ declare function pruneMetaByVisibility(meta: TMetaResponse, vis: MetaVisibility): TMetaResponse;
198
+ /**
199
+ * Shared body of both ARBAC controllers' `hasField` overrides (BUG-3 twin of
200
+ * the `/meta` pruning): a field outside the read-scope projection union must
201
+ * be indistinguishable from a field that does not exist. Returns `true` when
202
+ * the scopes impose no projection restriction.
203
+ */
204
+ declare function isScopedFieldVisible(scopes: ArbacDbScope[], path: string, alwaysVisible: ReadonlySet<string>): boolean;
205
+ /**
206
+ * PK + `preferredId` for a controller's table/readable — the
207
+ * {@link MetaVisibility.alwaysVisible} set both `applyMetaOverlay` and
208
+ * `hasField` pass into the visibility checks.
209
+ */
210
+ declare function metaAlwaysVisibleFields(controller: object, source: {
211
+ primaryKeys: readonly string[];
212
+ preferredId: readonly string[];
213
+ }): ReadonlySet<string>;
214
+ /**
215
+ * The full ARBAC `/meta` overlay, shared by `AsArbacDbController` and
216
+ * `AsArbacDbReadableController`: filter `actions` + `crud` by per-action
217
+ * evaluation, then prune the FIELD surface (`fields`, serialized `type`,
218
+ * `relations`, `versionColumn`) by the read scopes' projection union.
219
+ *
220
+ * Field pruning (BUG-3): a scope projection must remove fields from the META
221
+ * entirely — `transformProjection` already strips their VALUES from every
222
+ * read, but the envelope still advertised the full field map, so a scoped UI
223
+ * offered columns that could never populate, and secret-bearing column NAMES
224
+ * leaked. The pruning union comes from the read-op evaluations already
225
+ * computed for the `crud` overlay (same `unionProjections` the read path
226
+ * applies). An allowed read op WITHOUT scopes is an unscoped grant —
227
+ * universe, no pruning (unchanged behavior for unscoped roles). No read op
228
+ * allowed → the projection union is empty → no pruning: `crud` already
229
+ * advertises no read surface, and write-only principals still need `type`
230
+ * for their insert/update forms. `alwaysVisible` (PK + preferredId) is never
231
+ * pruned — reads always return those fields (projection widening / id
232
+ * addressing).
233
+ */
234
+ declare function applyArbacMetaOverlay(meta: TMetaResponse, alwaysVisible: ReadonlySet<string>): Promise<TMetaResponse>;
126
235
  //#endregion
127
236
  //#region src/moost-arbac.d.ts
128
237
  /**
@@ -138,4 +247,4 @@ declare class AsArbacDbReadableController<T extends TAtscriptAnnotatedType = TAt
138
247
  */
139
248
  declare class MoostArbac<TUserAttrs extends object, TScope extends object> extends Arbac$1<TUserAttrs, TScope> {}
140
249
  //#endregion
141
- export { type AoothArbacClaims, Arbac, ArbacAction, ArbacAuthorize, ArbacDbScope, ArbacResource, ArbacUserProvider, ArbacUserProviderToken, AsArbacDbController, AsArbacDbReadableController, type ControlGate, type ControlsOf, MoostArbac, type NavRelationKey, type NavTarget, type OwnFieldKey, type ProjectionOf, type TArbacCompiledRule, type TArbacEvalResult, TArbacMeta, type TArbacRole, type TArbacRoleForResource, type TArbacRule, applyAllowedFieldsAndSet, arbacAuthorizeInterceptor, arbacPatternToRegex, conjoinArbacDbScopes, enforceControlsPolicy, extractUsedControlValues, getArbacMate, useArbac };
250
+ 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, collectWithGrantNames, conjoinArbacDbScopes, enforceControlsPolicy, extractUsedControlValues, getArbacMate, isMetaFieldVisible, isScopedFieldVisible, metaAlwaysVisibleFields, pruneMetaByVisibility, unionScopeProjection, useArbac };
package/dist/index.mjs CHANGED
@@ -3,7 +3,7 @@ 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, getInstanceOwnMethods, getMoostMate, useControllerContext, useLogger } from "moost";
6
- import { conjoinScopeFilters, intersectControlsPolicy, mergeScopeFilters, restrictProjection, unionControlsPolicy, unionProjections } from "@aooth/arbac";
6
+ import { conjoinScopeFilters, intersectControlsPolicy, isFieldAllowed, mergeScopeFilters, restrictProjection, unionControlsPolicy, unionProjections } from "@aooth/arbac";
7
7
  import { AsDbController, AsDbReadableController } from "@atscript/moost-db";
8
8
  //#region src/attenuation.ts
9
9
  /**
@@ -239,6 +239,259 @@ const ArbacResource = (name) => getArbacMate().decorate("arbacResourceId", name)
239
239
  */
240
240
  const ArbacAction = (name) => getArbacMate().decorate("arbacActionId", name);
241
241
  //#endregion
242
+ //#region src/db/meta-projection.ts
243
+ /**
244
+ * Union the per-scope `projection` whitelists into the single access-control
245
+ * projection used for FIELD-EXISTENCE decisions (`hasField` parity + `/meta`
246
+ * pruning). Returns `undefined` when the union imposes no restriction — no
247
+ * scopes at all, or any scope without a `projection` (a universal grant makes
248
+ * `unionProjections` collapse to the universe `{}`).
249
+ */
250
+ function unionScopeProjection(scopes) {
251
+ if (scopes.length === 0) return void 0;
252
+ const allowed = unionProjections(...scopes.map((s) => s.projection ?? {}));
253
+ return Object.keys(allowed).length === 0 ? void 0 : allowed;
254
+ }
255
+ /**
256
+ * Relation names any scope explicitly grants via `with.<name>` — an explicit
257
+ * content grant implies the relation EXISTS for this principal even when the
258
+ * projection union does not name it (the sub-scope, not the projection, owns
259
+ * the joined rows' policy — see {@link ArbacDbScope.with}).
260
+ */
261
+ function collectWithGrantNames(scopes) {
262
+ const out = /* @__PURE__ */ new Set();
263
+ for (const s of scopes) if (s.with) for (const name of Object.keys(s.with)) out.add(name);
264
+ return out;
265
+ }
266
+ /**
267
+ * Whether a flattened dot-path field exists for this principal. A path under
268
+ * a `with`-granted relation passes unconditionally — the sub-scope governs
269
+ * the joined content, and rejecting the path here would break the very `$with`
270
+ * expansion the grant permits.
271
+ */
272
+ function isMetaFieldVisible(path, vis) {
273
+ if (vis.alwaysVisible.has(path)) return true;
274
+ const dot = path.indexOf(".");
275
+ const head = dot === -1 ? path : path.slice(0, dot);
276
+ if (vis.withGrants.has(head)) return true;
277
+ return isFieldAllowed(path, vis.allowed);
278
+ }
279
+ /**
280
+ * Prune a `/meta` envelope down to the fields the principal's read scopes can
281
+ * ever surface, so a scoped UI cannot even OFFER an out-of-scope column (the
282
+ * designed contract: the projection removes fields from the META entirely,
283
+ * not just from row payloads). Pruned facets:
284
+ *
285
+ * - `fields` — the flat capability map (sortable/filterable flags).
286
+ * - `type` — the serialized annotated type (what dynamic clients build
287
+ * tables/forms from); nested object props prune by dot-path, relation props
288
+ * survive whole when the relation itself is visible.
289
+ * - `relations` — entries neither projected nor `with`-granted.
290
+ * - `versionColumn` — dropped when the OCC column itself is hidden.
291
+ *
292
+ * NEVER mutates the input — the base controller caches the static envelope
293
+ * (`applyMetaOverlay` contract); every pruned branch is a fresh object.
294
+ */
295
+ function pruneMetaByVisibility(meta, vis) {
296
+ const fields = {};
297
+ for (const [path, fieldMeta] of Object.entries(meta.fields)) if (isMetaFieldVisible(path, vis)) fields[path] = fieldMeta;
298
+ const relationNames = new Set(meta.relations.map((r) => r.name));
299
+ const relations = meta.relations.filter((r) => isMetaFieldVisible(r.name, vis));
300
+ const out = {
301
+ ...meta,
302
+ fields,
303
+ relations,
304
+ type: pruneSerializedType(meta.type, "", vis, relationNames)
305
+ };
306
+ if (out.versionColumn !== void 0 && !isMetaFieldVisible(out.versionColumn, vis)) delete out.versionColumn;
307
+ return out;
308
+ }
309
+ /**
310
+ * Copy-on-prune walk over a serialized type node. `basePath` is the flattened
311
+ * dot-path prefix ("" at the root). Top-level relation props (nav props named
312
+ * in `meta.relations`) are kept or dropped WHOLE — their internal shape is the
313
+ * joined model's business (content scoping happens per-request through the
314
+ * `with` sub-scopes), while own-field subtrees prune recursively so an
315
+ * include-mode union like `{ "password.hash": 1 }` keeps `password` with only
316
+ * `hash` inside.
317
+ */
318
+ function pruneSerializedType(node, basePath, vis, relationNames) {
319
+ const def = node.type;
320
+ if (def.kind === "object") {
321
+ const props = {};
322
+ for (const [name, prop] of Object.entries(def.props)) {
323
+ const path = basePath ? `${basePath}.${name}` : name;
324
+ if (basePath === "" && relationNames.has(name)) {
325
+ if (isMetaFieldVisible(name, vis)) props[name] = prop;
326
+ continue;
327
+ }
328
+ if (!isMetaFieldVisible(path, vis)) continue;
329
+ props[name] = pruneSerializedType(prop, path, vis, relationNames);
330
+ }
331
+ return {
332
+ ...node,
333
+ type: {
334
+ ...def,
335
+ props
336
+ }
337
+ };
338
+ }
339
+ if (def.kind === "array") return {
340
+ ...node,
341
+ type: {
342
+ ...def,
343
+ of: pruneSerializedType(def.of, basePath, vis, relationNames)
344
+ }
345
+ };
346
+ if (def.kind === "union" || def.kind === "intersection" || def.kind === "tuple") return {
347
+ ...node,
348
+ type: {
349
+ ...def,
350
+ items: def.items.map((item) => pruneSerializedType(item, basePath, vis, relationNames))
351
+ }
352
+ };
353
+ return node;
354
+ }
355
+ const UNRESTRICTED_VISIBILITY = {
356
+ allowed: void 0,
357
+ withGrants: /* @__PURE__ */ new Set()
358
+ };
359
+ const scopeVisibilityCache = /* @__PURE__ */ new WeakMap();
360
+ function scopeVisibility(scopes) {
361
+ if (scopes.length === 0) return UNRESTRICTED_VISIBILITY;
362
+ let vis = scopeVisibilityCache.get(scopes);
363
+ if (!vis) {
364
+ vis = {
365
+ allowed: unionScopeProjection(scopes),
366
+ withGrants: collectWithGrantNames(scopes)
367
+ };
368
+ scopeVisibilityCache.set(scopes, vis);
369
+ }
370
+ return vis;
371
+ }
372
+ /**
373
+ * Shared body of both ARBAC controllers' `hasField` overrides (BUG-3 twin of
374
+ * the `/meta` pruning): a field outside the read-scope projection union must
375
+ * be indistinguishable from a field that does not exist. Returns `true` when
376
+ * the scopes impose no projection restriction.
377
+ */
378
+ function isScopedFieldVisible(scopes, path, alwaysVisible) {
379
+ const { allowed, withGrants } = scopeVisibility(scopes);
380
+ if (!allowed) return true;
381
+ return isMetaFieldVisible(path, {
382
+ allowed,
383
+ alwaysVisible,
384
+ withGrants
385
+ });
386
+ }
387
+ const alwaysVisibleFieldsCache = /* @__PURE__ */ new WeakMap();
388
+ /**
389
+ * PK + `preferredId` for a controller's table/readable — the
390
+ * {@link MetaVisibility.alwaysVisible} set both `applyMetaOverlay` and
391
+ * `hasField` pass into the visibility checks.
392
+ */
393
+ function metaAlwaysVisibleFields(controller, source) {
394
+ const ctor = controller.constructor;
395
+ let set = alwaysVisibleFieldsCache.get(ctor);
396
+ if (!set) {
397
+ set = new Set([...source.primaryKeys, ...source.preferredId]);
398
+ alwaysVisibleFieldsCache.set(ctor, set);
399
+ }
400
+ return set;
401
+ }
402
+ /**
403
+ * The write CRUD ops. Everything else in `meta.crud` is row-returning read
404
+ * surface whose scopes govern field VISIBILITY in `/meta` — classified by
405
+ * complement so a read op added upstream defaults to READ and the pruning
406
+ * fails closed (a principal whose only read grant is the new op still gets
407
+ * a pruned envelope rather than the full field map).
408
+ */
409
+ const WRITE_CRUD_OPS = new Set([
410
+ "insert",
411
+ "update",
412
+ "replace",
413
+ "remove"
414
+ ]);
415
+ /**
416
+ * The full ARBAC `/meta` overlay, shared by `AsArbacDbController` and
417
+ * `AsArbacDbReadableController`: filter `actions` + `crud` by per-action
418
+ * evaluation, then prune the FIELD surface (`fields`, serialized `type`,
419
+ * `relations`, `versionColumn`) by the read scopes' projection union.
420
+ *
421
+ * Field pruning (BUG-3): a scope projection must remove fields from the META
422
+ * entirely — `transformProjection` already strips their VALUES from every
423
+ * read, but the envelope still advertised the full field map, so a scoped UI
424
+ * offered columns that could never populate, and secret-bearing column NAMES
425
+ * leaked. The pruning union comes from the read-op evaluations already
426
+ * computed for the `crud` overlay (same `unionProjections` the read path
427
+ * applies). An allowed read op WITHOUT scopes is an unscoped grant —
428
+ * universe, no pruning (unchanged behavior for unscoped roles). No read op
429
+ * allowed → the projection union is empty → no pruning: `crud` already
430
+ * advertises no read surface, and write-only principals still need `type`
431
+ * for their insert/update forms. `alwaysVisible` (PK + preferredId) is never
432
+ * pruned — reads always return those fields (projection widening / id
433
+ * addressing).
434
+ */
435
+ async function applyArbacMetaOverlay(meta, alwaysVisible) {
436
+ const arbac = useArbac();
437
+ const actionToMethodMeta = collectActionMetaByName();
438
+ const crudKeys = Object.keys(meta.crud);
439
+ const [actionResults, crudResults] = await Promise.all([Promise.all(meta.actions.map((entry) => {
440
+ const methodMeta = actionToMethodMeta.get(entry.name);
441
+ const arbacAction = methodMeta?.arbacActionId ?? methodMeta?.id ?? entry.name;
442
+ return arbac.evaluate({ action: arbacAction });
443
+ })), Promise.all(crudKeys.map((key) => arbac.evaluate({ action: key })))]);
444
+ const filteredActions = [];
445
+ for (let i = 0; i < meta.actions.length; i++) if (actionResults[i].allowed) filteredActions.push(meta.actions[i]);
446
+ const filteredCrud = {};
447
+ for (let i = 0; i < crudKeys.length; i++) if (crudResults[i].allowed) filteredCrud[crudKeys[i]] = meta.crud[crudKeys[i]];
448
+ let overlaid = {
449
+ ...meta,
450
+ actions: filteredActions,
451
+ crud: filteredCrud
452
+ };
453
+ let readUnrestricted = false;
454
+ const readScopes = [];
455
+ for (let i = 0; i < crudKeys.length; i++) {
456
+ if (!crudResults[i].allowed || WRITE_CRUD_OPS.has(crudKeys[i])) continue;
457
+ const scopes = crudResults[i].scopes;
458
+ if (!scopes || scopes.length === 0) readUnrestricted = true;
459
+ else readScopes.push(...scopes);
460
+ }
461
+ if (!readUnrestricted) {
462
+ const allowed = unionScopeProjection(readScopes);
463
+ if (allowed) overlaid = pruneMetaByVisibility(overlaid, {
464
+ allowed,
465
+ alwaysVisible,
466
+ withGrants: collectWithGrantNames(readScopes)
467
+ });
468
+ }
469
+ return overlaid;
470
+ }
471
+ const actionMetaByClassCache = /* @__PURE__ */ new WeakMap();
472
+ function collectActionMetaByName() {
473
+ const cc = useControllerContext();
474
+ const instance = cc.getController();
475
+ const ctor = getConstructor(instance);
476
+ const cached = actionMetaByClassCache.get(ctor);
477
+ if (cached) return cached;
478
+ const map = /* @__PURE__ */ new Map();
479
+ const ctrlMeta = cc.getControllerMeta();
480
+ for (const entry of ctrlMeta?.atscript_db_actions ?? []) map.set(entry.name, {});
481
+ for (const methodName of getInstanceOwnMethods(instance)) {
482
+ if (typeof methodName !== "string") continue;
483
+ const m = cc.getMethodMeta(methodName);
484
+ if (!m) continue;
485
+ const actionMeta = m.atscript_db_action;
486
+ if (actionMeta?.name) map.set(actionMeta.name, {
487
+ arbacActionId: m.arbacActionId,
488
+ id: m.id
489
+ });
490
+ }
491
+ actionMetaByClassCache.set(ctor, map);
492
+ return map;
493
+ }
494
+ //#endregion
242
495
  //#region src/db/shared-read-helpers.ts
243
496
  /** Filter that matches no rows; used when ARBAC denies the request. */
244
497
  const DENY_FILTER = { $or: [] };
@@ -274,11 +527,12 @@ async function transformArbacFilter(filter) {
274
527
  * Union the per-scope `projection` whitelists and restrict the user-supplied
275
528
  * projection to that union. Returns the original projection untouched when
276
529
  * no scope declares a projection (so caller sees the unrestricted shape).
530
+ * Uses the same `unionScopeProjection` the field-visibility seams (`hasField`
531
+ * / `/meta` pruning) use, so value stripping and name hiding cannot drift.
277
532
  */
278
533
  function applyArbacProjection(projection, scopes) {
279
- if (scopes.length === 0) return projection;
280
- const allowed = unionProjections(...scopes.map((s) => s.projection ?? {}));
281
- if (Object.keys(allowed).length === 0) return projection;
534
+ const allowed = unionScopeProjection(scopes);
535
+ if (!allowed) return projection;
282
536
  return restrictProjection(projection ?? {}, allowed);
283
537
  }
284
538
  /**
@@ -411,24 +665,25 @@ let AsArbacDbController = class AsArbacDbController extends AsDbController {
411
665
  applyArbacControls(controls, scopes);
412
666
  applyArbacRelationScopes(controls, scopes);
413
667
  }
414
- async applyMetaOverlay(meta) {
415
- const arbac = useArbac();
416
- const actionToMethodMeta = collectActionMetaByName();
417
- const crudKeys = Object.keys(meta.crud);
418
- const [actionResults, crudResults] = await Promise.all([Promise.all(meta.actions.map((entry) => {
419
- const methodMeta = actionToMethodMeta.get(entry.name);
420
- const arbacAction = methodMeta?.arbacActionId ?? methodMeta?.id ?? entry.name;
421
- return arbac.evaluate({ action: arbacAction });
422
- })), Promise.all(crudKeys.map((key) => arbac.evaluate({ action: key })))]);
423
- const filteredActions = [];
424
- for (let i = 0; i < meta.actions.length; i++) if (actionResults[i].allowed) filteredActions.push(meta.actions[i]);
425
- const filteredCrud = {};
426
- for (let i = 0; i < crudKeys.length; i++) if (crudResults[i].allowed) filteredCrud[crudKeys[i]] = meta.crud[crudKeys[i]];
427
- return {
428
- ...meta,
429
- actions: filteredActions,
430
- crud: filteredCrud
431
- };
668
+ applyMetaOverlay(meta) {
669
+ return applyArbacMetaOverlay(meta, metaAlwaysVisibleFields(this, this.table));
670
+ }
671
+ /**
672
+ * Field-existence check, scope-aware (BUG-3 twin of the `/meta` pruning
673
+ * above): a field outside the read-scope projection union must be
674
+ * indistinguishable from a field that does not exist. The base controller's
675
+ * ONLY call site is `validateInsights`, which turns a `false` here into the
676
+ * same `Unknown field "x"` HTTP 400 a truly nonexistent field gets — so
677
+ * `$select`, filter, and sort references to a hidden field cannot be used
678
+ * as an existence/value oracle. Identifier fields stay visible (reads
679
+ * always return them — see {@link MetaVisibility.alwaysVisible}), and paths
680
+ * under a `with`-granted relation pass through to the sub-scope's own
681
+ * enforcement. Scopes were cached by the route-level authorize interceptor
682
+ * before validation runs (`useArbac` setScopes), so the union reflects the
683
+ * exact action being executed.
684
+ */
685
+ hasField(path) {
686
+ return super.hasField(path) && isScopedFieldVisible(readCachedScopes(), path, metaAlwaysVisibleFields(this, this.table));
432
687
  }
433
688
  async onWrite(action, data) {
434
689
  const scopes = readCachedScopes();
@@ -461,29 +716,6 @@ let AsArbacDbController = class AsArbacDbController extends AsDbController {
461
716
  };
462
717
  AsArbacDbController = __decorate([Inherit()], AsArbacDbController);
463
718
  const identifierFieldsCache = /* @__PURE__ */ new WeakMap();
464
- const actionMetaByClassCache = /* @__PURE__ */ new WeakMap();
465
- function collectActionMetaByName() {
466
- const cc = useControllerContext();
467
- const instance = cc.getController();
468
- const ctor = getConstructor(instance);
469
- const cached = actionMetaByClassCache.get(ctor);
470
- if (cached) return cached;
471
- const map = /* @__PURE__ */ new Map();
472
- const ctrlMeta = cc.getControllerMeta();
473
- for (const entry of ctrlMeta?.atscript_db_actions ?? []) map.set(entry.name, {});
474
- for (const methodName of getInstanceOwnMethods(instance)) {
475
- if (typeof methodName !== "string") continue;
476
- const m = cc.getMethodMeta(methodName);
477
- if (!m) continue;
478
- const actionMeta = m.atscript_db_action;
479
- if (actionMeta?.name) map.set(actionMeta.name, {
480
- arbacActionId: m.arbacActionId,
481
- id: m.id
482
- });
483
- }
484
- actionMetaByClassCache.set(ctor, map);
485
- return map;
486
- }
487
719
  /**
488
720
  * Test-friendly internal helper — exported for unit tests and helper
489
721
  * composition; regular consumers should not call this directly.
@@ -618,7 +850,25 @@ let AsArbacDbReadableController = class AsArbacDbReadableController extends AsDb
618
850
  applyArbacControls(controls, scopes);
619
851
  applyArbacRelationScopes(controls, scopes);
620
852
  }
853
+ /**
854
+ * Same ARBAC `/meta` overlay as the writable controller (actions/crud
855
+ * filtering + BUG-3 field-surface pruning by the read scopes' projection
856
+ * union) — view-style metas leak hidden field names identically.
857
+ */
858
+ applyMetaOverlay(meta) {
859
+ return applyArbacMetaOverlay(meta, metaAlwaysVisibleFields(this, this.readable));
860
+ }
861
+ /**
862
+ * Scope-aware field-existence check — same contract as
863
+ * {@link AsArbacDbController.hasField} (BUG-3): a field outside the
864
+ * read-scope projection union answers `false`, so `validateInsights` rejects
865
+ * `$select` / filter / sort references to it with the identical
866
+ * `Unknown field "x"` 400 a nonexistent field gets.
867
+ */
868
+ hasField(path) {
869
+ return super.hasField(path) && isScopedFieldVisible(readCachedScopes(), path, metaAlwaysVisibleFields(this, this.readable));
870
+ }
621
871
  };
622
872
  AsArbacDbReadableController = __decorate([Inherit()], AsArbacDbReadableController);
623
873
  //#endregion
624
- export { Arbac, ArbacAction, ArbacAuthorize, ArbacResource, ArbacUserProvider, ArbacUserProviderToken, AsArbacDbController, AsArbacDbReadableController, MoostArbac, applyAllowedFieldsAndSet, arbacAuthorizeInterceptor, arbacPatternToRegex, conjoinArbacDbScopes, enforceControlsPolicy, extractUsedControlValues, getArbacMate, useArbac };
874
+ export { Arbac, ArbacAction, ArbacAuthorize, ArbacResource, ArbacUserProvider, ArbacUserProviderToken, AsArbacDbController, AsArbacDbReadableController, MoostArbac, applyAllowedFieldsAndSet, applyArbacMetaOverlay, arbacAuthorizeInterceptor, arbacPatternToRegex, collectWithGrantNames, conjoinArbacDbScopes, enforceControlsPolicy, extractUsedControlValues, getArbacMate, isMetaFieldVisible, isScopedFieldVisible, metaAlwaysVisibleFields, pruneMetaByVisibility, unionScopeProjection, useArbac };
@@ -163,6 +163,21 @@ declare class AsArbacDbController<T extends TAtscriptAnnotatedType = TAtscriptAn
163
163
  */
164
164
  protected validateControls(controls: Record<string, unknown>, type: "query" | "pages" | "getOne"): string | undefined;
165
165
  protected applyMetaOverlay(meta: TMetaResponse): Promise<TMetaResponse>;
166
+ /**
167
+ * Field-existence check, scope-aware (BUG-3 twin of the `/meta` pruning
168
+ * above): a field outside the read-scope projection union must be
169
+ * indistinguishable from a field that does not exist. The base controller's
170
+ * ONLY call site is `validateInsights`, which turns a `false` here into the
171
+ * same `Unknown field "x"` HTTP 400 a truly nonexistent field gets — so
172
+ * `$select`, filter, and sort references to a hidden field cannot be used
173
+ * as an existence/value oracle. Identifier fields stay visible (reads
174
+ * always return them — see {@link MetaVisibility.alwaysVisible}), and paths
175
+ * under a `with`-granted relation pass through to the sub-scope's own
176
+ * enforcement. Scopes were cached by the route-level authorize interceptor
177
+ * before validation runs (`useArbac` setScopes), so the union reflects the
178
+ * exact action being executed.
179
+ */
180
+ protected hasField(path: string): boolean;
166
181
  protected onWrite(action: "insert" | "insertMany" | "replace" | "replaceMany" | "update" | "updateMany", data: unknown): Promise<unknown>;
167
182
  protected onRemove(id: unknown): Promise<unknown>;
168
183
  private assertInScope;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aooth/arbac-moost",
3
- "version": "0.1.21",
3
+ "version": "0.1.23",
4
4
  "description": "Moost RBAC integration for aoothjs (migrated from @moostjs/arbac)",
5
5
  "keywords": [
6
6
  "abac",
@@ -60,9 +60,9 @@
60
60
  "access": "public"
61
61
  },
62
62
  "dependencies": {
63
- "@aooth/arbac-core": "0.1.21",
64
- "@aooth/arbac": "0.1.21",
65
- "@aooth/user": "0.1.21"
63
+ "@aooth/arbac-core": "0.1.23",
64
+ "@aooth/arbac": "0.1.23",
65
+ "@aooth/user": "0.1.23"
66
66
  },
67
67
  "devDependencies": {
68
68
  "@atscript/core": "^0.1.76",