@aooth/arbac 0.1.70 → 0.1.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -92,44 +92,197 @@ function definePrivilege() {
92
92
  };
93
93
  }
94
94
  //#endregion
95
- //#region src/db-privileges.ts
96
- const TABLE_READ_ACTIONS = [
97
- "query",
98
- "pages",
99
- "getOne",
100
- "getOneComposite",
101
- "meta",
102
- "metaForm"
103
- ];
104
- const TABLE_WRITE_ACTIONS = [
105
- "insert",
106
- "update",
107
- "replace",
108
- "remove",
109
- "removeComposite"
110
- ];
111
- function rulesFor(resource, actions, scope) {
112
- return actions.map((action) => scope ? {
113
- resource,
114
- action,
115
- scope
116
- } : {
117
- resource,
118
- action
119
- });
95
+ //#region src/scope/controls.ts
96
+ /**
97
+ * Controls whose values can be sensibly whitelisted via `readonly string[]`.
98
+ *
99
+ * - `$with` — relation names; uniquery parses to `{ name, … }[]`.
100
+ * - `$groupBy` — column names; the value already IS the array of names.
101
+ *
102
+ * Other controls only support boolean gates in v1; supplying a whitelist
103
+ * for them throws from {@link unionControlsPolicy}.
104
+ */
105
+ const WHITELISTABLE_CONTROLS = /* @__PURE__ */ new Set(["$with", "$groupBy"]);
106
+ /**
107
+ * Union per-role `controls` policies. Additive RBAC semantics: more roles =
108
+ * broader access, mirroring how `mergeScopeFilters` and `unionProjections`
109
+ * combine other parts of an `ArbacDbScope`.
110
+ *
111
+ * Resolution per control key:
112
+ * 1. Collect each scope's value for the key (including `undefined` for
113
+ * silent scopes — i.e., scopes that have a `controls` map but no entry
114
+ * for this key).
115
+ * 2. If any value is `undefined` or `true` → return `true` (full allow).
116
+ * Silence and explicit allow both mean "this role has no objection".
117
+ * 3. Else (every scope is `false` or `string[]`):
118
+ * - If all are `false` → return `false` (full deny).
119
+ * - Else collect every `string[]` into a single sorted, de-duplicated
120
+ * array; return that array. (Roles that say `false` don't contribute
121
+ * positively; roles with whitelists do.)
122
+ *
123
+ * Scopes that omit the `controls` map entirely are treated as silent on
124
+ * EVERY key — i.e., they grant unrestricted control usage. As a result, if
125
+ * even a single scope in the input lacks a `controls` map, the result is
126
+ * always `{}` (no restrictions).
127
+ *
128
+ * Whitelist support is restricted to {@link WHITELISTABLE_CONTROLS}. Passing
129
+ * a whitelist for any other control throws — the gate cannot be enforced
130
+ * meaningfully in v1, so the misuse is rejected loudly at scope-merge time.
131
+ *
132
+ * @returns merged policy keyed by control name. Keys absent from the result
133
+ * mean "no opinion → fully allowed". The caller should treat a
134
+ * missing key the same as `true`.
135
+ *
136
+ * @throws when a non-whitelistable control is given a `readonly string[]` gate.
137
+ */
138
+ function unionControlsPolicy(scopes) {
139
+ if (scopes.length === 0) return {};
140
+ if (scopes.some((s) => !s.controls)) return {};
141
+ const allKeys = /* @__PURE__ */ new Set();
142
+ for (const s of scopes) for (const k of Object.keys(s.controls)) allKeys.add(k);
143
+ const result = {};
144
+ for (const key of allKeys) {
145
+ let anyAllow = false;
146
+ const whitelistAcc = /* @__PURE__ */ new Set();
147
+ let hadWhitelist = false;
148
+ for (const s of scopes) {
149
+ const v = s.controls[key];
150
+ if (v === void 0 || v === true) {
151
+ anyAllow = true;
152
+ break;
153
+ }
154
+ if (v === false) continue;
155
+ if (Array.isArray(v)) {
156
+ if (!WHITELISTABLE_CONTROLS.has(key)) throw new Error(`unionControlsPolicy: control "${key}" only supports boolean gates; whitelist arrays not yet implemented`);
157
+ hadWhitelist = true;
158
+ for (const item of v) whitelistAcc.add(item);
159
+ continue;
160
+ }
161
+ throw new Error(`unionControlsPolicy: invalid gate value ${String(v)} for control "${key}" (must be boolean or string[])`);
162
+ }
163
+ if (anyAllow) continue;
164
+ if (hadWhitelist) {
165
+ result[key] = [...whitelistAcc].toSorted();
166
+ continue;
167
+ }
168
+ result[key] = false;
169
+ }
170
+ return result;
120
171
  }
121
- /** Read-side actions on an `AsDbController` table: `query`, `pages`, `getOne`, `getOneComposite`, `meta`, `metaForm`. */
122
- function allowTableRead(resource, opts) {
123
- return () => rulesFor(resource, TABLE_READ_ACTIONS, opts?.scope);
172
+ /**
173
+ * Conjoin two ALREADY-UNIONED controls policies (each the output of
174
+ * {@link unionControlsPolicy} for one authority pass) under DENY-WINS
175
+ * intersection — a control is permitted only if BOTH passes permit it. This is
176
+ * the credential-attenuation combiner; polarity is the **opposite** of
177
+ * {@link unionControlsPolicy} (which is additive / silence-wins).
178
+ *
179
+ * Per control key (absent ≡ allowed, matching {@link unionControlsPolicy}):
180
+ * - denied (`false`) on EITHER side → `false`.
181
+ * - allowed on one side → the OTHER side's gate (`allow ∧ X = X`).
182
+ * - whitelist ∧ whitelist → the INTERSECTION of the two arrays (only values
183
+ * allowed by both; may be empty = nothing permitted).
184
+ *
185
+ * @returns merged policy; keys absent from the result mean "allowed" (callers
186
+ * treat a missing key as `true`), matching {@link unionControlsPolicy}.
187
+ */
188
+ function intersectControlsPolicy(a, b) {
189
+ const result = {};
190
+ const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
191
+ for (const key of keys) {
192
+ const av = key in a ? a[key] : true;
193
+ const bv = key in b ? b[key] : true;
194
+ if (av === false || bv === false) {
195
+ result[key] = false;
196
+ continue;
197
+ }
198
+ if (av === true) {
199
+ if (Array.isArray(bv)) result[key] = [...bv];
200
+ continue;
201
+ }
202
+ if (bv === true) {
203
+ if (Array.isArray(av)) result[key] = [...av];
204
+ continue;
205
+ }
206
+ const bset = new Set(bv);
207
+ result[key] = [...new Set(av)].filter((v) => bset.has(v)).toSorted();
208
+ }
209
+ return result;
124
210
  }
125
- /** All actions on an `AsDbController` table: read + `insert`, `update`, `replace`, `remove`, `removeComposite`. */
126
- function allowTableWrite(resource, opts) {
127
- return () => rulesFor(resource, [...TABLE_READ_ACTIONS, ...TABLE_WRITE_ACTIONS], opts?.scope);
211
+ //#endregion
212
+ //#region src/scope/filter.ts
213
+ /**
214
+ * A filter that matches no rows (`{ $or: [] }`) — what a denied read, or a
215
+ * conjunction whose field intersection is empty, filters by. Shared; never
216
+ * mutate it.
217
+ */
218
+ const DENY_FILTER = { $or: [] };
219
+ /**
220
+ * Merge multiple scope filters into a single filter using `$or` semantics.
221
+ *
222
+ * In RBAC, if multiple roles grant access with different filters,
223
+ * the user can see records matching ANY of them — hence `$or`.
224
+ *
225
+ * Behaviour:
226
+ * - Empty input → `undefined` (no filter)
227
+ * - Any empty filter → `undefined` (one role grants unrestricted access)
228
+ * - Single filter → returned as-is
229
+ * - All filters single-keyed on the same primitive field → `{ field: { $in: [...] } }`
230
+ * - Otherwise → `{ $or: [...] }`
231
+ *
232
+ * @returns the merged filter, or `undefined` for unrestricted access
233
+ */
234
+ function mergeScopeFilters(scopes) {
235
+ if (scopes.length === 0) return void 0;
236
+ if (scopes.some((s) => Object.keys(s).length === 0)) return void 0;
237
+ if (scopes.length === 1) return scopes[0];
238
+ if (canOptimizeToIn(scopes)) {
239
+ const key = Object.keys(scopes[0])[0];
240
+ const values = scopes.map((s) => s[key]);
241
+ return { [key]: { $in: values } };
242
+ }
243
+ return { $or: scopes };
128
244
  }
129
- /** One or more declarative actions on the same table, sharing a scope. */
130
- function allowTableAction(resource, name, opts) {
131
- const actions = typeof name === "string" ? [name] : name;
132
- return () => rulesFor(resource, actions, opts?.scope);
245
+ /**
246
+ * Conjoin two filters under `$and` semantics — a row survives only if BOTH
247
+ * sides admit it. The restrict-only combiner, used wherever two independent
248
+ * constraints must both hold:
249
+ *
250
+ * - **credential attenuation** — assigned authority ∧ presented authority, so
251
+ * a scoped token can only clip what the role grants (see
252
+ * `conjoinArbacDbScopes`);
253
+ * - **scope ∧ request** — the caller's scope union ∧ the user-supplied query
254
+ * filter, on both the top-level read and the per-relation `$with` overlay.
255
+ *
256
+ * Either side may be a {@link mergeScopeFilters} output or a raw filter.
257
+ *
258
+ * Polarity is the **opposite** of {@link mergeScopeFilters}: an empty `{}` /
259
+ * `undefined` filter is the universe and acts as the **identity** here
260
+ * (dropped from the `$and`, contributing NO constraint) — never the absorbing
261
+ * "unrestricted wins". Never object-spreads the two filters: a key present on
262
+ * both sides would be overwritten rather than intersected, silently widening
263
+ * access (a caller scoped to `tenantId: 'a'` asking for `'b'` would get `'b'`).
264
+ *
265
+ * @returns the conjoined filter, or `undefined` when BOTH sides are unrestricted.
266
+ */
267
+ function conjoinScopeFilters(a, b) {
268
+ const aEmpty = !a || Object.keys(a).length === 0;
269
+ const bEmpty = !b || Object.keys(b).length === 0;
270
+ if (aEmpty && bEmpty) return void 0;
271
+ if (aEmpty) return b;
272
+ if (bEmpty) return a;
273
+ return { $and: [a, b] };
274
+ }
275
+ function canOptimizeToIn(scopes) {
276
+ const firstKeys = Object.keys(scopes[0]);
277
+ if (firstKeys.length !== 1) return false;
278
+ const key = firstKeys[0];
279
+ for (const scope of scopes) {
280
+ const keys = Object.keys(scope);
281
+ if (keys.length !== 1 || keys[0] !== key) return false;
282
+ const value = scope[key];
283
+ if (value !== null && typeof value === "object") return false;
284
+ }
285
+ return true;
133
286
  }
134
287
  //#endregion
135
288
  //#region src/scope/projection.ts
@@ -329,197 +482,549 @@ function restrictProjection(desired, accessControl, childrenOf) {
329
482
  return intersectProjections(desired, accessControl, childrenOf) ?? { ...accessControl };
330
483
  }
331
484
  //#endregion
332
- //#region src/scope/filter.ts
485
+ //#region src/scope/db-scope.ts
333
486
  /**
334
- * A filter that matches no rows (`{ $or: [] }`) — what a denied read, or a
335
- * conjunction whose field intersection is empty, filters by. Shared; never
336
- * mutate it.
487
+ * The built-in {@link TDbScope} keys — the facets the scope algebra itself
488
+ * combines. Any other key is a custom (declaration-merged) scope field, which
489
+ * {@link conjoinScopes} combines only through a registered
490
+ * {@link TScopeFieldRule}.
491
+ *
492
+ * @since 0.1.72
337
493
  */
338
- const DENY_FILTER = { $or: [] };
494
+ const DB_SCOPE_KEYS = Object.freeze([
495
+ "filter",
496
+ "check",
497
+ "projection",
498
+ "controls",
499
+ "allowedFields",
500
+ "set",
501
+ "nestedWrites",
502
+ "checkRefs",
503
+ "with"
504
+ ]);
505
+ const BUILT_IN_KEYS = new Set(DB_SCOPE_KEYS);
339
506
  /**
340
- * Merge multiple scope filters into a single filter using `$or` semantics.
341
- *
342
- * In RBAC, if multiple roles grant access with different filters,
343
- * the user can see records matching ANY of them — hence `$or`.
507
+ * Thrown by {@link conjoinScopes} for a custom scope field without a
508
+ * conjunction rule — a server CONFIGURATION error (never a client error):
509
+ * the conjunction fails closed instead of dropping the field.
344
510
  *
345
- * Behaviour:
346
- * - Empty input → `undefined` (no filter)
347
- * - Any empty filter → `undefined` (one role grants unrestricted access)
348
- * - Single filter → returned as-is
349
- * - All filters single-keyed on the same primitive field → `{ field: { $in: [...] } }`
350
- * - Otherwise → `{ $or: [...] }`
351
- *
352
- * @returns the merged filter, or `undefined` for unrestricted access
511
+ * @since 0.1.72
353
512
  */
354
- function mergeScopeFilters(scopes) {
355
- if (scopes.length === 0) return void 0;
356
- if (scopes.some((s) => Object.keys(s).length === 0)) return void 0;
357
- if (scopes.length === 1) return scopes[0];
358
- if (canOptimizeToIn(scopes)) {
359
- const key = Object.keys(scopes[0])[0];
360
- const values = scopes.map((s) => s[key]);
361
- return { [key]: { $in: values } };
513
+ var ScopeFieldConfigError = class extends Error {
514
+ field;
515
+ name = "ScopeFieldConfigError";
516
+ constructor(field, message) {
517
+ super(message);
518
+ this.field = field;
362
519
  }
363
- return { $or: scopes };
364
- }
365
- /**
366
- * Conjoin two filters under `$and` semantics — a row survives only if BOTH
367
- * sides admit it. The restrict-only combiner, used wherever two independent
368
- * constraints must both hold:
369
- *
370
- * - **credential attenuation** — assigned authority ∧ presented authority, so
371
- * a scoped token can only clip what the role grants (see
372
- * `conjoinArbacDbScopes`);
373
- * - **scope ∧ request** — the caller's scope union ∧ the user-supplied query
374
- * filter, on both the top-level read and the per-relation `$with` overlay.
375
- *
376
- * Either side may be a {@link mergeScopeFilters} output or a raw filter.
377
- *
378
- * Polarity is the **opposite** of {@link mergeScopeFilters}: an empty `{}` /
379
- * `undefined` filter is the universe and acts as the **identity** here
380
- * (dropped from the `$and`, contributing NO constraint) — never the absorbing
381
- * "unrestricted wins". Never object-spreads the two filters: a key present on
382
- * both sides would be overwritten rather than intersected, silently widening
383
- * access (a caller scoped to `tenantId: 'a'` asking for `'b'` would get `'b'`).
520
+ };
521
+ const NONE = /* @__PURE__ */ new Set();
522
+ /** Lazily computed facets of one scopes array. */
523
+ var EffectiveDbScope = class {
524
+ scopes;
525
+ memo = /* @__PURE__ */ new Map();
526
+ withMemo;
527
+ constructor(scopes) {
528
+ this.scopes = scopes;
529
+ }
530
+ lazy(name, compute) {
531
+ if (this.memo.has(name)) return this.memo.get(name);
532
+ const value = compute();
533
+ this.memo.set(name, value);
534
+ return value;
535
+ }
536
+ get filter() {
537
+ return this.lazy("filter", () => mergeScopeFilters(this.scopes.map((s) => s.filter ?? {})));
538
+ }
539
+ get check() {
540
+ return this.lazy("check", () => this.hasExplicitCheck ? mergeScopeFilters(this.scopes.map((s) => s.check ?? s.filter ?? {})) : this.filter);
541
+ }
542
+ get hasExplicitCheck() {
543
+ return this.lazy("hasCheck", () => this.scopes.some((s) => s.check !== void 0));
544
+ }
545
+ get projection() {
546
+ return this.lazy("projection", () => unionProjections(...this.scopes.map((s) => s.projection ?? {})));
547
+ }
548
+ get controls() {
549
+ return this.lazy("controls", () => unionControlsPolicy(this.scopes));
550
+ }
551
+ get allowedFields() {
552
+ return this.lazy("allowedFields", () => {
553
+ let out;
554
+ for (const s of this.scopes) {
555
+ if (!Array.isArray(s.allowedFields)) continue;
556
+ out ??= /* @__PURE__ */ new Set();
557
+ for (const f of s.allowedFields) out.add(f);
558
+ }
559
+ return out;
560
+ });
561
+ }
562
+ get set() {
563
+ return this.lazy("set", () => {
564
+ let out;
565
+ for (const s of this.scopes) if (s.set) Object.assign(out ??= {}, s.set);
566
+ return out;
567
+ });
568
+ }
569
+ get nestedWrites() {
570
+ return this.lazy("nestedWrites", () => {
571
+ const out = new Set(this.scopes.flatMap((s) => s.nestedWrites ?? []));
572
+ return out.size > 0 ? out : NONE;
573
+ });
574
+ }
575
+ get checkRefs() {
576
+ return this.lazy("checkRefs", () => intersectEnabled(this.scopes.map((s) => s.checkRefs === true ? true : new Set(s.checkRefs ?? []))));
577
+ }
578
+ get withNames() {
579
+ return this.lazy("withNames", () => {
580
+ const out = /* @__PURE__ */ new Set();
581
+ for (const s of this.scopes) if (s.with) for (const name of Object.keys(s.with)) out.add(name);
582
+ return out.size > 0 ? out : NONE;
583
+ });
584
+ }
585
+ withScopes(name) {
586
+ this.withMemo ??= /* @__PURE__ */ new Map();
587
+ let list = this.withMemo.get(name);
588
+ if (!list) {
589
+ list = [];
590
+ for (const s of this.scopes) {
591
+ const sub = s.with?.[name];
592
+ if (sub) list.push(sub);
593
+ }
594
+ this.withMemo.set(name, list);
595
+ }
596
+ return list;
597
+ }
598
+ };
599
+ const effectiveCache = /* @__PURE__ */ new WeakMap();
600
+ /**
601
+ * The {@link TEffectiveDbScope} of `scopes`, memoized per scopes ARRAY (an
602
+ * identity-stable per-request list reuses every computed facet).
384
603
  *
385
- * @returns the conjoined filter, or `undefined` when BOTH sides are unrestricted.
604
+ * @since 0.1.72
386
605
  */
387
- function conjoinScopeFilters(a, b) {
388
- const aEmpty = !a || Object.keys(a).length === 0;
389
- const bEmpty = !b || Object.keys(b).length === 0;
390
- if (aEmpty && bEmpty) return void 0;
391
- if (aEmpty) return b;
392
- if (bEmpty) return a;
393
- return { $and: [a, b] };
394
- }
395
- function canOptimizeToIn(scopes) {
396
- const firstKeys = Object.keys(scopes[0]);
397
- if (firstKeys.length !== 1) return false;
398
- const key = firstKeys[0];
399
- for (const scope of scopes) {
400
- const keys = Object.keys(scope);
401
- if (keys.length !== 1 || keys[0] !== key) return false;
402
- const value = scope[key];
403
- if (value !== null && typeof value === "object") return false;
606
+ function effectiveScope(scopes) {
607
+ let eff = effectiveCache.get(scopes);
608
+ if (!eff) {
609
+ eff = new EffectiveDbScope(scopes);
610
+ effectiveCache.set(scopes, eff);
404
611
  }
405
- return true;
612
+ return eff;
406
613
  }
407
- //#endregion
408
- //#region src/scope/controls.ts
409
614
  /**
410
- * Controls whose values can be sensibly whitelisted via `readonly string[]`.
615
+ * Intersection of enforcement: an entry is enforced only when EVERY input
616
+ * enables it (`true` enables all). No input → nothing enforced.
411
617
  *
412
- * - `$with` — relation names; uniquery parses to `{ name, … }[]`.
413
- * - `$groupBy` — column names; the value already IS the array of names.
618
+ * @since 0.1.72
619
+ */
620
+ function intersectEnabled(inputs) {
621
+ let out;
622
+ for (const input of inputs) {
623
+ if (input === true) {
624
+ out ??= true;
625
+ continue;
626
+ }
627
+ out = out === void 0 || out === true ? new Set(input) : intersect(out, input);
628
+ if (out.size === 0) return out;
629
+ }
630
+ return out ?? /* @__PURE__ */ new Set();
631
+ }
632
+ function intersect(a, b) {
633
+ const out = /* @__PURE__ */ new Set();
634
+ for (const k of a) if (b.has(k)) out.add(k);
635
+ return out;
636
+ }
637
+ /**
638
+ * The unified empty-scope rule for an evaluation outcome: denied → `undefined`;
639
+ * allowed with `scopes` absent (a scope-agnostic evaluator) → `[{}]`
640
+ * (unrestricted); a scope predicate that returned nothing contributes nothing
641
+ * (fail closed for that rule); allowed with no scope left → `undefined` (deny).
414
642
  *
415
- * Other controls only support boolean gates in v1; supplying a whitelist
416
- * for them throws from {@link unionControlsPolicy}.
643
+ * @since 0.1.72
417
644
  */
418
- const WHITELISTABLE_CONTROLS = /* @__PURE__ */ new Set(["$with", "$groupBy"]);
645
+ function normalizeScopes(outcome) {
646
+ if (!outcome.allowed) return void 0;
647
+ const { scopes } = outcome;
648
+ if (scopes === void 0) return [{}];
649
+ const list = scopes.every((s) => s != null) ? scopes : scopes.filter((s) => s != null);
650
+ return list.length > 0 ? list : void 0;
651
+ }
419
652
  /**
420
- * Union per-role `controls` policies. Additive RBAC semantics: more roles =
421
- * broader access, mirroring how `mergeScopeFilters` and `unionProjections`
422
- * combine other parts of an `ArbacDbScope`.
653
+ * Several evaluations served as ONE surface (e.g. the handlers of one crud
654
+ * op): each normalized with {@link normalizeScopes}, the allowed ones'
655
+ * scopes concatenated — `undefined` when none allows.
423
656
  *
424
- * Resolution per control key:
425
- * 1. Collect each scope's value for the key (including `undefined` for
426
- * silent scopes — i.e., scopes that have a `controls` map but no entry
427
- * for this key).
428
- * 2. If any value is `undefined` or `true` → return `true` (full allow).
429
- * Silence and explicit allow both mean "this role has no objection".
430
- * 3. Else (every scope is `false` or `string[]`):
431
- * - If all are `false` → return `false` (full deny).
432
- * - Else collect every `string[]` into a single sorted, de-duplicated
433
- * array; return that array. (Roles that say `false` don't contribute
434
- * positively; roles with whitelists do.)
657
+ * @since 0.1.72
658
+ */
659
+ function unionOutcomes(outcomes) {
660
+ let out;
661
+ for (const o of outcomes) {
662
+ const scopes = normalizeScopes(o);
663
+ if (scopes) (out ??= []).push(...scopes);
664
+ }
665
+ return out;
666
+ }
667
+ /**
668
+ * Conjoin two row policies (`$and`, restrict-only): `filter = a.filter ∧
669
+ * b.filter`; the effective check `(a.check ?? a.filter) ∧ (b.check ??
670
+ * b.filter)`. `check` is set only when it differs from the conjoined
671
+ * `filter` (which an absent `check` defaults to) — `{}` when it is
672
+ * unrestricted while the filter is not.
435
673
  *
436
- * Scopes that omit the `controls` map entirely are treated as silent on
437
- * EVERY key — i.e., they grant unrestricted control usage. As a result, if
438
- * even a single scope in the input lacks a `controls` map, the result is
439
- * always `{}` (no restrictions).
674
+ * @since 0.1.72
675
+ */
676
+ function conjoinRowPolicies(a, b) {
677
+ const out = {};
678
+ const filter = conjoinScopeFilters(a.filter, b.filter);
679
+ if (filter && Object.keys(filter).length > 0) out.filter = filter;
680
+ if (a.check === void 0 && b.check === void 0) return out;
681
+ const check = conjoinScopeFilters(a.check ?? a.filter, b.check ?? b.filter);
682
+ if (stableKey(check ?? {}) !== stableKey(out.filter ?? {})) out.check = check ?? {};
683
+ return out;
684
+ }
685
+ /**
686
+ * Conjoin two scope lists (credential attenuation: the user's ceiling ∧ the
687
+ * credential's narrowed view) into ONE composite scope. Each side is first
688
+ * unioned ({@link effectiveScope}), then the two results are CONJOINED facet
689
+ * by facet — never with the additive union helpers, which would widen:
440
690
  *
441
- * Whitelist support is restricted to {@link WHITELISTABLE_CONTROLS}. Passing
442
- * a whitelist for any other control throws — the gate cannot be enforced
443
- * meaningfully in v1, so the misuse is rejected loudly at scope-merge time.
691
+ * - `filter` / `check` — {@link conjoinRowPolicies};
692
+ * - `projection` — {@link intersectProjections} (no common field → the
693
+ * first side's projection plus a match-nothing filter, never `{}`);
694
+ * - `controls` — {@link intersectControlsPolicy} (deny wins);
695
+ * - `allowedFields` — intersection (a side without a whitelist is unrestricted);
696
+ * - `nestedWrites` — intersection (a side without the key allows none);
697
+ * - `set` — combined, the FIRST side winning a key conflict;
698
+ * - `checkRefs` — enforced when EITHER side enforces it;
699
+ * - `with` — per relation, recursively; a relation declared by ONE side only
700
+ * is marked {@link INHERITED_CONJUNCTION} (the silent side's inherited
701
+ * grant on the related table must be conjoined at resolution time);
702
+ * - a custom field — its `opts.fields` rule; none registered → throws
703
+ * (fail closed: never silently dropped).
444
704
  *
445
- * @returns merged policy keyed by control name. Keys absent from the result
446
- * mean "no opinion → fully allowed". The caller should treat a
447
- * missing key the same as `true`.
705
+ * @since 0.1.72
706
+ */
707
+ function conjoinScopes(a, b, opts = {}) {
708
+ const custom = customFieldRules(a, b, opts.fields);
709
+ const ea = effectiveScope(a);
710
+ const eb = effectiveScope(b);
711
+ const intersected = intersectProjections(ea.projection, eb.projection, opts.childrenOf);
712
+ const projection = intersected ?? ea.projection;
713
+ const deny = (f) => intersected ? f : conjoinScopeFilters(f, DENY_FILTER);
714
+ const rows = conjoinRowPolicies({
715
+ filter: ea.filter,
716
+ check: ea.hasExplicitCheck ? ea.check ?? {} : void 0
717
+ }, {
718
+ filter: deny(eb.filter),
719
+ check: eb.hasExplicitCheck ? deny(eb.check) ?? {} : void 0
720
+ });
721
+ const controls = intersectControlsPolicy(ea.controls, eb.controls);
722
+ const allowedFields = intersectAllowedFields(ea.allowedFields, eb.allowedFields);
723
+ const nestedWrites = [...ea.nestedWrites].filter((rel) => eb.nestedWrites.has(rel)).toSorted();
724
+ const set = ea.set || eb.set ? {
725
+ ...eb.set,
726
+ ...ea.set
727
+ } : void 0;
728
+ const checkRefs = (opts.checkRefs ?? unionRefNames)(a, b);
729
+ const withMap = conjoinWith(ea, eb, opts.fields);
730
+ const s = { ...rows };
731
+ if (Object.keys(projection).length > 0) s.projection = projection;
732
+ if (Object.keys(controls).length > 0) s.controls = controls;
733
+ if (allowedFields) s.allowedFields = allowedFields;
734
+ if (nestedWrites.length > 0) s.nestedWrites = nestedWrites;
735
+ if (checkRefs) s.checkRefs = checkRefs;
736
+ if (set) s.set = set;
737
+ if (withMap) s.with = withMap;
738
+ for (const [key, rule] of custom) {
739
+ const value = rule.conjoin(a, b);
740
+ if (value !== void 0) s[key] = value;
741
+ }
742
+ return s;
743
+ }
744
+ /**
745
+ * The rules of the custom fields some scope of `a` / `b` carries (a key
746
+ * holding `undefined` does not count). Throws on a custom field without a
747
+ * rule — dropping it would silently widen (fail closed).
748
+ */
749
+ function customFieldRules(a, b, fields) {
750
+ const out = /* @__PURE__ */ new Map();
751
+ for (const scope of [...a, ...b]) for (const key of Object.keys(scope)) {
752
+ if (BUILT_IN_KEYS.has(key) || out.has(key)) continue;
753
+ if (scope[key] === void 0) continue;
754
+ const rule = fields && Object.hasOwn(fields, key) ? fields[key] : void 0;
755
+ if (!rule) throw new ScopeFieldConfigError(key, `Scope field "${key}" has no conjunction rule: a credential attenuation cannot combine it, and dropping it would widen access. Register one with MoostArbac.registerScopeFields({ ${key}: { conjoin: (a, b) => ... } }) (or the \`fields\` option of conjoinScopes).`);
756
+ out.set(key, rule);
757
+ }
758
+ return out;
759
+ }
760
+ function intersectAllowedFields(a, b) {
761
+ if (a === void 0) return b && [...b];
762
+ if (b === void 0) return [...a];
763
+ return [...a].filter((f) => b.has(f)).toSorted();
764
+ }
765
+ /** Schema-less `checkRefs` conjunction: what either side enforces. */
766
+ function unionRefNames(a, b) {
767
+ const ra = effectiveScope(a).checkRefs;
768
+ const rb = effectiveScope(b).checkRefs;
769
+ if (ra === true || rb === true) return true;
770
+ const names = /* @__PURE__ */ new Set([...ra, ...rb]);
771
+ return names.size > 0 ? [...names].toSorted() : void 0;
772
+ }
773
+ /** Per rule registry: its row rules and the fold memo (scope → folded scope). */
774
+ const foldStates = /* @__PURE__ */ new WeakMap();
775
+ const NO_FIELDS = Object.freeze({});
776
+ /**
777
+ * Fold every custom field's {@link TScopeFieldRule.rowFilter} into its
778
+ * scope's `filter` (`filter ∧ rowFilter(value, scope)`), recursively into
779
+ * `with` sub-scopes. Apply it per evaluated scope list — before any union or
780
+ * conjunction — so every row path sees the restriction. The custom value
781
+ * stays on the scope. Scopes are never mutated: a changed scope is a copy.
782
+ * Memoized per rule registry and scope object (process-wide), and
783
+ * idempotent (a folded scope folds to itself); without row rules (and no
784
+ * `onUnknownField`) the input list is returned as is.
448
785
  *
449
- * @throws when a non-whitelistable control is given a `readonly string[]` gate.
786
+ * @since 0.1.72
450
787
  */
451
- function unionControlsPolicy(scopes) {
452
- if (scopes.length === 0) return {};
453
- if (scopes.some((s) => !s.controls)) return {};
454
- const allKeys = /* @__PURE__ */ new Set();
455
- for (const s of scopes) for (const k of Object.keys(s.controls)) allKeys.add(k);
456
- const result = {};
457
- for (const key of allKeys) {
458
- let anyAllow = false;
459
- const whitelistAcc = /* @__PURE__ */ new Set();
460
- let hadWhitelist = false;
461
- for (const s of scopes) {
462
- const v = s.controls[key];
463
- if (v === void 0 || v === true) {
464
- anyAllow = true;
465
- break;
466
- }
467
- if (v === false) continue;
468
- if (Array.isArray(v)) {
469
- if (!WHITELISTABLE_CONTROLS.has(key)) throw new Error(`unionControlsPolicy: control "${key}" only supports boolean gates; whitelist arrays not yet implemented`);
470
- hadWhitelist = true;
471
- for (const item of v) whitelistAcc.add(item);
472
- continue;
473
- }
474
- throw new Error(`unionControlsPolicy: invalid gate value ${String(v)} for control "${key}" (must be boolean or string[])`);
788
+ function applyScopeFieldFilters(scopes, fields, opts = {}) {
789
+ const registry = fields ?? NO_FIELDS;
790
+ let state = foldStates.get(registry);
791
+ if (!state) {
792
+ const rules = [];
793
+ for (const [key, rule] of Object.entries(registry)) if (rule.rowFilter) rules.push([key, rule.rowFilter.bind(rule)]);
794
+ state = {
795
+ rules,
796
+ memo: /* @__PURE__ */ new WeakMap()
797
+ };
798
+ foldStates.set(registry, state);
799
+ }
800
+ const { rules, memo } = state;
801
+ const { onUnknownField } = opts;
802
+ if (rules.length === 0 && !onUnknownField) return scopes;
803
+ const fold = (scope) => {
804
+ if (scope === null || typeof scope !== "object") return scope;
805
+ const hit = memo.get(scope);
806
+ if (hit) return hit;
807
+ const record = scope;
808
+ if (onUnknownField) for (const key of Object.keys(scope)) {
809
+ if (BUILT_IN_KEYS.has(key) || Object.hasOwn(registry, key)) continue;
810
+ if (record[key] !== void 0) onUnknownField(key);
475
811
  }
476
- if (anyAllow) continue;
477
- if (hadWhitelist) {
478
- result[key] = [...whitelistAcc].toSorted();
479
- continue;
812
+ let filter = scope.filter;
813
+ for (const [key, rowFilter] of rules) {
814
+ const value = record[key];
815
+ if (value !== void 0) filter = conjoinScopeFilters(filter, rowFilter(value, scope));
480
816
  }
481
- result[key] = false;
817
+ let out = filter === scope.filter ? scope : {
818
+ ...scope,
819
+ filter
820
+ };
821
+ if (scope.with) {
822
+ const subs = scope.with;
823
+ let next;
824
+ for (const [rel, sub] of Object.entries(subs)) {
825
+ const folded = fold(sub);
826
+ if (folded !== sub) (next ??= { ...subs })[rel] = folded;
827
+ }
828
+ if (next) out = {
829
+ ...out,
830
+ with: next
831
+ };
832
+ }
833
+ memo.set(scope, out);
834
+ memo.set(out, out);
835
+ return out;
836
+ };
837
+ const out = scopes.map(fold);
838
+ return out.every((s, i) => s === scopes[i]) ? scopes : out;
839
+ }
840
+ /**
841
+ * Marks a `with.<rel>` sub-scope {@link conjoinScopes} built from ONE side's
842
+ * declaration. The other side is silent on the relation, so ITS policy for
843
+ * the joined rows is its inherited grant on the related table — unknown
844
+ * here. A resolver must conjoin the marked sub-scope with the caller's own
845
+ * grant on the related table (never apply it alone as parent authority);
846
+ * unresolved, the relation is hidden. See {@link needsInheritedConjunction}.
847
+ *
848
+ * @since 0.1.72
849
+ */
850
+ const INHERITED_CONJUNCTION = Symbol.for("aooth.arbac.inheritedConjunction");
851
+ /** A `with` sub-scope marked {@link INHERITED_CONJUNCTION}. @since 0.1.72 */
852
+ function needsInheritedConjunction(scope) {
853
+ return scope[INHERITED_CONJUNCTION] === true;
854
+ }
855
+ function conjoinWith(ea, eb, fields) {
856
+ const names = /* @__PURE__ */ new Set([...ea.withNames, ...eb.withNames]);
857
+ if (names.size === 0) return void 0;
858
+ const out = {};
859
+ for (const rel of names) {
860
+ const a = ea.withScopes(rel);
861
+ const b = eb.withScopes(rel);
862
+ const sub = conjoinScopes(a, b, { fields });
863
+ if (a.length === 0 || b.length === 0 || [...a, ...b].some(needsInheritedConjunction)) sub[INHERITED_CONJUNCTION] = true;
864
+ out[rel] = sub;
482
865
  }
483
- return result;
866
+ return out;
484
867
  }
485
868
  /**
486
- * Conjoin two ALREADY-UNIONED controls policies (each the output of
487
- * {@link unionControlsPolicy} for one authority pass) under DENY-WINS
488
- * intersection — a control is permitted only if BOTH passes permit it. This is
489
- * the credential-attenuation combiner; polarity is the **opposite** of
490
- * {@link unionControlsPolicy} (which is additive / silence-wins).
869
+ * A stable string key for a JSON-like value (bigint-safe) — for
870
+ * de-duplicating filters / tuples and comparing them structurally.
491
871
  *
492
- * Per control key (absent ≡ allowed, matching {@link unionControlsPolicy}):
493
- * - denied (`false`) on EITHER side → `false`.
494
- * - allowed on one side → the OTHER side's gate (`allow ∧ X = X`).
495
- * - whitelist ∧ whitelist → the INTERSECTION of the two arrays (only values
496
- * allowed by both; may be empty = nothing permitted).
872
+ * @since 0.1.72
873
+ */
874
+ function stableKey(value) {
875
+ return JSON.stringify(value, (_k, v) => typeof v === "bigint" ? `${v}n` : v);
876
+ }
877
+ //#endregion
878
+ //#region src/db-privileges.ts
879
+ /**
880
+ * Read-side handler actions of an `AsDbController` table: `query`, `pages`,
881
+ * `getOne`, `getOneComposite`, `geo`, `meta`, `metaForm`.
882
+ */
883
+ const TABLE_READ_ACTIONS = [
884
+ "query",
885
+ "pages",
886
+ "getOne",
887
+ "getOneComposite",
888
+ "geo",
889
+ "meta",
890
+ "metaForm"
891
+ ];
892
+ /** Write-side handler actions of an `AsDbController` table. */
893
+ const TABLE_WRITE_ACTIONS = [
894
+ "insert",
895
+ "update",
896
+ "replace",
897
+ "remove",
898
+ "removeComposite"
899
+ ];
900
+ /** The `/meta` + `/meta/form/:name` handler actions (a subset of {@link TABLE_READ_ACTIONS}). */
901
+ const TABLE_META_ACTIONS = ["meta", "metaForm"];
902
+ /**
903
+ * Operation → handler actions. `meta` is the form-metadata subset of `read`,
904
+ * for write-only principals that still render forms from `/meta`.
905
+ */
906
+ const TABLE_OP_ACTIONS = {
907
+ read: TABLE_READ_ACTIONS,
908
+ meta: TABLE_META_ACTIONS,
909
+ insert: ["insert"],
910
+ update: ["update"],
911
+ replace: ["replace"],
912
+ remove: ["remove", "removeComposite"]
913
+ };
914
+ const ALL_WRITE_OPS = [
915
+ "insert",
916
+ "update",
917
+ "replace",
918
+ "remove"
919
+ ];
920
+ function rulesFor(resource, actions, scope) {
921
+ return actions.map((action) => scope ? {
922
+ resource,
923
+ action,
924
+ scope
925
+ } : {
926
+ resource,
927
+ action
928
+ });
929
+ }
930
+ /** Handler actions of `ops`, in op order, without duplicates. Throws on an unknown op. */
931
+ function actionsForOps(ops) {
932
+ const out = /* @__PURE__ */ new Set();
933
+ for (const op of ops) {
934
+ const actions = TABLE_OP_ACTIONS[op];
935
+ if (!actions) throw new Error(`Unknown table operation "${op}"`);
936
+ for (const a of actions) out.add(a);
937
+ }
938
+ return [...out];
939
+ }
940
+ /** Read-side actions on an `AsDbController` table — see {@link TABLE_READ_ACTIONS}. */
941
+ function allowTableRead(resource, opts) {
942
+ return () => rulesFor(resource, TABLE_READ_ACTIONS, opts?.scope);
943
+ }
944
+ /** All actions on an `AsDbController` table: read + `insert`, `update`, `replace`, `remove`, `removeComposite`. */
945
+ function allowTableWrite(resource, opts) {
946
+ return () => rulesFor(resource, [...TABLE_READ_ACTIONS, ...TABLE_WRITE_ACTIONS], opts?.scope);
947
+ }
948
+ /**
949
+ * Selected operations on an `AsDbController` table, sharing a scope — e.g. an
950
+ * insert-only form principal: `allowTableOps("leads", ["insert", "meta"])`.
497
951
  *
498
- * @returns merged policy; keys absent from the result mean "allowed" (callers
499
- * treat a missing key as `true`), matching {@link unionControlsPolicy}.
952
+ * - `read` → `query`, `pages`, `getOne`, `getOneComposite`, `geo`, `meta`, `metaForm`
953
+ * - `meta` → `meta`, `metaForm` (forms for principals without `read`)
954
+ * - `remove` → `remove`, `removeComposite`
955
+ * - `insert` / `update` / `replace` → the same-named action
956
+ *
957
+ * Throws on an unknown op name.
500
958
  */
501
- function intersectControlsPolicy(a, b) {
502
- const result = {};
503
- const keys = /* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)]);
504
- for (const key of keys) {
505
- const av = key in a ? a[key] : true;
506
- const bv = key in b ? b[key] : true;
507
- if (av === false || bv === false) {
508
- result[key] = false;
509
- continue;
510
- }
511
- if (av === true) {
512
- if (Array.isArray(bv)) result[key] = [...bv];
513
- continue;
514
- }
515
- if (bv === true) {
516
- if (Array.isArray(av)) result[key] = [...av];
517
- continue;
518
- }
519
- const bset = new Set(bv);
520
- result[key] = [...new Set(av)].filter((v) => bset.has(v)).toSorted();
959
+ function allowTableOps(resource, ops, opts) {
960
+ const actions = actionsForOps(ops);
961
+ return () => rulesFor(resource, actions, opts?.scope);
962
+ }
963
+ /** One or more declarative actions on the same table, sharing a scope. */
964
+ function allowTableAction(resource, name, opts) {
965
+ const actions = typeof name === "string" ? [name] : name;
966
+ return () => rulesFor(resource, actions, opts?.scope);
967
+ }
968
+ /**
969
+ * Merge a part scope over the shared one: the row policy is CONJOINED
970
+ * ({@link conjoinRowPolicies} — a part can only narrow the shared rows, and
971
+ * the effective check is `(shared.check ?? shared.filter) ∧ (part.check ??
972
+ * part.filter)`); every other key (projection, `allowedFields`, `set`,
973
+ * `with`, …) comes from the part when it sets it, else from the shared scope.
974
+ */
975
+ function mergePartScope(shared, part) {
976
+ const merged = {
977
+ ...shared,
978
+ ...part
979
+ };
980
+ delete merged.filter;
981
+ delete merged.check;
982
+ return Object.assign(merged, conjoinRowPolicies(shared, part));
983
+ }
984
+ function composeScope(shared, part) {
985
+ if (!part) return shared;
986
+ if (!shared) return part;
987
+ return (attrs, userId) => mergePartScope(shared(attrs, userId), part(attrs, userId));
988
+ }
989
+ /**
990
+ * One table policy → consistent rules for every endpoint of an
991
+ * `AsDbController` table. The shared `scope` (row filter + projection +
992
+ * controls + `with` + `check` …) applies to every part; a part's own `scope`
993
+ * merges over it — `filter` / `check` conjoined (`$and`), other keys from the
994
+ * part. An action granted by an earlier part (`read` → `write` → `actions`)
995
+ * is not repeated by a later one. Throws on an unknown write op.
996
+ *
997
+ * @example
998
+ * ```ts
999
+ * defineTableAccess<Attrs, ArbacDbScope<typeof Task>>("tasks", {
1000
+ * scope: (a) => ({ filter: { tenantId: a.tenantId }, projection: { secret: 0 } }),
1001
+ * read: true,
1002
+ * write: { ops: ["insert", "update"], scope: () => ({ allowedFields: ["title", "status"] }) },
1003
+ * actions: ["markDone"],
1004
+ * });
1005
+ * ```
1006
+ */
1007
+ function defineTableAccess(resource, def) {
1008
+ const { read, write, actions } = def;
1009
+ const writePart = typeof write === "object" && !isList(write) ? write : void 0;
1010
+ const actionsPart = actions && !isList(actions) ? actions : void 0;
1011
+ const parts = [];
1012
+ if (read) parts.push([TABLE_READ_ACTIONS, typeof read === "object" ? read.scope : void 0]);
1013
+ if (write) {
1014
+ const ops = isList(write) ? write : writePart?.ops ?? ALL_WRITE_OPS;
1015
+ parts.push([actionsForOps(ops), writePart?.scope]);
521
1016
  }
522
- return result;
1017
+ if (actions) parts.push([actionsPart?.names ?? actions, actionsPart?.scope]);
1018
+ const seen = /* @__PURE__ */ new Set();
1019
+ const resolved = parts.map(([names, partScope]) => {
1020
+ const fresh = names.filter((n) => !seen.has(n));
1021
+ for (const n of fresh) seen.add(n);
1022
+ return [fresh, composeScope(def.scope, partScope)];
1023
+ });
1024
+ return () => resolved.flatMap(([names, scope]) => rulesFor(resource, names, scope));
1025
+ }
1026
+ function isList(v) {
1027
+ return Array.isArray(v);
523
1028
  }
524
1029
  //#endregion
525
1030
  //#region src/codegen/extract.ts
@@ -593,4 +1098,4 @@ function generateResourceTypes(map, options) {
593
1098
  return lines.join("\n");
594
1099
  }
595
1100
  //#endregion
596
- export { DENY_FILTER, allowTableAction, allowTableRead, allowTableWrite, conjoinScopeFilters, definePrivilege, defineRole, expandExcludeToLeaves, extractResourceActions, generateResourceTypes, getProjectionMode, intersectControlsPolicy, intersectProjections, isFieldAllowed, mergeScopeFilters, restrictProjection, unionControlsPolicy, unionProjections };
1101
+ export { DB_SCOPE_KEYS, DENY_FILTER, INHERITED_CONJUNCTION, ScopeFieldConfigError, TABLE_META_ACTIONS, TABLE_OP_ACTIONS, TABLE_READ_ACTIONS, TABLE_WRITE_ACTIONS, allowTableAction, allowTableOps, allowTableRead, allowTableWrite, applyScopeFieldFilters, conjoinRowPolicies, conjoinScopeFilters, conjoinScopes, definePrivilege, defineRole, defineTableAccess, effectiveScope, expandExcludeToLeaves, extractResourceActions, generateResourceTypes, getProjectionMode, intersectControlsPolicy, intersectEnabled, intersectProjections, isFieldAllowed, mergeScopeFilters, needsInheritedConjunction, normalizeScopes, restrictProjection, stableKey, unionControlsPolicy, unionOutcomes, unionProjections };