@classytic/arc 2.19.0 → 2.21.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 (139) hide show
  1. package/README.md +1 -1
  2. package/bin/arc.js +37 -0
  3. package/dist/{BaseController-Dy6EQHjM.mjs → BaseController-BnLYgE9A.mjs} +4 -4
  4. package/dist/{EventTransport-IEn7Tn-Q.d.mts → EventTransport-QmY7A258.d.mts} +0 -1
  5. package/dist/{QueryCache-TRfXau3W.d.mts → QueryCache-Z1B38koh.d.mts} +0 -1
  6. package/dist/{ResourceRegistry-5fKZwhK_.mjs → ResourceRegistry-7RnrEWNP.mjs} +1 -1
  7. package/dist/{actionPermissions-D7NGwcAR.mjs → actionPermissions-ze70xXJi.mjs} +1 -1
  8. package/dist/audit/index.d.mts +9 -5
  9. package/dist/audit/index.mjs +1 -1
  10. package/dist/auth/audit.d.mts +7 -4
  11. package/dist/auth/audit.mjs +5 -0
  12. package/dist/auth/index.d.mts +7 -14
  13. package/dist/auth/index.mjs +7 -5
  14. package/dist/auth/redis-session.d.mts +1 -2
  15. package/dist/{buildHandler-B0qvAguf.mjs → buildHandler-D_2ULWxZ.mjs} +3 -3
  16. package/dist/cache/index.d.mts +2 -3
  17. package/dist/cache/index.mjs +2 -2
  18. package/dist/cli/commands/describe.d.mts +1 -2
  19. package/dist/cli/commands/describe.mjs +2 -2
  20. package/dist/cli/commands/docs.mjs +2 -2
  21. package/dist/cli/commands/generate.mjs +1 -1
  22. package/dist/cli/commands/init.mjs +1 -1
  23. package/dist/cli/commands/introspect.mjs +4 -13
  24. package/dist/cli/index.mjs +2 -2
  25. package/dist/{constants-gaEeMlz-.mjs → constants-DNre0wRg.mjs} +1 -1
  26. package/dist/context/index.d.mts +2 -2
  27. package/dist/core/index.d.mts +3 -3
  28. package/dist/core/index.mjs +5 -5
  29. package/dist/{core-Ba-6Sue6.mjs → core-DZ6Xe1jR.mjs} +182 -55
  30. package/dist/{createActionRouter-B_WzBFyf.mjs → createActionRouter-BWMDW-dL.mjs} +4 -4
  31. package/dist/{createAggregationRouter-B_G_nIjm.mjs → createAggregationRouter-l5mNdYrZ.mjs} +5 -4
  32. package/dist/{createApp--703hQE1.mjs → createApp-Bi61XxqU.mjs} +229 -24
  33. package/dist/discovery/index.d.mts +0 -1
  34. package/dist/docs/index.d.mts +1 -2
  35. package/dist/docs/index.mjs +1 -1
  36. package/dist/{elevation-xGUBq6p_.d.mts → elevation-C9s5hFP_.d.mts} +0 -1
  37. package/dist/encryption/index.d.mts +2 -144
  38. package/dist/{errorHandler-BtUUDPEa.d.mts → errorHandler-B0e-bq-O.d.mts} +0 -1
  39. package/dist/{errors-DOHkjQlD.d.mts → errors-DHcHVAgk.d.mts} +0 -1
  40. package/dist/{eventPlugin-DbBFd3x_.d.mts → eventPlugin-A6EbthXR.d.mts} +10 -6
  41. package/dist/events/index.d.mts +9 -6
  42. package/dist/events/index.mjs +9 -6
  43. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  44. package/dist/events/transports/redis.d.mts +1 -2
  45. package/dist/factory/index.d.mts +5 -4
  46. package/dist/factory/index.mjs +3 -3
  47. package/dist/{fastify-augmentation-D-5_yQJi.d.mts → fastify-augmentation-DafigGQp.d.mts} +12 -2
  48. package/dist/{fields-C7ahdGYf.d.mts → fields-BGqrAa_u.d.mts} +1 -2
  49. package/dist/{generate-Brans3fW.mjs → generate-usRSdQmP.mjs} +4 -3
  50. package/dist/hooks/index.d.mts +1 -1
  51. package/dist/idempotency/index.d.mts +4 -3
  52. package/dist/idempotency/index.mjs +1 -1
  53. package/dist/idempotency/redis.d.mts +1 -1
  54. package/dist/{index-CP6A1Ffj.d.mts → index-B5N-NaHs.d.mts} +88 -7
  55. package/dist/{index-CRKiiP5o.d.mts → index-B5PoNbfu.d.mts} +41 -7
  56. package/dist/{index-CUTG2Cuo.d.mts → index-Dtrj6XZk.d.mts} +90 -29
  57. package/dist/index.d.mts +7 -7
  58. package/dist/index.mjs +7 -7
  59. package/dist/{init-Bqpt1Hxf.mjs → init-0YhHkXGk.mjs} +239 -140
  60. package/dist/integrations/event-gateway.d.mts +2 -3
  61. package/dist/integrations/index.d.mts +2 -2
  62. package/dist/integrations/jobs.d.mts +0 -1
  63. package/dist/integrations/mcp/index.d.mts +6 -7
  64. package/dist/integrations/mcp/index.mjs +1 -1
  65. package/dist/integrations/mcp/testing.d.mts +1 -2
  66. package/dist/integrations/mcp/testing.mjs +1 -1
  67. package/dist/integrations/streamline.d.mts +19 -7
  68. package/dist/integrations/streamline.mjs +9 -5
  69. package/dist/integrations/webhooks.d.mts +0 -1
  70. package/dist/integrations/websocket-pushref-redis.d.mts +1 -2
  71. package/dist/integrations/websocket-redis.d.mts +1 -2
  72. package/dist/integrations/websocket.d.mts +2 -2
  73. package/dist/{loadResources-B8UmpITt.mjs → loadResources-DpU8pfjV.mjs} +4 -3
  74. package/dist/{loadResourcesFromEntry-BTlFEx05.mjs → loadResourcesFromEntry-OMCvIEE7.mjs} +1 -1
  75. package/dist/{mcpPlugin-C088Nd-G.mjs → mcpPlugin-B9XjjNVG.mjs} +148 -49
  76. package/dist/middleware/index.d.mts +1 -2
  77. package/dist/middleware/index.mjs +1 -1
  78. package/dist/module-WeuU5jfd.d.mts +429 -0
  79. package/dist/{openapi-DZ-5a5Op.mjs → openapi-Kyod_di6.mjs} +3 -3
  80. package/dist/permissions/index.d.mts +3 -3
  81. package/dist/permissions/index.mjs +2 -2
  82. package/dist/{permissions-BPB1xqGf.mjs → permissions-BJMuKMl7.mjs} +43 -3
  83. package/dist/pipeline/index.d.mts +1 -2
  84. package/dist/plugins/index.d.mts +87 -10
  85. package/dist/plugins/index.mjs +91 -4
  86. package/dist/plugins/response-cache.d.mts +5 -3
  87. package/dist/plugins/tracing-entry.d.mts +1 -1
  88. package/dist/plugins/tracing-entry.mjs +1 -1
  89. package/dist/{pluralize-DccFV9R7.mjs → pluralize-BhrgwZb9.mjs} +3 -3
  90. package/dist/presets/filesUpload.d.mts +4 -5
  91. package/dist/presets/filesUpload.mjs +1 -1
  92. package/dist/presets/index.d.mts +16 -3
  93. package/dist/presets/index.mjs +1 -1
  94. package/dist/presets/multiTenant.d.mts +1 -2
  95. package/dist/presets/multiTenant.mjs +1 -1
  96. package/dist/presets/search.d.mts +2 -3
  97. package/dist/presets/search.mjs +3 -2
  98. package/dist/{presets-BtyFwTO4.mjs → presets-gw0ciIUD.mjs} +1 -1
  99. package/dist/{pushref-registry-DnCqL3aa.d.mts → pushref-registry-BbcnpNTm.d.mts} +4 -2
  100. package/dist/{queryCachePlugin-xzFyKAjT.d.mts → queryCachePlugin-CfYWq-Ak.d.mts} +1 -2
  101. package/dist/{queryCachePlugin-emHb0CuX.mjs → queryCachePlugin-DzqLN904.mjs} +2 -2
  102. package/dist/{redis-C3W0qbz8.d.mts → redis-UDtLaNj6.d.mts} +0 -1
  103. package/dist/{redis-stream-DsP1vqZB.d.mts → redis-stream-iVR2l7aS.d.mts} +1 -2
  104. package/dist/{registerRoute-BdV8DDwU.mjs → registerRoute-oGAl0oA8.mjs} +1 -1
  105. package/dist/registry/index.d.mts +2 -2
  106. package/dist/registry/index.mjs +2 -2
  107. package/dist/{registry-Bm9LjWKs.mjs → registry-vcOCtPij.mjs} +1 -1
  108. package/dist/{schemaIR-D1MJwqmp.mjs → schemaIR-ajetw3Fv.mjs} +1 -42
  109. package/dist/schemas/index.d.mts +0 -1
  110. package/dist/scim/index.d.mts +0 -1
  111. package/dist/scim/index.mjs +2 -2
  112. package/dist/scope/index.d.mts +2 -3
  113. package/dist/{sessionManager-BXG-HGwG.d.mts → sessionManager-BHOM3N_0.d.mts} +10 -6
  114. package/dist/{storage-Co_PQpeQ.d.mts → storage-DLUZ04kK.d.mts} +8 -4
  115. package/dist/sync/changelog.d.mts +2 -0
  116. package/dist/sync/changelog.mjs +2 -0
  117. package/dist/testing/index.d.mts +2 -3
  118. package/dist/testing/index.mjs +6 -3
  119. package/dist/testing/storageContract.d.mts +1 -2
  120. package/dist/{tracing-D8jlGXP7.d.mts → tracing-CsGKQJGg.d.mts} +0 -1
  121. package/dist/types/index.d.mts +4 -4
  122. package/dist/types/storage.d.mts +1 -1
  123. package/dist/types-Cc91nXuA.d.mts +132 -0
  124. package/dist/{types-LNebCoJx.d.mts → types-DH0GupI7.d.mts} +8 -4
  125. package/dist/{types-DpvSF5Hh.d.mts → types-DRj-Ut4n.d.mts} +1 -2
  126. package/dist/{types-9_wtNPsI.d.mts → types-DpxoNX5v.d.mts} +112 -214
  127. package/dist/utils/index.d.mts +182 -128
  128. package/dist/utils/index.mjs +2 -2
  129. package/dist/{utils-C6QQ9-hd.mjs → utils-CO8a3B_1.mjs} +1435 -1391
  130. package/dist/{validate-CRcaWzp2.mjs → validate-C2mGgSXg.mjs} +3 -3
  131. package/dist/{versioning-D_eBCfNr.d.mts → versioning-DQ4mCvg0.d.mts} +1 -2
  132. package/dist/{websocket-MlYlrYq0.d.mts → websocket-uuWqM6UY.d.mts} +5 -4
  133. package/package.json +18 -13
  134. package/skills/arc/SKILL.md +10 -0
  135. package/skills/arc/references/mcp.md +2 -0
  136. package/skills/arc-module-publishing/SKILL.md +242 -0
  137. package/skills/arc-module-publishing/references/authoring-modules.md +272 -0
  138. package/skills/arc-module-publishing/references/package-manifest.md +155 -0
  139. package/skills/arc-module-publishing/references/publishing-workflow.md +182 -0
@@ -3,6 +3,134 @@ import { arcLog } from "./logger/index.mjs";
3
3
  import { n as PUBLIC_SCOPE } from "./types-bHblZ9YN.mjs";
4
4
  import Fastify from "fastify";
5
5
  import qs from "qs";
6
+ //#region src/factory/module.ts
7
+ /**
8
+ * Resolve any module-input form to a concrete `ArcModule`. A thunk is invoked
9
+ * (this is where a dynamic `import()` fires); a promise (or plain module) is
10
+ * awaited. Called once per module at the start of the bootstrap phase.
11
+ */
12
+ async function resolveModule(input) {
13
+ const resolved = typeof input === "function" ? await input() : await input;
14
+ if (!resolved || typeof resolved !== "object" || typeof resolved.name !== "string") throw new Error("[arc] a `modules` entry resolved to something that is not an ArcModule (expected an object with a string `name`). Check dynamic-import thunks return `createXModule(deps)` (or the module object), not the namespace.");
15
+ return resolved;
16
+ }
17
+ /**
18
+ * Order modules for composition by their `dependsOn` edges — a STABLE
19
+ * topological sort. Called once at the start of the bootstrap phase; every
20
+ * subsequent phase (bootstrap, resources, afterResources, and reverse-order
21
+ * onClose) iterates the returned list, so a module's declared dependencies are
22
+ * always composed before it.
23
+ *
24
+ * "Stable" = modules with no edge between them keep their original list order,
25
+ * so declaring `dependsOn` on one module never silently reorders an unrelated
26
+ * one. Backward compatible: a `modules` array with NO `dependsOn` anywhere is
27
+ * returned unchanged.
28
+ *
29
+ * Fail-fast — throws (never reorders past a broken contract) on:
30
+ * - duplicate module names (the name is the graph key)
31
+ * - a `dependsOn` name not present in the composed set
32
+ * - a self-reference (`dependsOn` includes the module's own name)
33
+ * - a dependency cycle (reports the concrete `a → b → … → a` path)
34
+ */
35
+ function orderModules(modules) {
36
+ const byName = /* @__PURE__ */ new Map();
37
+ for (const m of modules) {
38
+ if (byName.has(m.name)) throw new Error(`[arc] Duplicate module name "${m.name}" — composed twice; check your modules array.`);
39
+ byName.set(m.name, m);
40
+ }
41
+ for (const m of modules) for (const dep of m.dependsOn ?? []) {
42
+ if (dep === m.name) throw new Error(`[arc] module "${m.name}" dependsOn itself — remove the self-reference.`);
43
+ if (!byName.has(dep)) throw new Error(`[arc] module "${m.name}" dependsOn "${dep}", which is not composed. Add the "${dep}" module to createApp({ modules }) (before this one is fine — arc orders them), or drop the dependency. Composed modules: ${[...byName.keys()].join(", ") || "(none)"}.`);
44
+ }
45
+ if (modules.every((m) => !m.dependsOn || m.dependsOn.length === 0)) return [...modules];
46
+ const originalIndex = /* @__PURE__ */ new Map();
47
+ let nextIndex = 0;
48
+ for (const m of modules) originalIndex.set(m.name, nextIndex++);
49
+ const pendingDeps = new Map(modules.map((m) => [m.name, (m.dependsOn ?? []).length]));
50
+ const dependents = /* @__PURE__ */ new Map();
51
+ for (const m of modules) for (const dep of m.dependsOn ?? []) {
52
+ const list = dependents.get(dep);
53
+ if (list) list.push(m.name);
54
+ else dependents.set(dep, [m.name]);
55
+ }
56
+ const ordered = [];
57
+ const remaining = new Set(modules.map((m) => m.name));
58
+ while (remaining.size > 0) {
59
+ let pick;
60
+ let pickIndex = Number.POSITIVE_INFINITY;
61
+ for (const name of remaining) {
62
+ if (pendingDeps.get(name) !== 0) continue;
63
+ const index = originalIndex.get(name) ?? Number.POSITIVE_INFINITY;
64
+ if (index < pickIndex) {
65
+ pick = name;
66
+ pickIndex = index;
67
+ }
68
+ }
69
+ if (pick === void 0) throw new Error(describeModuleCycle(remaining, byName));
70
+ const picked = byName.get(pick);
71
+ if (!picked) throw new Error(`[arc] internal: ordered module "${pick}" is missing from the index`);
72
+ ordered.push(picked);
73
+ remaining.delete(pick);
74
+ for (const dependent of dependents.get(pick) ?? []) pendingDeps.set(dependent, (pendingDeps.get(dependent) ?? 1) - 1);
75
+ }
76
+ return ordered;
77
+ }
78
+ /** Walk the still-unordered subgraph for one concrete cycle path. */
79
+ function describeModuleCycle(remaining, byName) {
80
+ const stack = [];
81
+ const onStack = /* @__PURE__ */ new Set();
82
+ const done = /* @__PURE__ */ new Set();
83
+ let cycle = null;
84
+ const walk = (name) => {
85
+ if (cycle) return;
86
+ stack.push(name);
87
+ onStack.add(name);
88
+ for (const dep of byName.get(name)?.dependsOn ?? []) {
89
+ if (!remaining.has(dep)) continue;
90
+ if (onStack.has(dep)) {
91
+ cycle = [...stack.slice(stack.indexOf(dep)), dep];
92
+ return;
93
+ }
94
+ if (!done.has(dep)) walk(dep);
95
+ if (cycle) return;
96
+ }
97
+ onStack.delete(name);
98
+ stack.pop();
99
+ done.add(name);
100
+ };
101
+ for (const name of remaining) {
102
+ if (!done.has(name)) walk(name);
103
+ if (cycle) break;
104
+ }
105
+ return `[arc] module dependency cycle: ${cycle ? cycle.join(" → ") : [...remaining].join(", ")}. Modules cannot dependsOn each other circularly — break it with a shared module both point at, or wire the softer direction through an event/port instead of a hard dependsOn.`;
106
+ }
107
+ /**
108
+ * Identity helper for authoring a typed module — mirrors `defineResource`.
109
+ * Gives inference + a single obvious construction site; does no work.
110
+ *
111
+ * `TExports` is inferred from the `bootstrap` return value, so a module
112
+ * author gets a typed public export for free:
113
+ *
114
+ * ```ts
115
+ * export const accountingModule = (deps: Deps) =>
116
+ * defineModule({
117
+ * name: "accounting",
118
+ * bootstrap: async () => createAccountingEngine(deps), // TExports inferred
119
+ * });
120
+ * ```
121
+ */
122
+ function defineModule(module) {
123
+ return module;
124
+ }
125
+ function getModuleExports(fastify, name) {
126
+ const modules = fastify.arc?.modules;
127
+ if (!modules || !Object.hasOwn(modules, name)) {
128
+ const available = modules ? Object.keys(modules) : [];
129
+ throw new Error(`[arc] no public export recorded for module "${name}".\nA module's export is its bootstrap() return value; it is readable only AFTER that module's bootstrap ran (dependsOn / list order = init order).\nModules with recorded exports: ${available.length ? available.join(", ") : "(none)"}.`);
130
+ }
131
+ return modules[name];
132
+ }
133
+ //#endregion
6
134
  //#region src/factory/presets.ts
7
135
  /**
8
136
  * Production preset - strict security, performance optimized
@@ -245,7 +373,7 @@ async function registerArcPlugins(fastify, config, trackPlugin, modules) {
245
373
  trackPlugin("arc-caching", opts);
246
374
  }
247
375
  if (config.arcPlugins?.queryCache) {
248
- const { queryCachePlugin } = await import("./queryCachePlugin-emHb0CuX.mjs").then((n) => n.n);
376
+ const { queryCachePlugin } = await import("./queryCachePlugin-DzqLN904.mjs").then((n) => n.n);
249
377
  const opts = config.arcPlugins.queryCache === true ? {} : config.arcPlugins.queryCache;
250
378
  const store = config.stores?.queryCache ?? new (await (import("./memory-CLXA2VeJ.mjs").then((n) => n.n))).MemoryCacheStore();
251
379
  await fastify.register(queryCachePlugin, {
@@ -344,11 +472,20 @@ async function registerElevation(fastify, config, trackPlugin) {
344
472
  }
345
473
  /**
346
474
  * Register error handler plugin (opt-out).
475
+ *
476
+ * `moduleErrorMappers` — mappers shipped by composed arc modules
477
+ * (`defineModule({ errorMappers })`), appended AFTER host-declared mappers
478
+ * so host config keeps priority. Collected by `createApp` from the
479
+ * pre-resolved module graph.
347
480
  */
348
- async function registerErrorHandler(fastify, config, trackPlugin) {
481
+ async function registerErrorHandler(fastify, config, trackPlugin, moduleErrorMappers) {
349
482
  if (config.errorHandler === false) return;
350
483
  const { errorHandlerPlugin } = await import("./errorHandler-Dxf6P_kV.mjs").then((n) => n.r);
351
- const errorOpts = typeof config.errorHandler === "object" ? config.errorHandler : { includeStack: config.preset !== "production" };
484
+ let errorOpts = typeof config.errorHandler === "object" ? config.errorHandler : { includeStack: config.preset !== "production" };
485
+ if (moduleErrorMappers && moduleErrorMappers.length > 0) errorOpts = {
486
+ ...errorOpts,
487
+ errorMappers: [...errorOpts.errorMappers ?? [], ...moduleErrorMappers]
488
+ };
352
489
  await fastify.register(errorHandlerPlugin, errorOpts);
353
490
  trackPlugin("arc-error-handler", errorOpts);
354
491
  fastify.log.debug("Arc error handler enabled");
@@ -401,17 +538,31 @@ async function registerOne(parent, resource) {
401
538
  }
402
539
  }
403
540
  /**
404
- * Execute the full resource lifecycle:
405
- * 1. plugins() — infra (DB, data, webhooks)
406
- * 2. bootstrap[] — domain init (singletons, event handlers)
407
- * 3. resources factory (if any) — resolved AFTER bootstrap, so engine-backed
541
+ * Execute the full resource lifecycle. The module graph is resolved + ordered
542
+ * by `dependsOn` (stable topological sort) once, up FRONT — before any
543
+ * side-effecting plugin — so a bad graph fails fast with no half-initialised
544
+ * infra. Every phase below iterates that order; each module phase runs BEFORE
545
+ * its app-level counterpart:
546
+ *
547
+ * 1. resolve + orderModules — validate the graph (dup names, missing/self
548
+ * deps, cycles fail fast) BEFORE side effects
549
+ * 2. plugins() — app infra (DB, data, webhooks)
550
+ * 3. module.plugins — module infra, dependsOn order, before bootstraps
551
+ * 4a. module.bootstrap — engine init (return value → arc.modules[name])
552
+ * 4b. bootstrap[] — app-level domain init
553
+ * 5. resources factory (if any) — resolved AFTER bootstrap, so engine-backed
408
554
  * adapters can `await ensureEngine()` and pass
409
555
  * live models/repos into `defineResource(...)`
410
- * 4. resources[] — register each (split by prefix)
411
- * 5. afterResources() — post-registration wiring
412
- * 6. onReady/onClose — lifecycle hooks
556
+ * 5b. module resources — RESOLVED after the app factory, but PREPENDED
557
+ * so they REGISTER before app resources (two
558
+ * orderings, both intentional — see §5b)
559
+ * 6. module.afterResources → afterResources() — post-registration wiring
560
+ * 7. onReady — lifecycle hook
561
+ * 7b. onClose — module.onClose (reverse composition order)
562
+ * THEN app onClose, in ONE hook so module
563
+ * teardown runs before shared infra closes.
413
564
  */
414
- async function registerResources(fastify, config) {
565
+ async function registerResources(fastify, config, preResolvedModules) {
415
566
  if (config.preset === "production") {
416
567
  if (config.strictResources === void 0) config = {
417
568
  ...config,
@@ -422,10 +573,37 @@ async function registerResources(fastify, config) {
422
573
  strictResourceDir: true
423
574
  };
424
575
  }
576
+ const modules = preResolvedModules ?? orderModules(await Promise.all((config.modules ?? []).map((m) => resolveModule(m))));
425
577
  if (config.plugins) {
426
578
  await config.plugins(fastify);
427
579
  fastify.log.debug("Custom plugins registered");
428
580
  }
581
+ for (const m of modules) {
582
+ if (!m.plugins) continue;
583
+ try {
584
+ await m.plugins(fastify);
585
+ } catch (err) {
586
+ const msg = err instanceof Error ? err.message : String(err);
587
+ throw new Error(`[arc] module "${m.name}" plugins() threw: ${msg}`, { cause: err });
588
+ }
589
+ }
590
+ for (const m of modules) {
591
+ if (!m.bootstrap) continue;
592
+ let exported;
593
+ try {
594
+ exported = await m.bootstrap(fastify);
595
+ } catch (err) {
596
+ const msg = err instanceof Error ? err.message : String(err);
597
+ throw new Error(`[arc] module "${m.name}" bootstrap threw: ${msg}`, { cause: err });
598
+ }
599
+ if (exported !== void 0) {
600
+ const arc = fastify.arc;
601
+ if (!arc) throw new Error(`[arc] module "${m.name}" returned a public export but fastify.arc is not decorated — arcCorePlugin must register first.`);
602
+ if (!arc.modules) arc.modules = Object.create(null);
603
+ arc.modules[m.name] = exported;
604
+ }
605
+ }
606
+ if (modules.length) fastify.log.debug(`${modules.length} module(s) composed (in dependency order): ${modules.map((m) => m.name).join(" → ")}`);
429
607
  if (config.bootstrap?.length) {
430
608
  for (const init of config.bootstrap) await init(fastify);
431
609
  fastify.log.debug(`${config.bootstrap.length} bootstrap function(s) executed`);
@@ -448,7 +626,7 @@ async function registerResources(fastify, config) {
448
626
  let discoveryPath;
449
627
  let discoveryYieldedZero = false;
450
628
  if (resolvedResources === void 0 && config.resourceDir) {
451
- const { loadResources } = await import("./loadResources-B8UmpITt.mjs").then((n) => n.n);
629
+ const { loadResources } = await import("./loadResources-DpU8pfjV.mjs").then((n) => n.n);
452
630
  const { resolve, dirname } = await import("node:path");
453
631
  const { fileURLToPath } = await import("node:url");
454
632
  const rawDir = config.resourceDir;
@@ -462,6 +640,20 @@ async function registerResources(fastify, config) {
462
640
  }
463
641
  resolvedResources = discovered;
464
642
  }
643
+ if (modules.length) {
644
+ const moduleResources = [];
645
+ for (const m of modules) {
646
+ if (!m.resources) continue;
647
+ try {
648
+ const rs = typeof m.resources === "function" ? await m.resources(fastify) : m.resources;
649
+ moduleResources.push(...rs);
650
+ } catch (err) {
651
+ const msg = err instanceof Error ? err.message : String(err);
652
+ throw new Error(`[arc] module "${m.name}" resources factory threw: ${msg}. Check the module's bootstrap ran and its adapters received booted engines.`, { cause: err });
653
+ }
654
+ }
655
+ if (moduleResources.length) resolvedResources = [...moduleResources, ...resolvedResources ?? []];
656
+ }
465
657
  if (resolvedResources && resolvedResources.length > 0) {
466
658
  const seen = /* @__PURE__ */ new Set();
467
659
  for (const resource of resolvedResources) if (resource.name) {
@@ -490,6 +682,15 @@ async function registerResources(fastify, config) {
490
682
  const hints = discoveryYieldedZero ? ` but yielded 0 resources. Check the path, file naming (*.resource.{ts,js,mts,mjs}), and runtime layout (src/ vs dist/). Use \`strictResourceDir: true\` to fail boot.` : "";
491
683
  fastify.log.warn(`0 resources registered${prefix}${scanned}${hints}`);
492
684
  }
685
+ for (const m of modules) {
686
+ if (!m.afterResources) continue;
687
+ try {
688
+ await m.afterResources(fastify);
689
+ } catch (err) {
690
+ const msg = err instanceof Error ? err.message : String(err);
691
+ throw new Error(`[arc] module "${m.name}" afterResources() threw: ${msg}`, { cause: err });
692
+ }
693
+ }
493
694
  if (config.afterResources) {
494
695
  await config.afterResources(fastify);
495
696
  fastify.log.debug("afterResources hook executed");
@@ -500,12 +701,12 @@ async function registerResources(fastify, config) {
500
701
  await onReady(fastify);
501
702
  });
502
703
  }
503
- if (config.onClose) {
504
- const onClose = config.onClose;
505
- fastify.addHook("onClose", async () => {
506
- await onClose(fastify);
507
- });
508
- }
704
+ const closers = modules.filter((m) => m.onClose).reverse();
705
+ const appOnClose = config.onClose;
706
+ if (closers.length || appOnClose) fastify.addHook("onClose", async () => {
707
+ for (const m of closers) await m.onClose?.(fastify);
708
+ if (appOnClose) await appOnClose(fastify);
709
+ });
509
710
  }
510
711
  //#endregion
511
712
  //#region src/factory/registerSecurity.ts
@@ -522,7 +723,8 @@ function buildRateLimitOpts(input) {
522
723
  };
523
724
  const matchesPath = compilePathMatcher(skipPaths);
524
725
  const combined = async (req, key) => {
525
- if (matchesPath((req.url ?? "").split("?", 1)[0] ?? "")) return true;
726
+ const path = (req.url ?? "").split("?", 1)[0] ?? "";
727
+ if (matchesPath(path)) return true;
526
728
  if (typeof allowList === "function") return await allowList(req, key);
527
729
  if (Array.isArray(allowList)) return allowList.includes(key);
528
730
  return false;
@@ -711,7 +913,7 @@ var createApp_exports = /* @__PURE__ */ __exportAll({
711
913
  createApp: () => createApp,
712
914
  resolveLoggerConfig: () => resolveLoggerConfig
713
915
  });
714
- const MEMORY_STORE_NAMES = new Set(["memory", "memory-cache"]);
916
+ const MEMORY_STORE_NAMES = /* @__PURE__ */ new Set(["memory", "memory-cache"]);
715
917
  /**
716
918
  * Default redact paths layered into Fastify's pino logger when the host
717
919
  * doesn't supply a `logger.redact` of their own. Covers the common token /
@@ -813,7 +1015,7 @@ Missing:\n${lines}\n\nFix: pass durable adapters under \`stores\`, or use runtim
813
1015
  *
814
1016
  * Boot order:
815
1017
  * ```
816
- * 0. Logger, validation, preset merge
1018
+ * 0. Logger, validation → beforeBoot() (DB connect etc.), preset merge
817
1019
  * 1. Create Fastify instance
818
1020
  * 2. Security plugins (Helmet, CORS, Rate Limit) — opt-out
819
1021
  * 3. Utility plugins (Under Pressure, Sensible, Multipart, Raw Body)
@@ -834,10 +1036,13 @@ async function createApp(options) {
834
1036
  }
835
1037
  validateAuthOptions(options);
836
1038
  const deferredWarnings = validateDistributedRuntime(options);
1039
+ if (options.beforeBoot) await options.beforeBoot();
837
1040
  const config = {
838
1041
  ...options.preset ? getPreset(options.preset) : {},
839
1042
  ...options
840
1043
  };
1044
+ const resolvedModules = orderModules(await Promise.all((config.modules ?? []).map((m) => resolveModule(m))));
1045
+ const moduleErrorMappers = resolvedModules.flatMap((m) => m.errorMappers ?? []);
841
1046
  const fastify = Fastify({
842
1047
  logger: resolveLoggerConfig(config.logger),
843
1048
  trustProxy: config.trustProxy ?? false,
@@ -893,8 +1098,8 @@ async function createApp(options) {
893
1098
  decorateRequestScope(fastify);
894
1099
  await registerAuth(fastify, config, trackPlugin);
895
1100
  await registerElevation(fastify, config, trackPlugin);
896
- await registerErrorHandler(fastify, config, trackPlugin);
897
- await registerResources(fastify, config);
1101
+ await registerErrorHandler(fastify, config, trackPlugin, moduleErrorMappers);
1102
+ await registerResources(fastify, config, resolvedModules);
898
1103
  if (config.replyHelpers) {
899
1104
  const { replyHelpersPlugin } = await import("./replyHelpers-67tRawQZ.mjs").then((n) => n.n);
900
1105
  await fastify.register(replyHelpersPlugin);
@@ -947,4 +1152,4 @@ const ArcFactory = {
947
1152
  }
948
1153
  };
949
1154
  //#endregion
950
- export { edgePreset as a, testingPreset as c, developmentPreset as i, createApp as n, getPreset as o, createApp_exports as r, productionPreset as s, ArcFactory as t };
1155
+ export { edgePreset as a, testingPreset as c, orderModules as d, resolveModule as f, developmentPreset as i, defineModule as l, createApp as n, getPreset as o, createApp_exports as r, productionPreset as s, ArcFactory as t, getModuleExports as u };
@@ -1,5 +1,4 @@
1
1
  import { FastifyPluginAsync } from "fastify";
2
-
3
2
  //#region src/discovery/index.d.ts
4
3
  /** A discovered resource — must have toPlugin() method */
5
4
  interface DiscoverableResource {
@@ -1,7 +1,6 @@
1
- import { p as RegistryEntry } from "../index-CUTG2Cuo.mjs";
1
+ import { p as RegistryEntry } from "../index-Dtrj6XZk.mjs";
2
2
  import { t as ExternalOpenApiPaths } from "../externalPaths-BE6wFpYv.mjs";
3
3
  import { FastifyPluginAsync } from "fastify";
4
-
5
4
  //#region src/docs/openapi/types.d.ts
6
5
  /**
7
6
  * OpenAPI 3.0 type primitives used by arc's spec emitter.
@@ -1,5 +1,5 @@
1
1
  import { t as getUserRoles } from "../types-XTApa8b6.mjs";
2
- import { n as openApiPlugin, r as openapi_default, t as buildOpenApiSpec } from "../openapi-DZ-5a5Op.mjs";
2
+ import { n as openApiPlugin, r as openapi_default, t as buildOpenApiSpec } from "../openapi-Kyod_di6.mjs";
3
3
  import fp from "fastify-plugin";
4
4
  //#region src/docs/scalar.ts
5
5
  const scalarPlugin = async (fastify, opts = {}) => {
@@ -1,5 +1,4 @@
1
1
  import { FastifyPluginAsync, FastifyRequest } from "fastify";
2
-
3
2
  //#region src/scope/elevation.d.ts
4
3
  interface ElevationOptions {
5
4
  /** Roles that can use elevation (default: ['superadmin']) */
@@ -1,135 +1,5 @@
1
- import { FastifyPluginAsync, FastifyRequest } from "fastify";
2
-
3
- //#region src/encryption/types.d.ts
4
- /** JWE key-management algorithm — the protected-header `alg`. */
5
- type JweAlg = "RSA-OAEP-256" | "RSA-OAEP-384" | "RSA-OAEP-512" | "ECDH-ES" | "ECDH-ES+A256KW" | "A256GCMKW" | "dir";
6
- /** JWE content-encryption algorithm — the protected-header `enc`. AEAD only. */
7
- type JweEnc = "A128GCM" | "A192GCM" | "A256GCM";
8
- /** Encryption strategy for a route's response. */
9
- type EncryptionMode = "jwe" | "fields";
10
- /**
11
- * Opaque key material. For `jwe` mode this is a `jose` `KeyLike` /
12
- * `CryptoKey` / `Uint8Array`; for `fields` mode a 32-byte secret
13
- * (`Buffer` / `Uint8Array`). Kept `unknown` at the contract layer so the
14
- * `KeyProvider` interface carries no hard `jose` type dependency — hosts
15
- * without `jose` installed can still implement field-mode providers.
16
- */
17
- type KeyMaterial = unknown;
18
- /** A key paired with its identifier — what encrypt operations consume. */
19
- interface ActiveKey {
20
- /** Key id, stamped into the JWE `kid` header / field envelope for rotation. */
21
- readonly kid: string;
22
- /** The key material (public key for JWE, 32-byte secret for fields). */
23
- readonly key: KeyMaterial;
24
- /** Optional per-key `alg` override (else the plugin default applies). */
25
- readonly alg?: JweAlg;
26
- }
27
- /** Context handed to the key provider so resolution can be request-scoped. */
28
- interface KeyContext {
29
- readonly request: FastifyRequest;
30
- }
31
- type MaybePromise<T> = T | Promise<T>;
32
- /**
33
- * Pluggable key resolution. Arc owns the request-time plumbing — `kid` →
34
- * key, algorithm allowlist enforcement; the host owns the trust store —
35
- * which keys are valid and where they live (file / env / AWS KMS / GCP KMS
36
- * / Vault). Encryption and decryption are deliberately separate operations
37
- * because they often use DIFFERENT keys (JWE: a public key encrypts the
38
- * outbound response, a private key by `kid` decrypts the inbound request —
39
- * exactly Visa's two-key-pair model).
40
- *
41
- * Rotation mirrors `@fastify/secure-session`: the active key encrypts; any
42
- * key resolvable by `kid` can decrypt, so you can roll keys without dropping
43
- * in-flight traffic.
44
- */
45
- interface KeyProvider {
46
- /** Active key for encrypting OUTBOUND responses. */
47
- encryptionKey(ctx: KeyContext): MaybePromise<ActiveKey>;
48
- /** Resolve a DECRYPTION key by the inbound message's `kid`. */
49
- decryptionKey(kid: string, ctx: KeyContext): MaybePromise<KeyMaterial | undefined>;
50
- }
51
- /**
52
- * Per-resource encryption directive — the typed slice plugins register on
53
- * `ResourceExtensions`. Declared on a resource via
54
- * `defineResource({ extensions: { encryption: { … } } })`; arc threads it
55
- * to request time on `request.routeOptions.config.arcExtensions.encryption`.
56
- */
57
- interface EncryptionDirective {
58
- /** Strategy. Defaults to the plugin-level `mode` (default `'jwe'`). */
59
- readonly mode?: EncryptionMode;
60
- /**
61
- * For `mode: 'fields'` — dot-paths of fields to encrypt, resolved against
62
- * the serialized response body (e.g. `'cardNumber'`, `'account.iban'`).
63
- * Ignored for `mode: 'jwe'` (the whole body is encrypted).
64
- */
65
- readonly fields?: readonly string[];
66
- /**
67
- * Explicit opt-out — set `false` to disable encryption for this resource
68
- * even when a plugin-level default (`routes`) would otherwise apply.
69
- */
70
- readonly enabled?: boolean;
71
- }
72
- /**
73
- * Plugin-level options. `keyProvider` is the only required field; everything
74
- * else has a secure default.
75
- */
76
- interface EncryptionOptions {
77
- /**
78
- * Master on/off. Default `true`. Set `false` to register the plugin as a
79
- * typed no-op (config-gated environments) — same pattern as
80
- * `idempotencyPlugin`.
81
- */
82
- readonly enabled?: boolean;
83
- /** Key provider — resolves `kid` → key for encrypt/decrypt. Required. */
84
- readonly keyProvider: KeyProvider;
85
- /** Default mode when a route opts in without naming one. Default `'jwe'`. */
86
- readonly mode?: EncryptionMode;
87
- /** JWE key-management algorithm. Default `'RSA-OAEP-256'`. */
88
- readonly alg?: JweAlg;
89
- /** JWE content-encryption algorithm. Default `'A256GCM'`. */
90
- readonly enc?: JweEnc;
91
- /**
92
- * Inbound-decryption algorithm allowlists — block algorithm-substitution
93
- * downgrade attacks. Default `[alg]` / `[enc]`. Set explicitly only when a
94
- * client legitimately uses more than one algorithm.
95
- */
96
- readonly allowedAlgs?: readonly JweAlg[];
97
- readonly allowedEncs?: readonly JweEnc[];
98
- /**
99
- * Fallback opt-in for routes that don't declare
100
- * `extensions.encryption`. Return a directive (or `true` for the default
101
- * mode) to encrypt, `false`/`undefined` to skip. The per-resource
102
- * `extensions.encryption` ALWAYS wins when present. Use this to scope ALE
103
- * to sensitive prefixes without touching every resource.
104
- *
105
- * @example confine ALE to `/payments` and `/accounts`
106
- * ```ts
107
- * routes: (req) => /^\/(payments|accounts)\b/.test(req.url)
108
- * ```
109
- */
110
- readonly routes?: (request: FastifyRequest) => EncryptionDirective | boolean | undefined;
111
- /** Decrypt inbound `application/jose` request bodies. Default `true`. */
112
- readonly decryptRequests?: boolean;
113
- /** Media type that signals an encrypted REQUEST body. Default `'application/jose'`. */
114
- readonly contentType?: string;
115
- /** Content-Type set on full-body JWE RESPONSES. Default `'application/jose'`. */
116
- readonly responseContentType?: string;
117
- /**
118
- * Response header advertising that the body is encrypted, so generic
119
- * client SDKs know to decrypt. Default `'x-encrypted'`; set `false` to omit.
120
- */
121
- readonly flagHeader?: string | false;
122
- }
123
- /** Request decorations added by the plugin. */
124
- declare module "fastify" {
125
- interface FastifyRequest {
126
- /** `true` when the inbound body was decrypted from a JWE by this plugin. */
127
- requestDecrypted?: boolean;
128
- /** `true` when the outbound body was encrypted by this plugin. */
129
- responseEncrypted?: boolean;
130
- }
131
- }
132
- //#endregion
1
+ import { a as JweAlg, c as KeyMaterial, i as EncryptionOptions, l as KeyProvider, n as EncryptionDirective, o as JweEnc, r as EncryptionMode, s as KeyContext, t as ActiveKey } from "../types-Cc91nXuA.mjs";
2
+ import { FastifyPluginAsync } from "fastify";
133
3
  //#region src/encryption/encryptionPlugin.d.ts
134
4
  declare const encryptionPlugin: FastifyPluginAsync<EncryptionOptions>;
135
5
  declare const _default: FastifyPluginAsync<EncryptionOptions>;
@@ -207,16 +77,4 @@ interface StaticKeyProviderOptions {
207
77
  */
208
78
  declare function createStaticKeyProvider(options: StaticKeyProviderOptions): KeyProvider;
209
79
  //#endregion
210
- //#region src/encryption/index.d.ts
211
- declare module "../types/resource.js" {
212
- interface ResourceExtensions {
213
- /**
214
- * Encrypt this resource's responses — full-body JWE or specific fields.
215
- * Threaded to request time on
216
- * `request.routeOptions.config.arcExtensions.encryption`.
217
- */
218
- encryption?: EncryptionDirective;
219
- }
220
- }
221
- //#endregion
222
80
  export { type ActiveKey, type EncryptionDirective, type EncryptionMode, type EncryptionOptions, type JweAlg, type JweEnc, type KeyContext, type KeyMaterial, type KeyProvider, type ParsedFieldEnvelope, type StaticKeyProviderOptions, createStaticKeyProvider, decryptField, _default as default, encryptField, encryptionPlugin, parseFieldEnvelope };
@@ -1,6 +1,5 @@
1
1
  import { ErrorDetail } from "@classytic/repo-core/errors";
2
2
  import { FastifyInstance, FastifyRequest } from "fastify";
3
-
4
3
  //#region src/plugins/errorHandler.d.ts
5
4
  /**
6
5
  * Class-based error mapper — `instanceof` check converts a thrown class
@@ -1,5 +1,4 @@
1
1
  import { HttpError } from "@classytic/repo-core/errors";
2
-
3
2
  //#region src/utils/errors.d.ts
4
3
  interface ErrorOptions {
5
4
  code?: string;
@@ -1,6 +1,5 @@
1
- import { a as EventTransport, i as EventLogger, n as DomainEvent, r as EventHandler } from "./EventTransport-IEn7Tn-Q.mjs";
1
+ import { a as EventTransport, i as EventLogger, n as DomainEvent, r as EventHandler } from "./EventTransport-QmY7A258.mjs";
2
2
  import { FastifyPluginAsync } from "fastify";
3
-
4
3
  //#region src/events/defineEvent.d.ts
5
4
  interface EventSchema {
6
5
  type: "object";
@@ -249,7 +248,8 @@ interface EventPluginOptions {
249
248
  * failed events are published to the `$deadLetter` event type by default.
250
249
  */
251
250
  deadLetterQueue?: {
252
- /** Custom store function. If omitted, publishes to '$deadLetter' event type. */store?: (event: DomainEvent, errors: Error[]) => void | Promise<void>;
251
+ /** Custom store function. If omitted, publishes to '$deadLetter' event type. */
252
+ store?: (event: DomainEvent, errors: Error[]) => void | Promise<void>;
253
253
  };
254
254
  /** Callback after successful publish (for metrics/tracking) */
255
255
  onPublish?: (event: DomainEvent) => void;
@@ -302,9 +302,13 @@ interface EventPluginOptions {
302
302
  declare module "fastify" {
303
303
  interface FastifyInstance {
304
304
  events: {
305
- /** Publish an event */publish: <T>(type: string, payload: T, meta?: Partial<DomainEvent["meta"]>) => Promise<void>; /** Subscribe to events */
306
- subscribe: (pattern: string, handler: EventHandler) => Promise<() => void>; /** Get transport name */
307
- transportName: string; /** Event registry for introspection (undefined when no registry configured) */
305
+ /** Publish an event */
306
+ publish: <T>(type: string, payload: T, meta?: Partial<DomainEvent["meta"]>) => Promise<void>;
307
+ /** Subscribe to events */
308
+ subscribe: (pattern: string, handler: EventHandler) => Promise<() => void>;
309
+ /** Get transport name */
310
+ transportName: string;
311
+ /** Event registry for introspection (undefined when no registry configured) */
308
312
  registry?: EventRegistry;
309
313
  };
310
314
  }
@@ -1,9 +1,8 @@
1
- import { a as EventTransport, i as EventLogger, n as DomainEvent, o as MemoryEventTransport, r as EventHandler, s as MemoryEventTransportOptions, t as DeadLetteredEvent } from "../EventTransport-IEn7Tn-Q.mjs";
2
- import { a as withRetry, c as EventDefinitionOutput, d as EventSchema, f as ValidationResult, i as createDeadLetterPublisher, l as EventRegistry, m as defineEvent, n as eventPlugin, o as CustomValidator, p as createEventRegistry, r as RetryOptions, s as EventDefinitionInput, t as EventPluginOptions, u as EventRegistryOptions } from "../eventPlugin-DbBFd3x_.mjs";
1
+ import { a as EventTransport, i as EventLogger, n as DomainEvent, o as MemoryEventTransport, r as EventHandler, s as MemoryEventTransportOptions, t as DeadLetteredEvent } from "../EventTransport-QmY7A258.mjs";
2
+ import { a as withRetry, c as EventDefinitionOutput, d as EventSchema, f as ValidationResult, i as createDeadLetterPublisher, l as EventRegistry, m as defineEvent, n as eventPlugin, o as CustomValidator, p as createEventRegistry, r as RetryOptions, s as EventDefinitionInput, t as EventPluginOptions, u as EventRegistryOptions } from "../eventPlugin-A6EbthXR.mjs";
3
3
  import { RedisEventTransportOptions, RedisLike } from "./transports/redis.mjs";
4
- import { r as RedisStreamTransportOptions, t as RedisStreamLike } from "../redis-stream-DsP1vqZB.mjs";
4
+ import { r as RedisStreamTransportOptions, t as RedisStreamLike } from "../redis-stream-iVR2l7aS.mjs";
5
5
  import { RepositoryLike } from "@classytic/repo-core/adapter";
6
-
7
6
  //#region src/events/eventTypes.d.ts
8
7
  /**
9
8
  * Event Type Constants and Helpers
@@ -38,14 +37,18 @@ type CrudEventSuffix = (typeof CRUD_EVENT_SUFFIXES)[number];
38
37
  declare function crudEventType(resource: string, suffix: CrudEventSuffix): string;
39
38
  /** Arc framework lifecycle events — emitted automatically by the framework */
40
39
  declare const ARC_LIFECYCLE_EVENTS: Readonly<{
41
- /** Emitted when a resource plugin is registered */readonly RESOURCE_REGISTERED: "arc.resource.registered"; /** Emitted when Arc is fully ready (all resources registered, onReady fired) */
40
+ /** Emitted when a resource plugin is registered */
41
+ readonly RESOURCE_REGISTERED: "arc.resource.registered";
42
+ /** Emitted when Arc is fully ready (all resources registered, onReady fired) */
42
43
  readonly READY: "arc.ready";
43
44
  }>;
44
45
  /** Type for Arc lifecycle event names */
45
46
  type ArcLifecycleEvent = (typeof ARC_LIFECYCLE_EVENTS)[keyof typeof ARC_LIFECYCLE_EVENTS];
46
47
  /** Cache-specific event types for observability and external triggers */
47
48
  declare const CACHE_EVENTS: Readonly<{
48
- /** Emitted when a resource's cache version is bumped */readonly VERSION_BUMPED: "arc.cache.version.bumped"; /** Emitted when a tag version is bumped */
49
+ /** Emitted when a resource's cache version is bumped */
50
+ readonly VERSION_BUMPED: "arc.cache.version.bumped";
51
+ /** Emitted when a tag version is bumped */
49
52
  readonly TAG_VERSION_BUMPED: "arc.cache.tag.bumped";
50
53
  }>;
51
54
  /** Type for cache event names */