@classytic/arc 2.25.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 (92) hide show
  1. package/README.md +2 -0
  2. package/dist/{BaseController--M8EMzt0.mjs → BaseController-B5DwufbB.mjs} +15 -11
  3. package/dist/EventTransport-C6YA8xY9.mjs +103 -0
  4. package/dist/{HookSystem-C1vB_Gc9.mjs → HookSystem-WkIougo6.mjs} +15 -35
  5. package/dist/applyPermissionResult-Cf6zUCvb.mjs +257 -0
  6. package/dist/audit/index.d.mts +2 -2
  7. package/dist/audit/index.mjs +1 -1
  8. package/dist/auth/index.d.mts +2 -2
  9. package/dist/auth/index.mjs +1 -1
  10. package/dist/{buildHandler-Dtwd8fdz.mjs → buildHandler-BYS3Vkfz.mjs} +32 -6
  11. package/dist/cleanup/index.d.mts +313 -0
  12. package/dist/cleanup/index.mjs +1267 -0
  13. package/dist/cli/commands/describe.d.mts +1 -1
  14. package/dist/cli/commands/docs.mjs +1 -1
  15. package/dist/core/index.d.mts +2 -2
  16. package/dist/core/index.mjs +5 -3
  17. package/dist/core-poalJLvp.mjs +175 -0
  18. package/dist/{createActionRouter-B6GvAuAX.mjs → createActionRouter-BxuywVRB.mjs} +2 -2
  19. package/dist/{createAggregationRouter-wzwFa_dW.mjs → createAggregationRouter-BiY-lGdY.mjs} +7 -20
  20. package/dist/{preloadResources-DfyngIN9.mjs → createApp-CXrKFciH.mjs} +25 -76
  21. package/dist/defineAggregation-ufWjUwml.mjs +47 -0
  22. package/dist/{core-EUNu0u07.mjs → defineResource-CxWqDCAy.mjs} +36 -261
  23. package/dist/docs/index.d.mts +1 -1
  24. package/dist/docs/index.mjs +1 -1
  25. package/dist/{eventPlugin-DrQ_FesK.mjs → eventPlugin-D2NNR4py.mjs} +2 -103
  26. package/dist/events/index.d.mts +28 -2
  27. package/dist/events/index.mjs +24 -343
  28. package/dist/factory/index.d.mts +4 -3
  29. package/dist/factory/index.mjs +3 -1
  30. package/dist/{fastify-augmentation-dPNBVeIj.d.mts → fastify-augmentation-0WhPnUH7.d.mts} +3 -2
  31. package/dist/{applyPermissionResult-Tw4iXtlf.mjs → fields-CQ_7KQWw.mjs} +1 -97
  32. package/dist/hooks/index.d.mts +1 -1
  33. package/dist/hooks/index.mjs +1 -1
  34. package/dist/idempotency/index.mjs +1 -1
  35. package/dist/index-CCzVR-N3.d.mts +107 -0
  36. package/dist/{index-BMnbzvtO.d.mts → index-Ch16aAah.d.mts} +201 -21
  37. package/dist/{index-f4PTrk3B.d.mts → index-Cx7bro4R.d.mts} +4 -4
  38. package/dist/{index-DPRH0Cme.d.mts → index-Q5gzEIWY.d.mts} +97 -66
  39. package/dist/index.d.mts +6 -6
  40. package/dist/index.mjs +8 -6
  41. package/dist/integrations/index.d.mts +1 -1
  42. package/dist/integrations/mcp/index.d.mts +6 -6
  43. package/dist/integrations/mcp/index.mjs +1 -1
  44. package/dist/integrations/mcp/testing.d.mts +1 -1
  45. package/dist/integrations/mcp/testing.mjs +1 -1
  46. package/dist/{mcpPlugin-Cb7olw0j.mjs → mcpPlugin-nRzm5g2s.mjs} +36 -43
  47. package/dist/middleware/index.d.mts +1 -1
  48. package/dist/migrations/index.d.mts +0 -3
  49. package/dist/migrations/index.mjs +0 -3
  50. package/dist/module-_-M7C5ES.mjs +29 -0
  51. package/dist/{openapi-DrLtog_B.mjs → openapi-B8Vkbxbn.mjs} +1 -1
  52. package/dist/permissions/index.d.mts +3 -3
  53. package/dist/permissions/index.mjs +4 -3
  54. package/dist/{permissions-BrtiBKGs.mjs → permissions-Bfu_8y1U.mjs} +318 -289
  55. package/dist/pipeline/index.d.mts +1 -1
  56. package/dist/{pipeline-BN5G05rd.mjs → pipeline-BJnpkY9i.mjs} +4 -18
  57. package/dist/plugins/index.d.mts +4 -4
  58. package/dist/plugins/index.mjs +2 -2
  59. package/dist/plugins/tracing-entry.mjs +1 -1
  60. package/dist/{preloadResources-0oe-JSSS.d.mts → preloadResources-DCfa2egz.d.mts} +3 -2
  61. package/dist/preloadResources-DSY5HGpS.mjs +45 -0
  62. package/dist/presets/filesUpload.d.mts +2 -2
  63. package/dist/presets/filesUpload.mjs +1 -1
  64. package/dist/presets/index.d.mts +14 -2
  65. package/dist/presets/index.mjs +1 -1
  66. package/dist/presets/multiTenant.d.mts +1 -1
  67. package/dist/presets/search.d.mts +2 -2
  68. package/dist/presets/search.mjs +1 -1
  69. package/dist/{presets-DwFDVKP0.mjs → presets-CJOEXLuk.mjs} +25 -9
  70. package/dist/projection-UBDgfMwu.mjs +17 -0
  71. package/dist/{realtime-Cuw_deA-.mjs → realtime-B9nmIY5V.mjs} +2 -1
  72. package/dist/{registerRoute-v0Wb6Plt.mjs → registerRoute-JkxIEBJq.mjs} +3 -1
  73. package/dist/registry/index.d.mts +2 -2
  74. package/dist/registry/index.mjs +1 -1
  75. package/dist/{registry-vcOCtPij.mjs → registry-BYXUUg21.mjs} +16 -1
  76. package/dist/repositoryFilter-Dz8k3S4P.mjs +44 -0
  77. package/dist/subscribe-helpers-DfzsivM6.mjs +339 -0
  78. package/dist/testing/cleanupStoreContract.d.mts +10 -0
  79. package/dist/testing/cleanupStoreContract.mjs +375 -0
  80. package/dist/testing/index.d.mts +34 -5
  81. package/dist/testing/index.mjs +316 -211
  82. package/dist/testing/outboxStoreContract.d.mts +13 -0
  83. package/dist/testing/outboxStoreContract.mjs +247 -0
  84. package/dist/types/index.d.mts +3 -3
  85. package/dist/{types-QFGc3Cjv.d.mts → types-Bmmn-fO0.d.mts} +1 -1
  86. package/dist/types-C_ZZRcD6.d.mts +450 -0
  87. package/dist/{types-Dmw-6Qeo.d.mts → types-ChvvVNP8.d.mts} +93 -88
  88. package/dist/{index-KUoxjIq1.d.mts → types-D_n8L6VY.d.mts} +33 -106
  89. package/dist/usage/index.d.mts +1 -1
  90. package/dist/usage/index.mjs +0 -0
  91. package/dist/utils/index.d.mts +2 -5
  92. package/package.json +539 -524
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # @classytic/arc
2
2
 
3
+ [![Sponsor](https://img.shields.io/github/sponsors/classytic?style=flat-square&label=Sponsor&logo=GitHub&color=EA4AAA)](https://github.com/sponsors/classytic)
4
+
3
5
  Database-agnostic resource framework for Fastify. One `defineResource()` call -> REST + auth + permissions + events + caching + OpenAPI + MCP tools.
4
6
 
5
7
  Fastify 5+ | Node.js 22+ | ESM only
@@ -1,10 +1,12 @@
1
1
  import { h as SYSTEM_FIELDS, o as DEFAULT_TENANT_FIELD } from "./constants-DNre0wRg.mjs";
2
2
  import { arcLog } from "./logger/index.mjs";
3
- import { d as createDomainError, f as createError, i as NotFoundError, r as ForbiddenError } from "./errors-DkO8Nk6-.mjs";
3
+ import { d as createDomainError, f as createError, i as NotFoundError, p as isArcError, r as ForbiddenError } from "./errors-DkO8Nk6-.mjs";
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-Tw4iXtlf.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
  /**
@@ -453,6 +455,7 @@ async function executeHookedOp(ctx, args, executor) {
453
455
  const beforeReturn = await hooks.executeBefore(resourceName, args.op, args.input, hookOpts);
454
456
  if (pipeProcessed) processedData = beforeReturn;
455
457
  } catch (err) {
458
+ if (isArcError(err)) throw err;
456
459
  throw createError(400, "Hook execution failed", {
457
460
  code: `BEFORE_${args.op.toUpperCase()}_HOOK_ERROR`,
458
461
  message: err.message
@@ -669,12 +672,12 @@ var QueryResolver = class {
669
672
  const page = parsed.after ? void 0 : parsed.page ? Math.max(1, parsed.page) : 1;
670
673
  const sortString = parsed.sort ? Object.entries(parsed.sort).map(([k, v]) => v === -1 ? `-${k}` : k).join(",") : this.defaultSort;
671
674
  const rawSelect = parsed.select ?? req.query?.select;
672
- const filters = { ...parsed.filters };
673
675
  const policyFilters = arcContext?._policyFilters;
674
- if (policyFilters) Object.assign(filters, policyFilters);
676
+ let filters = conjoinPolicyFilters(parsed.filters, policyFilters);
675
677
  const scope = arcContext?._scope;
676
678
  const orgId = scope ? getOrgId(scope) : void 0;
677
- 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);
678
681
  return {
679
682
  page,
680
683
  limit,
@@ -683,7 +686,7 @@ var QueryResolver = class {
683
686
  populate: this.sanitizePopulate(parsed.populate, this.schemaOptions),
684
687
  populateOptions: this.sanitizePopulateOptions(parsed.populateOptions, this.schemaOptions),
685
688
  lookups: this.sanitizeLookups(parsed.lookups, this.schemaOptions),
686
- filters,
689
+ filters: portableFilters,
687
690
  search: parsed.search,
688
691
  after: parsed.after,
689
692
  user: req.user,
@@ -799,7 +802,7 @@ var BaseCrudController = class {
799
802
  /**
800
803
  * Composable access control (ID filtering, policy checks, org scope, ownership).
801
804
  *
802
- * Not `readonly` since 2.15.0 — `configure()` rebuilds it when the host
805
+ * Not `readonly` — `configure()` rebuilds it when the host
803
806
  * supplies tenant/idField/matchesFilter post-construction. Same model as
804
807
  * `queryResolver` after `setQueryParser` shipped in 2.10.9.
805
808
  */
@@ -807,7 +810,7 @@ var BaseCrudController = class {
807
810
  /**
808
811
  * Composable body sanitization (field permissions, system fields).
809
812
  *
810
- * Not `readonly` since 2.15.0 — `configure()` rebuilds it when the host
813
+ * Not `readonly` — `configure()` rebuilds it when the host
811
814
  * supplies schemaOptions/onFieldWriteDenied post-construction.
812
815
  */
813
816
  bodySanitizer;
@@ -1683,6 +1686,7 @@ function SoftDeleteMixin(Base) {
1683
1686
  meta: { id }
1684
1687
  });
1685
1688
  } catch (err) {
1689
+ if (isArcError(err)) throw err;
1686
1690
  throw createError(400, "Hook execution failed", {
1687
1691
  code: "BEFORE_RESTORE_HOOK_ERROR",
1688
1692
  message: err.message
@@ -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