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