@aooth/arbac-moost 0.1.79 → 0.1.81

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-D0ySI_-A.mjs";
1
+ import { r as AoothArbacClaims, t as ArbacUserProvider } from "../user.provider-BZkeBV5s.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,6 +1,6 @@
1
- import { _ as ProjectionOf, a as conjoinArbacDbScopes, b as TArbacMeta, c as ArbacDbScope, d as extractUsedControlValues, f as applyAllowedFieldsAndSet, g as OwnFieldKey, h as NavTarget, i as ConjoinArbacDbScopesOptions, l as AsArbacDbController, m as NavRelationKey, n as ArbacUserProviderToken, o as RefForeignKey, p as ControlsOf, r as AoothArbacClaims, s as RefTableSource, t as ArbacUserProvider, u as enforceControlsPolicy, v as ARBAC_DELEGATED_AUTH, x as getArbacMate, y as ArbacDelegatedAuth } from "./user.provider-D0ySI_-A.mjs";
1
+ import { _ as ProjectionOf, a as conjoinArbacDbScopes, b as TArbacMeta, c as ArbacDbScope, d as extractUsedControlValues, f as applyAllowedFieldsAndSet, g as OwnFieldKey, h as NavTarget, i as ConjoinArbacDbScopesOptions, l as AsArbacDbController, m as NavRelationKey, n as ArbacUserProviderToken, o as RefForeignKey, p as ControlsOf, r as AoothArbacClaims, s as RefTableSource, t as ArbacUserProvider, u as enforceControlsPolicy, v as ARBAC_DELEGATED_AUTH, x as getArbacMate, y as ArbacDelegatedAuth } from "./user.provider-BZkeBV5s.mjs";
2
2
  import { Arbac, Arbac as Arbac$1, TArbacCompiledRule, TArbacEvalResult, TArbacEvalResult as TArbacEvalResult$1, TArbacRole, TArbacRoleForResource, TArbacRule, arbacPatternToRegex } from "@aooth/arbac-core";
3
- import { ControlGate, TProjection, TScopeFieldRules, TScopeFilter, conjoinScopeFilters } from "@aooth/arbac";
3
+ import { ControlGate, ControlGate as ControlGate$1, TProjection, TScopeFieldRules, TScopeFilter, conjoinScopeFilters } from "@aooth/arbac";
4
4
  import { TAuthGuardDef } from "@moostjs/event-http";
5
5
  import { EventContext } from "@wooksjs/event-core";
6
6
  import { FilterExpr, TDbRemoveGuardContext, TDbWriteCheckContext, TDbWriteGuardContext, TDeleteOptions, TMetaResponse, TRowResolveOptions, TWriteOptions, UniqueryControls } from "@atscript/db";
@@ -490,8 +490,13 @@ declare function pruneMetaByVisibility(meta: TMetaResponse, vis: MetaVisibility,
490
490
  * still need `type` for their insert/update forms. PK + `preferredId` are
491
491
  * never pruned.
492
492
  *
493
+ * 3. The capabilities (`crud[op]` control lists, field `groupable` /
494
+ * `bucketable` / `numeric` / `sortable`, relation `filterable`, the
495
+ * aggregate and search envelope keys) follow each read op's `controls` policy
496
+ * ({@link gateMetaControls}).
497
+ *
493
498
  * The search surface (`searchIndexes`, `searchable`, `vectorSearchable`,
494
- * `geoSearchable`) is narrowed by moost-db itself (≥ 0.1.147, under the
499
+ * `geoSearchable`) is also narrowed by moost-db itself (≥ 0.1.147, under the
495
500
  * overridden `hasField`), after this overlay.
496
501
  *
497
502
  * @param source - the controller's `this.readable` (identifiers, relations
package/dist/index.mjs CHANGED
@@ -5,9 +5,8 @@ import { Authenticate, HttpError } from "@moostjs/event-http";
5
5
  import { cached, cachedBy, current, eventTypeKey, key, tryGetCurrent } from "@wooksjs/event-core";
6
6
  import { Inherit, Injectable, TInterceptorPriority, defineBeforeInterceptor, getConstructor, getMoostMate, useControllerContext, useLogger } from "moost";
7
7
  import { computeInsights, containsRelationFilter, findAncestorInSet, getPath, hasRelationOp, isPlainObject, isRelationOp, translateQueryTree } from "@atscript/db";
8
- import { AsDbController, AsDbReadableController, AsJsonValueHelpController, AsValueHelpController, DB_CRUD_HANDLERS, VALUE_HELP_CRUD_HANDLERS, badRequest, discoverRowLevelActions, getControllerFormType, getDbEndpoint, hasActionDelegations, resolveTerminalRef } from "@atscript/moost-db";
8
+ import { AsDbController, AsDbReadableController, AsJsonValueHelpController, AsValueHelpController, DB_CRUD_HANDLERS, VALUE_HELP_CRUD_HANDLERS, badRequest, discoverRowLevelActions, getControllerFormType, getDbEndpoint, hasActionDelegations, resolveTerminalRef, stableMeta } from "@atscript/moost-db";
9
9
  import { groupByFields, walkFilter } from "@uniqu/core";
10
- import { prerenderJson } from "@wooksjs/event-http";
11
10
  //#region src/db/helpers.ts
12
11
  /** `map.get(key)`, created with `create()` and stored on a miss. */
13
12
  function getOrCreate(map, key, create) {
@@ -130,14 +129,14 @@ function identifierSet(table) {
130
129
  function metaAlwaysVisibleFields(_controller, source) {
131
130
  return identifierSet(source);
132
131
  }
133
- const NO_POLICY = {
132
+ const NO_POLICY$1 = {
134
133
  json: NO_NAMES$1,
135
134
  derived: /* @__PURE__ */ new Map(),
136
135
  relations: void 0
137
136
  };
138
137
  const tablePolicyCache = /* @__PURE__ */ new WeakMap();
139
138
  function tablePolicy(table) {
140
- if (!table) return NO_POLICY;
139
+ if (!table) return NO_POLICY$1;
141
140
  return getOrCreate(tablePolicyCache, table, () => {
142
141
  const derived = /* @__PURE__ */ new Map();
143
142
  for (const fd of table.fieldDescriptors ?? []) if (fd.derived?.sourcePath) derived.set(fd.path, fd.derived.sourcePath);
@@ -1242,15 +1241,25 @@ function metaDecisionKey(input, ctx) {
1242
1241
  const vis = p.refTargets.get(type);
1243
1242
  refTargets.push(idOf(type), ctx.isServed(type), vis ? encodeLevel(vis, NO_TREE, ctx, seen, 1) : 0);
1244
1243
  }
1245
- const { writable, nestedWrites, groupBy } = p;
1246
- if (groupBy !== void 0 && typeof groupBy !== "boolean" && !Array.isArray(groupBy)) refuse();
1244
+ const { writable, nestedWrites } = p;
1245
+ const controls = [];
1246
+ for (const op of Object.keys(p.controls).toSorted()) {
1247
+ const policy = p.controls[op] ?? {};
1248
+ const gates = [op];
1249
+ for (const key of Object.keys(policy).toSorted()) {
1250
+ const gate = policy[key];
1251
+ if (typeof gate !== "boolean" && !Array.isArray(gate)) refuse();
1252
+ gates.push(key, Array.isArray(gate) ? sortedNames(gate) : gate);
1253
+ }
1254
+ controls.push(gates);
1255
+ }
1247
1256
  return JSON.stringify([
1248
1257
  bits(input.actions),
1249
1258
  bits(input.crud),
1250
1259
  encodeLevel(p.vis, p.tree, ctx, seen, 0),
1251
1260
  writable === void 0 ? 0 : writable === "all" ? 1 : sortedNames(writable),
1252
1261
  nestedWrites ? sortedNames(nestedWrites) : 0,
1253
- Array.isArray(groupBy) ? sortedNames(groupBy) : groupBy ?? 0,
1262
+ controls,
1254
1263
  refTargets,
1255
1264
  p.vis.table?.type ? ctx.isServed(p.vis.table.type) : 0
1256
1265
  ]);
@@ -1286,29 +1295,13 @@ function memoMetaProjection(envelope, key, max, compute) {
1286
1295
  return out;
1287
1296
  }
1288
1297
  /**
1289
- * Make a memoized `/meta` object shareable across requests: frozen (deeply in
1290
- * development / test, so an in-place mutation throws instead of serving old
1291
- * bytes) and registered for prerendering with a weak ETag.
1298
+ * Make a memoized `/meta` object shareable across requests: moost-db's
1299
+ * `stableMeta()` deep-freezes it and marks it identity-stable, so every later
1300
+ * `/meta` step memoizes on it and `GET /meta` serves it (or the step's
1301
+ * memoized output) serialized once, with a weak ETag.
1292
1302
  */
1293
1303
  function shareMeta(obj) {
1294
- if (shouldDeepFreeze()) deepFreeze(obj);
1295
- else Object.freeze(obj);
1296
- return prerenderJson(obj, { etag: true });
1297
- }
1298
- let deepFreezeOn;
1299
- function shouldDeepFreeze() {
1300
- if (deepFreezeOn === void 0) {
1301
- const env = typeof process === "undefined" ? void 0 : process.env.NODE_ENV;
1302
- deepFreezeOn = env === "test" || env === "development";
1303
- }
1304
- return deepFreezeOn;
1305
- }
1306
- const deepFrozen = /* @__PURE__ */ new WeakSet();
1307
- function deepFreeze(value) {
1308
- if (value === null || typeof value !== "object" || deepFrozen.has(value)) return;
1309
- deepFrozen.add(value);
1310
- Object.freeze(value);
1311
- for (const key of Object.keys(value)) deepFreeze(value[key]);
1304
+ return stableMeta(obj);
1312
1305
  }
1313
1306
  //#endregion
1314
1307
  //#region src/db/meta-projection.ts
@@ -1856,8 +1849,13 @@ function crudHandlers(op, methods) {
1856
1849
  * still need `type` for their insert/update forms. PK + `preferredId` are
1857
1850
  * never pruned.
1858
1851
  *
1852
+ * 3. The capabilities (`crud[op]` control lists, field `groupable` /
1853
+ * `bucketable` / `numeric` / `sortable`, relation `filterable`, the
1854
+ * aggregate and search envelope keys) follow each read op's `controls` policy
1855
+ * ({@link gateMetaControls}).
1856
+ *
1859
1857
  * The search surface (`searchIndexes`, `searchable`, `vectorSearchable`,
1860
- * `geoSearchable`) is narrowed by moost-db itself (≥ 0.1.147, under the
1858
+ * `geoSearchable`) is also narrowed by moost-db itself (≥ 0.1.147, under the
1861
1859
  * overridden `hasField`), after this overlay.
1862
1860
  *
1863
1861
  * @param source - the controller's `this.readable` (identifiers, relations
@@ -1880,10 +1878,15 @@ async function applyArbacMetaOverlay(meta, source, _indexes) {
1880
1878
  const crudBits = crudScopes.map((scopes) => scopes !== void 0);
1881
1879
  const readScopes = [];
1882
1880
  const writeScopes = [];
1881
+ const controls = {};
1883
1882
  for (let i = 0; i < crudKeys.length; i++) {
1884
1883
  const scopes = crudScopes[i];
1885
1884
  if (!scopes) continue;
1886
- (WRITE_CRUD_OPS.has(crudKeys[i]) ? writeScopes : readScopes).push(...scopes);
1885
+ if (WRITE_CRUD_OPS.has(crudKeys[i])) writeScopes.push(...scopes);
1886
+ else {
1887
+ readScopes.push(...scopes);
1888
+ controls[crudKeys[i]] = effectiveScope(scopes).controls;
1889
+ }
1887
1890
  }
1888
1891
  const overlay = () => {
1889
1892
  const crud = {};
@@ -1900,18 +1903,17 @@ async function applyArbacMetaOverlay(meta, source, _indexes) {
1900
1903
  readScopes,
1901
1904
  writeScopes
1902
1905
  }, RECORD_CTX) : void 0, memoMax, overlay);
1903
- const groupBy = effectiveScope(readScopes).controls.$groupBy;
1904
1906
  const writable = collectWritableFields(writeScopes, false);
1905
1907
  const nestedWrites = effectiveScope(writeScopes).nestedWrites;
1906
1908
  if (isIdentifierSet(source)) {
1907
1909
  const vis = buildScopeVisibility(readScopes, void 0, source);
1908
1910
  const visible = Object.keys(vis.allowed).length > 0 || vis.withGrants.size > 0;
1909
1911
  const overlaid = overlay();
1910
- return gateGroupable(visible ? pruneMetaByVisibility(overlaid, {
1912
+ return gateMetaControls(visible ? pruneMetaByVisibility(overlaid, {
1911
1913
  ...vis,
1912
1914
  writable,
1913
1915
  nestedWrites
1914
- }) : overlaid, groupBy);
1916
+ }) : overlaid, controls);
1915
1917
  }
1916
1918
  const vis = buildScopeVisibility(readScopes, source, { relations: await resolveRelationTree(readScopes, source, metaRelationNames(meta), { listSiblings: false }) });
1917
1919
  const refTargets = await resolveRefTargets(vis, meta);
@@ -1925,14 +1927,14 @@ async function applyArbacMetaOverlay(meta, source, _indexes) {
1925
1927
  tree: metaRelationNames(meta),
1926
1928
  writable,
1927
1929
  nestedWrites,
1928
- groupBy,
1930
+ controls,
1929
1931
  refTargets
1930
1932
  }
1931
- }, RECORD_CTX) : void 0, memoMax, () => gateGroupable(pruneMetaByVisibility(overlay(), {
1933
+ }, RECORD_CTX) : void 0, memoMax, () => gateMetaControls(pruneMetaByVisibility(overlay(), {
1932
1934
  ...vis,
1933
1935
  writable,
1934
1936
  nestedWrites
1935
- }, refTargets), groupBy));
1937
+ }, refTargets), controls));
1936
1938
  }
1937
1939
  /** What the `/meta` memo's decision record reads from the pruning rules. */
1938
1940
  const RECORD_CTX = {
@@ -2095,28 +2097,143 @@ function stripFormTargets(runtime, node, readable, depth) {
2095
2097
  type: changed
2096
2098
  } : out;
2097
2099
  }
2098
- /**
2099
- * `fields[*].groupable` follows the `controls.$groupBy` policy, mirroring the
2100
- * 403 `applyArbacControls` raises: `false` drops the flag everywhere, a
2101
- * whitelist keeps it only on the listed paths. Returns `meta` itself when
2102
- * nothing is stripped.
2103
- *
2104
- * @since 0.1.75
2105
- */
2106
- function gateGroupable(meta, gate) {
2107
- if (gate === void 0 || gate === true) return meta;
2108
- const stripped = (path, field) => field.groupable && (gate === false || !gate.includes(path));
2109
- const entries = Object.entries(meta.fields);
2110
- if (!entries.some(([path, field]) => stripped(path, field))) return meta;
2100
+ const NO_POLICY = {};
2101
+ /** `crud[op]` entries that are query parts of their own, never seen by the controls gate. */
2102
+ const NOT_CONTROLS = /* @__PURE__ */ new Set(["filter", "insights"]);
2103
+ /**
2104
+ * Whether `control` is unusable in an op listing `list` under `policy`: its
2105
+ * gate is `false`, or everything it qualifies is unusable (`$index` /
2106
+ * `$fuzzy` / `$vector` name how `$search` runs, `$threshold` bounds
2107
+ * `$vector`, `$rowOrder` orders `first()` / `last()` in an aggregate
2108
+ * `$select`, `$nulls` places NULLs in `$sort` and `$rowOrder` keys).
2109
+ */
2110
+ function isControlOff(control, list, policy) {
2111
+ if (!NOT_CONTROLS.has(control) && policy[`$${control}`] === false) return true;
2112
+ const off = (c) => isControlOff(c, list, policy);
2113
+ switch (control) {
2114
+ case "index":
2115
+ case "fuzzy": return list.includes("search") && off("search");
2116
+ case "vector": return off("search");
2117
+ case "threshold": return off("vector");
2118
+ case "rowOrder": return off("groupBy") || off("select");
2119
+ case "nulls": return off("sort") && (!list.includes("rowOrder") || off("rowOrder"));
2120
+ default: return false;
2121
+ }
2122
+ }
2123
+ /** Envelope keys that advertise aggregate queries (`$groupBy`, aggregate / bucket `$select`). */
2124
+ const AGGREGATE_ENVELOPE_KEYS = [
2125
+ "aggregateFns",
2126
+ "aggregateExpressions",
2127
+ "bucketUnits"
2128
+ ];
2129
+ /**
2130
+ * The `/meta` capabilities follow each read op's `controls` policy (`policies`;
2131
+ * an op without one is unrestricted), mirroring the 403 `applyArbacControls`
2132
+ * raises on that op's requests:
2133
+ *
2134
+ * - `crud[op]` loses every control the op's policy makes unusable
2135
+ * ({@link isControlOff});
2136
+ * - a capability flag stays when SOME op listing its controls keeps them
2137
+ * (no op lists them → left as built): `groupable` (`groupBy`, and the
2138
+ * field on that op's `$groupBy` whitelist, if any), `bucketable` (the same
2139
+ * plus `select` — a bucket is checked as the field it buckets), `numeric`
2140
+ * and the aggregate envelope keys (`groupBy` + `select`: every aggregate,
2141
+ * calendar bucket or arithmetic `$select` is `$groupBy` use), `sortable`
2142
+ * (`sort`), `nullsPlacement` (`nulls`), `searchable` + `searchIndexes`
2143
+ * (`search`), `vectorSearchable` + the vector indexes (`search` +
2144
+ * `vector`), the non-default text indexes (`search` + `index`), a
2145
+ * relation's `filterable` (`filter` + `with`, and the relation on that
2146
+ * op's `$with` whitelist, if any).
2147
+ *
2148
+ * Returns `meta` itself when nothing changes.
2149
+ *
2150
+ * @since 0.1.75 (`groupable`), 0.1.80 (the rest)
2151
+ */
2152
+ function gateMetaControls(meta, policies) {
2153
+ if (!Object.values(policies).some((p) => p && Object.keys(p).length > 0)) return meta;
2154
+ let changed = false;
2155
+ const crud = {};
2156
+ const ops = [];
2157
+ for (const [op, list] of Object.entries(meta.crud)) {
2158
+ const policy = policies[op] ?? NO_POLICY;
2159
+ const kept = list.filter((c) => !isControlOff(c, list, policy));
2160
+ if (kept.length !== list.length) changed = true;
2161
+ crud[op] = kept;
2162
+ ops.push({
2163
+ list,
2164
+ kept,
2165
+ policy
2166
+ });
2167
+ }
2168
+ /** Some op listing `controls` keeps them all (and `name` passes its `whitelist` gate). */
2169
+ const served = (controls, name, whitelist = "$groupBy") => {
2170
+ const listing = ops.filter((o) => controls.every((c) => o.list.includes(c)));
2171
+ return listing.length === 0 || listing.some(({ kept, policy }) => {
2172
+ const gate = policy[whitelist];
2173
+ return controls.every((c) => kept.includes(c)) && (name === void 0 || !Array.isArray(gate) || gate.includes(name));
2174
+ });
2175
+ };
2176
+ const aggregates = served(["groupBy", "select"]);
2177
+ const sortable = served(["sort"]);
2111
2178
  const fields = {};
2112
- for (const [path, field] of entries) if (stripped(path, field)) {
2113
- const { groupable: _groupable, ...rest } = field;
2114
- fields[path] = rest;
2115
- } else fields[path] = field;
2116
- return {
2179
+ for (const [path, field] of Object.entries(meta.fields)) {
2180
+ let next = field;
2181
+ const strip = (flag, keep) => {
2182
+ if (!next[flag] || keep()) return;
2183
+ const { [flag]: _dropped, ...rest } = next;
2184
+ next = rest;
2185
+ };
2186
+ strip("groupable", () => served(["groupBy"], path));
2187
+ strip("bucketable", () => served(["groupBy", "select"], path));
2188
+ strip("numeric", () => aggregates);
2189
+ if (!sortable && next.sortable) next = {
2190
+ ...next,
2191
+ sortable: false
2192
+ };
2193
+ if (next !== field) changed = true;
2194
+ fields[path] = next;
2195
+ }
2196
+ const out = {
2117
2197
  ...meta,
2118
- fields
2198
+ fields,
2199
+ crud
2119
2200
  };
2201
+ const unfilterable = (r) => r.filterable && !served(["filter", "with"], r.name, "$with");
2202
+ if (out.relations.some(unfilterable)) {
2203
+ out.relations = out.relations.map((r) => {
2204
+ if (!unfilterable(r)) return r;
2205
+ const { filterable: _filterable, ...rest } = r;
2206
+ return rest;
2207
+ });
2208
+ changed = true;
2209
+ }
2210
+ if (!aggregates) for (const key of AGGREGATE_ENVELOPE_KEYS) {
2211
+ if (out[key] === void 0) continue;
2212
+ delete out[key];
2213
+ changed = true;
2214
+ }
2215
+ if (out.nullsPlacement && !served(["nulls"])) {
2216
+ delete out.nullsPlacement;
2217
+ changed = true;
2218
+ }
2219
+ const search = served(["search"]);
2220
+ const vector = search && served(["search", "vector"]);
2221
+ const named = served(["search", "index"]);
2222
+ const all = out.searchIndexes ?? [];
2223
+ const indexes = all.filter((i) => i.type === "vector" ? vector : search && (named || !!i.isDefault));
2224
+ if (indexes.length !== all.length) {
2225
+ out.searchIndexes = indexes;
2226
+ changed = true;
2227
+ }
2228
+ if (out.searchable && !search) {
2229
+ out.searchable = false;
2230
+ changed = true;
2231
+ }
2232
+ if (out.vectorSearchable && !vector) {
2233
+ out.vectorSearchable = false;
2234
+ changed = true;
2235
+ }
2236
+ return changed ? out : meta;
2120
2237
  }
2121
2238
  const metaNamesCache = /* @__PURE__ */ new WeakMap();
2122
2239
  /** The relation names `/meta`'s serialized type reaches: root relations, then nav props of each nav body. */
@@ -3264,14 +3381,19 @@ const preservedFieldsCache = /* @__PURE__ */ new WeakMap();
3264
3381
  * - `readonly string[]`: allow only the listed values; reject any other.
3265
3382
  *
3266
3383
  * "Used" means the control key is present AND non-empty (an empty array is
3267
- * treated as not used, matching how the parser leaves missing controls).
3384
+ * treated as not used, matching how the parser leaves missing controls) —
3385
+ * except `$groupBy`, whose presence alone marks an aggregate query: moost-db
3386
+ * (≥ 0.1.155) gives an ungrouped aggregate (`$select=sum(amount):total`, no
3387
+ * `$groupBy`) `$groupBy: []`, so a `false` gate denies it and a whitelist
3388
+ * passes it (no column is grouped on; the aggregated fields go through the
3389
+ * field-visibility gate like any other read).
3268
3390
  */
3269
3391
  function enforceControlsPolicy(policy, controls) {
3270
3392
  if (Object.keys(policy).length === 0) return;
3271
3393
  for (const [key, gate] of Object.entries(policy)) {
3272
3394
  const used = controls[key];
3273
3395
  if (used === void 0 || used === null) continue;
3274
- if (Array.isArray(used) && used.length === 0) continue;
3396
+ if (Array.isArray(used) && used.length === 0 && key !== "$groupBy") continue;
3275
3397
  if (gate === false) throw new HttpError(403, `Control "${key}" is not allowed for your role`);
3276
3398
  if (Array.isArray(gate)) {
3277
3399
  const usedValues = extractUsedControlValues(key, used, controls);
@@ -84,20 +84,21 @@ type NavRelationKey<T> = keyof NavPropsOf<T> extends never ? string : (keyof Nav
84
84
  * `unionProjections` / `restrictProjection` keep compiling.
85
85
  */
86
86
  type ProjectionOf<T> = IsUnknown<T> extends true ? TProjection : Partial<Record<OwnFieldKey<T>, 0 | 1>>;
87
+ /** The Uniquery controls a gate can only allow or deny (`true` / `false`). */
88
+ type BooleanControl = "$select" | "$sort" | "$nulls" | "$skip" | "$limit" | "$count" | "$page" | "$size" | "$search" | "$index" | "$fuzzy" | "$vector" | "$threshold" | "$actions" | "$rowOrder" | "$having" | "$center" | "$maxDistance" | "$minDistance";
87
89
  /**
88
- * Typed control gates: `$with` against nav names, `$select` against own
89
- * fields, other `$`-prefixed controls passthrough. Falls back to the
90
- * legacy untyped `Record<string, ControlGate>` for `T = unknown` so
91
- * internal helpers (`unionControlsPolicy`) and existing untyped call
92
- * sites keep compiling.
90
+ * Typed control gates, matching what `unionControlsPolicy` accepts: only
91
+ * `$with` (nav relation names) and `$groupBy` (own field paths) take a
92
+ * whitelist; every other control is `true` / `false` (a whitelist there
93
+ * throws at scope merge). Unknown control keys are rejected. Falls back to
94
+ * the legacy untyped `Record<string, ControlGate>` for `T = unknown` so
95
+ * internal helpers (`unionControlsPolicy`) and existing untyped call sites
96
+ * keep compiling.
93
97
  */
94
98
  type ControlsOf<T> = IsUnknown<T> extends true ? Record<string, ControlGate> : {
95
- $with?: Array<NavRelationKey<T>> | boolean;
96
- $select?: Array<OwnFieldKey<T>> | boolean;
97
- $groupBy?: ControlGate;
98
- $having?: ControlGate;
99
- [key: `$${string}`]: ControlGate | Array<string> | undefined;
100
- };
99
+ $with?: ReadonlyArray<NavRelationKey<T>> | boolean;
100
+ $groupBy?: ReadonlyArray<OwnFieldKey<T>> | boolean;
101
+ } & Partial<Record<BooleanControl, boolean>>;
101
102
  //#endregion
102
103
  //#region src/db/write-fields.d.ts
103
104
  /**
@@ -173,6 +174,10 @@ interface ArbacDbScope<T = unknown> {
173
174
  * `?$with=comments`, rejects `?$with=tasks`). Supported only for `$with`
174
175
  * (relation names) and `$groupBy` (column names) in v1.
175
176
  *
177
+ * An ungrouped aggregate (`$select=sum(amount):total`, no `$groupBy`) counts
178
+ * as `$groupBy` use (0.1.80+, with atscript-db 0.1.155+): `$groupBy: false`
179
+ * denies it; a `$groupBy` whitelist allows it (no column is grouped on).
180
+ *
176
181
  * Across roles, gates union additively (silence wins) via {@link unionControlsPolicy}.
177
182
  *
178
183
  * @example `{ controls: { $with: false } }` — disable $with for this role.
@@ -406,7 +411,12 @@ declare class AsArbacDbController<T extends TAtscriptAnnotatedType = TAtscriptAn
406
411
  * - `readonly string[]`: allow only the listed values; reject any other.
407
412
  *
408
413
  * "Used" means the control key is present AND non-empty (an empty array is
409
- * treated as not used, matching how the parser leaves missing controls).
414
+ * treated as not used, matching how the parser leaves missing controls) —
415
+ * except `$groupBy`, whose presence alone marks an aggregate query: moost-db
416
+ * (≥ 0.1.155) gives an ungrouped aggregate (`$select=sum(amount):total`, no
417
+ * `$groupBy`) `$groupBy: []`, so a `false` gate denies it and a whitelist
418
+ * passes it (no column is grouped on; the aggregated fields go through the
419
+ * field-visibility gate like any other read).
410
420
  */
411
421
  declare function enforceControlsPolicy(policy: Record<string, ControlGate>, controls: Record<string, unknown>): void;
412
422
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aooth/arbac-moost",
3
- "version": "0.1.79",
3
+ "version": "0.1.81",
4
4
  "description": "Moost RBAC integration for aoothjs (migrated from @moostjs/arbac)",
5
5
  "keywords": [
6
6
  "abac",
@@ -61,30 +61,29 @@
61
61
  },
62
62
  "dependencies": {
63
63
  "@uniqu/core": "^0.1.15",
64
- "@aooth/arbac": "0.1.79",
65
- "@aooth/arbac-core": "0.1.79",
66
- "@aooth/user": "0.1.79"
64
+ "@aooth/arbac": "0.1.81",
65
+ "@aooth/user": "0.1.81",
66
+ "@aooth/arbac-core": "0.1.81"
67
67
  },
68
68
  "devDependencies": {
69
- "@atscript/core": "^0.1.103",
70
- "@atscript/db": "^0.1.153",
71
- "@atscript/db-sql-tools": "^0.1.153",
72
- "@atscript/db-sqlite": "^0.1.153",
73
- "@atscript/moost-db": "^0.1.153",
74
- "@atscript/typescript": "^0.1.103",
75
- "@moostjs/event-http": "^0.6.50",
69
+ "@atscript/core": "^0.1.106",
70
+ "@atscript/db": "^0.1.156",
71
+ "@atscript/db-sql-tools": "^0.1.156",
72
+ "@atscript/db-sqlite": "^0.1.156",
73
+ "@atscript/moost-db": "^0.1.156",
74
+ "@atscript/typescript": "^0.1.106",
75
+ "@moostjs/event-http": "^0.6.51",
76
76
  "@types/better-sqlite3": "^7.6.13",
77
77
  "better-sqlite3": "^12.6.2",
78
- "unplugin-atscript": "^0.1.103"
78
+ "unplugin-atscript": "^0.1.106"
79
79
  },
80
80
  "peerDependencies": {
81
- "@atscript/db": "^0.1.153",
82
- "@atscript/moost-db": "^0.1.153",
83
- "@atscript/typescript": "^0.1.103",
84
- "@moostjs/event-http": "^0.6.50",
85
- "@wooksjs/event-core": "^0.7.28",
86
- "@wooksjs/event-http": "^0.7.28",
87
- "moost": "^0.6.50"
81
+ "@atscript/db": "^0.1.156",
82
+ "@atscript/moost-db": "^0.1.156",
83
+ "@atscript/typescript": "^0.1.106",
84
+ "@moostjs/event-http": "^0.6.51",
85
+ "@wooksjs/event-core": "^0.7.29",
86
+ "moost": "^0.6.51"
88
87
  },
89
88
  "peerDependenciesMeta": {
90
89
  "@atscript/db": {