@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/system.ts ADDED
@@ -0,0 +1,974 @@
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
+ import type {
16
+ AnyTarget,
17
+ CanResult,
18
+ FetchCtx,
19
+ Grant,
20
+ IndirectResourceInfo,
21
+ ListEntry,
22
+ Permission,
23
+ Resource,
24
+ SerializableSegment,
25
+ SerializableTarget,
26
+ Subject,
27
+ TreeNode,
28
+ } from "./types.ts";
29
+ import {
30
+ aggregateConstraints,
31
+ combineGrantConstraints,
32
+ type FilterUnion,
33
+ mergeFilterSpec,
34
+ resolveFilteredData,
35
+ } from "./aggregation.ts";
36
+ import { buildAggregationStages } from "./indirect-aggregation.ts";
37
+ import {
38
+ extractIndirectResource,
39
+ type IndirectResource,
40
+ } from "./indirect-resource.ts";
41
+
42
+ export type ProviderFn = (
43
+ subject: Subject,
44
+ key: string,
45
+ target?: readonly unknown[],
46
+ ) => readonly Grant[] | Promise<readonly Grant[]>;
47
+
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
+ export type ProviderObject = {
56
+ readonly keys?: readonly string[];
57
+ readonly matches?: (key: string) => boolean;
58
+ readonly cacheKey?: (
59
+ subject: Subject,
60
+ key: string,
61
+ target?: readonly unknown[],
62
+ ) => string;
63
+ readonly fetch: ProviderFn;
64
+ };
65
+
66
+ export type Provider = ProviderFn | ProviderObject;
67
+
68
+ function keyMatchesPattern(key: string, pattern: string): boolean {
69
+ if (pattern === key) return true;
70
+ if (pattern.endsWith("*")) {
71
+ return key.startsWith(pattern.slice(0, -1));
72
+ }
73
+ return false;
74
+ }
75
+
76
+ function providerHandlesKey(provider: ProviderObject, key: string): boolean {
77
+ if (!provider.keys && !provider.matches) return true;
78
+ if (provider.keys?.some((p) => keyMatchesPattern(key, p))) return true;
79
+ if (provider.matches?.(key)) return true;
80
+ return false;
81
+ }
82
+
83
+ export type CanContext = {
84
+ readonly checkDate?: Date;
85
+ 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
+ */
93
+ readonly input?: unknown;
94
+ };
95
+
96
+ export type System<TMeta = unknown> = {
97
+ list(): readonly ListEntry<TMeta>[];
98
+ tree(): { readonly children: Readonly<Record<string, TreeNode<TMeta>>> };
99
+ 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
+ */
107
+ 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
+ indirectsUsedBy(key: string): readonly IndirectResourceInfo[];
115
+ /** Check direct (sans cache cross-can) — préfère `context()` en HTTP. */
116
+ can(
117
+ subject: Subject,
118
+ key: string,
119
+ target?: readonly unknown[],
120
+ context?: CanContext,
121
+ ): 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
+ */
126
+ 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
+ */
133
+ can(
134
+ key: string,
135
+ target?: readonly unknown[],
136
+ perCall?: CanContext,
137
+ ): Promise<CanResult>;
138
+ /**
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`).
154
+ */
155
+ preseed<T, V = T>(
156
+ resource: Resource<unknown> | IndirectResource | string,
157
+ docs: ReadonlyArray<T>,
158
+ opts: {
159
+ dedupKey: (doc: T) => readonly unknown[];
160
+ value?: (doc: T) => V;
161
+ },
162
+ ): void;
163
+ /** Compteurs de fetches par resource.id (debug / observabilité). */
164
+ getFetchCounters(): Readonly<Record<string, number>>;
165
+ /** Reset des compteurs (pas du cache). */
166
+ clearCounters(): void;
167
+ /** Dump des grants émis dans ce context (debug). */
168
+ dumpGrants(): Promise<readonly Grant[]>;
169
+ };
170
+ };
171
+
172
+ // ─── Schema validation au boot ──────────────────────────────────────────
173
+
174
+ function validateSchema<TMeta>(
175
+ schema: Readonly<Record<string, Permission<TMeta>>>,
176
+ ): void {
177
+ const issues: string[] = [];
178
+ for (const [key, perm] of Object.entries(schema)) {
179
+ if (!perm.expandsTo) continue;
180
+ let samples: readonly Grant[] = [];
181
+ try {
182
+ samples = perm.expandsTo({
183
+ key,
184
+ target: ["*"],
185
+ });
186
+ } catch {
187
+ // Best-effort : si expandsTo throw sur le stub, on skip.
188
+ continue;
189
+ }
190
+ for (const child of samples) {
191
+ if (!(child.key in schema)) {
192
+ issues.push(
193
+ `intermediate "${key}" expands to unknown key "${child.key}"`,
194
+ );
195
+ }
196
+ }
197
+ }
198
+ if (issues.length > 0) {
199
+ throw new Error(`Schema validation failed:\n - ${issues.join("\n - ")}`);
200
+ }
201
+ }
202
+
203
+ // ─── Target matching (wildcards par segment) ────────────────────────────
204
+
205
+ function asPath(value: unknown): readonly unknown[] {
206
+ return Array.isArray(value) ? value : [value];
207
+ }
208
+
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
+ if (!target) return false;
217
+ return target.some(isWildcardSegment);
218
+ }
219
+
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 {
226
+ return seg === "*" || (typeof seg === "string" && seg.endsWith("*"));
227
+ }
228
+
229
+ function segmentOverlaps(a: unknown, b: unknown): boolean {
230
+ if (a === "*" || b === "*") return true;
231
+ if (typeof a === "string" && typeof b === "string") {
232
+ const aPrefix = a.endsWith("*") ? a.slice(0, -1) : null;
233
+ const bPrefix = b.endsWith("*") ? b.slice(0, -1) : null;
234
+ if (aPrefix !== null && bPrefix !== null) {
235
+ return aPrefix.startsWith(bPrefix) || bPrefix.startsWith(aPrefix);
236
+ }
237
+ if (aPrefix !== null) return b.startsWith(aPrefix);
238
+ if (bPrefix !== null) return a.startsWith(bPrefix);
239
+ }
240
+ return a === b;
241
+ }
242
+
243
+ function targetMatches(
244
+ grantTarget: readonly unknown[] | unknown | undefined,
245
+ requestTarget: readonly unknown[] | undefined,
246
+ schemaKind: AnyTarget["kind"],
247
+ capability: boolean,
248
+ ): boolean {
249
+ 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
+ }
256
+ if (requestTarget === undefined) return false;
257
+ const g = asPath(grantTarget);
258
+ 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
+ if (capability) {
262
+ return g.every((seg, i) => segmentOverlaps(seg, requestTarget[i]));
263
+ }
264
+ return g.every((seg, i) => {
265
+ if (seg === "*") return true;
266
+ if (typeof seg === "string" && seg.endsWith("*")) {
267
+ const prefix = seg.slice(0, -1);
268
+ return (
269
+ typeof requestTarget[i] === "string" &&
270
+ (requestTarget[i] as string).startsWith(prefix)
271
+ );
272
+ }
273
+ return seg === requestTarget[i];
274
+ });
275
+ }
276
+
277
+ // ─── Intermediate expansion ──────────────────────────────────────────────
278
+
279
+ function expandGrants<TMeta>(
280
+ grants: readonly Grant[],
281
+ schema: Readonly<Record<string, Permission<TMeta>>>,
282
+ maxDepth = 10,
283
+ ): Grant[] {
284
+ const out: Grant[] = [];
285
+ const queue: Array<{ grant: Grant; depth: number }> = grants.map((g) => ({
286
+ grant: g,
287
+ depth: 0,
288
+ }));
289
+ while (queue.length) {
290
+ const { grant, depth } = queue.shift()!;
291
+ 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
+ if (
297
+ perm &&
298
+ grant.target === undefined &&
299
+ (perm.target.kind === "required" || perm.target.kind === "path")
300
+ ) {
301
+ continue;
302
+ }
303
+ out.push(grant);
304
+ if (depth >= maxDepth) continue;
305
+ if (perm?.expandsTo) {
306
+ const children = perm.expandsTo(grant);
307
+ for (const child of children) {
308
+ queue.push({ grant: child, depth: depth + 1 });
309
+ }
310
+ }
311
+ }
312
+ return out;
313
+ }
314
+
315
+ // ─── Serialization (pour list/tree) ──────────────────────────────────────
316
+
317
+ function serializeSegment(s: {
318
+ readonly name: string;
319
+ readonly types: unknown;
320
+ }): SerializableSegment {
321
+ const types = s.types as string | readonly string[];
322
+ return { name: s.name, types };
323
+ }
324
+
325
+ function serializeTarget(t: AnyTarget): SerializableTarget {
326
+ switch (t.kind) {
327
+ case "none":
328
+ return { kind: "none" };
329
+ case "optional":
330
+ return { kind: "optional", segment: serializeSegment(t.segments[0]) };
331
+ case "required":
332
+ return { kind: "required", segment: serializeSegment(t.segments[0]) };
333
+ case "path":
334
+ return {
335
+ kind: "path",
336
+ segments: t.segments.map(serializeSegment),
337
+ };
338
+ }
339
+ }
340
+
341
+ function serializeIndirectResource(ir: IndirectResource): IndirectResourceInfo {
342
+ return {
343
+ id: ir.id,
344
+ kind: "indirect",
345
+ from: { id: ir.from.id, kind: ir.from.kind },
346
+ on: {
347
+ localField: ir.on.localField,
348
+ foreignField: ir.on.foreignField,
349
+ ...(ir.on.foreignCollection !== undefined && {
350
+ foreignCollection: ir.on.foreignCollection,
351
+ }),
352
+ },
353
+ ...(ir.to !== undefined && { to: { ...ir.to } }),
354
+ cardinality: ir.cardinality,
355
+ };
356
+ }
357
+
358
+ function collectIndirectsForPermission<TMeta>(
359
+ perm: Permission<TMeta>,
360
+ ): IndirectResource[] {
361
+ const collected = new Map<string, IndirectResource>();
362
+ for (const rule of perm.rules) {
363
+ const ir = extractIndirectResource(rule);
364
+ if (ir && !collected.has(ir.id)) collected.set(ir.id, ir);
365
+ }
366
+ return Array.from(collected.values());
367
+ }
368
+
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
+ function stubTargetFor(t: AnyTarget): readonly unknown[] | undefined {
381
+ if (t.kind === "none") return undefined;
382
+ if (t.kind === "optional" || t.kind === "required") return ["*"];
383
+ return t.segments.map(() => "*");
384
+ }
385
+
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,
409
+ schema: Readonly<Record<string, Permission<TMeta>>>,
410
+ 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 });
445
+ }
446
+ }
447
+ return [...leaves];
448
+ }
449
+
450
+ // ─── Tree builder ────────────────────────────────────────────────────────
451
+
452
+ function buildTree<TMeta>(
453
+ schema: Readonly<Record<string, Permission<TMeta>>>,
454
+ ): { children: Record<string, TreeNode<TMeta>> } {
455
+ const root: { children: Record<string, TreeNode<TMeta>> } = { children: {} };
456
+
457
+ function ensureGroup(
458
+ parent: { children: Record<string, TreeNode<TMeta>> },
459
+ name: string,
460
+ ): TreeNode<TMeta> {
461
+ const existing = parent.children[name];
462
+ if (existing) return existing;
463
+ const group: TreeNode<TMeta> = {
464
+ kind: "group",
465
+ metadata: undefined,
466
+ children: {},
467
+ };
468
+ parent.children[name] = group;
469
+ return group;
470
+ }
471
+
472
+ for (const [key, perm] of Object.entries(schema)) {
473
+ const parts = key.split(".");
474
+ let cursor: { children: Record<string, TreeNode<TMeta>> } = root;
475
+ for (let i = 0; i < parts.length - 1; i++) {
476
+ const node = ensureGroup(cursor, parts[i]);
477
+ if (node.kind !== "group") {
478
+ const newGroup: TreeNode<TMeta> = {
479
+ kind: "group",
480
+ metadata: undefined,
481
+ children: {},
482
+ };
483
+ cursor.children[parts[i]] = newGroup;
484
+ cursor = newGroup;
485
+ } else {
486
+ cursor = node as {
487
+ kind: "group";
488
+ metadata: undefined;
489
+ children: Record<string, TreeNode<TMeta>>;
490
+ };
491
+ }
492
+ }
493
+ const leafName = parts[parts.length - 1];
494
+ const descendants = perm.expandsTo
495
+ ? computeDescendants(key, schema)
496
+ : undefined;
497
+ cursor.children[leafName] = {
498
+ kind: perm.expandsTo ? "intermediate" : "permission",
499
+ key,
500
+ metadata: perm.metadata,
501
+ target: serializeTarget(perm.target),
502
+ rules: perm.rules.map((r) => r.descriptor),
503
+ ...(descendants !== undefined && { expandsTo: descendants }),
504
+ };
505
+ }
506
+
507
+ return root;
508
+ }
509
+
510
+ // ─── createSystem ────────────────────────────────────────────────────────
511
+
512
+ export function createSystem<TMeta = unknown>(opts: {
513
+ readonly schema: Readonly<Record<string, Permission<TMeta>>>;
514
+ readonly providers?: readonly Provider[];
515
+ }): System<TMeta> {
516
+ const { schema, providers = [] } = opts;
517
+ validateSchema(schema);
518
+
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.
524
+ const resourceRegistry = new Map<
525
+ string,
526
+ Resource<unknown> | IndirectResource
527
+ >();
528
+ for (const perm of Object.values(schema)) {
529
+ for (const rule of perm.rules) {
530
+ for (const r of rule.needs) {
531
+ if (!resourceRegistry.has(r.id)) resourceRegistry.set(r.id, r);
532
+ }
533
+ const ir = extractIndirectResource(rule);
534
+ if (ir && !resourceRegistry.has(ir.id)) resourceRegistry.set(ir.id, ir);
535
+ }
536
+ }
537
+
538
+ return {
539
+ list() {
540
+ return Object.entries(schema).map(([key, perm]) => {
541
+ const descendants = perm.expandsTo
542
+ ? computeDescendants(key, schema)
543
+ : undefined;
544
+ return {
545
+ key,
546
+ kind: perm.expandsTo
547
+ ? ("intermediate" as const)
548
+ : ("permission" as const),
549
+ metadata: perm.metadata,
550
+ target: serializeTarget(perm.target),
551
+ rules: perm.rules.map((r) => r.descriptor),
552
+ ...(descendants !== undefined && { expandsTo: descendants }),
553
+ };
554
+ });
555
+ },
556
+
557
+ tree() {
558
+ return buildTree<TMeta>(schema);
559
+ },
560
+
561
+ schema(key) {
562
+ return schema[key];
563
+ },
564
+
565
+ indirectResources() {
566
+ const seen = new Map<string, IndirectResource>();
567
+ for (const perm of Object.values(schema)) {
568
+ for (const ir of collectIndirectsForPermission(perm)) {
569
+ if (!seen.has(ir.id)) seen.set(ir.id, ir);
570
+ }
571
+ }
572
+ return Array.from(seen.values()).map(serializeIndirectResource);
573
+ },
574
+
575
+ indirectsUsedBy(key) {
576
+ const perm = schema[key];
577
+ if (!perm) return [];
578
+ return collectIndirectsForPermission(perm).map(serializeIndirectResource);
579
+ },
580
+
581
+ can(subject, key, target, context) {
582
+ return systemCan(subject, key, target, context, undefined);
583
+ },
584
+
585
+ context(bound) {
586
+ const cache = new Map<string, Promise<unknown>>();
587
+ const grantsCache = new Map<string, Promise<readonly Grant[]>>();
588
+ const fetchCounters = new Map<string, number>();
589
+ const { subject, ...boundCtx } = bound;
590
+ const resolveResource = (
591
+ resourceOrId: Resource<unknown> | IndirectResource | string,
592
+ ): Resource<unknown> | IndirectResource => {
593
+ if (typeof resourceOrId !== "string") return resourceOrId;
594
+ const r = resourceRegistry.get(resourceOrId);
595
+ if (!r) {
596
+ throw new Error(
597
+ `preseed: no resource registered with id "${resourceOrId}". ` +
598
+ `Available: ${[...resourceRegistry.keys()].join(", ") || "(none)"}`,
599
+ );
600
+ }
601
+ return r;
602
+ };
603
+ return {
604
+ can(key, target, perCall) {
605
+ const ctx: CanContext = perCall
606
+ ? { ...boundCtx, ...perCall }
607
+ : boundCtx;
608
+ return systemCan(subject, key, target, ctx, {
609
+ cache,
610
+ grantsCache,
611
+ fetchCounters,
612
+ });
613
+ },
614
+ preseed(resourceOrId, docs, opts) {
615
+ const resource = resolveResource(resourceOrId);
616
+ const extract = opts.value ?? ((d: unknown) => d);
617
+ for (const doc of docs) {
618
+ const target = opts.dedupKey(doc as never);
619
+ const cacheKey = resource.cacheKeyForTarget(target);
620
+ cache.set(cacheKey, Promise.resolve(extract(doc as never)));
621
+ }
622
+ },
623
+ getFetchCounters() {
624
+ return Object.fromEntries(fetchCounters);
625
+ },
626
+ clearCounters() {
627
+ fetchCounters.clear();
628
+ },
629
+ async dumpGrants() {
630
+ const arrays = await Promise.all(grantsCache.values());
631
+ return arrays.flat();
632
+ },
633
+ };
634
+ },
635
+ };
636
+
637
+ // ─── Implémentation can() ─────────────────────────────────────────────
638
+
639
+ type ContextState = {
640
+ /** Cache des fetches de Resource au sein d'un context. */
641
+ readonly cache: Map<string, Promise<unknown>>;
642
+ /** Cache des grants émis par les providers (pour cacheKey). */
643
+ readonly grantsCache: Map<string, Promise<readonly Grant[]>>;
644
+ readonly fetchCounters: Map<string, number>;
645
+ };
646
+
647
+ async function invokeProvider(
648
+ provider: Provider,
649
+ subject: Subject,
650
+ key: string,
651
+ target: readonly unknown[] | undefined,
652
+ state: ContextState | undefined,
653
+ ): Promise<readonly Grant[]> {
654
+ if (typeof provider === "function") {
655
+ return await provider(subject, key, target);
656
+ }
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;
666
+ }
667
+ return await provider.fetch(subject, key, target);
668
+ }
669
+
670
+ async function fetchResource(
671
+ resource: Resource<unknown>,
672
+ ctx: FetchCtx,
673
+ state: ContextState | undefined,
674
+ ): Promise<unknown> {
675
+ if (!state) {
676
+ // Mode `system.can()` direct — pas de cache cross-grant.
677
+ return Promise.resolve(resource.fetcher(ctx));
678
+ }
679
+ const key = resource.computeDedupKey(ctx);
680
+ const existing = state.cache.get(key);
681
+ if (existing) return existing;
682
+ state.fetchCounters.set(
683
+ resource.id,
684
+ (state.fetchCounters.get(resource.id) ?? 0) + 1,
685
+ );
686
+ const pending = Promise.resolve(resource.fetcher(ctx));
687
+ state.cache.set(key, pending);
688
+ return pending;
689
+ }
690
+
691
+ function validateArity(
692
+ perm: Permission<TMeta>,
693
+ target: readonly unknown[] | undefined,
694
+ ): string | null {
695
+ const expected = perm.target.segments.length;
696
+ const actual = target?.length ?? 0;
697
+ if (perm.target.kind === "none") {
698
+ if (actual > 0) return `target arity mismatch: expected 0, got ${actual}`;
699
+ return null;
700
+ }
701
+ if (perm.target.kind === "optional") {
702
+ if (actual !== 0 && actual !== expected) {
703
+ return `target arity mismatch: expected 0 or ${expected}, got ${actual}`;
704
+ }
705
+ return null;
706
+ }
707
+ if (actual !== expected) {
708
+ return `target arity mismatch: expected ${expected}, got ${actual}`;
709
+ }
710
+ return null;
711
+ }
712
+
713
+ async function systemCan(
714
+ subject: Subject,
715
+ key: string,
716
+ target: readonly unknown[] | undefined,
717
+ context: CanContext | undefined,
718
+ state: ContextState | undefined,
719
+ ): Promise<CanResult> {
720
+ const perm = schema[key];
721
+ if (!perm) {
722
+ return { ok: false, reasons: [`unknown permission: ${key}`] };
723
+ }
724
+
725
+ const arityErr = validateArity(perm, target);
726
+ if (arityErr) return { ok: false, reasons: [arityErr] };
727
+
728
+ // Collect grants depuis les providers (en série pour préserver l'ordre)
729
+ const allGrants: Grant[] = [];
730
+ for (const provider of providers) {
731
+ const grants = await invokeProvider(
732
+ provider,
733
+ subject,
734
+ key,
735
+ target,
736
+ state,
737
+ );
738
+ allGrants.push(...grants);
739
+ }
740
+
741
+ const expanded = expandGrants(allGrants, schema);
742
+
743
+ const capability = isCapabilityQuery(target);
744
+ const matching = expanded.filter(
745
+ (g) =>
746
+ g.key === key &&
747
+ targetMatches(g.target, target, perm.target.kind, capability),
748
+ );
749
+ if (matching.length === 0) {
750
+ return { ok: false, reasons: ["no matching grant"] };
751
+ }
752
+
753
+ const reasons: string[] = [];
754
+ let lastData: unknown = undefined;
755
+ const matchedGrantIds: string[] = [];
756
+ let anyOk = false;
757
+ // One entry per matched grant. undefined = "any" (no constraint).
758
+ 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
+ let referenceSource: unknown = undefined;
762
+ 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
+ const successfulGrants: Grant[] = [];
771
+
772
+ for (const grant of matching) {
773
+ const ctx: FetchCtx = {
774
+ subject,
775
+ target: target ?? [],
776
+ grant,
777
+ checkDate: context?.checkDate,
778
+ checkIp: context?.checkIp,
779
+ capability,
780
+ input: context?.input,
781
+ };
782
+
783
+ const activeRules = perm.rules.filter((r) => {
784
+ if (r.activeWhen && !r.activeWhen(grant)) return false;
785
+ return r.needs.every((res) => res.isActiveFor(grant));
786
+ });
787
+
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).
802
+ const requestTarget = target ?? [];
803
+ const targetSegmentIndexById = (() => {
804
+ const out = new Map<string, number>();
805
+ if (perm.target.kind === "none") return out;
806
+ const segments =
807
+ perm.target.kind === "path"
808
+ ? perm.target.segments
809
+ : [perm.target.segments[0]];
810
+ segments.forEach((seg, i) => {
811
+ if (seg) out.set(seg.name, i);
812
+ });
813
+ return out;
814
+ })();
815
+ const uniqueResources = capability
816
+ ? Array.from(
817
+ new Map(
818
+ activeRules
819
+ .flatMap((r) => r.needs)
820
+ .filter((r) => {
821
+ const idx = targetSegmentIndexById.get(r.id);
822
+ if (idx === undefined) return false;
823
+ const seg = requestTarget[idx];
824
+ return seg !== undefined && !isWildcardSegment(seg);
825
+ })
826
+ .map((r) => [r.id, r] as const),
827
+ ).values(),
828
+ )
829
+ : Array.from(
830
+ new Map(
831
+ activeRules
832
+ .flatMap((r) => r.needs)
833
+ .map((r) => [r.id, r] as const),
834
+ ).values(),
835
+ );
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
+ let grantOk = true;
888
+ 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
+ const grantConstraints: Record<string, unknown>[] = [];
892
+ 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,
901
+ );
902
+ break;
903
+ }
904
+ if (result.data !== undefined) grantData = result.data;
905
+ if (result.constraint !== undefined) {
906
+ grantConstraints.push(result.constraint);
907
+ }
908
+ if (result.filter !== undefined) {
909
+ referenceSource = result.filter.source;
910
+ filterUnion = mergeFilterSpec(filterUnion, result.filter.spec);
911
+ }
912
+ }
913
+
914
+ if (grantOk) {
915
+ anyOk = true;
916
+ successfulGrants.push(grant);
917
+ if (grant.id) matchedGrantIds.push(grant.id);
918
+ if (grantData !== undefined) lastData = grantData;
919
+ const grantConstraint = combineGrantConstraints(grantConstraints);
920
+ if (grantHasMatchRule && grantConstraint === undefined) {
921
+ collectedConstraints.push({});
922
+ } else {
923
+ collectedConstraints.push(grantConstraint);
924
+ }
925
+ }
926
+ }
927
+
928
+ if (!anyOk) {
929
+ return {
930
+ ok: false,
931
+ reasons: reasons.length > 0 ? reasons : ["no matching grant"],
932
+ };
933
+ }
934
+
935
+ const constraints = aggregateConstraints(collectedConstraints);
936
+ const finalData = resolveFilteredData(
937
+ filterUnion,
938
+ referenceSource,
939
+ lastData,
940
+ );
941
+
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
+ const declaredIndirect: IndirectResource[] = [];
947
+ for (const rule of perm.rules) {
948
+ const ir = extractIndirectResource(rule);
949
+ if (ir) declaredIndirect.push(ir);
950
+ }
951
+ let stages: readonly Record<string, unknown>[] | undefined;
952
+ if (declaredIndirect.length > 0) {
953
+ 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.
958
+ const result = buildAggregationStages(
959
+ baseFilter,
960
+ successfulGrants,
961
+ declaredIndirect,
962
+ );
963
+ if (result !== null) stages = result;
964
+ }
965
+
966
+ return {
967
+ ok: true,
968
+ ...(finalData !== undefined ? { data: finalData } : {}),
969
+ ...(constraints !== undefined ? { constraints } : {}),
970
+ ...(stages !== undefined ? { stages } : {}),
971
+ ...(matchedGrantIds.length > 0 ? { matchedGrants: matchedGrantIds } : {}),
972
+ };
973
+ }
974
+ }