@diister/quick-permission 0.9.0-beta.5

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.
Files changed (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +747 -0
  3. package/aggregation.ts +114 -0
  4. package/core/filtering.ts +70 -0
  5. package/core/matching.ts +82 -0
  6. package/core/merging.ts +143 -0
  7. package/dist/aggregation.d.ts +62 -0
  8. package/dist/aggregation.d.ts.map +1 -0
  9. package/dist/aggregation.js +97 -0
  10. package/dist/aggregation.js.map +1 -0
  11. package/dist/core/filtering.d.ts +35 -0
  12. package/dist/core/filtering.d.ts.map +1 -0
  13. package/dist/core/filtering.js +62 -0
  14. package/dist/core/filtering.js.map +1 -0
  15. package/dist/core/matching.d.ts +31 -0
  16. package/dist/core/matching.d.ts.map +1 -0
  17. package/dist/core/matching.js +75 -0
  18. package/dist/core/matching.js.map +1 -0
  19. package/dist/core/merging.d.ts +29 -0
  20. package/dist/core/merging.d.ts.map +1 -0
  21. package/dist/core/merging.js +124 -0
  22. package/dist/core/merging.js.map +1 -0
  23. package/dist/indirect-aggregation.d.ts +41 -0
  24. package/dist/indirect-aggregation.d.ts.map +1 -0
  25. package/dist/indirect-aggregation.js +185 -0
  26. package/dist/indirect-aggregation.js.map +1 -0
  27. package/dist/indirect-resource.d.ts +126 -0
  28. package/dist/indirect-resource.d.ts.map +1 -0
  29. package/dist/indirect-resource.js +109 -0
  30. package/dist/indirect-resource.js.map +1 -0
  31. package/dist/mod.d.ts +25 -0
  32. package/dist/mod.d.ts.map +1 -0
  33. package/dist/mod.js +25 -0
  34. package/dist/mod.js.map +1 -0
  35. package/dist/mongo-query.d.ts +38 -0
  36. package/dist/mongo-query.d.ts.map +1 -0
  37. package/dist/mongo-query.js +88 -0
  38. package/dist/mongo-query.js.map +1 -0
  39. package/dist/permission.d.ts +57 -0
  40. package/dist/permission.d.ts.map +1 -0
  41. package/dist/permission.js +60 -0
  42. package/dist/permission.js.map +1 -0
  43. package/dist/resource.d.ts +48 -0
  44. package/dist/resource.d.ts.map +1 -0
  45. package/dist/resource.js +298 -0
  46. package/dist/resource.js.map +1 -0
  47. package/dist/rules.d.ts +106 -0
  48. package/dist/rules.d.ts.map +1 -0
  49. package/dist/rules.js +183 -0
  50. package/dist/rules.js.map +1 -0
  51. package/dist/sift/core.d.ts +104 -0
  52. package/dist/sift/core.d.ts.map +1 -0
  53. package/dist/sift/core.js +248 -0
  54. package/dist/sift/core.js.map +1 -0
  55. package/dist/sift/index.d.ts +10 -0
  56. package/dist/sift/index.d.ts.map +1 -0
  57. package/dist/sift/index.js +18 -0
  58. package/dist/sift/index.js.map +1 -0
  59. package/dist/sift/operations.d.ts +87 -0
  60. package/dist/sift/operations.d.ts.map +1 -0
  61. package/dist/sift/operations.js +257 -0
  62. package/dist/sift/operations.js.map +1 -0
  63. package/dist/sift/utils.d.ts +12 -0
  64. package/dist/sift/utils.d.ts.map +1 -0
  65. package/dist/sift/utils.js +80 -0
  66. package/dist/sift/utils.js.map +1 -0
  67. package/dist/system.d.ts +113 -0
  68. package/dist/system.d.ts.map +1 -0
  69. package/dist/system.js +712 -0
  70. package/dist/system.js.map +1 -0
  71. package/dist/target.d.ts +18 -0
  72. package/dist/target.d.ts.map +1 -0
  73. package/dist/target.js +41 -0
  74. package/dist/target.js.map +1 -0
  75. package/dist/types.d.ts +345 -0
  76. package/dist/types.d.ts.map +1 -0
  77. package/dist/types.js +10 -0
  78. package/dist/types.js.map +1 -0
  79. package/indirect-aggregation.ts +216 -0
  80. package/indirect-resource.ts +205 -0
  81. package/mod.ts +81 -0
  82. package/mongo-query.ts +94 -0
  83. package/package.json +58 -0
  84. package/permission.ts +88 -0
  85. package/resource.ts +352 -0
  86. package/rules.ts +241 -0
  87. package/sift/MIT-LICENSE.txt +20 -0
  88. package/sift/core.ts +551 -0
  89. package/sift/index.ts +62 -0
  90. package/sift/operations.ts +449 -0
  91. package/sift/utils.ts +96 -0
  92. package/system.ts +974 -0
  93. package/target.ts +84 -0
  94. package/types.ts +408 -0
package/target.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Target builders : `seg(...)` pour décrire un segment et `target.{none,
3
+ * optional, required, path}` pour assembler un target.
4
+ */
5
+
6
+ import type {
7
+ AnySegment,
8
+ SegmentSpec,
9
+ SpecToSegment,
10
+ TargetNone,
11
+ TargetOptional,
12
+ TargetPath,
13
+ TargetRequired,
14
+ } from "./types.ts";
15
+
16
+ export function seg<
17
+ const N extends string,
18
+ const T extends string | readonly string[] | "*",
19
+ >(
20
+ name: N,
21
+ type: T,
22
+ opts?: { validate?: (value: unknown) => boolean },
23
+ ): SegmentSpec<N, T> {
24
+ return {
25
+ name,
26
+ types: type,
27
+ ...(opts?.validate ? { validate: opts.validate } : {}),
28
+ } as SegmentSpec<N, T>;
29
+ }
30
+
31
+ function normalize<S extends string | AnySegment>(spec: S): SpecToSegment<S> {
32
+ if (typeof spec === "string") {
33
+ return { name: spec, types: spec } as SpecToSegment<S>;
34
+ }
35
+ return spec as SpecToSegment<S>;
36
+ }
37
+
38
+ export interface TargetBuilders {
39
+ none(): TargetNone;
40
+ optional<const Spec extends string | AnySegment>(
41
+ spec: Spec,
42
+ ): TargetOptional<SpecToSegment<Spec>>;
43
+ required<const Spec extends string | AnySegment>(
44
+ spec: Spec,
45
+ ): TargetRequired<SpecToSegment<Spec>>;
46
+ path<const Specs extends readonly (string | AnySegment)[]>(
47
+ ...specs: Specs
48
+ ): TargetPath<{ -readonly [K in keyof Specs]: SpecToSegment<Specs[K]> }>;
49
+ }
50
+
51
+ export const target: TargetBuilders = {
52
+ none(): TargetNone {
53
+ return { kind: "none", segments: [] as const };
54
+ },
55
+
56
+ optional<const Spec extends string | AnySegment>(
57
+ spec: Spec,
58
+ ): TargetOptional<SpecToSegment<Spec>> {
59
+ return {
60
+ kind: "optional",
61
+ segments: [normalize(spec)] as const,
62
+ } as TargetOptional<SpecToSegment<Spec>>;
63
+ },
64
+
65
+ required<const Spec extends string | AnySegment>(
66
+ spec: Spec,
67
+ ): TargetRequired<SpecToSegment<Spec>> {
68
+ return {
69
+ kind: "required",
70
+ segments: [normalize(spec)] as const,
71
+ } as TargetRequired<SpecToSegment<Spec>>;
72
+ },
73
+
74
+ path<const Specs extends readonly (string | AnySegment)[]>(
75
+ ...specs: Specs
76
+ ): TargetPath<{ -readonly [K in keyof Specs]: SpecToSegment<Specs[K]> }> {
77
+ return {
78
+ kind: "path",
79
+ segments: specs.map(normalize) as unknown as {
80
+ -readonly [K in keyof Specs]: SpecToSegment<Specs[K]>;
81
+ },
82
+ } as TargetPath<{ -readonly [K in keyof Specs]: SpecToSegment<Specs[K]> }>;
83
+ },
84
+ };
package/types.ts ADDED
@@ -0,0 +1,408 @@
1
+ /**
2
+ * Types core de `@diister/quick-permission`.
3
+ *
4
+ * Couvre :
5
+ * - les target primitives (segments + 4 kinds : none/optional/required/path)
6
+ * - les types runtime du système (Subject, Grant, Rule, Resource, Permission)
7
+ * - les types sérialisables exposés via `system.list()` / `system.tree()`
8
+ */
9
+
10
+ // ─── Target primitives ───────────────────────────────────────────────────
11
+
12
+ export type SegmentSpec<N extends string = string, T = unknown> = {
13
+ readonly name: N;
14
+ readonly types: T;
15
+ readonly validate?: (value: unknown) => boolean;
16
+ };
17
+
18
+ export type AnySegment = SegmentSpec<string, unknown>;
19
+
20
+ export type SpecToSegment<S> = S extends string
21
+ ? SegmentSpec<S, S>
22
+ : S extends AnySegment
23
+ ? S
24
+ : never;
25
+
26
+ export type TargetNone = {
27
+ readonly kind: "none";
28
+ readonly segments: readonly [];
29
+ };
30
+
31
+ export type TargetOptional<S extends AnySegment> = {
32
+ readonly kind: "optional";
33
+ readonly segments: readonly [S];
34
+ };
35
+
36
+ export type TargetRequired<S extends AnySegment> = {
37
+ readonly kind: "required";
38
+ readonly segments: readonly [S];
39
+ };
40
+
41
+ export type TargetPath<Ss extends readonly AnySegment[]> = {
42
+ readonly kind: "path";
43
+ readonly segments: Ss;
44
+ };
45
+
46
+ export type AnyTarget =
47
+ | TargetNone
48
+ | TargetOptional<AnySegment>
49
+ | TargetRequired<AnySegment>
50
+ | TargetPath<readonly AnySegment[]>;
51
+
52
+ export type SegmentNames<T extends AnyTarget> = T extends TargetNone
53
+ ? never
54
+ : T extends TargetOptional<infer S>
55
+ ? S["name"]
56
+ : T extends TargetRequired<infer S>
57
+ ? S["name"]
58
+ : T extends TargetPath<infer Ss>
59
+ ? Ss extends readonly AnySegment[]
60
+ ? Ss[number]["name"]
61
+ : never
62
+ : never;
63
+
64
+ export type TargetArgs<T extends AnyTarget> = T extends TargetNone
65
+ ? readonly []
66
+ : T extends TargetOptional<AnySegment>
67
+ ? readonly [string?]
68
+ : T extends TargetRequired<AnySegment>
69
+ ? readonly [string]
70
+ : T extends TargetPath<infer Ss>
71
+ ? Ss extends readonly AnySegment[]
72
+ ? { -readonly [K in keyof Ss]: string }
73
+ : never
74
+ : never;
75
+
76
+ export type Subject = {
77
+ readonly id: string;
78
+ readonly [key: string]: unknown;
79
+ };
80
+
81
+ /**
82
+ * Forme d'un grant émis par un provider, avec les 4 slots sémantiques
83
+ * distincts (cf. RFC §"Slot `flags` séparé de `with`").
84
+ */
85
+ export type Grant = {
86
+ readonly id?: string;
87
+ readonly key: string;
88
+ readonly target?: readonly unknown[];
89
+ /** Constraint specs lus par `match` / `includes`. */
90
+ readonly with?: Readonly<Record<string, unknown>>;
91
+ /**
92
+ * Mongo spec read by `inputMatch()`, evaluated against `ctx.input`.
93
+ * Separate from `with` so payload-shaped specs don't collide with
94
+ * resource-shaped specs in the same grant.
95
+ */
96
+ readonly inputWith?: Readonly<Record<string, unknown>>;
97
+ /** Sélecteurs de champs lus par `filter`. */
98
+ readonly filter?: Readonly<Record<string, boolean>>;
99
+ /** Opt-ins booléens lus par les `require*` rules. */
100
+ readonly flags?: Readonly<Record<string, boolean>>;
101
+ /** Données arbitraires accessibles aux rules custom. */
102
+ readonly payload?: unknown;
103
+ };
104
+
105
+ /**
106
+ * Contexte transmis aux fetchers et aux rules.
107
+ * Toutes les rules d'un même grant partagent le même FetchCtx.
108
+ */
109
+ export type FetchCtx = {
110
+ readonly subject: Subject;
111
+ readonly target: readonly unknown[];
112
+ readonly grant: Grant;
113
+ readonly checkDate?: Date;
114
+ readonly checkIp?: string;
115
+ /**
116
+ * True si la request target contient des wildcards (capability query).
117
+ * Dans ce mode, les resources ne sont PAS fetchées (cf. RFC §"Sémantique
118
+ * d'exécution"). Les rules built-in font silent-pass quand leur contrainte
119
+ * ne peut pas être vérifiée sans ressource concrète.
120
+ */
121
+ readonly capability?: boolean;
122
+ /**
123
+ * Check-time payload (forwarded from `CanContext.input`). Distinct from
124
+ * `grant.payload` which is static (seed-time). Consumed by `inputMatch()`
125
+ * to validate a CREATE body against `grant.with`.
126
+ */
127
+ readonly input?: unknown;
128
+ };
129
+
130
+ /**
131
+ * Filter contribution from a single grant. The two fields always go together:
132
+ * - `source` : the unfiltered reference object (basis for the cross-grant union)
133
+ * - `spec` : the field whitelist for this grant; `null` means "all fields"
134
+ * (saturates the cross-grant union to the most permissive shape).
135
+ */
136
+ export type FilterContribution = {
137
+ readonly source: unknown;
138
+ readonly spec: Record<string, boolean> | null;
139
+ };
140
+
141
+ /**
142
+ * Rule evaluation result.
143
+ *
144
+ * Cross-grant aggregation:
145
+ * - `data` : last writer wins (generic transform output).
146
+ * - `constraint` : OR-merged across grants into `output.constraints` (match rules).
147
+ * - `filter` : `spec` unioned across grants, applied to `source` to produce
148
+ * the most permissive `output.data` (filter rules).
149
+ */
150
+ export type RuleResult =
151
+ | {
152
+ readonly ok: true;
153
+ readonly data?: unknown;
154
+ readonly constraint?: Record<string, unknown>;
155
+ readonly filter?: FilterContribution;
156
+ }
157
+ | { readonly ok: false; readonly reason: string };
158
+
159
+ /**
160
+ * Descripteur sérialisable d'une rule, exposé via `system.list()`.
161
+ * Le frontend matrix UI route ses pickers/toggles à partir de `kind`.
162
+ *
163
+ * Champs standardisés :
164
+ * - `kind` : identifiant de famille de rule
165
+ * - `source` : id de la resource si needs.length === 1
166
+ * - `sources` : ids des resources si needs.length > 1 (cross-resource)
167
+ * - `flag` : nom du flag d'opt-in (si la rule a un flag)
168
+ * - tout extra champs renvoyé par `describe()` est préservé tel quel
169
+ */
170
+ export type RuleDescriptor = {
171
+ readonly kind: string;
172
+ readonly source?: string;
173
+ readonly sources?: readonly string[];
174
+ readonly flag?: string;
175
+ readonly [extra: string]: unknown;
176
+ };
177
+
178
+ /**
179
+ * Une rule = unité atomique de validation. Toujours produite par
180
+ * `defineRule({...})` ou par les méthodes resource (sucre).
181
+ */
182
+ export interface Rule {
183
+ /** Métadonnée sérialisable pour matrix UI / introspection. */
184
+ readonly descriptor: RuleDescriptor;
185
+ /**
186
+ * Resources que la rule consomme. L'orchestrateur les fetche en parallèle
187
+ * et les passe à `check`. Vide pour les rules pures (ex: requireSelf).
188
+ */
189
+ readonly needs: readonly Resource<unknown>[];
190
+ /**
191
+ * Si défini et retourne false pour le grant courant, la rule est skip
192
+ * silencieusement (et ses needs ne sont pas fetchées).
193
+ * Mutuellement exclusif avec `flag` au niveau de defineRule (le flag est
194
+ * du sucre qui produit un activeWhen).
195
+ */
196
+ readonly activeWhen?: (grant: Grant) => boolean;
197
+ /**
198
+ * Logique d'évaluation. `data` est un tuple aligné sur `needs` (1 entrée
199
+ * par need, dans l'ordre).
200
+ */
201
+ readonly check: (data: readonly unknown[], ctx: FetchCtx) => RuleResult;
202
+ }
203
+
204
+ /**
205
+ * Une Resource = source de donnée fetchable, dédupliquée par contexte.
206
+ * Réutilisable entre toutes les permissions d'un domaine.
207
+ */
208
+ export interface Resource<T> {
209
+ readonly id: string;
210
+ /** Fetch la donnée à partir du contexte (subject, target, grant). */
211
+ readonly fetcher: (ctx: FetchCtx) => T | Promise<T>;
212
+ /** Si défini, la resource est skip si l'activator retourne false. */
213
+ readonly activator?: (grant: Grant) => boolean;
214
+ /**
215
+ * Calcule la clé de dedup. Par défaut : hash de tous les inputs.
216
+ * Le développeur explicite cette clé pour optimiser le dedup partiel
217
+ * (ex: target[0..1] pour programOf qui ignore target[2]).
218
+ */
219
+ readonly dedupKey?: (ctx: FetchCtx) => string;
220
+ /**
221
+ * Calcule la cache key pour un target donné (sans subject ni grant).
222
+ * Utilisé par `CanContext.preseed()` pour injecter une valeur déjà
223
+ * chargée dans le cache du context. Le caller passe les segments
224
+ * target attendus, l'engine reconstruit le même key qu'aurait produit
225
+ * `computeDedupKey({ target, subject, grant })`.
226
+ *
227
+ * Par défaut, dérive de `dedupKey` en utilisant un subject minimal.
228
+ */
229
+ cacheKeyForTarget(target: readonly unknown[]): string;
230
+
231
+ /**
232
+ * Vrai si la resource doit être fetchée pour ce grant. Dérive d'`activator`.
233
+ */
234
+ isActiveFor(grant: Grant): boolean;
235
+ /**
236
+ * Calcule la clé de dedup effective pour ce contexte (préfixée par l'id).
237
+ */
238
+ computeDedupKey(ctx: FetchCtx): string;
239
+
240
+ // === Méthodes de sucre — produisent des Rule via defineRule ===
241
+
242
+ /** Always-active. Silent-pass si `grant.with[id]` est undefined. */
243
+ match(extractor?: (data: T) => unknown): Rule;
244
+
245
+ /** Always-active. Retourne le resource entier si `grant.filter` absent. */
246
+ filter(extractor?: (data: T) => unknown): Rule;
247
+
248
+ /** Lazy : skip si `grant.with[grantField]` absent. */
249
+ includes(
250
+ grantField: string,
251
+ extractor: (data: T) => readonly unknown[],
252
+ ): Rule;
253
+
254
+ /** Always-active. Échoue si `data` est falsy (ex: lookup retourne null). */
255
+ requireTruthy(): Rule;
256
+
257
+ /** Opt-in via flag : active si `grant.flags[opts.flag] === true`. */
258
+ requireOwner(
259
+ getter: (data: T) => string | undefined,
260
+ opts: { readonly flag: string },
261
+ ): Rule;
262
+
263
+ /** Opt-in via flag : active si `grant.flags[opts.flag] === true`. */
264
+ requireMembership(
265
+ getter: (data: T) => readonly string[],
266
+ opts: { readonly flag: string },
267
+ ): Rule;
268
+
269
+ /**
270
+ * Escape hatch : prédicat custom. Active si `grant.flags[opts.flag] === true`.
271
+ * Le `descriptor` permet de transmettre des métadonnées au matrix UI.
272
+ */
273
+ requireCustom(
274
+ predicate: (data: T, ctx: FetchCtx) => boolean,
275
+ opts: {
276
+ readonly flag: string;
277
+ readonly descriptor?: Readonly<Record<string, unknown>>;
278
+ },
279
+ ): Rule;
280
+ }
281
+
282
+ /**
283
+ * Helper d'inférence : extrait T depuis un Resource<T>.
284
+ */
285
+ export type ResourceData<R> = R extends Resource<infer T> ? T : never;
286
+
287
+ /**
288
+ * Helper d'inférence : transforme un tuple de Resource en tuple de données T.
289
+ */
290
+ export type ResourcesData<RS extends readonly Resource<unknown>[]> = {
291
+ [K in keyof RS]: ResourceData<RS[K]>;
292
+ };
293
+
294
+ /**
295
+ * Forme stockée d'une permission après `permission().rules([...])`.
296
+ */
297
+ export interface Permission<TMeta = unknown> {
298
+ readonly metadata?: TMeta;
299
+ readonly target: AnyTarget;
300
+ readonly rules: readonly Rule[];
301
+ /**
302
+ * Pour les permissions intermediates : fonction qui expand un grant en
303
+ * grants sur les enfants. Si défini, la permission est traitée comme
304
+ * un macro pendant l'expansion.
305
+ */
306
+ readonly expandsTo?: (grant: Grant) => readonly Grant[];
307
+ }
308
+
309
+ /**
310
+ * Résultat de `system.list()` : un entry plat par permission.
311
+ *
312
+ * `expandsTo` — pour les intermediates uniquement, contient la liste plate
313
+ * (leaves-only) des clés effectivement octroyées par cette macro, calculée
314
+ * par BFS transitive sur le schéma au moment du `list()`. Undefined pour
315
+ * `kind === "permission"` (les leaves n'expandent pas). Voir `system.ts:
316
+ * computeDescendants` pour la sémantique exacte (déduplication, cycle
317
+ * detection, leaves-only, sampling par stub wildcard).
318
+ */
319
+ export type ListEntry<TMeta = unknown> = {
320
+ readonly key: string;
321
+ readonly kind: "permission" | "intermediate";
322
+ readonly metadata: TMeta | undefined;
323
+ readonly target: SerializableTarget;
324
+ readonly rules: readonly RuleDescriptor[];
325
+ readonly expandsTo?: readonly string[];
326
+ };
327
+
328
+ /**
329
+ * Nœud de l'arbre exposé par `system.tree()`. Un nœud est soit un leaf
330
+ * (permission/intermediate) avec ses metadata + target + rules, soit un
331
+ * groupe synthétique avec des enfants (groupé par préfixe dotted-key).
332
+ */
333
+ export type TreeNode<TMeta = unknown> =
334
+ | {
335
+ readonly kind: "permission" | "intermediate";
336
+ readonly key: string;
337
+ readonly metadata: TMeta | undefined;
338
+ readonly target: SerializableTarget;
339
+ readonly rules: readonly RuleDescriptor[];
340
+ readonly expandsTo?: readonly string[];
341
+ }
342
+ | {
343
+ readonly kind: "group";
344
+ readonly metadata: undefined;
345
+ readonly children: Readonly<Record<string, TreeNode<TMeta>>>;
346
+ };
347
+
348
+ export type SerializableSegment = {
349
+ readonly name: string;
350
+ readonly types: string | readonly string[];
351
+ };
352
+
353
+ /**
354
+ * Serialized form of an `IndirectResource` — function fields stripped
355
+ * (`fetcher`, `cacheKeyForTarget`, `match` are runtime-only). Returned
356
+ * by `System.indirectResources()` and `System.indirectsUsedBy(key)` so
357
+ * matrix UIs / catalog endpoints can introspect joins without walking
358
+ * every permission's rules manually.
359
+ */
360
+ export type IndirectResourceInfo = {
361
+ readonly id: string;
362
+ readonly kind: "indirect";
363
+ readonly from: { readonly id: string; readonly kind: "direct" | "indirect" };
364
+ readonly on: {
365
+ readonly localField: string;
366
+ readonly foreignField: string;
367
+ readonly foreignCollection?: string;
368
+ };
369
+ readonly to?: { readonly _type?: string };
370
+ readonly cardinality: "one" | "many";
371
+ };
372
+
373
+ export type SerializableTarget =
374
+ | { readonly kind: "none" }
375
+ | { readonly kind: "optional"; readonly segment: SerializableSegment }
376
+ | { readonly kind: "required"; readonly segment: SerializableSegment }
377
+ | {
378
+ readonly kind: "path";
379
+ readonly segments: readonly SerializableSegment[];
380
+ };
381
+
382
+ /**
383
+ * Résultat d'un `can()` call.
384
+ *
385
+ * `constraints` est l'expression Mongo unifiée des grants qui ont passé —
386
+ * utile pour le pushdown DB (`collection.find(constraints)`). Sémantique :
387
+ * - 0 grant matched → `ok: false`
388
+ * - 1 grant matched → `constraints` = la spec de ce grant (ou `{}` si pas de spec)
389
+ * - N grants matched → `constraints` = `{ $or: [spec1, spec2, ...] }`
390
+ * - Au moins 1 grant sans spec / sans constraint → `constraints` = `{}` (= "any",
391
+ * le filtre n'apporte aucune restriction au pushdown)
392
+ */
393
+ export type CanResult =
394
+ | {
395
+ readonly ok: true;
396
+ readonly data?: unknown;
397
+ readonly constraints?: Record<string, unknown>;
398
+ /**
399
+ * Pipeline d'aggregation MongoDB produit quand au moins une
400
+ * `IndirectResource` est référencée dans les rules de la permission.
401
+ * Le consommateur (adapter mongodbee côté appli) choisit entre
402
+ * `find(constraints)` et `aggregate(stages)` selon la présence de
403
+ * `stages`. Cf. `indirect-aggregation.ts`.
404
+ */
405
+ readonly stages?: readonly Record<string, unknown>[];
406
+ readonly matchedGrants?: readonly string[];
407
+ }
408
+ | { readonly ok: false; readonly reasons: readonly string[] };