@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/system.ts CHANGED
@@ -1,21 +1,8 @@
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
-
15
1
  import type {
16
2
  AnyTarget,
17
3
  CanResult,
18
4
  FetchCtx,
5
+ FilterContribution,
19
6
  Grant,
20
7
  IndirectResourceInfo,
21
8
  ListEntry,
@@ -45,26 +32,53 @@ export type ProviderFn = (
45
32
  target?: readonly unknown[],
46
33
  ) => readonly Grant[] | Promise<readonly Grant[]>;
47
34
 
48
- /**
49
- * Provider sous forme objet : permet d'opt-in à la dedup de grants
50
- * (cacheKey) et au filtrage par préfixe de key (keys/matches).
51
- *
52
- * - `keys`/`matches` : le provider est skip si la key demandée ne match pas
53
- * - `cacheKey` : grants memoizés par (subject, key, target) au sein d'un context
54
- */
55
35
  export type ProviderObject = {
36
+ readonly name?: string;
56
37
  readonly keys?: readonly string[];
57
38
  readonly matches?: (key: string) => boolean;
39
+ readonly targetType?: string | readonly string[];
58
40
  readonly cacheKey?: (
59
41
  subject: Subject,
60
42
  key: string,
61
43
  target?: readonly unknown[],
62
- ) => string;
44
+ ) => string | undefined;
63
45
  readonly fetch: ProviderFn;
64
46
  };
65
47
 
66
48
  export type Provider = ProviderFn | ProviderObject;
67
49
 
50
+ export type ProviderFetchEvent = {
51
+ readonly provider: string;
52
+ readonly subject: Subject;
53
+ readonly key: string;
54
+ readonly target: readonly unknown[] | undefined;
55
+ readonly durationMs: number;
56
+ readonly grantCount: number;
57
+ };
58
+
59
+ export type PermissionErrorEvent =
60
+ | {
61
+ readonly source: "provider";
62
+ readonly provider: string;
63
+ readonly subject: Subject;
64
+ readonly key: string;
65
+ readonly target: readonly unknown[] | undefined;
66
+ readonly error: unknown;
67
+ }
68
+ | {
69
+ readonly source: "rule";
70
+ readonly grantId: string | undefined;
71
+ readonly subject: Subject;
72
+ readonly key: string;
73
+ readonly target: readonly unknown[] | undefined;
74
+ readonly error: unknown;
75
+ };
76
+
77
+ export type SystemHooks = {
78
+ readonly onProviderFetch?: (event: ProviderFetchEvent) => void;
79
+ readonly onError?: (event: PermissionErrorEvent) => void;
80
+ };
81
+
68
82
  function keyMatchesPattern(key: string, pattern: string): boolean {
69
83
  if (pattern === key) return true;
70
84
  if (pattern.endsWith("*")) {
@@ -73,23 +87,54 @@ function keyMatchesPattern(key: string, pattern: string): boolean {
73
87
  return false;
74
88
  }
75
89
 
76
- function providerHandlesKey(provider: ProviderObject, key: string): boolean {
77
- if (!provider.keys && !provider.matches) return true;
90
+ function providerDeclaresKey(provider: ProviderObject, key: string): boolean {
78
91
  if (provider.keys?.some((p) => keyMatchesPattern(key, p))) return true;
79
- if (provider.matches?.(key)) return true;
80
- return false;
92
+ return provider.matches?.(key) === true;
93
+ }
94
+
95
+ export function refType(segment: unknown): string | undefined {
96
+ if (typeof segment !== "string") return undefined;
97
+ const colon = segment.indexOf(":");
98
+ return colon > 0 ? segment.slice(0, colon) : undefined;
99
+ }
100
+
101
+ function providerHandlesTarget(
102
+ provider: ProviderObject,
103
+ target: readonly unknown[] | undefined,
104
+ ): boolean {
105
+ if (provider.targetType === undefined) return true;
106
+ const first = target?.[0];
107
+ if (first === undefined || isWildcardSegment(first)) return false;
108
+ const type = refType(first);
109
+ if (type === undefined) return false;
110
+ return typeof provider.targetType === "string"
111
+ ? provider.targetType === type
112
+ : provider.targetType.includes(type);
113
+ }
114
+
115
+ type ProviderOutcome = {
116
+ readonly grants: readonly Grant[];
117
+ readonly failure?: string;
118
+ };
119
+
120
+ type GrantsCache = Map<Provider, Map<string, Promise<ProviderOutcome>>>;
121
+
122
+ function callHook(invoke: () => void): void {
123
+ try {
124
+ invoke();
125
+ } catch {
126
+ return;
127
+ }
128
+ }
129
+
130
+ function describeError(error: unknown): string {
131
+ return error instanceof Error ? error.message : String(error);
81
132
  }
82
133
 
83
134
  export type CanContext = {
84
135
  readonly checkDate?: Date;
85
136
  readonly checkIp?: string;
86
- /** True : le target peut contenir des wildcards et matche les grants overlap. */
87
- readonly broadMatch?: boolean;
88
- /**
89
- * Check-time payload exposed to rules as `ctx.input`. Distinct from
90
- * `grant.payload` (static, seed-time). Consumed by `inputMatch()` to
91
- * validate a CREATE body against `grant.with`.
92
- */
137
+ /** Check-time payload read by `inputMatch()`, as opposed to the grant's static `payload`. */
93
138
  readonly input?: unknown;
94
139
  };
95
140
 
@@ -97,60 +142,28 @@ export type System<TMeta = unknown> = {
97
142
  list(): readonly ListEntry<TMeta>[];
98
143
  tree(): { readonly children: Readonly<Record<string, TreeNode<TMeta>>> };
99
144
  schema(key: string): Permission<TMeta> | undefined;
100
- /**
101
- * Every distinct indirect resource referenced by the schema's rules,
102
- * deduped by id. Returned in declaration-walk order (first-occurrence
103
- * wins for dedup). Function fields are stripped — see
104
- * `IndirectResourceInfo`. Empty if no permission uses `.match()` on an
105
- * `indirectResource`.
106
- */
145
+ /** Deduplicated by id, in declaration order, without their functions. */
107
146
  indirectResources(): readonly IndirectResourceInfo[];
108
- /**
109
- * Indirect resources referenced by a single permission's rules.
110
- * `[]` if `key` is unknown or has no `indirect-match` rule. Useful so
111
- * matrix UIs only render the indirect-match editor on permissions where
112
- * it has a semantic.
113
- */
114
147
  indirectsUsedBy(key: string): readonly IndirectResourceInfo[];
115
- /** Check direct (sans cache cross-can) — préfère `context()` en HTTP. */
148
+ /** Nothing is cached across calls; prefer `context()` for a request. */
116
149
  can(
117
150
  subject: Subject,
118
151
  key: string,
119
152
  target?: readonly unknown[],
120
153
  context?: CanContext,
121
154
  ): Promise<CanResult>;
122
- /**
123
- * Crée un context request-scoped avec cache de fetches partagé entre
124
- * tous les `can()` qui en découlent.
125
- */
155
+ /** Resources and provider grants are cached across every `can()` of the context. */
126
156
  context(bound: { readonly subject: Subject } & CanContext): {
127
- /**
128
- * `perCall` overrides the bound `CanContext` for this single check.
129
- * Canonical use : passing `input` for a CREATE while keeping the
130
- * shared (per-request) context. Other fields stay bound — override
131
- * only when truly per-call.
132
- */
157
+ /** `perCall` overrides the bound context for this check only, typically `input` on a create. */
133
158
  can(
134
159
  key: string,
135
160
  target?: readonly unknown[],
136
161
  perCall?: CanContext,
137
162
  ): Promise<CanResult>;
138
163
  /**
139
- * Inject pre-loaded docs into the resource cache so subsequent
140
- * `can()` calls skip the fetch. Typical use : after a paginated
141
- * list, the caller has all docs in memory — pre-seed them so the
142
- * per-doc projection check doesn't hit the DB again.
143
- *
144
- * `dedupKey(doc)` MUST return the same target segments the resource's
145
- * `dedupKey` would compute at fetch time — otherwise the cache key
146
- * won't match and the preseed is silently ineffective. For most
147
- * resources whose dedupKey is `target[i]`, just return `[doc._id]`
148
- * (or the relevant segments).
149
- *
150
- * `value(doc)` is optional. Defaults to identity (the doc itself,
151
- * used for direct resources). Provide it for indirect resources
152
- * where the cached value is an array of joined docs nested under
153
- * an alias (e.g. `d => d._memberships_of_participant`).
164
+ * `dedupKey(doc)` must return the segments the resource's own `dedupKey`
165
+ * computes at fetch time, or the seeded value is never found. `value(doc)`
166
+ * defaults to the doc; an indirect resource caches its joined docs instead.
154
167
  */
155
168
  preseed<T, V = T>(
156
169
  resource: Resource<unknown> | IndirectResource | string,
@@ -160,17 +173,14 @@ export type System<TMeta = unknown> = {
160
173
  value?: (doc: T) => V;
161
174
  },
162
175
  ): void;
163
- /** Compteurs de fetches par resource.id (debug / observabilité). */
164
176
  getFetchCounters(): Readonly<Record<string, number>>;
165
- /** Reset des compteurs (pas du cache). */
177
+ /** Resets the counters, never the cache. */
166
178
  clearCounters(): void;
167
- /** Dump des grants émis dans ce context (debug). */
179
+ /** Only the grants of cached providers already consulted in this context. */
168
180
  dumpGrants(): Promise<readonly Grant[]>;
169
181
  };
170
182
  };
171
183
 
172
- // ─── Schema validation au boot ──────────────────────────────────────────
173
-
174
184
  function validateSchema<TMeta>(
175
185
  schema: Readonly<Record<string, Permission<TMeta>>>,
176
186
  ): void {
@@ -184,7 +194,6 @@ function validateSchema<TMeta>(
184
194
  target: ["*"],
185
195
  });
186
196
  } catch {
187
- // Best-effort : si expandsTo throw sur le stub, on skip.
188
197
  continue;
189
198
  }
190
199
  for (const child of samples) {
@@ -200,29 +209,19 @@ function validateSchema<TMeta>(
200
209
  }
201
210
  }
202
211
 
203
- // ─── Target matching (wildcards par segment) ────────────────────────────
204
-
205
212
  function asPath(value: unknown): readonly unknown[] {
206
213
  return Array.isArray(value) ? value : [value];
207
214
  }
208
215
 
209
- /**
210
- * Une request target est une "capability query" si elle contient au moins
211
- * un wildcard (`"*"` ou suffix `":*"`). Dans ce mode, on n'a pas de
212
- * ressource concrète à fetcher — les rules font silent-pass quand elles
213
- * ne peuvent pas vérifier leur contrainte.
214
- */
215
- function isCapabilityQuery(target: readonly unknown[] | undefined): boolean {
216
+ /** A query with any wildcard segment asks "could this subject, on some target"; rules that need a concrete document pass silently. */
217
+ export function isCapabilityQuery(
218
+ target: readonly unknown[] | undefined,
219
+ ): boolean {
216
220
  if (!target) return false;
217
221
  return target.some(isWildcardSegment);
218
222
  }
219
223
 
220
- /**
221
- * True if a single target segment is a wildcard (`"*"` or `"xxx:*"`).
222
- * Inverse de "concret" — utilisé pour décider si une resource peut être
223
- * fetchée en cap-mode (segment concret → fetch OK).
224
- */
225
- function isWildcardSegment(seg: unknown): boolean {
224
+ export function isWildcardSegment(seg: unknown): boolean {
226
225
  return seg === "*" || (typeof seg === "string" && seg.endsWith("*"));
227
226
  }
228
227
 
@@ -247,17 +246,10 @@ function targetMatches(
247
246
  capability: boolean,
248
247
  ): boolean {
249
248
  if (schemaKind === "none") return true;
250
- if (grantTarget === undefined) {
251
- // A target-less grant only matches when the schema permits it.
252
- // `optional` documents this "global grant" mode; `required` and `path`
253
- // require the grant to carry a target.
254
- return schemaKind === "optional";
255
- }
249
+ if (grantTarget === undefined) return schemaKind === "optional";
256
250
  if (requestTarget === undefined) return false;
257
251
  const g = asPath(grantTarget);
258
252
  if (g.length !== requestTarget.length) return false;
259
- // Capability mode (request has wildcards) uses overlap semantics so a
260
- // specific-target grant like ["user:lucas"] still matches ["user:*"].
261
253
  if (capability) {
262
254
  return g.every((seg, i) => segmentOverlaps(seg, requestTarget[i]));
263
255
  }
@@ -274,8 +266,6 @@ function targetMatches(
274
266
  });
275
267
  }
276
268
 
277
- // ─── Intermediate expansion ──────────────────────────────────────────────
278
-
279
269
  function expandGrants<TMeta>(
280
270
  grants: readonly Grant[],
281
271
  schema: Readonly<Record<string, Permission<TMeta>>>,
@@ -286,13 +276,9 @@ function expandGrants<TMeta>(
286
276
  grant: g,
287
277
  depth: 0,
288
278
  }));
289
- while (queue.length) {
290
- const { grant, depth } = queue.shift()!;
279
+ for (let head = 0; head < queue.length; head++) {
280
+ const { grant, depth } = queue[head];
291
281
  const perm = schema[grant.key];
292
- // Drop grants that violate the schema's target contract: a grant
293
- // without `target` on a `required` / `path` schema cannot match
294
- // (see `targetMatches`) and its `expandsTo` cannot synthesize valid
295
- // children. Skip silently so one bad grant doesn't break the call.
296
282
  if (
297
283
  perm &&
298
284
  grant.target === undefined &&
@@ -312,8 +298,6 @@ function expandGrants<TMeta>(
312
298
  return out;
313
299
  }
314
300
 
315
- // ─── Serialization (pour list/tree) ──────────────────────────────────────
316
-
317
301
  function serializeSegment(s: {
318
302
  readonly name: string;
319
303
  readonly types: unknown;
@@ -366,91 +350,64 @@ function collectIndirectsForPermission<TMeta>(
366
350
  return Array.from(collected.values());
367
351
  }
368
352
 
369
- /**
370
- * Build a wildcard target stub matching the schema's arity. Used to sample
371
- * `expandsTo(grant)` at catalog enumeration time — we don't have a real
372
- * grant target available, but we need ARG_ARITY to match or `targetMatches`
373
- * would drop the synthesized child grants (see `expandGrants:228-234`).
374
- *
375
- * - none → undefined (no segments)
376
- * - optional → ["*"]
377
- * - required → ["*"]
378
- * - path(n) → ["*", "*", ..., "*"] (n segments)
379
- */
380
353
  function stubTargetFor(t: AnyTarget): readonly unknown[] | undefined {
381
354
  if (t.kind === "none") return undefined;
382
355
  if (t.kind === "optional" || t.kind === "required") return ["*"];
383
356
  return t.segments.map(() => "*");
384
357
  }
385
358
 
386
- // ─── Descendant computation (pour list/tree) ─────────────────────────────
387
-
388
- /**
389
- * BFS transitive expansion of an intermediate's `expandsTo` callback,
390
- * collecting LEAVES only (keys whose schema entry has no `expandsTo`).
391
- *
392
- * Sampling strategy : we call `perm.expandsTo({ key, target: <wildcard> })`
393
- * with an arity-matched wildcard stub. Real consumers (`expandGrants`)
394
- * call expandsTo with a concrete grant carrying `with`/`filter`/`flags` —
395
- * but for catalog enumeration we only care about the resulting child KEYS
396
- * (the same keys would be produced regardless of grant payload, since
397
- * macros are by convention pure key-routers).
398
- *
399
- * Defensive : caught exceptions in `expandsTo` (e.g. a callback that
400
- * asserts on a concrete segment) silently drop that branch — same posture
401
- * as `validateSchema`. Cycles are broken by the `visited` set.
402
- *
403
- * Returns `undefined` if `rootKey` is a leaf or not in schema (the caller
404
- * uses this signal to omit the field from the serialized entry rather
405
- * than emit an empty array).
406
- */
407
- function computeDescendants<TMeta>(
408
- rootKey: string,
359
+ type ExpansionGraph = {
360
+ readonly descendants: ReadonlyMap<string, readonly string[]>;
361
+ readonly ancestors: ReadonlyMap<string, readonly string[]>;
362
+ };
363
+
364
+ // An intermediate is sampled with a wildcard grant: by convention `expandsTo`
365
+ // routes keys and does not depend on the grant's target or payload.
366
+ function expansionGraph<TMeta>(
409
367
  schema: Readonly<Record<string, Permission<TMeta>>>,
410
368
  maxDepth = 10,
411
- ): readonly string[] | undefined {
412
- const root = schema[rootKey];
413
- if (!root?.expandsTo) return undefined;
414
-
415
- const leaves = new Set<string>();
416
- const visited = new Set<string>([rootKey]);
417
- const queue: Array<{ key: string; depth: number }> = [
418
- { key: rootKey, depth: 0 },
419
- ];
420
-
421
- while (queue.length) {
422
- const { key, depth } = queue.shift()!;
423
- const perm = schema[key];
424
- if (!perm) continue;
425
- if (!perm.expandsTo) {
426
- // Leaf reached. Exclude the root itself from its own descendant list.
427
- if (key !== rootKey) leaves.add(key);
428
- continue;
429
- }
430
- if (depth >= maxDepth) continue;
431
- let children: readonly Grant[] = [];
432
- try {
433
- children = perm.expandsTo({
434
- key,
435
- target: stubTargetFor(perm.target),
436
- });
437
- } catch {
438
- // Stub-throwing macro — best-effort skip, same as validateSchema.
439
- continue;
440
- }
441
- for (const child of children) {
442
- if (visited.has(child.key)) continue;
443
- visited.add(child.key);
444
- queue.push({ key: child.key, depth: depth + 1 });
369
+ ): ExpansionGraph {
370
+ const descendants = new Map<string, string[]>();
371
+ const ancestors = new Map<string, string[]>();
372
+ for (const [rootKey, root] of Object.entries(schema)) {
373
+ if (!root.expandsTo) continue;
374
+ const leaves: string[] = [];
375
+ const visited = new Set<string>([rootKey]);
376
+ const queue: Array<{ key: string; depth: number }> = [
377
+ { key: rootKey, depth: 0 },
378
+ ];
379
+ for (let head = 0; head < queue.length; head++) {
380
+ const { key, depth } = queue[head];
381
+ const perm = schema[key];
382
+ if (!perm) continue;
383
+ if (!perm.expandsTo) {
384
+ leaves.push(key);
385
+ continue;
386
+ }
387
+ if (depth >= maxDepth) continue;
388
+ let children: readonly Grant[] = [];
389
+ try {
390
+ children = perm.expandsTo({ key, target: stubTargetFor(perm.target) });
391
+ } catch {
392
+ continue;
393
+ }
394
+ for (const child of children) {
395
+ if (visited.has(child.key)) continue;
396
+ visited.add(child.key);
397
+ const list = ancestors.get(child.key) ?? [];
398
+ list.push(rootKey);
399
+ ancestors.set(child.key, list);
400
+ queue.push({ key: child.key, depth: depth + 1 });
401
+ }
445
402
  }
403
+ descendants.set(rootKey, leaves);
446
404
  }
447
- return [...leaves];
405
+ return { descendants, ancestors };
448
406
  }
449
407
 
450
- // ─── Tree builder ────────────────────────────────────────────────────────
451
-
452
408
  function buildTree<TMeta>(
453
409
  schema: Readonly<Record<string, Permission<TMeta>>>,
410
+ graph: ExpansionGraph,
454
411
  ): { children: Record<string, TreeNode<TMeta>> } {
455
412
  const root: { children: Record<string, TreeNode<TMeta>> } = { children: {} };
456
413
 
@@ -491,9 +448,7 @@ function buildTree<TMeta>(
491
448
  }
492
449
  }
493
450
  const leafName = parts[parts.length - 1];
494
- const descendants = perm.expandsTo
495
- ? computeDescendants(key, schema)
496
- : undefined;
451
+ const descendants = graph.descendants.get(key);
497
452
  cursor.children[leafName] = {
498
453
  kind: perm.expandsTo ? "intermediate" : "permission",
499
454
  key,
@@ -507,20 +462,41 @@ function buildTree<TMeta>(
507
462
  return root;
508
463
  }
509
464
 
510
- // ─── createSystem ────────────────────────────────────────────────────────
511
-
512
465
  export function createSystem<TMeta = unknown>(opts: {
513
466
  readonly schema: Readonly<Record<string, Permission<TMeta>>>;
514
467
  readonly providers?: readonly Provider[];
468
+ readonly hooks?: SystemHooks;
515
469
  }): System<TMeta> {
516
- const { schema, providers = [] } = opts;
470
+ const { schema, providers = [], hooks = {} } = opts;
517
471
  validateSchema(schema);
518
472
 
519
- // Resource registry built at boot from all rules' `needs` (direct
520
- // resources) and indirect descriptors. Enables `ctx.preseed("id", ...)`
521
- // to look up the resource by id — callers don't need to import the
522
- // resource instance (handy when it lives inside a factory closure).
523
- // Typo guard : throws with the list of available ids when missed.
473
+ const graph = expansionGraph(schema);
474
+ const providerNames = new Map<Provider, string>(
475
+ providers.map((provider, index) => [
476
+ provider,
477
+ (typeof provider === "object" && provider.name) || `provider#${index}`,
478
+ ]),
479
+ );
480
+ const handledKeys = new Map<ProviderObject, Map<string, boolean>>();
481
+
482
+ function providerHandlesKey(provider: ProviderObject, key: string): boolean {
483
+ if (!provider.keys && !provider.matches) return true;
484
+ let memo = handledKeys.get(provider);
485
+ if (!memo) {
486
+ memo = new Map();
487
+ handledKeys.set(provider, memo);
488
+ }
489
+ const known = memo.get(key);
490
+ if (known !== undefined) return known;
491
+ const handled =
492
+ providerDeclaresKey(provider, key) ||
493
+ (graph.ancestors.get(key) ?? []).some((ancestor) =>
494
+ providerDeclaresKey(provider, ancestor),
495
+ );
496
+ memo.set(key, handled);
497
+ return handled;
498
+ }
499
+
524
500
  const resourceRegistry = new Map<
525
501
  string,
526
502
  Resource<unknown> | IndirectResource
@@ -538,9 +514,7 @@ export function createSystem<TMeta = unknown>(opts: {
538
514
  return {
539
515
  list() {
540
516
  return Object.entries(schema).map(([key, perm]) => {
541
- const descendants = perm.expandsTo
542
- ? computeDescendants(key, schema)
543
- : undefined;
517
+ const descendants = graph.descendants.get(key);
544
518
  return {
545
519
  key,
546
520
  kind: perm.expandsTo
@@ -555,7 +529,7 @@ export function createSystem<TMeta = unknown>(opts: {
555
529
  },
556
530
 
557
531
  tree() {
558
- return buildTree<TMeta>(schema);
532
+ return buildTree<TMeta>(schema, graph);
559
533
  },
560
534
 
561
535
  schema(key) {
@@ -584,7 +558,7 @@ export function createSystem<TMeta = unknown>(opts: {
584
558
 
585
559
  context(bound) {
586
560
  const cache = new Map<string, Promise<unknown>>();
587
- const grantsCache = new Map<string, Promise<readonly Grant[]>>();
561
+ const grantsCache: GrantsCache = new Map();
588
562
  const fetchCounters = new Map<string, number>();
589
563
  const { subject, ...boundCtx } = bound;
590
564
  const resolveResource = (
@@ -627,44 +601,113 @@ export function createSystem<TMeta = unknown>(opts: {
627
601
  fetchCounters.clear();
628
602
  },
629
603
  async dumpGrants() {
630
- const arrays = await Promise.all(grantsCache.values());
631
- return arrays.flat();
604
+ const outcomes = await Promise.all(
605
+ [...grantsCache.values()].flatMap((byKey) => [...byKey.values()]),
606
+ );
607
+ return outcomes.flatMap((outcome) => outcome.grants);
632
608
  },
633
609
  };
634
610
  },
635
611
  };
636
612
 
637
- // ─── Implémentation can() ─────────────────────────────────────────────
638
-
639
613
  type ContextState = {
640
- /** Cache des fetches de Resource au sein d'un context. */
641
614
  readonly cache: Map<string, Promise<unknown>>;
642
- /** Cache des grants émis par les providers (pour cacheKey). */
643
- readonly grantsCache: Map<string, Promise<readonly Grant[]>>;
615
+ readonly grantsCache: GrantsCache;
644
616
  readonly fetchCounters: Map<string, number>;
645
617
  };
646
618
 
619
+ function reportError(event: PermissionErrorEvent): void {
620
+ callHook(() => hooks.onError?.(event));
621
+ }
622
+
623
+ async function fetchFromProvider(
624
+ provider: Provider,
625
+ name: string,
626
+ subject: Subject,
627
+ key: string,
628
+ target: readonly unknown[] | undefined,
629
+ ): Promise<ProviderOutcome> {
630
+ const started = performance.now();
631
+ let grants: readonly Grant[];
632
+ try {
633
+ grants =
634
+ typeof provider === "function"
635
+ ? await provider(subject, key, target)
636
+ : await provider.fetch(subject, key, target);
637
+ } catch (error) {
638
+ reportError({
639
+ source: "provider",
640
+ provider: name,
641
+ subject,
642
+ key,
643
+ target,
644
+ error,
645
+ });
646
+ return {
647
+ grants: [],
648
+ failure: `provider ${name} failed: ${describeError(error)}`,
649
+ };
650
+ }
651
+ const durationMs = performance.now() - started;
652
+ callHook(() =>
653
+ hooks.onProviderFetch?.({
654
+ provider: name,
655
+ subject,
656
+ key,
657
+ target,
658
+ durationMs,
659
+ grantCount: grants.length,
660
+ }),
661
+ );
662
+ return { grants };
663
+ }
664
+
647
665
  async function invokeProvider(
648
666
  provider: Provider,
649
667
  subject: Subject,
650
668
  key: string,
651
669
  target: readonly unknown[] | undefined,
652
670
  state: ContextState | undefined,
653
- ): Promise<readonly Grant[]> {
654
- if (typeof provider === "function") {
655
- return await provider(subject, key, target);
671
+ ): Promise<ProviderOutcome> {
672
+ const name = providerNames.get(provider) ?? "provider";
673
+ if (typeof provider === "object") {
674
+ if (!providerHandlesKey(provider, key)) return { grants: [] };
675
+ if (!providerHandlesTarget(provider, target)) return { grants: [] };
656
676
  }
657
- if (!providerHandlesKey(provider, key)) return [];
658
- const ck = provider.cacheKey?.(subject, key, target);
659
- if (state && ck) {
660
- let pending = state.grantsCache.get(ck);
661
- if (!pending) {
662
- pending = Promise.resolve(provider.fetch(subject, key, target));
663
- state.grantsCache.set(ck, pending);
664
- }
665
- return await pending;
677
+ let ck: string | undefined;
678
+ try {
679
+ ck =
680
+ typeof provider === "object"
681
+ ? provider.cacheKey?.(subject, key, target)
682
+ : undefined;
683
+ } catch (error) {
684
+ reportError({
685
+ source: "provider",
686
+ provider: name,
687
+ subject,
688
+ key,
689
+ target,
690
+ error,
691
+ });
692
+ return {
693
+ grants: [],
694
+ failure: `provider ${name} failed: ${describeError(error)}`,
695
+ };
666
696
  }
667
- return await provider.fetch(subject, key, target);
697
+ if (!state || !ck) {
698
+ return fetchFromProvider(provider, name, subject, key, target);
699
+ }
700
+ let byKey = state.grantsCache.get(provider);
701
+ if (!byKey) {
702
+ byKey = new Map();
703
+ state.grantsCache.set(provider, byKey);
704
+ }
705
+ let pending = byKey.get(ck);
706
+ if (!pending) {
707
+ pending = fetchFromProvider(provider, name, subject, key, target);
708
+ byKey.set(ck, pending);
709
+ }
710
+ return pending;
668
711
  }
669
712
 
670
713
  async function fetchResource(
@@ -673,7 +716,6 @@ export function createSystem<TMeta = unknown>(opts: {
673
716
  state: ContextState | undefined,
674
717
  ): Promise<unknown> {
675
718
  if (!state) {
676
- // Mode `system.can()` direct — pas de cache cross-grant.
677
719
  return Promise.resolve(resource.fetcher(ctx));
678
720
  }
679
721
  const key = resource.computeDedupKey(ctx);
@@ -725,17 +767,18 @@ export function createSystem<TMeta = unknown>(opts: {
725
767
  const arityErr = validateArity(perm, target);
726
768
  if (arityErr) return { ok: false, reasons: [arityErr] };
727
769
 
728
- // Collect grants depuis les providers (en série pour préserver l'ordre)
729
770
  const allGrants: Grant[] = [];
771
+ const failures: string[] = [];
730
772
  for (const provider of providers) {
731
- const grants = await invokeProvider(
773
+ const outcome = await invokeProvider(
732
774
  provider,
733
775
  subject,
734
776
  key,
735
777
  target,
736
778
  state,
737
779
  );
738
- allGrants.push(...grants);
780
+ allGrants.push(...outcome.grants);
781
+ if (outcome.failure) failures.push(outcome.failure);
739
782
  }
740
783
 
741
784
  const expanded = expandGrants(allGrants, schema);
@@ -747,26 +790,16 @@ export function createSystem<TMeta = unknown>(opts: {
747
790
  targetMatches(g.target, target, perm.target.kind, capability),
748
791
  );
749
792
  if (matching.length === 0) {
750
- return { ok: false, reasons: ["no matching grant"] };
793
+ return { ok: false, reasons: ["no matching grant", ...failures] };
751
794
  }
752
795
 
753
796
  const reasons: string[] = [];
754
797
  let lastData: unknown = undefined;
755
798
  const matchedGrantIds: string[] = [];
756
799
  let anyOk = false;
757
- // One entry per matched grant. undefined = "any" (no constraint).
758
800
  const collectedConstraints: Array<Record<string, unknown> | undefined> = [];
759
- // Filter rule cross-grant aggregation. `null` once any grant exposes
760
- // no filter (= all fields). Else accumulates the union of filter specs.
761
801
  let referenceSource: unknown = undefined;
762
802
  let filterUnion: FilterUnion = undefined;
763
- // Grants that PASSED their rules — needed by indirect-resource
764
- // aggregation. `matching` contains all grants that match key + target
765
- // shape, but rules can reject some (e.g. a `with` spec evaluated
766
- // against an auto-fetched resource in cap-mode). The indirect
767
- // orchestrator must consider only successful grants — otherwise a
768
- // rejected grant without indirect reference would trigger any-wins
769
- // and disable the filter.
770
803
  const successfulGrants: Grant[] = [];
771
804
 
772
805
  for (const grant of matching) {
@@ -785,20 +818,8 @@ export function createSystem<TMeta = unknown>(opts: {
785
818
  return r.needs.every((res) => res.isActiveFor(grant));
786
819
  });
787
820
 
788
- // In cap-mode the engine normally skips all fetches. Exception : a
789
- // resource whose `id` matches a segment NAME of the permission's
790
- // target AND whose corresponding segment in the request target is
791
- // CONCRETE (not `*` / not `xxx:*`) is fetched anyway. This lets
792
- // `match()` evaluate properly for foreign resources (auxiliary
793
- // checks like "the request's exposition belongs to my tenant")
794
- // when only one segment of a multi-segment target is wildcard.
795
- //
796
- // Auto-binding is by convention : `resource.id === segment.name`.
797
- // No opt-in needed — it just works if the convention is respected.
798
- // Resources whose id matches no segment in the permission's schema
799
- // are skipped in cap-mode (silent-pass + constraint emit, the
800
- // legacy behaviour that powers `users.read` + `userOf.match`
801
- // self-referencing pushdown).
821
+ // A capability query still fetches a resource named like a segment
822
+ // (`resource.id === segment.name`) when that segment is concrete.
802
823
  const requestTarget = target ?? [];
803
824
  const targetSegmentIndexById = (() => {
804
825
  const out = new Map<string, number>();
@@ -833,85 +854,91 @@ export function createSystem<TMeta = unknown>(opts: {
833
854
  .map((r) => [r.id, r] as const),
834
855
  ).values(),
835
856
  );
836
- const fetched = new Map<string, unknown>();
837
- await Promise.all(
838
- uniqueResources.map(async (r) => {
839
- fetched.set(r.id, await fetchResource(r, ctx, state));
840
- }),
841
- );
842
-
843
- // In concrete mode, indirect resources that declared a `fetcher`
844
- // are fetched as well (their joined docs are attached to the ctx
845
- // so `indirect.match()` can evaluate the spec). The cache key is
846
- // target-derived so `CanContext.preseed()` can inject values
847
- // produced by a cap-mode pipeline aggregation (no DB hit when
848
- // listed docs already carry the `_lookupAlias`).
849
- const indirectFetched = new Map<string, readonly unknown[]>();
850
- if (!capability) {
851
- const indirectsToFetch = perm.rules
852
- .map((r) => extractIndirectResource(r))
853
- .filter(
854
- (ir): ir is IndirectResource =>
855
- ir !== null && ir.fetcher !== undefined,
856
- );
857
- await Promise.all(
858
- indirectsToFetch.map(async (ir) => {
859
- const sourceDoc = fetched.get(ir.from.id);
860
- if (sourceDoc === undefined || sourceDoc === null) return;
861
- const cacheKey = ir.cacheKeyForTarget(ctx.target);
862
- if (state) {
863
- const existing = state.cache.get(cacheKey);
864
- if (existing) {
865
- indirectFetched.set(
866
- ir.id,
867
- (await existing) as readonly unknown[],
868
- );
869
- return;
870
- }
871
- const pending = Promise.resolve(ir.fetcher!(sourceDoc, ctx));
872
- state.cache.set(cacheKey, pending);
873
- indirectFetched.set(ir.id, await pending);
874
- } else {
875
- indirectFetched.set(ir.id, await ir.fetcher!(sourceDoc, ctx));
876
- }
877
- }),
878
- );
879
- }
880
-
881
- // Attach indirect-fetched joined docs to the context so
882
- // `indirect.match()` rule can read them.
883
- const ctxWithIndirect = Object.assign({}, ctx, {
884
- _indirectFetched: indirectFetched,
885
- });
886
-
887
857
  let grantOk = true;
888
858
  let grantData: unknown = undefined;
889
- // Multiple match rules in one permission (e.g., expositionInfo + badge)
890
- // contribute distinct constraints — AND-merge them per grant.
891
859
  const grantConstraints: Record<string, unknown>[] = [];
860
+ const grantFilters: FilterContribution[] = [];
892
861
  let grantHasMatchRule = false;
893
- for (const rule of activeRules) {
894
- if (rule.descriptor.kind === "match") grantHasMatchRule = true;
895
- const data = rule.needs.map((r) => fetched.get(r.id));
896
- const result = rule.check(data, ctxWithIndirect);
897
- if (!result.ok) {
898
- grantOk = false;
899
- reasons.push(
900
- grant.id ? `[${grant.id}] ${result.reason}` : result.reason,
862
+ try {
863
+ const fetched = new Map<string, unknown>();
864
+ await Promise.all(
865
+ uniqueResources.map(async (r) => {
866
+ fetched.set(r.id, await fetchResource(r, ctx, state));
867
+ }),
868
+ );
869
+
870
+ const indirectFetched = new Map<string, readonly unknown[]>();
871
+ if (!capability) {
872
+ const indirectsToFetch = perm.rules
873
+ .map((r) => extractIndirectResource(r))
874
+ .filter(
875
+ (ir): ir is IndirectResource =>
876
+ ir !== null && ir.fetcher !== undefined,
877
+ );
878
+ await Promise.all(
879
+ indirectsToFetch.map(async (ir) => {
880
+ const sourceDoc = fetched.get(ir.from.id);
881
+ if (sourceDoc === undefined || sourceDoc === null) return;
882
+ const cacheKey = ir.cacheKeyForTarget(ctx.target);
883
+ if (state) {
884
+ const existing = state.cache.get(cacheKey);
885
+ if (existing) {
886
+ indirectFetched.set(
887
+ ir.id,
888
+ (await existing) as readonly unknown[],
889
+ );
890
+ return;
891
+ }
892
+ const pending = Promise.resolve(ir.fetcher!(sourceDoc, ctx));
893
+ state.cache.set(cacheKey, pending);
894
+ indirectFetched.set(ir.id, await pending);
895
+ } else {
896
+ indirectFetched.set(ir.id, await ir.fetcher!(sourceDoc, ctx));
897
+ }
898
+ }),
901
899
  );
902
- break;
903
- }
904
- if (result.data !== undefined) grantData = result.data;
905
- if (result.constraint !== undefined) {
906
- grantConstraints.push(result.constraint);
907
900
  }
908
- if (result.filter !== undefined) {
909
- referenceSource = result.filter.source;
910
- filterUnion = mergeFilterSpec(filterUnion, result.filter.spec);
901
+
902
+ const ctxWithIndirect = Object.assign({}, ctx, {
903
+ _indirectFetched: indirectFetched,
904
+ });
905
+
906
+ for (const rule of activeRules) {
907
+ if (rule.descriptor.kind === "match") grantHasMatchRule = true;
908
+ const data = rule.needs.map((r) => fetched.get(r.id));
909
+ const result = rule.check(data, ctxWithIndirect);
910
+ if (!result.ok) {
911
+ grantOk = false;
912
+ reasons.push(
913
+ grant.id ? `[${grant.id}] ${result.reason}` : result.reason,
914
+ );
915
+ break;
916
+ }
917
+ if (result.data !== undefined) grantData = result.data;
918
+ if (result.constraint !== undefined) {
919
+ grantConstraints.push(result.constraint);
920
+ }
921
+ if (result.filter !== undefined) grantFilters.push(result.filter);
911
922
  }
923
+ } catch (error) {
924
+ grantOk = false;
925
+ reportError({
926
+ source: "rule",
927
+ grantId: grant.id,
928
+ subject,
929
+ key,
930
+ target,
931
+ error,
932
+ });
933
+ const reason = `rule evaluation failed: ${describeError(error)}`;
934
+ reasons.push(grant.id ? `[${grant.id}] ${reason}` : reason);
912
935
  }
913
936
 
914
937
  if (grantOk) {
938
+ for (const contribution of grantFilters) {
939
+ referenceSource = contribution.source;
940
+ filterUnion = mergeFilterSpec(filterUnion, contribution.spec);
941
+ }
915
942
  anyOk = true;
916
943
  successfulGrants.push(grant);
917
944
  if (grant.id) matchedGrantIds.push(grant.id);
@@ -928,7 +955,10 @@ export function createSystem<TMeta = unknown>(opts: {
928
955
  if (!anyOk) {
929
956
  return {
930
957
  ok: false,
931
- reasons: reasons.length > 0 ? reasons : ["no matching grant"],
958
+ reasons: [
959
+ ...(reasons.length > 0 ? reasons : ["no matching grant"]),
960
+ ...failures,
961
+ ],
932
962
  };
933
963
  }
934
964
 
@@ -939,10 +969,6 @@ export function createSystem<TMeta = unknown>(opts: {
939
969
  lastData,
940
970
  );
941
971
 
942
- // Collect indirect resources referenced by the permission's rules
943
- // (sentinel rules with descriptor.kind === "indirect-match"). If any
944
- // are referenced by matched grants' `with`, emit an aggregation
945
- // pipeline that pushes the JOIN constraint to the DB.
946
972
  const declaredIndirect: IndirectResource[] = [];
947
973
  for (const rule of perm.rules) {
948
974
  const ir = extractIndirectResource(rule);
@@ -951,10 +977,8 @@ export function createSystem<TMeta = unknown>(opts: {
951
977
  let stages: readonly Record<string, unknown>[] | undefined;
952
978
  if (declaredIndirect.length > 0) {
953
979
  const baseFilter = constraints ?? {};
954
- // Must pass `successfulGrants` (rules accepted), NOT `matching`
955
- // (raw key+target match). A grant rejected by a rule must not
956
- // contribute to indirect any-wins — otherwise it would silently
957
- // disable the indirect filter for its siblings.
980
+ // A rejected grant without an indirect reference would win "any" and
981
+ // lift the join filter for its siblings, so only accepted grants count.
958
982
  const result = buildAggregationStages(
959
983
  baseFilter,
960
984
  successfulGrants,