@aooth/arbac-moost 0.1.77 → 0.1.79

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.
@@ -32,6 +32,61 @@ interface ArbacUserTable<T extends object> {
32
32
  };
33
33
  }): Promise<T | null>;
34
34
  }
35
+ /** Options of {@link AtscriptArbacUserProvider}. @since 0.1.78 */
36
+ interface AtscriptArbacUserProviderOptions {
37
+ /**
38
+ * Extra user fields to select in the provider's single per-event user
39
+ * fetch, next to the id, `@arbac.role` and `@arbac.attribute` fields — read
40
+ * them in an `extractAttrs` / `extractRoles` override instead of fetching
41
+ * the user a second time (e.g. a `username` that scope functions need but
42
+ * that cannot carry `@arbac.attribute`).
43
+ */
44
+ extraSelect?: readonly string[];
45
+ /**
46
+ * OPT-IN cross-request cache of the fetched user record (roles + attrs),
47
+ * see {@link ArbacUserRecordCache}. Off by default: without it every
48
+ * request reads the user once.
49
+ */
50
+ cache?: ArbacUserRecordCache;
51
+ }
52
+ /** Options of {@link ArbacUserRecordCache}. @since 0.1.78 */
53
+ interface ArbacUserRecordCacheOptions {
54
+ /** How long a fetched record is reused, in ms (keep it short — seconds). */
55
+ ttlMs: number;
56
+ /** Most cached users; the oldest entry is dropped past it. Default 1000. */
57
+ max?: number;
58
+ /** Clock (ms) — for tests. Default `Date.now`. */
59
+ now?: () => number;
60
+ }
61
+ /**
62
+ * OPT-IN, process-local cache of the user records an
63
+ * {@link AtscriptArbacUserProvider} fetches for ARBAC (`userId` → roles +
64
+ * attributes), shared across requests for `ttlMs`. Pass it as the provider's
65
+ * `cache` option; off by default.
66
+ *
67
+ * Staleness: a role or attribute change reaches ARBAC within `ttlMs` — or
68
+ * immediately on THIS process when you call {@link invalidate} (wire it to
69
+ * your user writes, e.g. `userService.onUserWrite((id) => cache.invalidate(id))`).
70
+ * Other processes keep their entry until it expires, so the cross-node lag is
71
+ * at most `ttlMs`. Only the stored record is cached: credential attenuation
72
+ * (read from the token per request) and every policy evaluation still run
73
+ * per request. A missing user is never cached.
74
+ *
75
+ * @since 0.1.78
76
+ */
77
+ declare class ArbacUserRecordCache {
78
+ private readonly ttlMs;
79
+ private readonly max;
80
+ private readonly now;
81
+ private readonly entries;
82
+ constructor(opts: ArbacUserRecordCacheOptions);
83
+ /** Drop the cached record of `userId` (all users when omitted). */
84
+ invalidate(userId?: string): void;
85
+ /** @internal The live record cached for `owner` (a provider), if any. */
86
+ get(owner: object, userId: string): Promise<unknown> | undefined;
87
+ /** @internal Cache `record` (dropped again if it rejects or resolves to `null`). */
88
+ set(owner: object, userId: string, record: Promise<unknown>): void;
89
+ }
35
90
  /**
36
91
  * `@Injectable()` (SINGLETON) — moost@0.6.x's `Injectable` metadata is NOT
37
92
  * inherited from the base `ArbacUserProvider` by infact, so the decorator
@@ -44,7 +99,9 @@ interface ArbacUserTable<T extends object> {
44
99
  * table, and register the subclass via `setReplaceRegistry`.
45
100
  *
46
101
  * `extractRoles` and `extractAttrs` are protected seams — override to
47
- * reshape roles/attrs without re-implementing the fetch path.
102
+ * reshape roles/attrs without re-implementing the fetch path; pass
103
+ * `extraSelect` (third constructor argument) to have the fetch include the
104
+ * extra fields they read.
48
105
  */
49
106
  declare abstract class AtscriptArbacUserProvider<T extends object = object> extends ArbacUserProvider {
50
107
  protected readonly userType: TAtscriptAnnotatedType;
@@ -52,7 +109,8 @@ declare abstract class AtscriptArbacUserProvider<T extends object = object> exte
52
109
  private readonly spec;
53
110
  private readonly projection;
54
111
  private readonly withClause;
55
- constructor(userType: TAtscriptAnnotatedType, table: ArbacUserTable<T>);
112
+ private readonly recordCache;
113
+ constructor(userType: TAtscriptAnnotatedType, table: ArbacUserTable<T>, options?: AtscriptArbacUserProviderOptions);
56
114
  abstract getUserId(): string | Promise<string>;
57
115
  getRoles(id: string): Promise<string[]>;
58
116
  getAttrs(id: string): Promise<object>;
@@ -233,4 +291,4 @@ declare class AoothArbacUserCredentials extends AoothUserCredentials {
233
291
  }
234
292
  // prettier-ignore-end
235
293
  //#endregion
236
- export { AoothArbacUserCredentials, type AoothCredentialMetadataSpec, type AoothUserHandleSpec, type ArbacAttenuationSpec, type ArbacUserTable, AtscriptArbacUserProvider, extractAttenuation, getAoothCredentialMetadataSpec, getAoothUserHandleSpec, getArbacAttenuationSpec, validateAttenuationTargets };
294
+ export { AoothArbacUserCredentials, type AoothCredentialMetadataSpec, type AoothUserHandleSpec, type ArbacAttenuationSpec, ArbacUserRecordCache, type ArbacUserRecordCacheOptions, type ArbacUserTable, AtscriptArbacUserProvider, type AtscriptArbacUserProviderOptions, extractAttenuation, getAoothCredentialMetadataSpec, getAoothUserHandleSpec, getArbacAttenuationSpec, validateAttenuationTargets };
@@ -141,20 +141,88 @@ const useFetchCache = defineWook((ctx) => {
141
141
  }
142
142
  return cache;
143
143
  });
144
+ /**
145
+ * OPT-IN, process-local cache of the user records an
146
+ * {@link AtscriptArbacUserProvider} fetches for ARBAC (`userId` → roles +
147
+ * attributes), shared across requests for `ttlMs`. Pass it as the provider's
148
+ * `cache` option; off by default.
149
+ *
150
+ * Staleness: a role or attribute change reaches ARBAC within `ttlMs` — or
151
+ * immediately on THIS process when you call {@link invalidate} (wire it to
152
+ * your user writes, e.g. `userService.onUserWrite((id) => cache.invalidate(id))`).
153
+ * Other processes keep their entry until it expires, so the cross-node lag is
154
+ * at most `ttlMs`. Only the stored record is cached: credential attenuation
155
+ * (read from the token per request) and every policy evaluation still run
156
+ * per request. A missing user is never cached.
157
+ *
158
+ * @since 0.1.78
159
+ */
160
+ var ArbacUserRecordCache = class {
161
+ ttlMs;
162
+ max;
163
+ now;
164
+ entries = /* @__PURE__ */ new Map();
165
+ constructor(opts) {
166
+ if (!(opts.ttlMs > 0)) throw new Error("ArbacUserRecordCache: ttlMs must be > 0");
167
+ this.ttlMs = opts.ttlMs;
168
+ this.max = opts.max ?? 1e3;
169
+ this.now = opts.now ?? Date.now;
170
+ }
171
+ /** Drop the cached record of `userId` (all users when omitted). */
172
+ invalidate(userId) {
173
+ if (userId === void 0) this.entries.clear();
174
+ else this.entries.delete(userId);
175
+ }
176
+ /** @internal The live record cached for `owner` (a provider), if any. */
177
+ get(owner, userId) {
178
+ const entry = this.entries.get(userId);
179
+ if (!entry) return void 0;
180
+ if (entry.owner !== owner || entry.expiresAt <= this.now()) {
181
+ this.entries.delete(userId);
182
+ return;
183
+ }
184
+ return entry.record;
185
+ }
186
+ /** @internal Cache `record` (dropped again if it rejects or resolves to `null`). */
187
+ set(owner, userId, record) {
188
+ this.entries.delete(userId);
189
+ if (this.entries.size >= this.max) {
190
+ const oldest = this.entries.keys().next();
191
+ if (!oldest.done) this.entries.delete(oldest.value);
192
+ }
193
+ const entry = {
194
+ owner,
195
+ expiresAt: this.now() + this.ttlMs,
196
+ record
197
+ };
198
+ this.entries.set(userId, entry);
199
+ const drop = () => {
200
+ if (this.entries.get(userId) === entry) this.entries.delete(userId);
201
+ };
202
+ record.then((r) => r === null && drop(), drop);
203
+ }
204
+ };
144
205
  let AtscriptArbacUserProvider = class AtscriptArbacUserProvider extends ArbacUserProvider {
145
206
  userType;
146
207
  table;
147
208
  spec;
148
209
  projection;
149
210
  withClause;
150
- constructor(userType, table) {
211
+ recordCache;
212
+ constructor(userType, table, options) {
151
213
  super();
152
214
  this.userType = userType;
153
215
  this.table = table;
154
216
  this.spec = getArbacExtractSpec(userType);
155
217
  if (!this.spec.userIdField) throw new Error("AtscriptArbacUserProvider: userType has no @arbac.userId, @db.table.preferredId.uniqueIndex field, or @meta.id — annotate one to identify users");
156
- this.projection = getArbacProjection(userType);
218
+ const projection = getArbacProjection(userType);
219
+ const extra = options?.extraSelect?.filter((f) => !Object.hasOwn(projection, f)) ?? [];
220
+ this.projection = extra.length > 0 ? {
221
+ ...projection,
222
+ ...Object.fromEntries(extra.map((f) => [f, 1]))
223
+ } : projection;
157
224
  this.withClause = this.spec.roleField?.shape === "rel.from" ? [{ name: this.spec.roleField.name }] : void 0;
225
+ this.recordCache = options?.cache;
158
226
  }
159
227
  async getRoles(id) {
160
228
  const record = await this.fetchRecord(id);
@@ -217,6 +285,11 @@ let AtscriptArbacUserProvider = class AtscriptArbacUserProvider extends ArbacUse
217
285
  }
218
286
  const cached = perProvider.get(userId);
219
287
  if (cached !== void 0) return cached;
288
+ const shared = this.recordCache?.get(this, userId);
289
+ if (shared !== void 0) {
290
+ perProvider.set(userId, shared);
291
+ return shared;
292
+ }
220
293
  const userIdField = this.spec.userIdField;
221
294
  const controls = { $select: this.projection };
222
295
  if (this.withClause) controls.$with = this.withClause;
@@ -226,10 +299,15 @@ let AtscriptArbacUserProvider = class AtscriptArbacUserProvider extends ArbacUse
226
299
  });
227
300
  promise.catch(() => perProvider?.delete(userId));
228
301
  perProvider.set(userId, promise);
302
+ this.recordCache?.set(this, userId, promise);
229
303
  return promise;
230
304
  }
231
305
  };
232
- AtscriptArbacUserProvider = __decorate([Injectable(), __decorateMetadata("design:paramtypes", [Object, Object])], AtscriptArbacUserProvider);
306
+ AtscriptArbacUserProvider = __decorate([Injectable(), __decorateMetadata("design:paramtypes", [
307
+ Object,
308
+ Object,
309
+ Object
310
+ ])], AtscriptArbacUserProvider);
233
311
  //#endregion
234
312
  //#region src/atscript/attenuation-extract.ts
235
313
  const specCache$2 = /* @__PURE__ */ new WeakMap();
@@ -439,4 +517,4 @@ var AoothArbacUserCredentials = class {
439
517
  };
440
518
  defineAnnotatedType("object", AoothArbacUserCredentials).prop("id", defineAnnotatedType().designType("string").tags("string").annotate("meta.id", true).annotate("db.default.uuid", true).$type).prop("username", defineAnnotatedType().designType("string").tags("string").annotate("db.index.unique", "username_idx", true).$type).prop("version", defineAnnotatedType().designType("number").tags("int", "number").annotate("db.column.version", true).annotate("expect.int", true).$type).prop("password", defineAnnotatedType("object").prop("hash", defineAnnotatedType().designType("string").tags("string").$type).prop("history", defineAnnotatedType("array").of(defineAnnotatedType().designType("string").tags("string").$type).$type).prop("lastChanged", defineAnnotatedType().designType("number").tags("timestamp", "number").annotate("expect.int", true).$type).prop("isInitial", defineAnnotatedType().designType("boolean").tags("boolean").$type).annotate("db.patch.strategy", "merge").$type).prop("account", defineAnnotatedType("object").prop("active", defineAnnotatedType().designType("boolean").tags("boolean").$type).prop("locked", defineAnnotatedType().designType("boolean").tags("boolean").$type).prop("lockReason", defineAnnotatedType().designType("string").tags("string").$type).prop("lockEnds", defineAnnotatedType().designType("number").tags("timestamp", "number").annotate("expect.int", true).$type).prop("failedLoginAttempts", defineAnnotatedType().designType("number").tags("number").$type).prop("lastLogin", defineAnnotatedType().designType("number").tags("timestamp", "number").annotate("expect.int", true).$type).prop("pendingInvitation", defineAnnotatedType().designType("boolean").tags("boolean").optional().$type).prop("verifiedEmail", defineAnnotatedType().designType("string").tags("string").optional().$type).annotate("db.patch.strategy", "merge").$type).prop("mfa", defineAnnotatedType("object").prop("methods", defineAnnotatedType("array").of(defineAnnotatedType("object").prop("name", defineAnnotatedType().designType("string").tags("string").$type).prop("confirmed", defineAnnotatedType().designType("boolean").tags("boolean").$type).prop("value", defineAnnotatedType().designType("string").tags("string").$type).prop("lastUsedWindow", defineAnnotatedType().designType("number").tags("int", "number").annotate("expect.int", true).optional().$type).$type).$type).prop("defaultMethod", defineAnnotatedType().designType("string").tags("string").$type).prop("autoSend", defineAnnotatedType().designType("boolean").tags("boolean").$type).annotate("db.patch.strategy", "merge").$type).prop("trustedDevices", defineAnnotatedType("array").of(defineAnnotatedType("object").prop("token", defineAnnotatedType().designType("string").tags("string").$type).prop("ip", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("issuedAt", defineAnnotatedType().designType("number").tags("timestamp", "number").annotate("expect.int", true).$type).prop("expiresAt", defineAnnotatedType().designType("number").tags("timestamp", "number").annotate("expect.int", true).$type).prop("name", defineAnnotatedType().designType("string").tags("string").optional().$type).$type).annotate("db.patch.strategy", "merge").optional().$type).prop("seenDevices", defineAnnotatedType("array").of(defineAnnotatedType("object").prop("token", defineAnnotatedType().designType("string").tags("string").$type).prop("ip", defineAnnotatedType().designType("string").tags("string").optional().$type).prop("issuedAt", defineAnnotatedType().designType("number").tags("timestamp", "number").annotate("expect.int", true).$type).prop("expiresAt", defineAnnotatedType().designType("number").tags("timestamp", "number").annotate("expect.int", true).$type).prop("name", defineAnnotatedType().designType("string").tags("string").optional().$type).$type).annotate("db.patch.strategy", "merge").optional().$type).prop("roles", defineAnnotatedType("array").of(defineAnnotatedType().designType("string").tags("string").$type).annotate("arbac.role", true).$type);
441
519
  //#endregion
442
- export { AoothArbacUserCredentials, AtscriptArbacUserProvider, extractAttenuation, getAoothCredentialMetadataSpec, getAoothUserHandleSpec, getArbacAttenuationSpec, validateAttenuationTargets };
520
+ export { AoothArbacUserCredentials, ArbacUserRecordCache, AtscriptArbacUserProvider, extractAttenuation, getAoothCredentialMetadataSpec, getAoothUserHandleSpec, getArbacAttenuationSpec, validateAttenuationTargets };
package/dist/index.d.mts CHANGED
@@ -884,6 +884,23 @@ declare class MoostArbac<TUserAttrs extends object, TScope extends object> exten
884
884
  }): Promise<TArbacEvalResult$1<TScope>>;
885
885
  private readonly warnedFields;
886
886
  private readonly warnUnknownField;
887
+ private metaMemoMax;
888
+ /**
889
+ * Configure the `/meta` projection memo of the ARBAC DB / value-help
890
+ * controllers. The overlay still evaluates every grant per request; only
891
+ * the pure pruning of the cached envelope by those decisions is reused —
892
+ * keyed by the decisions themselves (never by user, scopes' JSON or row
893
+ * filters), up to `max` projections per envelope (default 64, LRU).
894
+ * Memoized projections are frozen and pre-serialized with a weak ETag.
895
+ * `false` turns it off (every `/meta` is pruned afresh).
896
+ *
897
+ * @since 0.1.78
898
+ */
899
+ setMetaMemo(options: false | {
900
+ max?: number;
901
+ }): this;
902
+ /** Most memoized `/meta` projections per envelope (`0` = memo off). @since 0.1.78 */
903
+ getMetaMemoMax(): number;
887
904
  /** The registered custom scope field rules. @since 0.1.72 */
888
905
  getScopeFields(): TScopeFieldRules<TScope>;
889
906
  }
package/dist/index.mjs CHANGED
@@ -7,6 +7,7 @@ import { Inherit, Injectable, TInterceptorPriority, defineBeforeInterceptor, get
7
7
  import { computeInsights, containsRelationFilter, findAncestorInSet, getPath, hasRelationOp, isPlainObject, isRelationOp, translateQueryTree } from "@atscript/db";
8
8
  import { AsDbController, AsDbReadableController, AsJsonValueHelpController, AsValueHelpController, DB_CRUD_HANDLERS, VALUE_HELP_CRUD_HANDLERS, badRequest, discoverRowLevelActions, getControllerFormType, getDbEndpoint, hasActionDelegations, resolveTerminalRef } from "@atscript/moost-db";
9
9
  import { groupByFields, walkFilter } from "@uniqu/core";
10
+ import { prerenderJson } from "@wooksjs/event-http";
10
11
  //#region src/db/helpers.ts
11
12
  /** `map.get(key)`, created with `create()` and stored on a miss. */
12
13
  function getOrCreate(map, key, create) {
@@ -248,7 +249,7 @@ function buildScopeVisibility(scopes, table, opts = {}) {
248
249
  return source === void 0 || alwaysVisible.has(source) || base(source);
249
250
  };
250
251
  const declared = /* @__PURE__ */ new Map();
251
- return {
252
+ return tagBuilt({
252
253
  allowed,
253
254
  alwaysVisible,
254
255
  withGrants,
@@ -261,7 +262,16 @@ function buildScopeVisibility(scopes, table, opts = {}) {
261
262
  if (resolved || !withGrants.has(name)) return resolved ?? void 0;
262
263
  return getOrCreate(declared, name, () => buildScopeVisibility(eff.withScopes(name), table?.relatedTable?.(name)));
263
264
  }
264
- };
265
+ });
266
+ }
267
+ const BUILT = Symbol("arbac.builtVisibility");
268
+ function tagBuilt(vis) {
269
+ vis[BUILT] = vis;
270
+ return vis;
271
+ }
272
+ /** `vis` came from {@link buildScopeVisibility} as is (not hand-built, not a copy). */
273
+ function isBuiltVisibility(vis) {
274
+ return vis[BUILT] === vis;
265
275
  }
266
276
  const scopeVisibilityCache = /* @__PURE__ */ new WeakMap();
267
277
  const NO_TABLE = {
@@ -725,6 +735,8 @@ function fieldChildrenOf(table) {
725
735
  }
726
736
  //#endregion
727
737
  //#region src/moost-arbac.ts
738
+ /** Default number of memoized `/meta` projections per envelope. */
739
+ const DEFAULT_META_MEMO_MAX = 64;
728
740
  let MoostArbac = class MoostArbac extends Arbac$1 {
729
741
  scopeFieldRules = {};
730
742
  /**
@@ -790,6 +802,26 @@ let MoostArbac = class MoostArbac extends Arbac$1 {
790
802
  this.warnedFields.add(field);
791
803
  console.warn(`[arbac] Scope field "${field}" has no rule registered with registerScopeFields: it reaches only your own code, and an attenuated request carrying it fails with 500.`);
792
804
  };
805
+ metaMemoMax = DEFAULT_META_MEMO_MAX;
806
+ /**
807
+ * Configure the `/meta` projection memo of the ARBAC DB / value-help
808
+ * controllers. The overlay still evaluates every grant per request; only
809
+ * the pure pruning of the cached envelope by those decisions is reused —
810
+ * keyed by the decisions themselves (never by user, scopes' JSON or row
811
+ * filters), up to `max` projections per envelope (default 64, LRU).
812
+ * Memoized projections are frozen and pre-serialized with a weak ETag.
813
+ * `false` turns it off (every `/meta` is pruned afresh).
814
+ *
815
+ * @since 0.1.78
816
+ */
817
+ setMetaMemo(options) {
818
+ this.metaMemoMax = options === false ? 0 : Math.max(0, options.max ?? DEFAULT_META_MEMO_MAX);
819
+ return this;
820
+ }
821
+ /** Most memoized `/meta` projections per envelope (`0` = memo off). @since 0.1.78 */
822
+ getMetaMemoMax() {
823
+ return this.metaMemoMax;
824
+ }
793
825
  /** The registered custom scope field rules. @since 0.1.72 */
794
826
  getScopeFields() {
795
827
  return this.scopeFieldRules;
@@ -843,6 +875,7 @@ async function loadPrincipal(user) {
843
875
  attrs: (id) => attrs ??= Promise.resolve(user.getAttrs(id))
844
876
  };
845
877
  }
878
+ const arbacDeps = cachedBy((_controller, ctx) => Promise.all([useControllerContext(ctx).instantiate(ArbacUserProviderToken), useControllerContext(ctx).instantiate(MoostArbac)]));
846
879
  const principalKey = key("arbac.principal");
847
880
  function isHttpEvent(ctx) {
848
881
  return ctx.has(eventTypeKey) && ctx.get(eventTypeKey) === "http";
@@ -858,7 +891,7 @@ function evaluateArbac(ctx, target, memoPrincipal = false) {
858
891
  const cc = useControllerContext(ctx);
859
892
  const table = target.table ?? cc.getController()?.readable;
860
893
  const pending = (async () => {
861
- const [user, arbac] = await Promise.all([cc.instantiate(ArbacUserProviderToken), cc.instantiate(MoostArbac)]);
894
+ const [user, arbac] = await arbacDeps(cc.getController(), ctx);
862
895
  let principal;
863
896
  if (!memoPrincipal || !isHttpEvent(ctx)) principal = loadPrincipal(user);
864
897
  else if (ctx.has(principalKey)) principal = ctx.get(principalKey);
@@ -919,7 +952,12 @@ function conjoinForTable(a, b, table, fields) {
919
952
  fields
920
953
  })[0]);
921
954
  }
922
- const scopeFieldsSlot = cached(async (ctx) => (await useControllerContext(ctx).instantiate(MoostArbac)).getScopeFields());
955
+ const scopeFieldsSlot = cached(async (ctx) => (await arbacDeps(useControllerContext(ctx).getController(), ctx))[1].getScopeFields());
956
+ /** The current controller's `MoostArbac` — resolved once per (event, controller). */
957
+ async function currentMoostArbac() {
958
+ const ctx = current();
959
+ return (await arbacDeps(useControllerContext(ctx).getController(), ctx))[1];
960
+ }
923
961
  /** The current app's custom scope field rules — resolved once per event. */
924
962
  function currentScopeFields() {
925
963
  return current().get(scopeFieldsSlot);
@@ -1090,6 +1128,189 @@ const ArbacResource = (name) => getArbacMate().decorate("arbacResourceId", name)
1090
1128
  */
1091
1129
  const ArbacAction = (name) => getArbacMate().decorate("arbacActionId", name);
1092
1130
  //#endregion
1131
+ //#region src/db/meta-memo.ts
1132
+ /** Deepest relation nesting the decision record follows; deeper → not memoized. */
1133
+ const MAX_RECORD_DEPTH = 16;
1134
+ var Unkeyable = class extends Error {};
1135
+ const UNKEYABLE = new Unkeyable("unkeyable /meta decision");
1136
+ function refuse() {
1137
+ throw UNKEYABLE;
1138
+ }
1139
+ const ids = /* @__PURE__ */ new WeakMap();
1140
+ let nextId = 1;
1141
+ function idOf(obj) {
1142
+ if (!obj) return 0;
1143
+ let id = ids.get(obj);
1144
+ if (id === void 0) ids.set(obj, id = nextId++);
1145
+ return id;
1146
+ }
1147
+ /**
1148
+ * Validate a scope list without copying it: no symbol-keyed scope (the
1149
+ * `INHERITED_CONJUNCTION` marker of a one-sided `with` sub-scope) and no
1150
+ * function-valued scope piece, `with` sub-scopes recursively. Each scope
1151
+ * object is visited once per record (`seen`). The record itself keys on the
1152
+ * visibility DERIVED from the scopes (row data never reaches `/meta`).
1153
+ */
1154
+ function checkScopes(scopes, seen, depth = 0) {
1155
+ if (!scopes) return;
1156
+ if (depth > MAX_RECORD_DEPTH) refuse();
1157
+ for (const scope of scopes) {
1158
+ if (seen.has(scope)) continue;
1159
+ if (scope === null || typeof scope !== "object") refuse();
1160
+ seen.add(scope);
1161
+ if (Object.getOwnPropertySymbols(scope).length > 0) refuse();
1162
+ const record = scope;
1163
+ for (const key of Object.keys(record)) {
1164
+ const value = record[key];
1165
+ if (typeof value === "function" || typeof value === "symbol") refuse();
1166
+ if (key === "with" && value !== void 0 && value !== null) {
1167
+ if (typeof value !== "object" || Object.getOwnPropertySymbols(value).length > 0) refuse();
1168
+ checkScopes(Object.values(value), seen, depth + 1);
1169
+ }
1170
+ }
1171
+ }
1172
+ }
1173
+ /** A projection (`path` → `0 | 1`) as sorted `[path, flag, …]`; anything else refuses. */
1174
+ function projectionKey(projection) {
1175
+ if (Object.getOwnPropertySymbols(projection).length > 0) refuse();
1176
+ const keys = Object.keys(projection).toSorted();
1177
+ const out = [];
1178
+ for (const key of keys) {
1179
+ const flag = projection[key];
1180
+ if (flag !== 0 && flag !== 1) refuse();
1181
+ out.push(key, flag);
1182
+ }
1183
+ return out;
1184
+ }
1185
+ /** Sorted strings of a set / list; a non-string refuses. */
1186
+ function sortedNames(names) {
1187
+ const out = [];
1188
+ for (const name of names) {
1189
+ if (typeof name !== "string") refuse();
1190
+ out.push(name);
1191
+ }
1192
+ return out.toSorted();
1193
+ }
1194
+ const NO_TREE = /* @__PURE__ */ new Map();
1195
+ /**
1196
+ * One visibility level: its table, normalized projection, parent-authority
1197
+ * relations, and every relation the pruning can resolve at this level — the
1198
+ * `/meta` relation names (`tree`, what the overlay resolved) and the declared
1199
+ * `with` names — as the child level or `null`. Only levels built by
1200
+ * `buildScopeVisibility` (whose `isAllowed` / `relation` are pure functions
1201
+ * of exactly these) are accepted.
1202
+ */
1203
+ function encodeLevel(vis, tree, ctx, seen, depth) {
1204
+ if (depth > MAX_RECORD_DEPTH || !isBuiltVisibility(vis)) refuse();
1205
+ if (vis.alwaysVisible !== ctx.identifiers(vis.table)) refuse();
1206
+ checkScopes(vis.scopes, seen);
1207
+ const withGrants = sortedNames(vis.withGrants);
1208
+ const names = tree.size === 0 ? withGrants : sortedNames(/* @__PURE__ */ new Set([...tree.keys(), ...withGrants]));
1209
+ const relations = [];
1210
+ for (const name of names) {
1211
+ const sub = vis.relation?.(name);
1212
+ relations.push(name, sub ? encodeLevel(sub, tree.get(name) ?? NO_TREE, ctx, seen, depth + 1) : 0);
1213
+ }
1214
+ return [
1215
+ idOf(vis.table),
1216
+ projectionKey(vis.allowed),
1217
+ withGrants,
1218
+ relations
1219
+ ];
1220
+ }
1221
+ /** Bit string of `flags`. */
1222
+ function bits(flags) {
1223
+ let out = "";
1224
+ for (const flag of flags) out += flag ? "1" : "0";
1225
+ return out;
1226
+ }
1227
+ /**
1228
+ * The memo key of a `/meta` projection: its decision record, serialized —
1229
+ * or `undefined` when an input cannot be keyed exactly (then the request is
1230
+ * not memoized).
1231
+ */
1232
+ function metaDecisionKey(input, ctx) {
1233
+ try {
1234
+ const seen = /* @__PURE__ */ new Set();
1235
+ checkScopes(input.writeScopes, seen);
1236
+ checkScopes(input.readScopes, seen);
1237
+ const p = input.projection;
1238
+ if (!p) return JSON.stringify([bits(input.actions), bits(input.crud)]);
1239
+ const refTargets = [];
1240
+ const types = [...p.refTargets.keys()].toSorted((a, b) => idOf(a) - idOf(b));
1241
+ for (const type of types) {
1242
+ const vis = p.refTargets.get(type);
1243
+ refTargets.push(idOf(type), ctx.isServed(type), vis ? encodeLevel(vis, NO_TREE, ctx, seen, 1) : 0);
1244
+ }
1245
+ const { writable, nestedWrites, groupBy } = p;
1246
+ if (groupBy !== void 0 && typeof groupBy !== "boolean" && !Array.isArray(groupBy)) refuse();
1247
+ return JSON.stringify([
1248
+ bits(input.actions),
1249
+ bits(input.crud),
1250
+ encodeLevel(p.vis, p.tree, ctx, seen, 0),
1251
+ writable === void 0 ? 0 : writable === "all" ? 1 : sortedNames(writable),
1252
+ nestedWrites ? sortedNames(nestedWrites) : 0,
1253
+ Array.isArray(groupBy) ? sortedNames(groupBy) : groupBy ?? 0,
1254
+ refTargets,
1255
+ p.vis.table?.type ? ctx.isServed(p.vis.table.type) : 0
1256
+ ]);
1257
+ } catch (error) {
1258
+ if (error instanceof Unkeyable) return void 0;
1259
+ throw error;
1260
+ }
1261
+ }
1262
+ const variants = /* @__PURE__ */ new WeakMap();
1263
+ const seenEnvelopes = /* @__PURE__ */ new WeakSet();
1264
+ /**
1265
+ * The projection of `envelope` for `key`, computed once per (envelope, key)
1266
+ * and reused while it stays among the `max` most recently used. No `key`
1267
+ * (unkeyable decisions) or `max` ≤ 0 → computed per call, uncached.
1268
+ */
1269
+ function memoMetaProjection(envelope, key, max, compute) {
1270
+ if (key === void 0 || max <= 0) return compute();
1271
+ if (!seenEnvelopes.has(envelope)) {
1272
+ seenEnvelopes.add(envelope);
1273
+ return compute();
1274
+ }
1275
+ let byKey = variants.get(envelope);
1276
+ const hit = byKey?.get(key);
1277
+ if (hit) {
1278
+ byKey.delete(key);
1279
+ byKey.set(key, hit);
1280
+ return hit;
1281
+ }
1282
+ const out = shareMeta(compute());
1283
+ if (!byKey) variants.set(envelope, byKey = /* @__PURE__ */ new Map());
1284
+ while (byKey.size >= max) byKey.delete(byKey.keys().next().value);
1285
+ byKey.set(key, out);
1286
+ return out;
1287
+ }
1288
+ /**
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.
1292
+ */
1293
+ 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]);
1312
+ }
1313
+ //#endregion
1093
1314
  //#region src/db/meta-projection.ts
1094
1315
  /**
1095
1316
  * Exact-or-ancestor membership in the write whitelist: `credit.credentials.user`
@@ -1648,30 +1869,45 @@ async function applyArbacMetaOverlay(meta, source, _indexes) {
1648
1869
  const instance = useControllerContext().getController();
1649
1870
  const { methods } = controllerMethodIndex(instance);
1650
1871
  const crudKeys = Object.keys(meta.crud);
1651
- const [allowedActions, crudScopes] = await Promise.all([grantedActions(instance, meta.actions.map((entry) => entry.name)), Promise.all(crudKeys.map((op) => evaluateHandlers(crudHandlerIds(instance, op, methods))))]);
1872
+ const [allowedActions, crudScopes, arbac] = await Promise.all([
1873
+ grantedActions(instance, meta.actions.map((entry) => entry.name)),
1874
+ Promise.all(crudKeys.map((op) => evaluateHandlers(crudHandlerIds(instance, op, methods)))),
1875
+ currentMoostArbac()
1876
+ ]);
1877
+ const memoMax = arbac.getMetaMemoMax();
1652
1878
  const allowed = new Set(allowedActions);
1653
- const actions = meta.actions.filter((entry) => allowed.has(entry.name));
1654
- const crud = {};
1879
+ const actionBits = meta.actions.map((entry) => allowed.has(entry.name));
1880
+ const crudBits = crudScopes.map((scopes) => scopes !== void 0);
1655
1881
  const readScopes = [];
1656
1882
  const writeScopes = [];
1657
1883
  for (let i = 0; i < crudKeys.length; i++) {
1658
1884
  const scopes = crudScopes[i];
1659
1885
  if (!scopes) continue;
1660
- crud[crudKeys[i]] = meta.crud[crudKeys[i]];
1661
1886
  (WRITE_CRUD_OPS.has(crudKeys[i]) ? writeScopes : readScopes).push(...scopes);
1662
1887
  }
1663
- const overlaid = {
1664
- ...meta,
1665
- actions,
1666
- crud
1888
+ const overlay = () => {
1889
+ const crud = {};
1890
+ for (let i = 0; i < crudKeys.length; i++) if (crudBits[i]) crud[crudKeys[i]] = meta.crud[crudKeys[i]];
1891
+ return {
1892
+ ...meta,
1893
+ actions: meta.actions.filter((_, i) => actionBits[i]),
1894
+ crud
1895
+ };
1667
1896
  };
1668
- if (readScopes.length === 0) return overlaid;
1897
+ if (readScopes.length === 0) return memoMetaProjection(meta, memoMax > 0 ? metaDecisionKey({
1898
+ actions: actionBits,
1899
+ crud: crudBits,
1900
+ readScopes,
1901
+ writeScopes
1902
+ }, RECORD_CTX) : void 0, memoMax, overlay);
1669
1903
  const groupBy = effectiveScope(readScopes).controls.$groupBy;
1670
1904
  const writable = collectWritableFields(writeScopes, false);
1671
1905
  const nestedWrites = effectiveScope(writeScopes).nestedWrites;
1672
1906
  if (isIdentifierSet(source)) {
1673
1907
  const vis = buildScopeVisibility(readScopes, void 0, source);
1674
- return gateGroupable(Object.keys(vis.allowed).length > 0 || vis.withGrants.size > 0 ? pruneMetaByVisibility(overlaid, {
1908
+ const visible = Object.keys(vis.allowed).length > 0 || vis.withGrants.size > 0;
1909
+ const overlaid = overlay();
1910
+ return gateGroupable(visible ? pruneMetaByVisibility(overlaid, {
1675
1911
  ...vis,
1676
1912
  writable,
1677
1913
  nestedWrites
@@ -1679,12 +1915,30 @@ async function applyArbacMetaOverlay(meta, source, _indexes) {
1679
1915
  }
1680
1916
  const vis = buildScopeVisibility(readScopes, source, { relations: await resolveRelationTree(readScopes, source, metaRelationNames(meta), { listSiblings: false }) });
1681
1917
  const refTargets = await resolveRefTargets(vis, meta);
1682
- return gateGroupable(pruneMetaByVisibility(overlaid, {
1918
+ return memoMetaProjection(meta, memoMax > 0 ? metaDecisionKey({
1919
+ actions: actionBits,
1920
+ crud: crudBits,
1921
+ readScopes,
1922
+ writeScopes,
1923
+ projection: {
1924
+ vis,
1925
+ tree: metaRelationNames(meta),
1926
+ writable,
1927
+ nestedWrites,
1928
+ groupBy,
1929
+ refTargets
1930
+ }
1931
+ }, RECORD_CTX) : void 0, memoMax, () => gateGroupable(pruneMetaByVisibility(overlay(), {
1683
1932
  ...vis,
1684
1933
  writable,
1685
1934
  nestedWrites
1686
- }, refTargets), groupBy);
1935
+ }, refTargets), groupBy));
1687
1936
  }
1937
+ /** What the `/meta` memo's decision record reads from the pruning rules. */
1938
+ const RECORD_CTX = {
1939
+ isServed: isDbServed,
1940
+ identifiers: identifierSet
1941
+ };
1688
1942
  /**
1689
1943
  * The `/meta/form/:name` overlay: a form is a plain annotated type (no table),
1690
1944
  * so only its cross-table pointers are projected. Every prop's `ref` and
@@ -2595,8 +2849,9 @@ function insertRowsVerdict(rows, check) {
2595
2849
  if (error instanceof UnsupportedCheckError) return void 0;
2596
2850
  throw error;
2597
2851
  }
2852
+ const fieldList = [...fields];
2598
2853
  let verdict = true;
2599
- for (const row of rows) if ([...fields].some((f) => getPath(row, f) === void 0)) verdict = void 0;
2854
+ for (const row of rows) if (fieldList.some((f) => getPath(row, f) === void 0)) verdict = void 0;
2600
2855
  else if (!predicate(row)) return false;
2601
2856
  return verdict;
2602
2857
  }
@@ -3190,11 +3445,21 @@ function requestsField(requested, field) {
3190
3445
  async function applyValueHelpMetaOverlay(host, meta) {
3191
3446
  const overlaid = await applyArbacMetaOverlay(meta, host.source);
3192
3447
  if (!overlaid.searchable || overlaid.fields === meta.fields) return overlaid;
3193
- return host.searchableFields.some((f) => f in overlaid.fields) ? overlaid : {
3448
+ if (host.searchableFields.some((f) => f in overlaid.fields)) return overlaid;
3449
+ if (!Object.isFrozen(overlaid)) return {
3194
3450
  ...overlaid,
3195
3451
  searchable: false
3196
3452
  };
3453
+ let perHost = unsearchable.get(overlaid);
3454
+ if (!perHost) unsearchable.set(overlaid, perHost = /* @__PURE__ */ new WeakMap());
3455
+ let out = perHost.get(host);
3456
+ if (!out) perHost.set(host, out = shareMeta({
3457
+ ...overlaid,
3458
+ searchable: false
3459
+ }));
3460
+ return out;
3197
3461
  }
3462
+ const unsearchable = /* @__PURE__ */ new WeakMap();
3198
3463
  let AsArbacValueHelpController = class AsArbacValueHelpController extends AsValueHelpController {
3199
3464
  _arbacHost;
3200
3465
  /** Makes this controller the binding / `ref` policy source for its bound type's ARBAC resource. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aooth/arbac-moost",
3
- "version": "0.1.77",
3
+ "version": "0.1.79",
4
4
  "description": "Moost RBAC integration for aoothjs (migrated from @moostjs/arbac)",
5
5
  "keywords": [
6
6
  "abac",
@@ -60,31 +60,31 @@
60
60
  "access": "public"
61
61
  },
62
62
  "dependencies": {
63
- "@uniqu/core": "^0.1.13",
64
- "@aooth/arbac": "0.1.77",
65
- "@aooth/arbac-core": "0.1.77",
66
- "@aooth/user": "0.1.77"
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"
67
67
  },
68
68
  "devDependencies": {
69
- "@atscript/core": "^0.1.101",
70
- "@atscript/db": "^0.1.150",
71
- "@atscript/db-sql-tools": "^0.1.150",
72
- "@atscript/db-sqlite": "^0.1.150",
73
- "@atscript/moost-db": "^0.1.150",
74
- "@atscript/typescript": "^0.1.101",
75
- "@moostjs/event-http": "^0.6.47",
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",
76
76
  "@types/better-sqlite3": "^7.6.13",
77
77
  "better-sqlite3": "^12.6.2",
78
- "unplugin-atscript": "^0.1.101"
78
+ "unplugin-atscript": "^0.1.103"
79
79
  },
80
80
  "peerDependencies": {
81
- "@atscript/db": "^0.1.150",
82
- "@atscript/moost-db": "^0.1.150",
83
- "@atscript/typescript": "^0.1.101",
84
- "@moostjs/event-http": "^0.6.47",
85
- "@wooksjs/event-core": "^0.7.26",
86
- "@wooksjs/event-http": "^0.7.26",
87
- "moost": "^0.6.47"
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"
88
88
  },
89
89
  "peerDependenciesMeta": {
90
90
  "@atscript/db": {