@classytic/arc 2.28.0 → 2.30.0

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 (83) hide show
  1. package/dist/{BaseController-B65zbz9m.mjs → BaseController-B5DwufbB.mjs} +12 -10
  2. package/dist/EventTransport-C6YA8xY9.mjs +103 -0
  3. package/dist/{HookSystem-C1vB_Gc9.mjs → HookSystem-WkIougo6.mjs} +15 -35
  4. package/dist/applyPermissionResult-Cf6zUCvb.mjs +257 -0
  5. package/dist/audit/index.d.mts +2 -2
  6. package/dist/audit/index.mjs +1 -1
  7. package/dist/auth/index.d.mts +2 -2
  8. package/dist/auth/index.mjs +1 -1
  9. package/dist/{buildHandler-Dtwd8fdz.mjs → buildHandler-BYS3Vkfz.mjs} +32 -6
  10. package/dist/cleanup/index.d.mts +2 -2
  11. package/dist/cleanup/index.mjs +1 -1
  12. package/dist/cli/commands/describe.d.mts +1 -1
  13. package/dist/cli/commands/docs.mjs +1 -1
  14. package/dist/core/index.d.mts +2 -2
  15. package/dist/core/index.mjs +5 -4
  16. package/dist/{core-BGa8TXJR.mjs → core-poalJLvp.mjs} +2 -48
  17. package/dist/{createActionRouter-C-N7z2EL.mjs → createActionRouter-BxuywVRB.mjs} +2 -2
  18. package/dist/{createAggregationRouter-wzwFa_dW.mjs → createAggregationRouter-BiY-lGdY.mjs} +7 -20
  19. package/dist/{createApp-Du5G54TM.mjs → createApp-CXrKFciH.mjs} +24 -3
  20. package/dist/defineAggregation-ufWjUwml.mjs +47 -0
  21. package/dist/{defineResource-2zvVVpxg.mjs → defineResource-CxWqDCAy.mjs} +35 -42
  22. package/dist/docs/index.d.mts +1 -1
  23. package/dist/docs/index.mjs +1 -1
  24. package/dist/{eventPlugin-DrQ_FesK.mjs → eventPlugin-D2NNR4py.mjs} +2 -103
  25. package/dist/events/index.mjs +4 -339
  26. package/dist/factory/index.d.mts +4 -4
  27. package/dist/factory/index.mjs +1 -1
  28. package/dist/{fastify-augmentation-OzYGIiU1.d.mts → fastify-augmentation-0WhPnUH7.d.mts} +3 -3
  29. package/dist/{applyPermissionResult-BnyVE9eD.mjs → fields-CQ_7KQWw.mjs} +1 -107
  30. package/dist/hooks/index.d.mts +1 -1
  31. package/dist/hooks/index.mjs +1 -1
  32. package/dist/idempotency/index.mjs +1 -1
  33. package/dist/{index-CZY4EGga.d.mts → index-CCzVR-N3.d.mts} +1 -1
  34. package/dist/{index-BMnbzvtO.d.mts → index-Ch16aAah.d.mts} +201 -21
  35. package/dist/{index-CjVg-Epg.d.mts → index-Cx7bro4R.d.mts} +4 -4
  36. package/dist/{index-DPRH0Cme.d.mts → index-Q5gzEIWY.d.mts} +97 -66
  37. package/dist/index.d.mts +6 -6
  38. package/dist/index.mjs +8 -7
  39. package/dist/integrations/index.d.mts +1 -1
  40. package/dist/integrations/mcp/index.d.mts +6 -6
  41. package/dist/integrations/mcp/index.mjs +1 -1
  42. package/dist/integrations/mcp/testing.d.mts +1 -1
  43. package/dist/integrations/mcp/testing.mjs +1 -1
  44. package/dist/{mcpPlugin-Cs6nRGCt.mjs → mcpPlugin-nRzm5g2s.mjs} +36 -43
  45. package/dist/middleware/index.d.mts +1 -1
  46. package/dist/migrations/index.d.mts +0 -3
  47. package/dist/migrations/index.mjs +0 -3
  48. package/dist/{openapi-BLV4OMXb.mjs → openapi-B8Vkbxbn.mjs} +1 -1
  49. package/dist/permissions/index.d.mts +3 -3
  50. package/dist/permissions/index.mjs +4 -3
  51. package/dist/{permissions-BrtiBKGs.mjs → permissions-Bfu_8y1U.mjs} +318 -289
  52. package/dist/pipeline/index.d.mts +1 -1
  53. package/dist/{pipeline-BGRnV-To.mjs → pipeline-BJnpkY9i.mjs} +4 -18
  54. package/dist/plugins/index.d.mts +4 -4
  55. package/dist/plugins/index.mjs +2 -2
  56. package/dist/plugins/tracing-entry.mjs +1 -1
  57. package/dist/{preloadResources-CM4uUaFv.d.mts → preloadResources-DCfa2egz.d.mts} +3 -3
  58. package/dist/presets/filesUpload.d.mts +2 -2
  59. package/dist/presets/filesUpload.mjs +1 -1
  60. package/dist/presets/index.d.mts +14 -2
  61. package/dist/presets/index.mjs +1 -1
  62. package/dist/presets/multiTenant.d.mts +1 -1
  63. package/dist/presets/search.d.mts +2 -2
  64. package/dist/presets/search.mjs +1 -1
  65. package/dist/{presets-DwFDVKP0.mjs → presets-CJOEXLuk.mjs} +25 -9
  66. package/dist/projection-UBDgfMwu.mjs +17 -0
  67. package/dist/{realtime-DEuDu9io.mjs → realtime-B9nmIY5V.mjs} +2 -1
  68. package/dist/{registerRoute-DTp18Ah_.mjs → registerRoute-JkxIEBJq.mjs} +3 -1
  69. package/dist/registry/index.d.mts +2 -2
  70. package/dist/registry/index.mjs +1 -1
  71. package/dist/{registry-9km_w1aP.mjs → registry-BYXUUg21.mjs} +1 -1
  72. package/dist/repositoryFilter-Dz8k3S4P.mjs +44 -0
  73. package/dist/subscribe-helpers-DfzsivM6.mjs +339 -0
  74. package/dist/testing/index.d.mts +34 -6
  75. package/dist/testing/index.mjs +314 -211
  76. package/dist/types/index.d.mts +3 -3
  77. package/dist/{types-QFGc3Cjv.d.mts → types-Bmmn-fO0.d.mts} +1 -1
  78. package/dist/{types-Dmw-6Qeo.d.mts → types-ChvvVNP8.d.mts} +93 -88
  79. package/dist/{types-Cu6vVJmh.d.mts → types-D_n8L6VY.d.mts} +32 -1
  80. package/dist/usage/index.d.mts +1 -1
  81. package/dist/usage/index.mjs +0 -0
  82. package/dist/utils/index.d.mts +2 -5
  83. package/package.json +539 -539
@@ -4,7 +4,9 @@ import { d as createDomainError, f as createError, i as NotFoundError, p as isAr
4
4
  import { n as getUserRoles, t as getUserId } from "./userHelpers-Cg_y3fBz.mjs";
5
5
  import { b as isElevated, c as getOrgId, n as PUBLIC_SCOPE, x as isMember } from "./types-CSyCxl2V.mjs";
6
6
  import { t as simpleEqualityMatcher } from "./simpleEqualityMatcher-Cg05HPsn.mjs";
7
- import { a as applyFieldWritePermissions, s as resolveEffectiveRoles } from "./applyPermissionResult-BnyVE9eD.mjs";
7
+ import { t as toRepositoryFilter } from "./repositoryFilter-Dz8k3S4P.mjs";
8
+ import { o as conjoinPolicyFilters } from "./applyPermissionResult-Cf6zUCvb.mjs";
9
+ import { i as resolveEffectiveRoles, n as applyFieldWritePermissions } from "./fields-CQ_7KQWw.mjs";
8
10
  import { g as ArcQueryParser, n as scheduleBackground } from "./utils-taUVD4p3.mjs";
9
11
  import { t as buildQueryKey } from "./keys-Cd316u_y.mjs";
10
12
  //#region src/core/AccessControl.ts
@@ -38,7 +40,7 @@ var AccessControl = class {
38
40
  const scope = arcContext?._scope;
39
41
  const orgId = scope ? getOrgId(scope) : void 0;
40
42
  if (this.tenantField && orgId && !policyFilters?.[this.tenantField]) filter[this.tenantField] = orgId;
41
- return filter;
43
+ return toRepositoryFilter(filter);
42
44
  }
43
45
  /**
44
46
  * Check if a post-fetch item matches the request's `_policyFilters`.
@@ -110,9 +112,9 @@ var AccessControl = class {
110
112
  checkOwnership(item, req) {
111
113
  const ownershipCheck = this._meta(req)?._ownershipCheck;
112
114
  if (!item || !ownershipCheck) return true;
113
- const { field, userId } = ownershipCheck;
115
+ const { field, userId, missingOwner } = ownershipCheck;
114
116
  const itemOwnerId = item[field];
115
- if (!itemOwnerId) return true;
117
+ if (!itemOwnerId) return missingOwner === "allow";
116
118
  return String(itemOwnerId) === String(userId);
117
119
  }
118
120
  /**
@@ -670,12 +672,12 @@ var QueryResolver = class {
670
672
  const page = parsed.after ? void 0 : parsed.page ? Math.max(1, parsed.page) : 1;
671
673
  const sortString = parsed.sort ? Object.entries(parsed.sort).map(([k, v]) => v === -1 ? `-${k}` : k).join(",") : this.defaultSort;
672
674
  const rawSelect = parsed.select ?? req.query?.select;
673
- const filters = { ...parsed.filters };
674
675
  const policyFilters = arcContext?._policyFilters;
675
- if (policyFilters) Object.assign(filters, policyFilters);
676
+ let filters = conjoinPolicyFilters(parsed.filters, policyFilters);
676
677
  const scope = arcContext?._scope;
677
678
  const orgId = scope ? getOrgId(scope) : void 0;
678
- if (this.tenantField && orgId && !policyFilters?.[this.tenantField]) filters[this.tenantField] = orgId;
679
+ if (this.tenantField && orgId && !policyFilters?.[this.tenantField]) filters = conjoinPolicyFilters(filters, { [this.tenantField]: orgId });
680
+ const portableFilters = toRepositoryFilter(filters);
679
681
  return {
680
682
  page,
681
683
  limit,
@@ -684,7 +686,7 @@ var QueryResolver = class {
684
686
  populate: this.sanitizePopulate(parsed.populate, this.schemaOptions),
685
687
  populateOptions: this.sanitizePopulateOptions(parsed.populateOptions, this.schemaOptions),
686
688
  lookups: this.sanitizeLookups(parsed.lookups, this.schemaOptions),
687
- filters,
689
+ filters: portableFilters,
688
690
  search: parsed.search,
689
691
  after: parsed.after,
690
692
  user: req.user,
@@ -800,7 +802,7 @@ var BaseCrudController = class {
800
802
  /**
801
803
  * Composable access control (ID filtering, policy checks, org scope, ownership).
802
804
  *
803
- * Not `readonly` since 2.15.0 — `configure()` rebuilds it when the host
805
+ * Not `readonly` — `configure()` rebuilds it when the host
804
806
  * supplies tenant/idField/matchesFilter post-construction. Same model as
805
807
  * `queryResolver` after `setQueryParser` shipped in 2.10.9.
806
808
  */
@@ -808,7 +810,7 @@ var BaseCrudController = class {
808
810
  /**
809
811
  * Composable body sanitization (field permissions, system fields).
810
812
  *
811
- * Not `readonly` since 2.15.0 — `configure()` rebuilds it when the host
813
+ * Not `readonly` — `configure()` rebuilds it when the host
812
814
  * supplies schemaOptions/onFieldWriteDenied post-construction.
813
815
  */
814
816
  bodySanitizer;
@@ -0,0 +1,103 @@
1
+ import { createEvent, matchEventPattern } from "@classytic/primitives/events";
2
+ //#region src/events/EventTransport.ts
3
+ /**
4
+ * Internal arc events module — concrete in-memory transport.
5
+ *
6
+ * **Public type contracts moved out (arc 2.12).** `EventMeta`, `DomainEvent`,
7
+ * `EventHandler`, `EventLogger`, `EventTransport`, `DeadLetteredEvent`,
8
+ * `PublishManyResult`, plus the `createEvent` / `createChildEvent` /
9
+ * `matchEventPattern` helpers, are now owned by `@classytic/primitives/events`.
10
+ * Two reasons:
11
+ *
12
+ * 1. The shapes ARE the cross-package contract. arc, arc-next, mongokit's
13
+ * audit-log plugin, future kits and downstream services all share them.
14
+ * Owning them in `primitives` (pure types, zero runtime, zero deps)
15
+ * eliminates the "manually mirror in two places" problem the previous
16
+ * `primitives/src/events.ts` header explicitly called out.
17
+ * 2. Inverting the dependency direction lets future packages (sqlitekit
18
+ * audit, billing services) consume the contract without depending on
19
+ * arc's HTTP-coupled stack.
20
+ *
21
+ * Hosts MUST import the types from primitives directly:
22
+ *
23
+ * ```typescript
24
+ * import type {
25
+ * EventMeta, DomainEvent, EventHandler, EventTransport,
26
+ * DeadLetteredEvent, PublishManyResult,
27
+ * } from '@classytic/primitives/events';
28
+ * import { createEvent, createChildEvent } from '@classytic/primitives/events';
29
+ * ```
30
+ *
31
+ * arc's `events/index.ts` barrel does NOT re-export these — by design, so
32
+ * that consumer code is forced into the canonical import path and the org's
33
+ * "no re-exports" rule holds at every public surface.
34
+ *
35
+ * What stays here:
36
+ * - `MemoryEventTransport` — the in-memory `EventTransport` implementation
37
+ * used as arc's default transport. Its handler-set state is process-local;
38
+ * wrong layer for primitives.
39
+ * - `MemoryEventTransportOptions` — its options shape.
40
+ *
41
+ * Inside arc, files that previously imported types from `./EventTransport.js`
42
+ * keep working: this file re-exports them from primitives for arc's own
43
+ * call sites. That re-export is an internal refactor convenience — the
44
+ * public `events/index.ts` barrel stays clean.
45
+ */
46
+ /**
47
+ * In-memory event transport (default).
48
+ *
49
+ * Events are delivered synchronously within the process. Not suitable for
50
+ * multi-instance deployments — pair with `RedisEventTransport` /
51
+ * `RedisStreamTransport` (subpath imports) for those.
52
+ */
53
+ var MemoryEventTransport = class {
54
+ name = "memory";
55
+ handlers = /* @__PURE__ */ new Map();
56
+ logger;
57
+ constructor(options) {
58
+ this.logger = options?.logger ?? console;
59
+ }
60
+ async publish(event) {
61
+ const allHandlers = /* @__PURE__ */ new Set();
62
+ for (const [pattern, handlers] of this.handlers) if (matchEventPattern(pattern, event.type)) for (const h of handlers) allHandlers.add(h);
63
+ for (const handler of allHandlers) try {
64
+ await handler(event);
65
+ } catch (err) {
66
+ this.logger.error(`[EventTransport] Handler error for ${event.type}:`, err);
67
+ }
68
+ }
69
+ /**
70
+ * Reference `publishMany` implementation — delegates to `publish()` in order.
71
+ *
72
+ * Production transports (Kafka, Redis pipeline, SQS batch) should override
73
+ * this with a single batched network call. Memory transport has nothing to
74
+ * batch, so we just loop — the loop still returns a proper result map so
75
+ * `EventOutbox.relay` can exercise the batched code path in tests.
76
+ */
77
+ async publishMany(events) {
78
+ const results = /* @__PURE__ */ new Map();
79
+ for (const event of events) try {
80
+ await this.publish(event);
81
+ results.set(event.meta.id, null);
82
+ } catch (err) {
83
+ results.set(event.meta.id, err instanceof Error ? err : new Error(String(err)));
84
+ }
85
+ return results;
86
+ }
87
+ async subscribe(pattern, handler) {
88
+ if (!this.handlers.has(pattern)) this.handlers.set(pattern, /* @__PURE__ */ new Set());
89
+ this.handlers.get(pattern)?.add(handler);
90
+ return () => {
91
+ const set = this.handlers.get(pattern);
92
+ if (set) {
93
+ set.delete(handler);
94
+ if (set.size === 0) this.handlers.delete(pattern);
95
+ }
96
+ };
97
+ }
98
+ async close() {
99
+ this.handlers.clear();
100
+ }
101
+ };
102
+ //#endregion
103
+ export { createEvent as n, MemoryEventTransport as t };
@@ -1,4 +1,15 @@
1
+ import { t as buildRequestScopeProjection } from "./projection-UBDgfMwu.mjs";
1
2
  //#region src/hooks/HookSystem.ts
3
+ /**
4
+ * Hook System
5
+ *
6
+ * Lifecycle hooks for resource operations.
7
+ * Allows intercepting and modifying data at various points.
8
+ */
9
+ /** Lift the validated scope out of the request context (undefined when unscoped). */
10
+ function scopeOf(context) {
11
+ return buildRequestScopeProjection(context?._scope);
12
+ }
2
13
  var HookSystem = class {
3
14
  hooks;
4
15
  logger;
@@ -9,16 +20,10 @@ var HookSystem = class {
9
20
  this.logger = options?.logger ?? { error: noop };
10
21
  this.warn = options?.logger?.warn ?? noop;
11
22
  }
12
- /**
13
- * Generate hook key
14
- */
15
23
  getKey(resource, operation, phase) {
16
24
  return `${resource}:${operation}:${phase}`;
17
25
  }
18
- /**
19
- * Register a hook
20
- * Supports both object parameter and positional arguments
21
- */
26
+ /** Supports both the object-parameter and positional argument forms. */
22
27
  register(resourceOrOptions, operation, phase, handler, priority = 10) {
23
28
  let hookName;
24
29
  let resource;
@@ -63,15 +68,9 @@ var HookSystem = class {
63
68
  if (idx !== -1) hooks.splice(idx, 1);
64
69
  };
65
70
  }
66
- /**
67
- * Register before hook
68
- */
69
71
  before(resource, operation, handler, priority = 10) {
70
72
  return this.register(resource, operation, "before", handler, priority);
71
73
  }
72
- /**
73
- * Register after hook
74
- */
75
74
  after(resource, operation, handler, priority = 10) {
76
75
  return this.register(resource, operation, "after", handler, priority);
77
76
  }
@@ -105,6 +104,7 @@ var HookSystem = class {
105
104
  data,
106
105
  user: options?.user,
107
106
  context: options?.context,
107
+ scope: scopeOf(options?.context),
108
108
  meta: options?.meta
109
109
  };
110
110
  return hook.handler(ctx, next);
@@ -113,9 +113,6 @@ var HookSystem = class {
113
113
  };
114
114
  return next();
115
115
  }
116
- /**
117
- * Execute hooks for a given context
118
- */
119
116
  async execute(ctx) {
120
117
  const key = this.getKey(ctx.resource, ctx.operation, ctx.phase);
121
118
  const hooks = this.hooks.get(key) ?? [];
@@ -133,6 +130,7 @@ var HookSystem = class {
133
130
  result: ctx.result,
134
131
  user: ctx.user,
135
132
  context: ctx.context,
133
+ scope: ctx.scope ?? scopeOf(ctx.context),
136
134
  meta: ctx.meta
137
135
  };
138
136
  const hookResult = await hook.handler(handlerContext);
@@ -140,9 +138,6 @@ var HookSystem = class {
140
138
  }
141
139
  return result;
142
140
  }
143
- /**
144
- * Execute before hooks
145
- */
146
141
  async executeBefore(resource, operation, data, options) {
147
142
  return await this.execute({
148
143
  resource,
@@ -154,10 +149,7 @@ var HookSystem = class {
154
149
  meta: options?.meta
155
150
  }) ?? data;
156
151
  }
157
- /**
158
- * Execute after hooks
159
- * Errors in after hooks are logged but don't fail the request
160
- */
152
+ /** Errors in after hooks are logged but never fail the request. */
161
153
  async executeAfter(resource, operation, result, options) {
162
154
  try {
163
155
  await this.execute({
@@ -226,17 +218,11 @@ var HookSystem = class {
226
218
  }
227
219
  return result;
228
220
  }
229
- /**
230
- * Get all registered hooks
231
- */
232
221
  getAll() {
233
222
  const all = [];
234
223
  for (const hooks of this.hooks.values()) all.push(...hooks);
235
224
  return all;
236
225
  }
237
- /**
238
- * Get hooks for a specific resource
239
- */
240
226
  getForResource(resource) {
241
227
  const all = [];
242
228
  for (const [key, hooks] of this.hooks.entries()) if (key.startsWith(`${resource}:`)) all.push(...hooks);
@@ -298,15 +284,9 @@ var HookSystem = class {
298
284
  const key = this.getKey(resource, operation, phase);
299
285
  return (this.hooks.get(key)?.length ?? 0) > 0;
300
286
  }
301
- /**
302
- * Clear all hooks
303
- */
304
287
  clear() {
305
288
  this.hooks.clear();
306
289
  }
307
- /**
308
- * Clear hooks for a specific resource
309
- */
310
290
  clearResource(resource) {
311
291
  for (const key of this.hooks.keys()) if (key.startsWith(`${resource}:`)) this.hooks.delete(key);
312
292
  }
@@ -0,0 +1,257 @@
1
+ import { arcLog } from "./logger/index.mjs";
2
+ import { t as ArcError } from "./errors-DkO8Nk6-.mjs";
3
+ //#region src/permissions/filter-merge.ts
4
+ /**
5
+ * Policy-filter composition — logical AND, never last-writer-wins.
6
+ *
7
+ * Row-level policy filters can arrive from several INDEPENDENT sources for one
8
+ * request: a tenant preset, an `ownedByUser` grant, an `allOf(...)` branch, a
9
+ * host permission check. Every one of them is a RESTRICTION the row must
10
+ * satisfy, so composing them is logical AND.
11
+ *
12
+ * The historical `{ ...base, ...incoming }` shallow spread did NOT implement AND:
13
+ * when two sources constrained the SAME key with different values, the later one
14
+ * silently REPLACED the earlier one — widening (or erasing) a restriction another
15
+ * layer deliberately imposed. That is a defense-in-depth defect, not a
16
+ * refinement. "Later can refine" is only sound when the refinement is provably
17
+ * narrower, and a bare overwrite proves nothing.
18
+ *
19
+ * {@link conjoinPolicyFilters} composes with AND semantics while preserving the
20
+ * common case byte-for-byte:
21
+ *
22
+ * - key present on only one side → carried through flat. Non-overlapping
23
+ * filters (the ~99% case) produce exactly the same object the old spread did,
24
+ * so there is ZERO behavior change for them.
25
+ * - same key, deep-equal value → kept once (idempotent — e.g. two checks
26
+ * both pinning the same `organizationId`).
27
+ * - same key, DIFFERENT value → BOTH constraints are preserved under an
28
+ * `$and`, so a row must satisfy both. For flat equality that conjunction is
29
+ * unsatisfiable, so the query returns nothing — the safe, correct outcome for
30
+ * contradictory policies (deny), and the earlier restriction is never dropped.
31
+ *
32
+ * Adapter safety: mongokit / sqlitekit compile `$and` natively at the DB layer,
33
+ * and arc's in-memory `simpleEqualityMatcher` (AccessControl.checkPolicyFilters)
34
+ * already fail-closes on operator-shaped filters. So a produced `$and` is either
35
+ * evaluated correctly by the DB or rejected fail-closed in memory — both safe.
36
+ *
37
+ * This is the pragmatic, adapter-neutral form of the AND semantics. A canonical
38
+ * filter IR in `repo-core` (nested and/or/not nodes each adapter compiles) is the
39
+ * fuller design, but this closes the security defect without one.
40
+ */
41
+ /**
42
+ * Validate + return a `$and` operand's fragment array. A security-policy
43
+ * normalizer must NEVER silently drop a malformed constraint — a non-array
44
+ * `$and` (e.g. `{ $and: { organizationId: "a" } }`) is a policy bug, so fail
45
+ * loud (fail-closed) rather than discard the restriction it was meant to carry.
46
+ */
47
+ function coercePolicyAnd(value) {
48
+ if (!Array.isArray(value)) throw new Error(`conjoinPolicyFilters: malformed '$and' — expected an array of filter fragments, got ${value === null ? "null" : typeof value}. A policy filter constraint must not be silently dropped.`);
49
+ return value;
50
+ }
51
+ /** Are two flat policy-filter values equivalent for conjunction purposes? */
52
+ function filterValueEqual(a, b) {
53
+ if (Object.is(a, b)) return true;
54
+ if (a !== null && b !== null && typeof a === "object" && typeof b === "object") try {
55
+ return JSON.stringify(a) === JSON.stringify(b);
56
+ } catch {
57
+ return false;
58
+ }
59
+ return false;
60
+ }
61
+ /**
62
+ * Conjoin two policy-filter maps with AND semantics. Never silently overwrites a
63
+ * same-key constraint with a different value — conflicting keys are preserved
64
+ * under `$and`. See the module doc for the full contract.
65
+ */
66
+ function conjoinPolicyFilters(base, incoming) {
67
+ const baseEmpty = !base || Object.keys(base).length === 0;
68
+ const incomingEmpty = !incoming || Object.keys(incoming).length === 0;
69
+ if (baseEmpty) return incomingEmpty ? {} : { ...incoming };
70
+ if (incomingEmpty) return { ...base };
71
+ const result = {};
72
+ const andParts = [];
73
+ const baseAnd = base.$and;
74
+ if (baseAnd !== void 0) andParts.push(...coercePolicyAnd(baseAnd));
75
+ for (const [key, value] of Object.entries(base)) {
76
+ if (key === "$and") continue;
77
+ result[key] = value;
78
+ }
79
+ for (const [key, value] of Object.entries(incoming)) {
80
+ if (key === "$and") {
81
+ andParts.push(...coercePolicyAnd(value));
82
+ continue;
83
+ }
84
+ if (!(key in result)) {
85
+ result[key] = value;
86
+ continue;
87
+ }
88
+ if (filterValueEqual(result[key], value)) continue;
89
+ andParts.push({ [key]: result[key] }, { [key]: value });
90
+ delete result[key];
91
+ }
92
+ if (andParts.length > 0) result.$and = andParts;
93
+ return result;
94
+ }
95
+ //#endregion
96
+ //#region src/permissions/applyPermissionResult.ts
97
+ const log = arcLog("permissions:enforce");
98
+ /**
99
+ * Normalize a check return into the canonical {@link AuthorizationDecision}.
100
+ * A boolean promotes to `{ effect: "allow" | "deny" }`; a decision passes
101
+ * through. This is the ONE place booleans are promoted, so the rest of the
102
+ * pipeline only ever handles decisions.
103
+ */
104
+ function normalizeToDecision(result) {
105
+ return typeof result === "boolean" ? { effect: result ? "allow" : "deny" } : result;
106
+ }
107
+ /**
108
+ * Run a permission check once and classify the result. The single source of the
109
+ * try/normalize/catch sequence — see {@link PermissionOutcome}. Non-`ArcError`
110
+ * throws are logged once here through arc's namespaced logger (a deliberate
111
+ * `ArcError` denial is expected control-flow, not a warning). Pure w.r.t. the
112
+ * request — neither reads a reply nor mutates the request.
113
+ */
114
+ async function evaluatePermissionOutcome(check, context) {
115
+ try {
116
+ return {
117
+ status: "decided",
118
+ decision: normalizeToDecision(await check(context))
119
+ };
120
+ } catch (err) {
121
+ if (err instanceof ArcError) return {
122
+ status: "structured",
123
+ error: err
124
+ };
125
+ log.warn({
126
+ err,
127
+ resource: context.resource,
128
+ action: context.action
129
+ }, "Permission check threw — denying (fail-closed)");
130
+ return {
131
+ status: "failed",
132
+ error: err
133
+ };
134
+ }
135
+ }
136
+ /**
137
+ * Run a permission check and return a canonical {@link AuthorizationDecision} —
138
+ * the transport-neutral PDP (HTTP, aggregation, MCP, and any future transport).
139
+ * A thin mapper over {@link evaluatePermissionOutcome}:
140
+ *
141
+ * - a structured `ArcError` is RE-THROWN so the transport surfaces it verbatim
142
+ * (e.g. a tier gate throwing `arc.tier_required` with machine-readable meta);
143
+ * - any OTHER throw is fail-closed to a `deny` decision (a broken check must
144
+ * never fall open to a 500 that skips authorization).
145
+ *
146
+ * Pure w.r.t. the request — it neither reads a reply nor mutates the request.
147
+ * Callers translate the returned decision into their own response mechanism and
148
+ * apply side-effects via {@link applyAuthorizationDecision}. HTTP additionally
149
+ * hardens a *thrown* check to 403-even-when-anonymous — that transport-specific
150
+ * nuance lives in {@link evaluateAndApplyPermission}, not here.
151
+ */
152
+ async function evaluatePermissionDecision(check, context) {
153
+ const outcome = await evaluatePermissionOutcome(check, context);
154
+ switch (outcome.status) {
155
+ case "decided": return outcome.decision;
156
+ case "structured": throw outcome.error;
157
+ case "failed": return { effect: "deny" };
158
+ }
159
+ }
160
+ /**
161
+ * Apply an allowed {@link AuthorizationDecision} to a Fastify request — conjoins
162
+ * the decision's row-level `policy` into `_policyFilters` and conditionally
163
+ * installs the `scope`.
164
+ *
165
+ * **Scope install rule:** only writes `scope` when the current request scope
166
+ * is absent or `public`. This prevents downgrading an already-authenticated
167
+ * request (e.g. Better Auth set `member`, then a permission check returns a
168
+ * narrower `service` scope — the original `member` wins because it came from
169
+ * a more authoritative source).
170
+ *
171
+ * Safe to call with a denied decision — it simply no-ops. Callers should still
172
+ * check `decision.effect` and send an error response before reaching here, but
173
+ * this function tolerates the misuse defensively.
174
+ *
175
+ * This is the enforcement point (PEP) — the ONE place scope + data policy are
176
+ * installed (and the obligation seam lives).
177
+ */
178
+ function applyAuthorizationDecision(decision, request) {
179
+ if (decision.effect !== "allow") return;
180
+ if (decision.policy) request._policyFilters = conjoinPolicyFilters(request._policyFilters, decision.policy);
181
+ if (decision.scope) {
182
+ const current = request.scope;
183
+ if (!current || current.kind === "public") request.scope = decision.scope;
184
+ }
185
+ }
186
+ /**
187
+ * @deprecated Renamed to {@link applyAuthorizationDecision} — the contract is an
188
+ * `AuthorizationDecision`, not a `PermissionResult`. This alias is kept for
189
+ * back-compat and will be removed in a future major.
190
+ */
191
+ const applyPermissionResult = applyAuthorizationDecision;
192
+ /**
193
+ * Max length of a decision `reason` string before we fall back to the
194
+ * generic default message. Upstream checks can return arbitrary strings; we
195
+ * clamp to prevent accidental leakage of internal diagnostics or oversized
196
+ * payloads via the 4xx response body.
197
+ */
198
+ const MAX_DENIAL_REASON_LENGTH = 100;
199
+ /**
200
+ * End-to-end HTTP PEP: evaluates the check through the shared
201
+ * {@link evaluatePermissionOutcome} core, sends a 401/403 response on denial,
202
+ * and applies side-effects on grant. Returns `true` if the caller should
203
+ * continue, `false` if a response has been sent and the caller should return.
204
+ *
205
+ * The HTTP specialization of the ONE evaluation core — it does NOT re-run or
206
+ * re-normalize the check; it only maps the classified outcome onto Fastify:
207
+ *
208
+ * - `structured` (thrown `ArcError`) → surface verbatim, byte-compatible with
209
+ * the global error handler (`code`/`message`/`status` + `meta`);
210
+ * - `failed` (other throw) → 403 EVEN WHEN ANONYMOUS (a broken check is a hard
211
+ * denial, not a "please authenticate");
212
+ * - `decided` → allow, or a returned deny → 401 (no user) / 403 (user) with a
213
+ * clamped `reason`.
214
+ *
215
+ * Context construction, pre-check auth gating, and success-path handler
216
+ * invocation stay at the callsite — those are genuinely different per router
217
+ * and don't belong here.
218
+ *
219
+ * @returns `true` if authorized (caller continues), `false` if a response was sent
220
+ */
221
+ async function evaluateAndApplyPermission(check, context, request, reply, opts) {
222
+ const outcome = await evaluatePermissionOutcome(check, context);
223
+ if (outcome.status === "structured") {
224
+ const err = outcome.error;
225
+ reply.code(err.status).send({
226
+ code: err.code,
227
+ message: err.message,
228
+ status: err.status,
229
+ ...err.meta ? { meta: err.meta } : {}
230
+ });
231
+ return false;
232
+ }
233
+ if (outcome.status === "failed") {
234
+ reply.code(403).send({
235
+ code: "arc.forbidden",
236
+ message: "Permission denied",
237
+ status: 403
238
+ });
239
+ return false;
240
+ }
241
+ const { decision } = outcome;
242
+ if (decision.effect !== "allow") {
243
+ const defaultMsg = opts?.defaultDenialMessage?.(context.user) ?? (context.user ? "Permission denied" : "Authentication required");
244
+ const reason = decision.reason && decision.reason.length <= MAX_DENIAL_REASON_LENGTH ? decision.reason : defaultMsg;
245
+ const status = context.user ? 403 : 401;
246
+ reply.code(status).send({
247
+ code: context.user ? "arc.forbidden" : "arc.unauthorized",
248
+ message: reason,
249
+ status
250
+ });
251
+ return false;
252
+ }
253
+ applyAuthorizationDecision(decision, request);
254
+ return true;
255
+ }
256
+ //#endregion
257
+ export { normalizeToDecision as a, evaluatePermissionDecision as i, applyPermissionResult as n, conjoinPolicyFilters as o, evaluateAndApplyPermission as r, applyAuthorizationDecision as t };
@@ -1,5 +1,5 @@
1
- import "../index-DPRH0Cme.mjs";
2
- import { i as UserBase } from "../types-Dmw-6Qeo.mjs";
1
+ import "../index-Q5gzEIWY.mjs";
2
+ import { a as UserBase } from "../types-ChvvVNP8.mjs";
3
3
  import { FastifyPluginAsync } from "fastify";
4
4
  import { RepositoryLike } from "@classytic/repo-core/adapter";
5
5
  //#region src/audit/stores/interface.d.ts
@@ -1,6 +1,6 @@
1
1
  import { t as transactionContext } from "../transactionContext-PNdoyLLm.mjs";
2
- import fp from "fastify-plugin";
3
2
  import { and, anyOf, eq, gte, lt, lte } from "@classytic/repo-core/filter";
3
+ import fp from "fastify-plugin";
4
4
  //#region src/audit/repository-audit-adapter.ts
5
5
  function repositoryAsAuditStore(repository, options = {}) {
6
6
  const idField = repository.idField ?? "_id";
@@ -1,5 +1,5 @@
1
- import { Ht as AuthHelpers, Ut as AuthPluginOptions } from "../index-DPRH0Cme.mjs";
2
- import { t as PermissionCheck } from "../types-Dmw-6Qeo.mjs";
1
+ import { Ht as AuthHelpers, Ut as AuthPluginOptions } from "../index-Q5gzEIWY.mjs";
2
+ import { r as PermissionCheck } from "../types-ChvvVNP8.mjs";
3
3
  import { t as ExternalOpenApiPaths } from "../externalPaths-BE6wFpYv.mjs";
4
4
  import { a as SessionManagerOptions, c as createSessionManager, i as SessionData, n as MemorySessionStoreOptions, o as SessionManagerResult, r as SessionCookieOptions, s as SessionStore, t as MemorySessionStore } from "../sessionManager-BHOM3N_0.mjs";
5
5
  import { FastifyPluginAsync, FastifyReply as FastifyReply$1, FastifyRequest } from "fastify";
@@ -2,7 +2,7 @@ import { d as createDomainError, l as UnauthorizedError, p as isArcError, t as A
2
2
  import { n as requireSingleHeaderValue } from "../headers-BvlQSDMB.mjs";
3
3
  import { n as getUserRoles, r as normalizeRoles } from "../userHelpers-Cg_y3fBz.mjs";
4
4
  import { o as toJsonSchema } from "../schemaConverter-DJvodiHj.mjs";
5
- import { _ as requireOrgMembership, v as requireOrgRole, x as requireTeamMembership } from "../permissions-BrtiBKGs.mjs";
5
+ import { S as requireOrgRole, T as requireTeamMembership, x as requireOrgMembership } from "../permissions-Bfu_8y1U.mjs";
6
6
  import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
7
7
  import fp from "fastify-plugin";
8
8
  //#region src/auth/authPlugin.ts
@@ -1,3 +1,5 @@
1
+ import { t as toRepositoryFilter } from "./repositoryFilter-Dz8k3S4P.mjs";
2
+ import { o as conjoinPolicyFilters } from "./applyPermissionResult-Cf6zUCvb.mjs";
1
3
  import { i as compileAggRequest, t as adapterSupportsAggregate } from "./validate-Bk44F2yT.mjs";
2
4
  //#region src/core/aggregation/buildHandler.ts
3
5
  /**
@@ -18,13 +20,13 @@ async function executeAggregation(normalized, deps, ctx) {
18
20
  const { repo } = deps;
19
21
  const config = normalized.base;
20
22
  const aggregationName = normalized.name;
21
- const { query, tenantOptions } = ctx;
23
+ const { query, tenantOptions, policyFilter } = ctx;
22
24
  const guardError = checkRequestGuards(query, config);
23
25
  if (guardError) return {
24
26
  status: 400,
25
27
  body: guardError
26
28
  };
27
- const aggReq = compileAggRequest(normalized, extractCallerFilter(query), tenantOptions, query);
29
+ const aggReq = compileAggRequest(normalized, conjoinPolicyFilters(extractCallerFilter(query), policyFilter), tenantOptions, query);
28
30
  if (config.materialized) {
29
31
  const matCtx = {
30
32
  filter: aggReq.filter,
@@ -51,9 +53,13 @@ async function executeAggregation(normalized, deps, ctx) {
51
53
  }
52
54
  }
53
55
  };
56
+ const aggReqForRepo = aggReq.filter && typeof aggReq.filter === "object" ? {
57
+ ...aggReq,
58
+ filter: toRepositoryFilter(aggReq.filter)
59
+ } : aggReq;
54
60
  let result;
55
61
  try {
56
- result = await repo.aggregate(aggReq, tenantOptions);
62
+ result = await repo.aggregate(aggReqForRepo, tenantOptions);
57
63
  } catch (err) {
58
64
  return mapAggregateError(err, aggregationName);
59
65
  }
@@ -95,9 +101,13 @@ async function executeAggregation(normalized, deps, ctx) {
95
101
  function buildAggregationHandler(normalized, deps) {
96
102
  const { buildOptions } = deps;
97
103
  return async (request, reply) => {
104
+ const query = request.query ?? {};
105
+ const tenantOptions = buildOptions(request);
106
+ const policyFilter = request._policyFilters;
98
107
  const result = await executeAggregation(normalized, deps, {
99
- query: request.query ?? {},
100
- tenantOptions: buildOptions(request)
108
+ query,
109
+ tenantOptions,
110
+ policyFilter
101
111
  });
102
112
  reply.status(result.status);
103
113
  if (result.headers) for (const [k, v] of Object.entries(result.headers)) reply.header(k, v);
@@ -199,6 +209,20 @@ const SHORTHAND_RANGE_OPS = /* @__PURE__ */ new Set([
199
209
  "lt",
200
210
  "lte"
201
211
  ]);
212
+ /**
213
+ * Operators whose operand MUST be a list. A URL carries `priority[in]=high,urgent`
214
+ * as ONE string, and `$in` against a non-array is invalid — MongoDB rejects it,
215
+ * and the portable Filter IR would spread the string into characters. Split it
216
+ * the same way `ArcQueryParser` does for the CRUD list route, which is the
217
+ * contract this expansion claims to match.
218
+ */
219
+ const SHORTHAND_LIST_OPS = /* @__PURE__ */ new Set(["in", "nin"]);
220
+ /** Normalize a list-operator operand to an array (URL string → split on commas). */
221
+ function toOperandList(value) {
222
+ if (Array.isArray(value)) return value;
223
+ if (typeof value === "string") return value.split(",").map((v) => v.trim()).filter((v) => v.length > 0);
224
+ return [value];
225
+ }
202
226
  const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
203
227
  function tryCoerceDate(v) {
204
228
  if (typeof v !== "string" || !ISO_DATE_RE.test(v)) return v;
@@ -222,7 +246,9 @@ function expandShorthandOperators(value) {
222
246
  for (const [op, opVal] of Object.entries(nested)) {
223
247
  const mongoOp = OPERATOR_SHORTHAND[op];
224
248
  if (!mongoOp) continue;
225
- expanded[mongoOp] = SHORTHAND_RANGE_OPS.has(op) ? tryCoerceDate(opVal) : opVal;
249
+ if (SHORTHAND_RANGE_OPS.has(op)) expanded[mongoOp] = tryCoerceDate(opVal);
250
+ else if (SHORTHAND_LIST_OPS.has(op)) expanded[mongoOp] = toOperandList(opVal);
251
+ else expanded[mongoOp] = opVal;
226
252
  }
227
253
  return expanded;
228
254
  }