@classytic/arc 2.21.0 → 2.23.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 (115) hide show
  1. package/dist/{BaseController-BnLYgE9A.mjs → BaseController-BQ8KNvWC.mjs} +3 -2
  2. package/dist/{ResourceRegistry-7RnrEWNP.mjs → ResourceRegistry-y9Hnypus.mjs} +1 -0
  3. package/dist/{actionPermissions-ze70xXJi.mjs → actionPermissions-lszoJle1.mjs} +1 -1
  4. package/dist/applyPermissionResult-Tw4iXtlf.mjs +264 -0
  5. package/dist/audit/index.d.mts +2 -2
  6. package/dist/auth/index.d.mts +30 -2
  7. package/dist/auth/index.mjs +6 -3
  8. package/dist/cli/commands/describe.d.mts +1 -1
  9. package/dist/cli/commands/describe.mjs +1 -1
  10. package/dist/cli/commands/docs.mjs +2 -2
  11. package/dist/cli/commands/init.mjs +1 -1
  12. package/dist/cli/commands/introspect.mjs +1 -1
  13. package/dist/cli/index.mjs +1 -1
  14. package/dist/core/index.d.mts +2 -2
  15. package/dist/core/index.mjs +3 -3
  16. package/dist/{core-DZ6Xe1jR.mjs → core-DQ6rDTyH.mjs} +126 -68
  17. package/dist/{createActionRouter-BWMDW-dL.mjs → createActionRouter-BuOv-m4Y.mjs} +10 -6
  18. package/dist/{createAggregationRouter-l5mNdYrZ.mjs → createAggregationRouter-CgJv_BU_.mjs} +3 -3
  19. package/dist/{createApp-Bi61XxqU.mjs → createApp-CjOeWJ9_.mjs} +252 -57
  20. package/dist/docs/index.d.mts +1 -1
  21. package/dist/docs/index.mjs +1 -1
  22. package/dist/encryption/index.mjs +9 -5
  23. package/dist/{errorHandler-Dxf6P_kV.mjs → errorHandler-Bq2OeEQL.mjs} +34 -10
  24. package/dist/{errorHandler-B0e-bq-O.d.mts → errorHandler-C6yv4ZTW.d.mts} +13 -0
  25. package/dist/events/index.mjs +5 -2
  26. package/dist/factory/index.d.mts +46 -9
  27. package/dist/factory/index.mjs +64 -13
  28. package/dist/{fastify-augmentation-DafigGQp.d.mts → fastify-augmentation-g_f9omSc.d.mts} +2 -2
  29. package/dist/fields-D3lhgzhD.d.mts +121 -0
  30. package/dist/health-ScyjIVo5.mjs +202 -0
  31. package/dist/hooks/index.d.mts +1 -1
  32. package/dist/idempotency/index.d.mts +7 -0
  33. package/dist/idempotency/index.mjs +7 -17
  34. package/dist/{index-Dtrj6XZk.d.mts → index-BBsvQt7U.d.mts} +1310 -1226
  35. package/dist/{types-DpxoNX5v.d.mts → index-Ba10fdKp.d.mts} +196 -66
  36. package/dist/{index-B5N-NaHs.d.mts → index-CC5ruDGh.d.mts} +3 -3
  37. package/dist/{index-B5PoNbfu.d.mts → index-wMoWBLkn.d.mts} +68 -2
  38. package/dist/index.d.mts +6 -5
  39. package/dist/index.mjs +6 -5
  40. package/dist/{init-0YhHkXGk.mjs → init-CjO4p_q1.mjs} +123 -4
  41. package/dist/integrations/event-gateway.mjs +1 -1
  42. package/dist/integrations/index.d.mts +1 -1
  43. package/dist/integrations/mcp/index.d.mts +67 -4
  44. package/dist/integrations/mcp/index.mjs +1 -1
  45. package/dist/integrations/mcp/testing.d.mts +1 -1
  46. package/dist/integrations/mcp/testing.mjs +1 -1
  47. package/dist/integrations/webhooks.d.mts +131 -7
  48. package/dist/integrations/webhooks.mjs +169 -10
  49. package/dist/interface-n5hpKg1d.mjs +16 -0
  50. package/dist/jsonBody-BlzAoGCe.mjs +43 -0
  51. package/dist/{loadResourcesFromEntry-OMCvIEE7.mjs → loadResourcesFromEntry-kvGT6EcO.mjs} +1 -1
  52. package/dist/logger/index.d.mts +26 -1
  53. package/dist/logger/index.mjs +39 -1
  54. package/dist/{mcpPlugin-B9XjjNVG.mjs → mcpPlugin-BtzIL_kM.mjs} +312 -39
  55. package/dist/middleware/index.d.mts +1 -1
  56. package/dist/{module-WeuU5jfd.d.mts → module-B4u10iOJ.d.mts} +26 -1
  57. package/dist/{openapi-Kyod_di6.mjs → openapi-jSDx-VDv.mjs} +3 -3
  58. package/dist/permissions/index.d.mts +4 -3
  59. package/dist/permissions/index.mjs +3 -2
  60. package/dist/{permissions-BJMuKMl7.mjs → permissions-tbxFHxjZ.mjs} +77 -264
  61. package/dist/pipeline/index.d.mts +1 -1
  62. package/dist/plugins/index.d.mts +24 -7
  63. package/dist/plugins/index.mjs +14 -282
  64. package/dist/plugins/response-cache.mjs +1 -0
  65. package/dist/plugins/tracing-entry.mjs +7 -4
  66. package/dist/presets/filesUpload.d.mts +2 -2
  67. package/dist/presets/filesUpload.mjs +1 -1
  68. package/dist/presets/index.d.mts +1 -1
  69. package/dist/presets/index.mjs +1 -1
  70. package/dist/presets/multiTenant.d.mts +1 -1
  71. package/dist/presets/search.d.mts +2 -2
  72. package/dist/presets/search.mjs +1 -1
  73. package/dist/{presets-gw0ciIUD.mjs → presets-BOe4Ugkl.mjs} +1 -1
  74. package/dist/rateLimitKey-BoWRH3e7.mjs +14 -0
  75. package/dist/realtime-I8OrfnMR.mjs +375 -0
  76. package/dist/{registerRoute-oGAl0oA8.mjs → registerRoute-CWO6mdYe.mjs} +38 -18
  77. package/dist/registry/index.d.mts +2 -2
  78. package/dist/registry/index.mjs +1 -1
  79. package/dist/requestId-5BhTVzBv.mjs +113 -0
  80. package/dist/schemaConverter-DJvodiHj.mjs +171 -0
  81. package/dist/{schemaIR-ajetw3Fv.mjs → schemaIR-Ca7ySNPx.mjs} +2 -2
  82. package/dist/scim/index.mjs +6 -7
  83. package/dist/scope/index.mjs +2 -76
  84. package/dist/scope-LONQ3mfZ.mjs +37 -0
  85. package/dist/simpleEqualityMatcher-Cg05HPsn.mjs +68 -0
  86. package/dist/{sse-CfUjwjDk.mjs → sse-0u4P4-OI.mjs} +7 -30
  87. package/dist/sseStream-BQ7Qaynp.mjs +57 -0
  88. package/dist/testing/index.d.mts +83 -3
  89. package/dist/testing/index.mjs +70 -3
  90. package/dist/types/index.d.mts +2 -2
  91. package/dist/{types-DRj-Ut4n.d.mts → types-5OuqeTDX.d.mts} +37 -2
  92. package/dist/{fields-BGqrAa_u.d.mts → types-Csxga7d3.d.mts} +1 -121
  93. package/dist/usage/index.d.mts +142 -0
  94. package/dist/usage/index.mjs +0 -0
  95. package/dist/utils/index.d.mts +101 -19
  96. package/dist/utils/index.mjs +5 -3
  97. package/dist/{utils-CO8a3B_1.mjs → utils-CZyeRXTh.mjs} +80 -82
  98. package/dist/{versioning-DQ4mCvg0.d.mts → versioning-D6PvCwpq.d.mts} +43 -9
  99. package/llms.txt +39 -0
  100. package/package.json +513 -447
  101. package/skills/arc/SKILL.md +14 -8
  102. package/skills/arc/references/api-reference.md +34 -0
  103. package/skills/arc/references/auth.md +21 -4
  104. package/skills/arc/references/mcp.md +1 -1
  105. package/skills/arc/references/production.md +734 -654
  106. package/skills/arc-code-review/SKILL.md +1 -1
  107. package/skills/arc-code-review/references/anti-patterns.md +71 -18
  108. package/skills/arc-code-review/references/arc-cheatsheet.md +15 -2
  109. package/skills/arc-code-review/references/migration-recipes.md +7 -7
  110. package/skills/arc-code-review/references/mongokit-migration.md +4 -3
  111. package/skills/arc-module-publishing/SKILL.md +2 -1
  112. package/skills/arc-module-publishing/references/authoring-modules.md +55 -0
  113. package/skills/arc-module-publishing/references/package-manifest.md +4 -2
  114. package/dist/preloadResources-CHw3_oA_.mjs +0 -45
  115. package/dist/schemaConverter-U0MnO9vd.mjs +0 -110
@@ -1,8 +1,55 @@
1
1
  import { t as __exportAll } from "./rolldown-runtime-8H4AJuhK.mjs";
2
- import { arcLog } from "./logger/index.mjs";
2
+ import { arcLog, configureArcLogger, createPinoWriter } from "./logger/index.mjs";
3
3
  import { n as PUBLIC_SCOPE } from "./types-bHblZ9YN.mjs";
4
+ import { t as createTenantKeyGenerator } from "./rateLimitKey-BoWRH3e7.mjs";
5
+ import { t as createRequestIdGenerator } from "./requestId-5BhTVzBv.mjs";
6
+ import { t as parseJsonBody } from "./jsonBody-BlzAoGCe.mjs";
4
7
  import Fastify from "fastify";
5
8
  import qs from "qs";
9
+ //#region src/testing/preloadResources.ts
10
+ /**
11
+ * Normalize an eager `import.meta.glob` result into a `ResourceLike[]`.
12
+ *
13
+ * Accepts either:
14
+ * - `{ import: 'default' }` form: values are the resource directly
15
+ * - default form: values are the full module — picks first export with `toPlugin()`
16
+ *
17
+ * Throws if any module doesn't yield a valid `ResourceLike`.
18
+ */
19
+ function preloadResources(globResult) {
20
+ const resources = [];
21
+ for (const [path, value] of Object.entries(globResult)) {
22
+ const resource = pickResource(value);
23
+ if (!resource) throw new Error(`preloadResources: ${path} does not export a valid resource.\n Expected: a default export OR a named export with toPlugin().`);
24
+ resources.push(resource);
25
+ }
26
+ return resources.sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
27
+ }
28
+ /**
29
+ * Normalize a lazy `import.meta.glob` result into a `Promise<ResourceLike[]>`.
30
+ *
31
+ * Use this when resources depend on prior bootstrap (e.g., engine init) and
32
+ * cannot be evaluated at import time of the preload file.
33
+ */
34
+ async function preloadResourcesAsync(globResult) {
35
+ return (await Promise.all(Object.entries(globResult).map(async ([path, loader]) => {
36
+ const resource = pickResource(await loader());
37
+ if (!resource) throw new Error(`preloadResourcesAsync: ${path} does not export a valid resource.\n Expected: a default export OR a named export with toPlugin().`);
38
+ return resource;
39
+ }))).sort((a, b) => (a.name ?? "").localeCompare(b.name ?? ""));
40
+ }
41
+ function pickResource(value) {
42
+ if (!value || typeof value !== "object") return void 0;
43
+ if (typeof value.toPlugin === "function") return value;
44
+ const mod = value;
45
+ const candidates = [
46
+ mod.default,
47
+ mod.resource,
48
+ ...Object.values(mod)
49
+ ];
50
+ for (const c of candidates) if (c && typeof c === "object" && typeof c.toPlugin === "function") return c;
51
+ }
52
+ //#endregion
6
53
  //#region src/factory/module.ts
7
54
  /**
8
55
  * Resolve any module-input form to a concrete `ArcModule`. A thunk is invoked
@@ -310,8 +357,33 @@ const edgePreset = {
310
357
  /**
311
358
  * Get preset by name
312
359
  */
360
+ /**
361
+ * Worker preset (2.23) — the headless process role. Flips the HTTP surface
362
+ * off and enables the `mountRoutes: false` primitive; runtime tiers
363
+ * (events, jobs, schedules, caching, audit, usage) stay AS CONFIGURED by
364
+ * the host's options. Per the preset contract, explicit narrow settings
365
+ * win — `arcPlugins: { metrics: true }` re-enables Prometheus on workers,
366
+ * `auth: {...}` keeps auth. See wiki/factory.md § createWorker.
367
+ */
368
+ const workerPreset = {
369
+ mountRoutes: false,
370
+ auth: false,
371
+ helmet: false,
372
+ cors: false,
373
+ rateLimit: false,
374
+ underPressure: false,
375
+ sensible: false,
376
+ arcPlugins: {
377
+ requestId: false,
378
+ health: false,
379
+ sse: false,
380
+ metrics: false,
381
+ gracefulShutdown: true
382
+ }
383
+ };
313
384
  function getPreset(name) {
314
385
  switch (name) {
386
+ case "worker": return workerPreset;
315
387
  case "production": return productionPreset;
316
388
  case "development": return developmentPreset;
317
389
  case "testing": return testingPreset;
@@ -384,11 +456,18 @@ async function registerArcPlugins(fastify, config, trackPlugin, modules) {
384
456
  }
385
457
  if (config.arcPlugins?.sse) if (config.arcPlugins?.events === false) fastify.log.warn("SSE plugin requires events plugin (arcPlugins.events). SSE disabled.");
386
458
  else {
387
- const { default: ssePlugin } = await import("./sse-CfUjwjDk.mjs").then((n) => n.r);
459
+ const { default: ssePlugin } = await import("./sse-0u4P4-OI.mjs").then((n) => n.r);
388
460
  const opts = config.arcPlugins.sse === true ? {} : config.arcPlugins.sse;
389
461
  await fastify.register(ssePlugin, opts);
390
462
  trackPlugin("arc-sse", opts);
391
463
  }
464
+ if (config.arcPlugins?.realtime) if (config.arcPlugins?.events === false) fastify.log.warn("Realtime plugin requires events plugin (arcPlugins.events). Realtime disabled.");
465
+ else {
466
+ const { default: realtimePlugin } = await import("./realtime-I8OrfnMR.mjs").then((n) => n.r);
467
+ const opts = config.arcPlugins.realtime === true ? {} : config.arcPlugins.realtime;
468
+ await fastify.register(realtimePlugin, opts);
469
+ trackPlugin("arc-realtime", opts);
470
+ }
392
471
  if (config.arcPlugins?.metrics) {
393
472
  const { default: metricsPlugin } = await import("./metrics-fdxI5mn_.mjs").then((n) => n.r);
394
473
  const opts = config.arcPlugins.metrics === true ? {} : config.arcPlugins.metrics;
@@ -424,7 +503,7 @@ async function registerAuth(fastify, config, trackPlugin) {
424
503
  }
425
504
  switch (authConfig.type) {
426
505
  case "betterAuth": {
427
- const { plugin, openapi } = authConfig.betterAuth;
506
+ const { plugin, openapi } = typeof authConfig.betterAuth === "function" ? await authConfig.betterAuth() : authConfig.betterAuth;
428
507
  await fastify.register(plugin);
429
508
  trackPlugin("auth-better-auth");
430
509
  const arc = fastify.arc;
@@ -480,7 +559,7 @@ async function registerElevation(fastify, config, trackPlugin) {
480
559
  */
481
560
  async function registerErrorHandler(fastify, config, trackPlugin, moduleErrorMappers) {
482
561
  if (config.errorHandler === false) return;
483
- const { errorHandlerPlugin } = await import("./errorHandler-Dxf6P_kV.mjs").then((n) => n.r);
562
+ const { errorHandlerPlugin } = await import("./errorHandler-Bq2OeEQL.mjs").then((n) => n.r);
484
563
  let errorOpts = typeof config.errorHandler === "object" ? config.errorHandler : { includeStack: config.preset !== "production" };
485
564
  if (moduleErrorMappers && moduleErrorMappers.length > 0) errorOpts = {
486
565
  ...errorOpts,
@@ -526,10 +605,10 @@ function isResourcesFactory(value) {
526
605
  return typeof value === "function";
527
606
  }
528
607
  /** Register a single resource with descriptive error on failure. */
529
- async function registerOne(parent, resource) {
608
+ async function registerOne(parent, resource, mountRoutes) {
530
609
  const name = resource.name ?? "unknown";
531
610
  try {
532
- await parent.register(resource.toPlugin());
611
+ await parent.register(resource.toPlugin(), mountRoutes === false ? { arcMountRoutes: false } : {});
533
612
  } catch (err) {
534
613
  const rawMsg = err instanceof Error ? err.message : String(err);
535
614
  const stripped = rawMsg.replace(new RegExp(`^Resource "${name}"\\s*`), "").replace(/\.+\s*$/, "");
@@ -640,6 +719,16 @@ async function registerResources(fastify, config, preResolvedModules) {
640
719
  }
641
720
  resolvedResources = discovered;
642
721
  }
722
+ if (modules.length && resolvedResources && resolvedResources.length > 0) {
723
+ const owned = /* @__PURE__ */ new Set();
724
+ for (const m of modules) for (const name of m.owns ?? []) owned.add(name);
725
+ if (owned.size > 0) {
726
+ const before = resolvedResources.length;
727
+ resolvedResources = resolvedResources.filter((r) => !(r.name != null && owned.has(r.name)));
728
+ const dropped = before - resolvedResources.length;
729
+ if (dropped > 0) fastify.log.debug(`[arc] ${dropped} app resource(s) superseded by module owns() — the module version registers instead`);
730
+ }
731
+ }
643
732
  if (modules.length) {
644
733
  const moduleResources = [];
645
734
  for (const m of modules) {
@@ -668,11 +757,11 @@ async function registerResources(fastify, config, preResolvedModules) {
668
757
  const root = [];
669
758
  for (const resource of resolvedResources) if (resource.skipGlobalPrefix) root.push(resource);
670
759
  else prefixed.push(resource);
671
- for (const resource of root) await registerOne(fastify, resource);
760
+ for (const resource of root) await registerOne(fastify, resource, config.mountRoutes);
672
761
  if (prefixed.length) if (config.resourcePrefix) await fastify.register(async (scoped) => {
673
- for (const resource of prefixed) await registerOne(scoped, resource);
762
+ for (const resource of prefixed) await registerOne(scoped, resource, config.mountRoutes);
674
763
  }, { prefix: config.resourcePrefix });
675
- else for (const resource of prefixed) await registerOne(fastify, resource);
764
+ else for (const resource of prefixed) await registerOne(fastify, resource, config.mountRoutes);
676
765
  const names = resolvedResources.map((r) => r.name ?? "?").join(", ");
677
766
  const prefix = config.resourcePrefix ? ` (prefix: ${config.resourcePrefix})` : "";
678
767
  fastify.log.info(`${resolvedResources.length} resource(s) registered${prefix}: ${names}`);
@@ -709,42 +798,69 @@ async function registerResources(fastify, config, preResolvedModules) {
709
798
  });
710
799
  }
711
800
  //#endregion
712
- //#region src/factory/registerSecurity.ts
801
+ //#region src/factory/security/cors.ts
713
802
  /**
714
- * Translate `skipPaths` sugar into a `@fastify/rate-limit` `allowList`
715
- * function. A user-supplied `allowList` (array of IPs or function) is
716
- * preserved and OR-ed with the path match.
803
+ * Headers ARC ITSELF reads from cross-origin requests. Auto-merged into a
804
+ * host-declared CORS `allowedHeaders` allow-list (2.22) so hosts never
805
+ * enumerate framework internals. Lowercase — header names are
806
+ * case-insensitive and @fastify/cors joins them verbatim.
717
807
  */
718
- function buildRateLimitOpts(input) {
719
- const { skipPaths, allowList, ...rest } = input;
720
- if (!skipPaths || skipPaths.length === 0) return allowList === void 0 ? rest : {
721
- ...rest,
722
- allowList
723
- };
724
- const matchesPath = compilePathMatcher(skipPaths);
725
- const combined = async (req, key) => {
726
- const path = (req.url ?? "").split("?", 1)[0] ?? "";
727
- if (matchesPath(path)) return true;
728
- if (typeof allowList === "function") return await allowList(req, key);
729
- if (Array.isArray(allowList)) return allowList.includes(key);
730
- return false;
731
- };
808
+ const ARC_PROTOCOL_HEADERS = [
809
+ "x-organization-id",
810
+ "x-arc-scope",
811
+ "x-request-id"
812
+ ];
813
+ /**
814
+ * Response headers ARC'S OWN auth protocol emits that cross-origin JS MUST
815
+ * be able to read. Better Auth's bearer plugin returns the session token in
816
+ * `set-auth-token` — without exposing it, cross-origin clients (Capacitor /
817
+ * Ionic WebViews at `https://localhost`, cross-domain SPAs) complete the
818
+ * login POST but can never READ the token, so bearer auth silently dies
819
+ * (login appears to succeed, then every request is unauthenticated).
820
+ * Unlike `allowedHeaders`, an UNSET `exposedHeaders` is NOT safe — CORS
821
+ * exposes nothing beyond the safelist by default — so this is always
822
+ * merged, creating the list when the host declared none.
823
+ */
824
+ const ARC_EXPOSED_HEADERS = ["set-auth-token"];
825
+ /**
826
+ * Default `Access-Control-Max-Age` (seconds) when the host didn't set one.
827
+ * Arc's CORS method/header lists are static per deploy, so letting browsers
828
+ * cache the preflight kills the extra OPTIONS round-trip on every request —
829
+ * on high-latency links (mobile webviews) that's a visible chunk of every
830
+ * API call. Browsers clamp internally (Chromium 2h, Firefox 24h).
831
+ */
832
+ const DEFAULT_CORS_MAX_AGE = 86400;
833
+ /**
834
+ * Resolve the host's CORS config into `@fastify/cors` options + warnings.
835
+ * Throws on the one configuration arc refuses to ship (see module header).
836
+ * Callers decide only HOW to surface warnings — the policy is all here.
837
+ */
838
+ function resolveCorsOptions(config) {
839
+ const corsOptions = { ...config.cors ?? {} };
840
+ const warnings = [];
841
+ const originDeclared = "origin" in corsOptions && corsOptions.origin !== void 0;
842
+ if (config.preset === "production" && !originDeclared) warnings.push("CORS origin is not explicitly configured in production. Browser apps: set cors.origin to allowed domains (e.g. ['https://app.example.com']) with credentials: true. Server-to-server / API-key services: cors: { origin: '*', credentials: false } OR cors: false to disable. Tip: when wiring cors.origin from an env var, fail fast on missing (`if (!process.env.ALLOWED_ORIGINS) throw ...`) instead of letting `undefined` slip through.");
843
+ if (corsOptions.credentials === true && corsOptions.origin === "*") throw new Error("[Arc] cors: `origin: '*'` cannot be combined with `credentials: true`.\nReflecting arbitrary origins with credentials lets any website read authenticated responses (CSRF-adjacent data theft).\nFix one of:\n • cors: { origin: ['https://app.example.com'], credentials: true } — explicit allow-list\n • cors: { origin: /\\.example\\.com$/, credentials: true } — pattern allow-list\n • cors: { origin: '*', credentials: false } — public, uncredentialed API");
844
+ if (Array.isArray(corsOptions.allowedHeaders)) {
845
+ const allowed = [...corsOptions.allowedHeaders];
846
+ const declared = new Set(allowed.map((h) => h.toLowerCase()));
847
+ for (const arcHeader of ARC_PROTOCOL_HEADERS) if (!declared.has(arcHeader)) allowed.push(arcHeader);
848
+ corsOptions.allowedHeaders = allowed;
849
+ }
850
+ {
851
+ const exposed = Array.isArray(corsOptions.exposedHeaders) ? [...corsOptions.exposedHeaders] : typeof corsOptions.exposedHeaders === "string" ? corsOptions.exposedHeaders.split(",").map((h) => h.trim()) : [];
852
+ const declared = new Set(exposed.map((h) => h.toLowerCase()));
853
+ for (const arcHeader of ARC_EXPOSED_HEADERS) if (!declared.has(arcHeader)) exposed.push(arcHeader);
854
+ corsOptions.exposedHeaders = exposed;
855
+ }
856
+ if (corsOptions.maxAge === void 0) corsOptions.maxAge = DEFAULT_CORS_MAX_AGE;
732
857
  return {
733
- ...rest,
734
- allowList: combined
735
- };
736
- }
737
- function compilePathMatcher(patterns) {
738
- const prefixes = [];
739
- const exact = /* @__PURE__ */ new Set();
740
- for (const p of patterns) if (p.endsWith("*")) prefixes.push(p.slice(0, -1));
741
- else exact.add(p);
742
- return (path) => {
743
- if (exact.has(path)) return true;
744
- for (const pre of prefixes) if (path.startsWith(pre)) return true;
745
- return false;
858
+ options: corsOptions,
859
+ warnings
746
860
  };
747
861
  }
862
+ //#endregion
863
+ //#region src/factory/security/pluginLoader.ts
748
864
  const PLUGIN_REGISTRY = {
749
865
  cors: {
750
866
  package: "@fastify/cors",
@@ -793,6 +909,77 @@ async function loadPlugin(name, logger) {
793
909
  throw new Error(`Failed to load plugin '${name}': ${err.message}`);
794
910
  }
795
911
  }
912
+ //#endregion
913
+ //#region src/factory/security/rateLimit.ts
914
+ /** "Unlimited" as a number — @fastify/rate-limit's `max` fn must return one. */
915
+ const UNLIMITED_MAX = Number.MAX_SAFE_INTEGER;
916
+ /**
917
+ * Expand `plan` sugar (2.22) into `@fastify/rate-limit` primitives: a
918
+ * per-request async `max` that resolves the caller's plan once (cached
919
+ * per request), and — unless the host supplied one — the tenant
920
+ * keyGenerator so buckets follow actors, not IPs. Unknown plans and
921
+ * resolver errors fall back to `plan.default`, then the global `max`
922
+ * (fail-safe: never fail-open to unlimited).
923
+ */
924
+ function buildPlanOpts(rest, plan) {
925
+ const globalMax = typeof rest.max === "number" ? rest.max : 100;
926
+ const fallbackLimit = plan.default !== void 0 ? plan.limits[plan.default] ?? { max: globalMax } : { max: globalMax };
927
+ const planCache = /* @__PURE__ */ new WeakMap();
928
+ const limitOf = (req) => {
929
+ let cached = planCache.get(req);
930
+ if (!cached) {
931
+ cached = Promise.resolve().then(() => plan.resolve(req)).then((name) => name !== void 0 ? plan.limits[name] ?? fallbackLimit : fallbackLimit).catch(() => fallbackLimit);
932
+ planCache.set(req, cached);
933
+ }
934
+ return cached;
935
+ };
936
+ return {
937
+ ...rest,
938
+ max: async (req, _key) => {
939
+ const limit = await limitOf(req);
940
+ return limit === false ? UNLIMITED_MAX : limit.max;
941
+ },
942
+ keyGenerator: rest.keyGenerator ?? createTenantKeyGenerator()
943
+ };
944
+ }
945
+ /**
946
+ * Translate `skipPaths` sugar into a `@fastify/rate-limit` `allowList`
947
+ * function. A user-supplied `allowList` (array of IPs or function) is
948
+ * preserved and OR-ed with the path match.
949
+ */
950
+ function buildRateLimitOpts(input) {
951
+ const { skipPaths, allowList, plan, ...bare } = input;
952
+ const rest = plan ? buildPlanOpts(bare, plan) : bare;
953
+ if (!skipPaths || skipPaths.length === 0) return allowList === void 0 ? rest : {
954
+ ...rest,
955
+ allowList
956
+ };
957
+ const matchesPath = compilePathMatcher(skipPaths);
958
+ const combined = async (req, key) => {
959
+ const path = (req.url ?? "").split("?", 1)[0] ?? "";
960
+ if (matchesPath(path)) return true;
961
+ if (typeof allowList === "function") return await allowList(req, key);
962
+ if (Array.isArray(allowList)) return allowList.includes(key);
963
+ return false;
964
+ };
965
+ return {
966
+ ...rest,
967
+ allowList: combined
968
+ };
969
+ }
970
+ function compilePathMatcher(patterns) {
971
+ const prefixes = [];
972
+ const exact = /* @__PURE__ */ new Set();
973
+ for (const p of patterns) if (p.endsWith("*")) prefixes.push(p.slice(0, -1));
974
+ else exact.add(p);
975
+ return (path) => {
976
+ if (exact.has(path)) return true;
977
+ for (const pre of prefixes) if (path.startsWith(pre)) return true;
978
+ return false;
979
+ };
980
+ }
981
+ //#endregion
982
+ //#region src/factory/registerSecurity.ts
796
983
  /**
797
984
  * Register security plugins (Helmet, CORS, Rate Limiting).
798
985
  * All enabled by default — set to `false` to opt out.
@@ -805,10 +992,8 @@ async function registerSecurityPlugins(fastify, config) {
805
992
  } else fastify.log.warn("Helmet disabled - security headers not applied");
806
993
  if (config.cors !== false) {
807
994
  const cors = await loadPlugin("cors");
808
- const corsOptions = { ...config.cors ?? {} };
809
- const originDeclared = "origin" in corsOptions && corsOptions.origin !== void 0;
810
- if (config.preset === "production" && !originDeclared) fastify.log.warn("CORS origin is not explicitly configured in production. Browser apps: set cors.origin to allowed domains (e.g. ['https://app.example.com']) with credentials: true. Server-to-server / API-key services: cors: { origin: '*', credentials: false } OR cors: false to disable. Tip: when wiring cors.origin from an env var, fail fast on missing (`if (!process.env.ALLOWED_ORIGINS) throw ...`) instead of letting `undefined` slip through.");
811
- if (corsOptions.credentials && corsOptions.origin === "*") corsOptions.origin = true;
995
+ const { options: corsOptions, warnings } = resolveCorsOptions(config);
996
+ for (const warning of warnings) fastify.log.warn(warning);
812
997
  await fastify.register(cors, corsOptions);
813
998
  fastify.log.debug("CORS enabled");
814
999
  } else fastify.log.warn("CORS disabled");
@@ -844,13 +1029,17 @@ async function registerUtilityPlugins(fastify, config) {
844
1029
  if (config.multipart !== false) {
845
1030
  const multipart = await loadPlugin("multipart", fastify.log);
846
1031
  if (multipart) {
1032
+ const hostMultipart = typeof config.multipart === "object" && config.multipart !== null ? config.multipart : {};
847
1033
  await fastify.register(multipart, {
1034
+ throwFileSizeLimit: true,
1035
+ ...hostMultipart,
848
1036
  limits: {
849
1037
  fileSize: 10 * 1024 * 1024,
850
- files: 10
851
- },
852
- throwFileSizeLimit: true,
853
- ...config.multipart
1038
+ files: 10,
1039
+ fields: 100,
1040
+ parts: 120,
1041
+ ...hostMultipart.limits
1042
+ }
854
1043
  });
855
1044
  fastify.log.debug("Multipart (file uploads) enabled");
856
1045
  }
@@ -1030,10 +1219,7 @@ Missing:\n${lines}\n\nFix: pass durable adapters under \`stores\`, or use runtim
1030
1219
  * ```
1031
1220
  */
1032
1221
  async function createApp(options) {
1033
- if (options.debug !== void 0 && options.debug !== false) {
1034
- const { configureArcLogger } = await import("./logger/index.mjs");
1035
- configureArcLogger({ debug: options.debug });
1036
- }
1222
+ if (options.debug !== void 0 && options.debug !== false) configureArcLogger({ debug: options.debug });
1037
1223
  validateAuthOptions(options);
1038
1224
  const deferredWarnings = validateDistributedRuntime(options);
1039
1225
  if (options.beforeBoot) await options.beforeBoot();
@@ -1041,13 +1227,19 @@ async function createApp(options) {
1041
1227
  ...options.preset ? getPreset(options.preset) : {},
1042
1228
  ...options
1043
1229
  };
1230
+ const inheritedPermissiveTrustProxy = options.preset === "production" && options.trustProxy === void 0;
1044
1231
  const resolvedModules = orderModules(await Promise.all((config.modules ?? []).map((m) => resolveModule(m))));
1045
1232
  const moduleErrorMappers = resolvedModules.flatMap((m) => m.errorMappers ?? []);
1233
+ const resolvedLogger = resolveLoggerConfig(config.logger);
1046
1234
  const fastify = Fastify({
1047
- logger: resolveLoggerConfig(config.logger),
1235
+ logger: resolvedLogger,
1236
+ genReqId: createRequestIdGenerator(),
1048
1237
  trustProxy: config.trustProxy ?? false,
1049
1238
  pluginTimeout: config.pluginTimeout ?? 1e4,
1050
1239
  ...config.bodyLimit !== void 0 ? { bodyLimit: config.bodyLimit } : {},
1240
+ ...config.requestTimeout !== void 0 ? { requestTimeout: config.requestTimeout } : {},
1241
+ ...config.connectionTimeout !== void 0 ? { connectionTimeout: config.connectionTimeout } : {},
1242
+ ...config.keepAliveTimeout !== void 0 ? { keepAliveTimeout: config.keepAliveTimeout } : {},
1051
1243
  allowErrorHandlerOverride: true,
1052
1244
  routerOptions: {
1053
1245
  querystringParser: (str) => qs.parse(str, {
@@ -1066,19 +1258,22 @@ async function createApp(options) {
1066
1258
  keywords: ["example", ...config.ajv?.keywords ?? []]
1067
1259
  } }
1068
1260
  });
1261
+ if (resolvedLogger !== false) configureArcLogger({
1262
+ ...options.debug !== void 0 && options.debug !== false ? { debug: options.debug } : {},
1263
+ writer: createPinoWriter(fastify.log)
1264
+ });
1069
1265
  for (const warning of deferredWarnings) fastify.log.warn(warning);
1266
+ if (inheritedPermissiveTrustProxy) fastify.log.warn("[arc] preset: 'production' defaults to trustProxy: true (trust EVERY proxy). If this app is reachable without your load balancer, clients can spoof X-Forwarded-For/Proto (rate-limit bypass, wrong audit IPs, forged protocol). Silence this by setting trustProxy explicitly: a hop count (trustProxy: 1), a CIDR allow-list ('10.0.0.0/8'), a resolver function, or true if you accept the trade-off. v3 flips this preset default to false — set it now to be upgrade-proof.");
1070
1267
  if (config.typeProvider === "typebox") try {
1071
1268
  const { TypeBoxValidatorCompiler } = await import("@fastify/type-provider-typebox");
1072
1269
  fastify.setValidatorCompiler(TypeBoxValidatorCompiler);
1073
1270
  } catch {
1074
1271
  fastify.log.warn("typeProvider: \"typebox\" requested but @fastify/type-provider-typebox is not installed.");
1075
1272
  }
1076
- const sjp = await import("secure-json-parse");
1077
1273
  fastify.removeContentTypeParser("application/json");
1078
1274
  fastify.addContentTypeParser("application/json", { parseAs: "string" }, (_req, body, done) => {
1079
- if (!body || body.length === 0) return done(null, void 0);
1080
1275
  try {
1081
- done(null, sjp.parse(body));
1276
+ done(null, parseJsonBody(body));
1082
1277
  } catch (err) {
1083
1278
  done(err);
1084
1279
  }
@@ -1152,4 +1347,4 @@ const ArcFactory = {
1152
1347
  }
1153
1348
  };
1154
1349
  //#endregion
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 };
1350
+ export { edgePreset as a, testingPreset as c, getModuleExports as d, orderModules as f, preloadResourcesAsync as h, developmentPreset as i, workerPreset as l, preloadResources as m, createApp as n, getPreset as o, resolveModule as p, createApp_exports as r, productionPreset as s, ArcFactory as t, defineModule as u };
@@ -1,4 +1,4 @@
1
- import { p as RegistryEntry } from "../index-Dtrj6XZk.mjs";
1
+ import { p as RegistryEntry } from "../index-BBsvQt7U.mjs";
2
2
  import { t as ExternalOpenApiPaths } from "../externalPaths-BE6wFpYv.mjs";
3
3
  import { FastifyPluginAsync } from "fastify";
4
4
  //#region src/docs/openapi/types.d.ts
@@ -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-Kyod_di6.mjs";
2
+ import { n as openApiPlugin, r as openapi_default, t as buildOpenApiSpec } from "../openapi-jSDx-VDv.mjs";
3
3
  import fp from "fastify-plugin";
4
4
  //#region src/docs/scalar.ts
5
5
  const scalarPlugin = async (fastify, opts = {}) => {
@@ -1,3 +1,5 @@
1
+ import { t as ArcError } from "../errors-DkO8Nk6-.mjs";
2
+ import { t as parseJsonBody } from "../jsonBody-BlzAoGCe.mjs";
1
3
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
2
4
  import fp from "fastify-plugin";
3
5
  //#region src/encryption/fieldCipher.ts
@@ -182,17 +184,19 @@ const encryptionPlugin = async (fastify, opts) => {
182
184
  }
183
185
  if (decryptRequests) fastify.addContentTypeParser(contentType, { parseAs: "string" }, async (request, body) => {
184
186
  const provider = keyProvider;
185
- const plaintext = await jweDecrypt(body, (kid) => provider.decryptionKey(kid, { request }), allowedAlgs, allowedEncs);
186
- request.requestDecrypted = true;
187
- if (plaintext.length === 0) return void 0;
187
+ let plaintext;
188
188
  try {
189
- return JSON.parse(plaintext);
189
+ plaintext = await jweDecrypt(body, (kid) => provider.decryptionKey(kid, { request }), allowedAlgs, allowedEncs);
190
190
  } catch (cause) {
191
- throw Object.assign(/* @__PURE__ */ new Error("[arc/encryption] decrypted request body is not valid JSON"), {
191
+ throw new ArcError("Invalid encrypted request body", {
192
+ code: "arc.bad_request",
192
193
  statusCode: 400,
193
194
  cause
194
195
  });
195
196
  }
197
+ request.requestDecrypted = true;
198
+ if (plaintext.length === 0) return void 0;
199
+ return parseJsonBody(plaintext, "Decrypted request body is not valid JSON");
196
200
  });
197
201
  fastify.addHook("preSerialization", async (request, reply, payload) => {
198
202
  const directive = resolveDirective(request, defaultMode, fallback);
@@ -14,13 +14,16 @@ var errorHandler_exports = /* @__PURE__ */ __exportAll({
14
14
  * {@link ErrorHandlerOptions.isDuplicateKeyError}.
15
15
  */
16
16
  function defaultIsDuplicateKeyError(err) {
17
- if (!err || typeof err !== "object") return false;
18
- const e = err;
19
- if (e.code === 11e3 || e.codeName === "DuplicateKey") return true;
20
- if (e.code === "P2002") return true;
21
- if (e.code === "23505") return true;
22
- if (e.code === "ER_DUP_ENTRY" || e.errno === 1062) return true;
23
- if (e.code === "SQLITE_CONSTRAINT_UNIQUE" || e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") return true;
17
+ let current = err;
18
+ for (let depth = 0; depth < 5 && current && typeof current === "object"; depth++) {
19
+ const e = current;
20
+ if (e.code === 11e3 || e.codeName === "DuplicateKey") return true;
21
+ if (e.code === "P2002") return true;
22
+ if (e.code === "23505") return true;
23
+ if (e.code === "ER_DUP_ENTRY" || e.errno === 1062) return true;
24
+ if (e.code === "SQLITE_CONSTRAINT_UNIQUE" || e.code === "SQLITE_CONSTRAINT_PRIMARYKEY") return true;
25
+ current = e.cause;
26
+ }
24
27
  return false;
25
28
  }
26
29
  function extractDuplicateFields(err) {
@@ -91,7 +94,7 @@ function mongooseValidationDetails(errors) {
91
94
  }
92
95
  async function errorHandlerPluginFn(fastify, options = {}) {
93
96
  const isProduction = process.env.NODE_ENV === "production";
94
- const { includeStack = !isProduction, onError, errorMap = {}, errorMappers = [], isDuplicateKeyError = defaultIsDuplicateKeyError } = options;
97
+ const { includeStack = !isProduction, exposeInternalMessages = !isProduction, onError, errorMap = {}, errorMappers = [], isDuplicateKeyError = defaultIsDuplicateKeyError } = options;
95
98
  fastify.setErrorHandler(async (error, request, reply) => {
96
99
  if (onError) try {
97
100
  await onError(error, request);
@@ -112,6 +115,7 @@ async function errorHandlerPluginFn(fastify, options = {}) {
112
115
  ...Object.keys(meta).length > 0 ? { meta } : {}
113
116
  };
114
117
  const status = wire.status ?? 500;
118
+ if (status >= 500 && wire.code === "arc.internal_error" && !exposeInternalMessages) wire.message = "Internal Server Error";
115
119
  if (status >= 500) request.log.error({
116
120
  err: error,
117
121
  status
@@ -120,11 +124,31 @@ async function errorHandlerPluginFn(fastify, options = {}) {
120
124
  err: error,
121
125
  status
122
126
  }, "Client error");
123
- if (isArcError(error) && error.cause) request.log.error({ cause: error.cause }, "Error cause chain");
127
+ if (isArcError(error) && error.cause) {
128
+ const causeLevel = status >= 500 ? "error" : "warn";
129
+ request.log[causeLevel]({ cause: error.cause }, "Error cause chain");
130
+ }
124
131
  return reply.status(status).send(wire);
125
132
  });
126
133
  }
127
134
  /**
135
+ * Is `code` a caller-defined DOMAIN error code that is safe to surface on the
136
+ * wire, as opposed to a framework/runtime code that must never leak?
137
+ *
138
+ * Rejects Fastify internals (`FST_*`) and Node system errnos (`ECONNREFUSED`,
139
+ * `ENOENT`, …). Domain codes in the wild use separators (`PURCHASE_NOT_FOUND`,
140
+ * `access.forbidden`) so they pass; a bare all-caps single word that collides
141
+ * with the Node-errno shape (e.g. `EXPIRED`) is conservatively treated as a
142
+ * runtime code — use a separatored code (`TOKEN_EXPIRED`) to surface it, or
143
+ * throw via `createDomainError` which bypasses this heuristic entirely.
144
+ */
145
+ function isDomainErrorCode(code) {
146
+ if (typeof code !== "string" || code.length === 0) return false;
147
+ if (code.startsWith("FST_")) return false;
148
+ if (/^E[A-Z]{2,}$/.test(code)) return false;
149
+ return true;
150
+ }
151
+ /**
128
152
  * Single-pass error → `ErrorContract` classifier. Dispatch order is fixed:
129
153
  *
130
154
  * 1. Class-based mappers (`instanceof`) — user-registered domain errors
@@ -167,7 +191,7 @@ function classify(error, ctx) {
167
191
  };
168
192
  }
169
193
  if (typeof fastifyErr.statusCode === "number") return {
170
- code: statusToArcCode(fastifyErr.statusCode),
194
+ code: (isDomainErrorCode(fastifyErr.code) ? fastifyErr.code : void 0) ?? statusToArcCode(fastifyErr.statusCode),
171
195
  message: error.message || "Error",
172
196
  status: fastifyErr.statusCode
173
197
  };
@@ -18,6 +18,19 @@ interface ErrorMapper<T extends Error = Error> {
18
18
  interface ErrorHandlerOptions {
19
19
  /** Include `meta.stack` on the wire (defaults to `NODE_ENV !== 'production'`). */
20
20
  includeStack?: boolean;
21
+ /**
22
+ * Ship the raw `error.message` of UNCLASSIFIED 500s on the wire
23
+ * (defaults to `NODE_ENV !== 'production'`).
24
+ *
25
+ * Only the `arc.internal_error` fallback is affected — an unclassified
26
+ * throw carries whatever some driver/library put in the message (connection
27
+ * strings, file paths, SQL fragments), which must not reach clients in
28
+ * production. Deliberate 5xx contracts are untouched: `ArcError` /
29
+ * domain-coded throws (`payment.gateway.timeout`, …) keep their message
30
+ * because the thrower chose it for the client. The raw message is always
31
+ * logged via `request.log.error({ err })` regardless of this flag.
32
+ */
33
+ exposeInternalMessages?: boolean;
21
34
  /** Custom callback fired for every error — log to Sentry / Datadog / etc. */
22
35
  onError?: (error: Error, request: FastifyRequest) => void | Promise<void>;
23
36
  /** Map by `error.name` string. Lower priority than `errorMappers`. */
@@ -176,8 +176,11 @@ function validatePayload(payload, schema) {
176
176
  for (const [key, propSchema] of Object.entries(schema.properties)) if (key in record && record[key] !== void 0 && record[key] !== null) {
177
177
  const expectedType = propSchema.type;
178
178
  if (expectedType) {
179
- const actualType = Array.isArray(record[key]) ? "array" : typeof record[key];
180
- if (expectedType !== actualType) errors.push(`Field '${key}': expected ${expectedType}, got ${actualType}`);
179
+ const value = record[key];
180
+ const actualType = Array.isArray(value) ? "array" : typeof value;
181
+ if (expectedType === "integer") {
182
+ if (actualType !== "number" || !Number.isInteger(value)) errors.push(`Field '${key}': expected integer, got ${actualType}`);
183
+ } else if (expectedType !== actualType) errors.push(`Field '${key}': expected ${expectedType}, got ${actualType}`);
181
184
  }
182
185
  }
183
186
  }