@diister/quick-permission 0.9.0-beta.6 → 0.9.0-beta.7

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/system.js CHANGED
@@ -1,16 +1,3 @@
1
- /**
2
- * Moteur d'orchestration resource-pipe.
3
- *
4
- * Responsabilités :
5
- * - Maintenir un cache `(resource.id, dedupKey) → Promise<T>` par context()
6
- * - Filtrer les rules actives (activeWhen) avant de fetcher leurs needs
7
- * - Lancer les fetches en parallèle (Promise.all) avec dedup
8
- * - Évaluer les rules d'un grant en série (préserve l'ordre des reasons)
9
- * - OR sémantique entre grants : un grant qui passe = `ok: true`
10
- * - Validation d'arity du target au boot et au check
11
- *
12
- * Cf. RFC §"Sémantique d'exécution".
13
- */
14
1
  import { aggregateConstraints, combineGrantConstraints, mergeFilterSpec, resolveFilteredData, } from "./aggregation.js";
15
2
  import { buildAggregationStages } from "./indirect-aggregation.js";
16
3
  import { extractIndirectResource, } from "./indirect-resource.js";
@@ -22,16 +9,41 @@ function keyMatchesPattern(key, pattern) {
22
9
  }
23
10
  return false;
24
11
  }
25
- function providerHandlesKey(provider, key) {
26
- if (!provider.keys && !provider.matches)
27
- return true;
12
+ function providerDeclaresKey(provider, key) {
28
13
  if (provider.keys?.some((p) => keyMatchesPattern(key, p)))
29
14
  return true;
30
- if (provider.matches?.(key))
15
+ return provider.matches?.(key) === true;
16
+ }
17
+ export function refType(segment) {
18
+ if (typeof segment !== "string")
19
+ return undefined;
20
+ const colon = segment.indexOf(":");
21
+ return colon > 0 ? segment.slice(0, colon) : undefined;
22
+ }
23
+ function providerHandlesTarget(provider, target) {
24
+ if (provider.targetType === undefined)
31
25
  return true;
32
- return false;
26
+ const first = target?.[0];
27
+ if (first === undefined || isWildcardSegment(first))
28
+ return false;
29
+ const type = refType(first);
30
+ if (type === undefined)
31
+ return false;
32
+ return typeof provider.targetType === "string"
33
+ ? provider.targetType === type
34
+ : provider.targetType.includes(type);
35
+ }
36
+ function callHook(invoke) {
37
+ try {
38
+ invoke();
39
+ }
40
+ catch {
41
+ return;
42
+ }
43
+ }
44
+ function describeError(error) {
45
+ return error instanceof Error ? error.message : String(error);
33
46
  }
34
- // ─── Schema validation au boot ──────────────────────────────────────────
35
47
  function validateSchema(schema) {
36
48
  const issues = [];
37
49
  for (const [key, perm] of Object.entries(schema)) {
@@ -45,7 +57,6 @@ function validateSchema(schema) {
45
57
  });
46
58
  }
47
59
  catch {
48
- // Best-effort : si expandsTo throw sur le stub, on skip.
49
60
  continue;
50
61
  }
51
62
  for (const child of samples) {
@@ -58,27 +69,16 @@ function validateSchema(schema) {
58
69
  throw new Error(`Schema validation failed:\n - ${issues.join("\n - ")}`);
59
70
  }
60
71
  }
61
- // ─── Target matching (wildcards par segment) ────────────────────────────
62
72
  function asPath(value) {
63
73
  return Array.isArray(value) ? value : [value];
64
74
  }
65
- /**
66
- * Une request target est une "capability query" si elle contient au moins
67
- * un wildcard (`"*"` ou suffix `":*"`). Dans ce mode, on n'a pas de
68
- * ressource concrète à fetcher — les rules font silent-pass quand elles
69
- * ne peuvent pas vérifier leur contrainte.
70
- */
71
- function isCapabilityQuery(target) {
75
+ /** A query with any wildcard segment asks "could this subject, on some target"; rules that need a concrete document pass silently. */
76
+ export function isCapabilityQuery(target) {
72
77
  if (!target)
73
78
  return false;
74
79
  return target.some(isWildcardSegment);
75
80
  }
76
- /**
77
- * True if a single target segment is a wildcard (`"*"` or `"xxx:*"`).
78
- * Inverse de "concret" — utilisé pour décider si une resource peut être
79
- * fetchée en cap-mode (segment concret → fetch OK).
80
- */
81
- function isWildcardSegment(seg) {
81
+ export function isWildcardSegment(seg) {
82
82
  return seg === "*" || (typeof seg === "string" && seg.endsWith("*"));
83
83
  }
84
84
  function segmentOverlaps(a, b) {
@@ -100,19 +100,13 @@ function segmentOverlaps(a, b) {
100
100
  function targetMatches(grantTarget, requestTarget, schemaKind, capability) {
101
101
  if (schemaKind === "none")
102
102
  return true;
103
- if (grantTarget === undefined) {
104
- // A target-less grant only matches when the schema permits it.
105
- // `optional` documents this "global grant" mode; `required` and `path`
106
- // require the grant to carry a target.
103
+ if (grantTarget === undefined)
107
104
  return schemaKind === "optional";
108
- }
109
105
  if (requestTarget === undefined)
110
106
  return false;
111
107
  const g = asPath(grantTarget);
112
108
  if (g.length !== requestTarget.length)
113
109
  return false;
114
- // Capability mode (request has wildcards) uses overlap semantics so a
115
- // specific-target grant like ["user:lucas"] still matches ["user:*"].
116
110
  if (capability) {
117
111
  return g.every((seg, i) => segmentOverlaps(seg, requestTarget[i]));
118
112
  }
@@ -127,20 +121,15 @@ function targetMatches(grantTarget, requestTarget, schemaKind, capability) {
127
121
  return seg === requestTarget[i];
128
122
  });
129
123
  }
130
- // ─── Intermediate expansion ──────────────────────────────────────────────
131
124
  function expandGrants(grants, schema, maxDepth = 10) {
132
125
  const out = [];
133
126
  const queue = grants.map((g) => ({
134
127
  grant: g,
135
128
  depth: 0,
136
129
  }));
137
- while (queue.length) {
138
- const { grant, depth } = queue.shift();
130
+ for (let head = 0; head < queue.length; head++) {
131
+ const { grant, depth } = queue[head];
139
132
  const perm = schema[grant.key];
140
- // Drop grants that violate the schema's target contract: a grant
141
- // without `target` on a `required` / `path` schema cannot match
142
- // (see `targetMatches`) and its `expandsTo` cannot synthesize valid
143
- // children. Skip silently so one bad grant doesn't break the call.
144
133
  if (perm &&
145
134
  grant.target === undefined &&
146
135
  (perm.target.kind === "required" || perm.target.kind === "path")) {
@@ -158,7 +147,6 @@ function expandGrants(grants, schema, maxDepth = 10) {
158
147
  }
159
148
  return out;
160
149
  }
161
- // ─── Serialization (pour list/tree) ──────────────────────────────────────
162
150
  function serializeSegment(s) {
163
151
  const types = s.types;
164
152
  return { name: s.name, types };
@@ -203,17 +191,6 @@ function collectIndirectsForPermission(perm) {
203
191
  }
204
192
  return Array.from(collected.values());
205
193
  }
206
- /**
207
- * Build a wildcard target stub matching the schema's arity. Used to sample
208
- * `expandsTo(grant)` at catalog enumeration time — we don't have a real
209
- * grant target available, but we need ARG_ARITY to match or `targetMatches`
210
- * would drop the synthesized child grants (see `expandGrants:228-234`).
211
- *
212
- * - none → undefined (no segments)
213
- * - optional → ["*"]
214
- * - required → ["*"]
215
- * - path(n) → ["*", "*", ..., "*"] (n segments)
216
- */
217
194
  function stubTargetFor(t) {
218
195
  if (t.kind === "none")
219
196
  return undefined;
@@ -221,70 +198,52 @@ function stubTargetFor(t) {
221
198
  return ["*"];
222
199
  return t.segments.map(() => "*");
223
200
  }
224
- // ─── Descendant computation (pour list/tree) ─────────────────────────────
225
- /**
226
- * BFS transitive expansion of an intermediate's `expandsTo` callback,
227
- * collecting LEAVES only (keys whose schema entry has no `expandsTo`).
228
- *
229
- * Sampling strategy : we call `perm.expandsTo({ key, target: <wildcard> })`
230
- * with an arity-matched wildcard stub. Real consumers (`expandGrants`)
231
- * call expandsTo with a concrete grant carrying `with`/`filter`/`flags` —
232
- * but for catalog enumeration we only care about the resulting child KEYS
233
- * (the same keys would be produced regardless of grant payload, since
234
- * macros are by convention pure key-routers).
235
- *
236
- * Defensive : caught exceptions in `expandsTo` (e.g. a callback that
237
- * asserts on a concrete segment) silently drop that branch — same posture
238
- * as `validateSchema`. Cycles are broken by the `visited` set.
239
- *
240
- * Returns `undefined` if `rootKey` is a leaf or not in schema (the caller
241
- * uses this signal to omit the field from the serialized entry rather
242
- * than emit an empty array).
243
- */
244
- function computeDescendants(rootKey, schema, maxDepth = 10) {
245
- const root = schema[rootKey];
246
- if (!root?.expandsTo)
247
- return undefined;
248
- const leaves = new Set();
249
- const visited = new Set([rootKey]);
250
- const queue = [
251
- { key: rootKey, depth: 0 },
252
- ];
253
- while (queue.length) {
254
- const { key, depth } = queue.shift();
255
- const perm = schema[key];
256
- if (!perm)
257
- continue;
258
- if (!perm.expandsTo) {
259
- // Leaf reached. Exclude the root itself from its own descendant list.
260
- if (key !== rootKey)
261
- leaves.add(key);
262
- continue;
263
- }
264
- if (depth >= maxDepth)
201
+ // An intermediate is sampled with a wildcard grant: by convention `expandsTo`
202
+ // routes keys and does not depend on the grant's target or payload.
203
+ function expansionGraph(schema, maxDepth = 10) {
204
+ const descendants = new Map();
205
+ const ancestors = new Map();
206
+ for (const [rootKey, root] of Object.entries(schema)) {
207
+ if (!root.expandsTo)
265
208
  continue;
266
- let children = [];
267
- try {
268
- children = perm.expandsTo({
269
- key,
270
- target: stubTargetFor(perm.target),
271
- });
272
- }
273
- catch {
274
- // Stub-throwing macro — best-effort skip, same as validateSchema.
275
- continue;
276
- }
277
- for (const child of children) {
278
- if (visited.has(child.key))
209
+ const leaves = [];
210
+ const visited = new Set([rootKey]);
211
+ const queue = [
212
+ { key: rootKey, depth: 0 },
213
+ ];
214
+ for (let head = 0; head < queue.length; head++) {
215
+ const { key, depth } = queue[head];
216
+ const perm = schema[key];
217
+ if (!perm)
218
+ continue;
219
+ if (!perm.expandsTo) {
220
+ leaves.push(key);
221
+ continue;
222
+ }
223
+ if (depth >= maxDepth)
224
+ continue;
225
+ let children = [];
226
+ try {
227
+ children = perm.expandsTo({ key, target: stubTargetFor(perm.target) });
228
+ }
229
+ catch {
279
230
  continue;
280
- visited.add(child.key);
281
- queue.push({ key: child.key, depth: depth + 1 });
231
+ }
232
+ for (const child of children) {
233
+ if (visited.has(child.key))
234
+ continue;
235
+ visited.add(child.key);
236
+ const list = ancestors.get(child.key) ?? [];
237
+ list.push(rootKey);
238
+ ancestors.set(child.key, list);
239
+ queue.push({ key: child.key, depth: depth + 1 });
240
+ }
282
241
  }
242
+ descendants.set(rootKey, leaves);
283
243
  }
284
- return [...leaves];
244
+ return { descendants, ancestors };
285
245
  }
286
- // ─── Tree builder ────────────────────────────────────────────────────────
287
- function buildTree(schema) {
246
+ function buildTree(schema, graph) {
288
247
  const root = { children: {} };
289
248
  function ensureGroup(parent, name) {
290
249
  const existing = parent.children[name];
@@ -317,9 +276,7 @@ function buildTree(schema) {
317
276
  }
318
277
  }
319
278
  const leafName = parts[parts.length - 1];
320
- const descendants = perm.expandsTo
321
- ? computeDescendants(key, schema)
322
- : undefined;
279
+ const descendants = graph.descendants.get(key);
323
280
  cursor.children[leafName] = {
324
281
  kind: perm.expandsTo ? "intermediate" : "permission",
325
282
  key,
@@ -331,15 +288,31 @@ function buildTree(schema) {
331
288
  }
332
289
  return root;
333
290
  }
334
- // ─── createSystem ────────────────────────────────────────────────────────
335
291
  export function createSystem(opts) {
336
- const { schema, providers = [] } = opts;
292
+ const { schema, providers = [], hooks = {} } = opts;
337
293
  validateSchema(schema);
338
- // Resource registry built at boot from all rules' `needs` (direct
339
- // resources) and indirect descriptors. Enables `ctx.preseed("id", ...)`
340
- // to look up the resource by id — callers don't need to import the
341
- // resource instance (handy when it lives inside a factory closure).
342
- // Typo guard : throws with the list of available ids when missed.
294
+ const graph = expansionGraph(schema);
295
+ const providerNames = new Map(providers.map((provider, index) => [
296
+ provider,
297
+ (typeof provider === "object" && provider.name) || `provider#${index}`,
298
+ ]));
299
+ const handledKeys = new Map();
300
+ function providerHandlesKey(provider, key) {
301
+ if (!provider.keys && !provider.matches)
302
+ return true;
303
+ let memo = handledKeys.get(provider);
304
+ if (!memo) {
305
+ memo = new Map();
306
+ handledKeys.set(provider, memo);
307
+ }
308
+ const known = memo.get(key);
309
+ if (known !== undefined)
310
+ return known;
311
+ const handled = providerDeclaresKey(provider, key) ||
312
+ (graph.ancestors.get(key) ?? []).some((ancestor) => providerDeclaresKey(provider, ancestor));
313
+ memo.set(key, handled);
314
+ return handled;
315
+ }
343
316
  const resourceRegistry = new Map();
344
317
  for (const perm of Object.values(schema)) {
345
318
  for (const rule of perm.rules) {
@@ -355,9 +328,7 @@ export function createSystem(opts) {
355
328
  return {
356
329
  list() {
357
330
  return Object.entries(schema).map(([key, perm]) => {
358
- const descendants = perm.expandsTo
359
- ? computeDescendants(key, schema)
360
- : undefined;
331
+ const descendants = graph.descendants.get(key);
361
332
  return {
362
333
  key,
363
334
  kind: perm.expandsTo
@@ -371,7 +342,7 @@ export function createSystem(opts) {
371
342
  });
372
343
  },
373
344
  tree() {
374
- return buildTree(schema);
345
+ return buildTree(schema, graph);
375
346
  },
376
347
  schema(key) {
377
348
  return schema[key];
@@ -437,32 +408,95 @@ export function createSystem(opts) {
437
408
  fetchCounters.clear();
438
409
  },
439
410
  async dumpGrants() {
440
- const arrays = await Promise.all(grantsCache.values());
441
- return arrays.flat();
411
+ const outcomes = await Promise.all([...grantsCache.values()].flatMap((byKey) => [...byKey.values()]));
412
+ return outcomes.flatMap((outcome) => outcome.grants);
442
413
  },
443
414
  };
444
415
  },
445
416
  };
417
+ function reportError(event) {
418
+ callHook(() => hooks.onError?.(event));
419
+ }
420
+ async function fetchFromProvider(provider, name, subject, key, target) {
421
+ const started = performance.now();
422
+ let grants;
423
+ try {
424
+ grants =
425
+ typeof provider === "function"
426
+ ? await provider(subject, key, target)
427
+ : await provider.fetch(subject, key, target);
428
+ }
429
+ catch (error) {
430
+ reportError({
431
+ source: "provider",
432
+ provider: name,
433
+ subject,
434
+ key,
435
+ target,
436
+ error,
437
+ });
438
+ return {
439
+ grants: [],
440
+ failure: `provider ${name} failed: ${describeError(error)}`,
441
+ };
442
+ }
443
+ const durationMs = performance.now() - started;
444
+ callHook(() => hooks.onProviderFetch?.({
445
+ provider: name,
446
+ subject,
447
+ key,
448
+ target,
449
+ durationMs,
450
+ grantCount: grants.length,
451
+ }));
452
+ return { grants };
453
+ }
446
454
  async function invokeProvider(provider, subject, key, target, state) {
447
- if (typeof provider === "function") {
448
- return await provider(subject, key, target);
455
+ const name = providerNames.get(provider) ?? "provider";
456
+ if (typeof provider === "object") {
457
+ if (!providerHandlesKey(provider, key))
458
+ return { grants: [] };
459
+ if (!providerHandlesTarget(provider, target))
460
+ return { grants: [] };
449
461
  }
450
- if (!providerHandlesKey(provider, key))
451
- return [];
452
- const ck = provider.cacheKey?.(subject, key, target);
453
- if (state && ck) {
454
- let pending = state.grantsCache.get(ck);
455
- if (!pending) {
456
- pending = Promise.resolve(provider.fetch(subject, key, target));
457
- state.grantsCache.set(ck, pending);
458
- }
459
- return await pending;
462
+ let ck;
463
+ try {
464
+ ck =
465
+ typeof provider === "object"
466
+ ? provider.cacheKey?.(subject, key, target)
467
+ : undefined;
468
+ }
469
+ catch (error) {
470
+ reportError({
471
+ source: "provider",
472
+ provider: name,
473
+ subject,
474
+ key,
475
+ target,
476
+ error,
477
+ });
478
+ return {
479
+ grants: [],
480
+ failure: `provider ${name} failed: ${describeError(error)}`,
481
+ };
482
+ }
483
+ if (!state || !ck) {
484
+ return fetchFromProvider(provider, name, subject, key, target);
485
+ }
486
+ let byKey = state.grantsCache.get(provider);
487
+ if (!byKey) {
488
+ byKey = new Map();
489
+ state.grantsCache.set(provider, byKey);
490
+ }
491
+ let pending = byKey.get(ck);
492
+ if (!pending) {
493
+ pending = fetchFromProvider(provider, name, subject, key, target);
494
+ byKey.set(ck, pending);
460
495
  }
461
- return await provider.fetch(subject, key, target);
496
+ return pending;
462
497
  }
463
498
  async function fetchResource(resource, ctx, state) {
464
499
  if (!state) {
465
- // Mode `system.can()` direct — pas de cache cross-grant.
466
500
  return Promise.resolve(resource.fetcher(ctx));
467
501
  }
468
502
  const key = resource.computeDedupKey(ctx);
@@ -501,36 +535,28 @@ export function createSystem(opts) {
501
535
  const arityErr = validateArity(perm, target);
502
536
  if (arityErr)
503
537
  return { ok: false, reasons: [arityErr] };
504
- // Collect grants depuis les providers (en série pour préserver l'ordre)
505
538
  const allGrants = [];
539
+ const failures = [];
506
540
  for (const provider of providers) {
507
- const grants = await invokeProvider(provider, subject, key, target, state);
508
- allGrants.push(...grants);
541
+ const outcome = await invokeProvider(provider, subject, key, target, state);
542
+ allGrants.push(...outcome.grants);
543
+ if (outcome.failure)
544
+ failures.push(outcome.failure);
509
545
  }
510
546
  const expanded = expandGrants(allGrants, schema);
511
547
  const capability = isCapabilityQuery(target);
512
548
  const matching = expanded.filter((g) => g.key === key &&
513
549
  targetMatches(g.target, target, perm.target.kind, capability));
514
550
  if (matching.length === 0) {
515
- return { ok: false, reasons: ["no matching grant"] };
551
+ return { ok: false, reasons: ["no matching grant", ...failures] };
516
552
  }
517
553
  const reasons = [];
518
554
  let lastData = undefined;
519
555
  const matchedGrantIds = [];
520
556
  let anyOk = false;
521
- // One entry per matched grant. undefined = "any" (no constraint).
522
557
  const collectedConstraints = [];
523
- // Filter rule cross-grant aggregation. `null` once any grant exposes
524
- // no filter (= all fields). Else accumulates the union of filter specs.
525
558
  let referenceSource = undefined;
526
559
  let filterUnion = undefined;
527
- // Grants that PASSED their rules — needed by indirect-resource
528
- // aggregation. `matching` contains all grants that match key + target
529
- // shape, but rules can reject some (e.g. a `with` spec evaluated
530
- // against an auto-fetched resource in cap-mode). The indirect
531
- // orchestrator must consider only successful grants — otherwise a
532
- // rejected grant without indirect reference would trigger any-wins
533
- // and disable the filter.
534
560
  const successfulGrants = [];
535
561
  for (const grant of matching) {
536
562
  const ctx = {
@@ -547,20 +573,8 @@ export function createSystem(opts) {
547
573
  return false;
548
574
  return r.needs.every((res) => res.isActiveFor(grant));
549
575
  });
550
- // In cap-mode the engine normally skips all fetches. Exception : a
551
- // resource whose `id` matches a segment NAME of the permission's
552
- // target AND whose corresponding segment in the request target is
553
- // CONCRETE (not `*` / not `xxx:*`) is fetched anyway. This lets
554
- // `match()` evaluate properly for foreign resources (auxiliary
555
- // checks like "the request's exposition belongs to my tenant")
556
- // when only one segment of a multi-segment target is wildcard.
557
- //
558
- // Auto-binding is by convention : `resource.id === segment.name`.
559
- // No opt-in needed — it just works if the convention is respected.
560
- // Resources whose id matches no segment in the permission's schema
561
- // are skipped in cap-mode (silent-pass + constraint emit, the
562
- // legacy behaviour that powers `users.read` + `userOf.match`
563
- // self-referencing pushdown).
576
+ // A capability query still fetches a resource named like a segment
577
+ // (`resource.id === segment.name`) when that segment is concrete.
564
578
  const requestTarget = target ?? [];
565
579
  const targetSegmentIndexById = (() => {
566
580
  const out = new Map();
@@ -589,73 +603,81 @@ export function createSystem(opts) {
589
603
  : Array.from(new Map(activeRules
590
604
  .flatMap((r) => r.needs)
591
605
  .map((r) => [r.id, r])).values());
592
- const fetched = new Map();
593
- await Promise.all(uniqueResources.map(async (r) => {
594
- fetched.set(r.id, await fetchResource(r, ctx, state));
595
- }));
596
- // In concrete mode, indirect resources that declared a `fetcher`
597
- // are fetched as well (their joined docs are attached to the ctx
598
- // so `indirect.match()` can evaluate the spec). The cache key is
599
- // target-derived so `CanContext.preseed()` can inject values
600
- // produced by a cap-mode pipeline aggregation (no DB hit when
601
- // listed docs already carry the `_lookupAlias`).
602
- const indirectFetched = new Map();
603
- if (!capability) {
604
- const indirectsToFetch = perm.rules
605
- .map((r) => extractIndirectResource(r))
606
- .filter((ir) => ir !== null && ir.fetcher !== undefined);
607
- await Promise.all(indirectsToFetch.map(async (ir) => {
608
- const sourceDoc = fetched.get(ir.from.id);
609
- if (sourceDoc === undefined || sourceDoc === null)
610
- return;
611
- const cacheKey = ir.cacheKeyForTarget(ctx.target);
612
- if (state) {
613
- const existing = state.cache.get(cacheKey);
614
- if (existing) {
615
- indirectFetched.set(ir.id, (await existing));
616
- return;
617
- }
618
- const pending = Promise.resolve(ir.fetcher(sourceDoc, ctx));
619
- state.cache.set(cacheKey, pending);
620
- indirectFetched.set(ir.id, await pending);
621
- }
622
- else {
623
- indirectFetched.set(ir.id, await ir.fetcher(sourceDoc, ctx));
624
- }
625
- }));
626
- }
627
- // Attach indirect-fetched joined docs to the context so
628
- // `indirect.match()` rule can read them.
629
- const ctxWithIndirect = Object.assign({}, ctx, {
630
- _indirectFetched: indirectFetched,
631
- });
632
606
  let grantOk = true;
633
607
  let grantData = undefined;
634
- // Multiple match rules in one permission (e.g., expositionInfo + badge)
635
- // contribute distinct constraints — AND-merge them per grant.
636
608
  const grantConstraints = [];
609
+ const grantFilters = [];
637
610
  let grantHasMatchRule = false;
638
- for (const rule of activeRules) {
639
- if (rule.descriptor.kind === "match")
640
- grantHasMatchRule = true;
641
- const data = rule.needs.map((r) => fetched.get(r.id));
642
- const result = rule.check(data, ctxWithIndirect);
643
- if (!result.ok) {
644
- grantOk = false;
645
- reasons.push(grant.id ? `[${grant.id}] ${result.reason}` : result.reason);
646
- break;
647
- }
648
- if (result.data !== undefined)
649
- grantData = result.data;
650
- if (result.constraint !== undefined) {
651
- grantConstraints.push(result.constraint);
611
+ try {
612
+ const fetched = new Map();
613
+ await Promise.all(uniqueResources.map(async (r) => {
614
+ fetched.set(r.id, await fetchResource(r, ctx, state));
615
+ }));
616
+ const indirectFetched = new Map();
617
+ if (!capability) {
618
+ const indirectsToFetch = perm.rules
619
+ .map((r) => extractIndirectResource(r))
620
+ .filter((ir) => ir !== null && ir.fetcher !== undefined);
621
+ await Promise.all(indirectsToFetch.map(async (ir) => {
622
+ const sourceDoc = fetched.get(ir.from.id);
623
+ if (sourceDoc === undefined || sourceDoc === null)
624
+ return;
625
+ const cacheKey = ir.cacheKeyForTarget(ctx.target);
626
+ if (state) {
627
+ const existing = state.cache.get(cacheKey);
628
+ if (existing) {
629
+ indirectFetched.set(ir.id, (await existing));
630
+ return;
631
+ }
632
+ const pending = Promise.resolve(ir.fetcher(sourceDoc, ctx));
633
+ state.cache.set(cacheKey, pending);
634
+ indirectFetched.set(ir.id, await pending);
635
+ }
636
+ else {
637
+ indirectFetched.set(ir.id, await ir.fetcher(sourceDoc, ctx));
638
+ }
639
+ }));
652
640
  }
653
- if (result.filter !== undefined) {
654
- referenceSource = result.filter.source;
655
- filterUnion = mergeFilterSpec(filterUnion, result.filter.spec);
641
+ const ctxWithIndirect = Object.assign({}, ctx, {
642
+ _indirectFetched: indirectFetched,
643
+ });
644
+ for (const rule of activeRules) {
645
+ if (rule.descriptor.kind === "match")
646
+ grantHasMatchRule = true;
647
+ const data = rule.needs.map((r) => fetched.get(r.id));
648
+ const result = rule.check(data, ctxWithIndirect);
649
+ if (!result.ok) {
650
+ grantOk = false;
651
+ reasons.push(grant.id ? `[${grant.id}] ${result.reason}` : result.reason);
652
+ break;
653
+ }
654
+ if (result.data !== undefined)
655
+ grantData = result.data;
656
+ if (result.constraint !== undefined) {
657
+ grantConstraints.push(result.constraint);
658
+ }
659
+ if (result.filter !== undefined)
660
+ grantFilters.push(result.filter);
656
661
  }
657
662
  }
663
+ catch (error) {
664
+ grantOk = false;
665
+ reportError({
666
+ source: "rule",
667
+ grantId: grant.id,
668
+ subject,
669
+ key,
670
+ target,
671
+ error,
672
+ });
673
+ const reason = `rule evaluation failed: ${describeError(error)}`;
674
+ reasons.push(grant.id ? `[${grant.id}] ${reason}` : reason);
675
+ }
658
676
  if (grantOk) {
677
+ for (const contribution of grantFilters) {
678
+ referenceSource = contribution.source;
679
+ filterUnion = mergeFilterSpec(filterUnion, contribution.spec);
680
+ }
659
681
  anyOk = true;
660
682
  successfulGrants.push(grant);
661
683
  if (grant.id)
@@ -674,15 +696,14 @@ export function createSystem(opts) {
674
696
  if (!anyOk) {
675
697
  return {
676
698
  ok: false,
677
- reasons: reasons.length > 0 ? reasons : ["no matching grant"],
699
+ reasons: [
700
+ ...(reasons.length > 0 ? reasons : ["no matching grant"]),
701
+ ...failures,
702
+ ],
678
703
  };
679
704
  }
680
705
  const constraints = aggregateConstraints(collectedConstraints);
681
706
  const finalData = resolveFilteredData(filterUnion, referenceSource, lastData);
682
- // Collect indirect resources referenced by the permission's rules
683
- // (sentinel rules with descriptor.kind === "indirect-match"). If any
684
- // are referenced by matched grants' `with`, emit an aggregation
685
- // pipeline that pushes the JOIN constraint to the DB.
686
707
  const declaredIndirect = [];
687
708
  for (const rule of perm.rules) {
688
709
  const ir = extractIndirectResource(rule);
@@ -692,10 +713,8 @@ export function createSystem(opts) {
692
713
  let stages;
693
714
  if (declaredIndirect.length > 0) {
694
715
  const baseFilter = constraints ?? {};
695
- // Must pass `successfulGrants` (rules accepted), NOT `matching`
696
- // (raw key+target match). A grant rejected by a rule must not
697
- // contribute to indirect any-wins — otherwise it would silently
698
- // disable the indirect filter for its siblings.
716
+ // A rejected grant without an indirect reference would win "any" and
717
+ // lift the join filter for its siblings, so only accepted grants count.
699
718
  const result = buildAggregationStages(baseFilter, successfulGrants, declaredIndirect);
700
719
  if (result !== null)
701
720
  stages = result;