@classytic/arc 2.18.5 → 2.20.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 +1 -1
  2. package/bin/arc.js +37 -0
  3. package/dist/{BaseController-Dy6EQHjM.mjs → BaseController-D-MHZ27_.mjs} +4 -4
  4. package/dist/{ResourceRegistry-5fKZwhK_.mjs → ResourceRegistry-7RnrEWNP.mjs} +1 -1
  5. package/dist/{actionPermissions-h7DYDNMg.mjs → actionPermissions-C9RMiIpc.mjs} +2 -1
  6. package/dist/audit/index.mjs +1 -1
  7. package/dist/auth/index.d.mts +1 -1
  8. package/dist/auth/index.mjs +17 -6
  9. package/dist/{buildHandler-B0qvAguf.mjs → buildHandler-D_2ULWxZ.mjs} +3 -3
  10. package/dist/cache/index.mjs +2 -2
  11. package/dist/cli/commands/describe.d.mts +1 -1
  12. package/dist/cli/commands/describe.mjs +2 -2
  13. package/dist/cli/commands/docs.mjs +2 -2
  14. package/dist/cli/commands/generate.mjs +1 -1
  15. package/dist/cli/commands/init.mjs +1 -1
  16. package/dist/cli/commands/introspect.mjs +4 -13
  17. package/dist/cli/index.mjs +2 -2
  18. package/dist/{constants-gaEeMlz-.mjs → constants-DNre0wRg.mjs} +1 -1
  19. package/dist/core/index.d.mts +2 -2
  20. package/dist/core/index.mjs +4 -4
  21. package/dist/{core-ZnQBa5rh.mjs → core-CWhyGkiM.mjs} +136 -59
  22. package/dist/{createActionRouter-CagItpxI.mjs → createActionRouter-HlQso0jO.mjs} +8 -7
  23. package/dist/{createAggregationRouter-DVKroXhC.mjs → createAggregationRouter-5LzPzgw1.mjs} +5 -4
  24. package/dist/{createApp--703hQE1.mjs → createApp-D6uwzCKu.mjs} +210 -17
  25. package/dist/docs/index.d.mts +1 -1
  26. package/dist/docs/index.mjs +1 -1
  27. package/dist/encryption/index.d.mts +222 -0
  28. package/dist/encryption/index.mjs +270 -0
  29. package/dist/events/index.mjs +9 -6
  30. package/dist/factory/index.d.mts +3 -2
  31. package/dist/factory/index.mjs +2 -2
  32. package/dist/{fastify-augmentation-Cxb0ROIw.d.mts → fastify-augmentation-CCQJEDgh.d.mts} +12 -1
  33. package/dist/{generate-Brans3fW.mjs → generate-usRSdQmP.mjs} +4 -3
  34. package/dist/hooks/index.d.mts +1 -1
  35. package/dist/idempotency/index.mjs +1 -1
  36. package/dist/{index-CRKiiP5o.d.mts → index-CdFkIMEd.d.mts} +5 -0
  37. package/dist/{index-Dh0_RSTd.d.mts → index-DUa8WHz3.d.mts} +7 -7
  38. package/dist/{index-CHNQIhcb.d.mts → index-_--GyzcO.d.mts} +111 -2
  39. package/dist/index.d.mts +3 -3
  40. package/dist/index.mjs +6 -6
  41. package/dist/{init-Bqpt1Hxf.mjs → init-YH_nslQR.mjs} +87 -5
  42. package/dist/integrations/index.d.mts +1 -1
  43. package/dist/integrations/mcp/index.d.mts +2 -2
  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/streamline.d.mts +17 -5
  48. package/dist/integrations/streamline.mjs +9 -5
  49. package/dist/{loadResourcesFromEntry-BTlFEx05.mjs → loadResourcesFromEntry-OMCvIEE7.mjs} +1 -1
  50. package/dist/{mcpPlugin-D1wuRD2c.mjs → mcpPlugin-CuW6L_uD.mjs} +148 -49
  51. package/dist/middleware/index.d.mts +1 -1
  52. package/dist/middleware/index.mjs +1 -1
  53. package/dist/module-JYH9V0I8.d.mts +384 -0
  54. package/dist/{openapi-xVJibapr.mjs → openapi-Dq6RbEGK.mjs} +3 -3
  55. package/dist/permissions/index.d.mts +1 -1
  56. package/dist/permissions/index.mjs +1 -1
  57. package/dist/{permissions-BPB1xqGf.mjs → permissions-CgbOLJZG.mjs} +10 -2
  58. package/dist/pipeline/index.d.mts +1 -1
  59. package/dist/plugins/index.d.mts +10 -5
  60. package/dist/plugins/index.mjs +3 -3
  61. package/dist/plugins/tracing-entry.mjs +1 -1
  62. package/dist/{pluralize-DccFV9R7.mjs → pluralize-BhrgwZb9.mjs} +3 -3
  63. package/dist/presets/filesUpload.d.mts +1 -1
  64. package/dist/presets/filesUpload.mjs +1 -1
  65. package/dist/presets/index.d.mts +16 -2
  66. package/dist/presets/index.mjs +1 -1
  67. package/dist/presets/multiTenant.d.mts +1 -1
  68. package/dist/presets/multiTenant.mjs +1 -1
  69. package/dist/presets/search.d.mts +1 -1
  70. package/dist/presets/search.mjs +3 -2
  71. package/dist/{presets-BtyFwTO4.mjs → presets-DWsYQdbV.mjs} +1 -1
  72. package/dist/{queryCachePlugin-emHb0CuX.mjs → queryCachePlugin-DzqLN904.mjs} +2 -2
  73. package/dist/{registerRoute-CPmdkZGu.mjs → registerRoute-DGSREwPw.mjs} +20 -2
  74. package/dist/registry/index.d.mts +1 -1
  75. package/dist/registry/index.mjs +2 -2
  76. package/dist/{registry-Bm9LjWKs.mjs → registry-vcOCtPij.mjs} +1 -1
  77. package/dist/{schemaIR-D1MJwqmp.mjs → schemaIR-ajetw3Fv.mjs} +1 -42
  78. package/dist/scim/index.mjs +2 -2
  79. package/dist/sync/changelog.d.mts +2 -0
  80. package/dist/sync/changelog.mjs +2 -0
  81. package/dist/testing/index.d.mts +2 -2
  82. package/dist/testing/index.mjs +2 -2
  83. package/dist/types/index.d.mts +2 -2
  84. package/dist/{types-CmkknLzI.d.mts → types-BHMKlMBU.d.mts} +53 -195
  85. package/dist/{types-D60PFwfG.d.mts → types-C9b2HR5W.d.mts} +1 -1
  86. package/dist/utils/index.d.mts +1 -1
  87. package/dist/utils/index.mjs +1 -1
  88. package/dist/{utils-C6QQ9-hd.mjs → utils-ChjYQpjn.mjs} +1400 -1400
  89. package/dist/{validate-CRcaWzp2.mjs → validate-C2mGgSXg.mjs} +3 -3
  90. package/package.json +22 -8
  91. package/skills/arc/SKILL.md +10 -0
  92. package/skills/arc/references/mcp.md +2 -0
@@ -245,7 +245,7 @@ async function registerArcPlugins(fastify, config, trackPlugin, modules) {
245
245
  trackPlugin("arc-caching", opts);
246
246
  }
247
247
  if (config.arcPlugins?.queryCache) {
248
- const { queryCachePlugin } = await import("./queryCachePlugin-emHb0CuX.mjs").then((n) => n.n);
248
+ const { queryCachePlugin } = await import("./queryCachePlugin-DzqLN904.mjs").then((n) => n.n);
249
249
  const opts = config.arcPlugins.queryCache === true ? {} : config.arcPlugins.queryCache;
250
250
  const store = config.stores?.queryCache ?? new (await (import("./memory-CLXA2VeJ.mjs").then((n) => n.n))).MemoryCacheStore();
251
251
  await fastify.register(queryCachePlugin, {
@@ -384,6 +384,134 @@ function createOptionalAuthenticate(authenticate) {
384
384
  };
385
385
  }
386
386
  //#endregion
387
+ //#region src/factory/module.ts
388
+ /**
389
+ * Resolve any module-input form to a concrete `ArcModule`. A thunk is invoked
390
+ * (this is where a dynamic `import()` fires); a promise (or plain module) is
391
+ * awaited. Called once per module at the start of the bootstrap phase.
392
+ */
393
+ async function resolveModule(input) {
394
+ const resolved = typeof input === "function" ? await input() : await input;
395
+ 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.");
396
+ return resolved;
397
+ }
398
+ /**
399
+ * Order modules for composition by their `dependsOn` edges — a STABLE
400
+ * topological sort. Called once at the start of the bootstrap phase; every
401
+ * subsequent phase (bootstrap, resources, afterResources, and reverse-order
402
+ * onClose) iterates the returned list, so a module's declared dependencies are
403
+ * always composed before it.
404
+ *
405
+ * "Stable" = modules with no edge between them keep their original list order,
406
+ * so declaring `dependsOn` on one module never silently reorders an unrelated
407
+ * one. Backward compatible: a `modules` array with NO `dependsOn` anywhere is
408
+ * returned unchanged.
409
+ *
410
+ * Fail-fast — throws (never reorders past a broken contract) on:
411
+ * - duplicate module names (the name is the graph key)
412
+ * - a `dependsOn` name not present in the composed set
413
+ * - a self-reference (`dependsOn` includes the module's own name)
414
+ * - a dependency cycle (reports the concrete `a → b → … → a` path)
415
+ */
416
+ function orderModules(modules) {
417
+ const byName = /* @__PURE__ */ new Map();
418
+ for (const m of modules) {
419
+ if (byName.has(m.name)) throw new Error(`[arc] Duplicate module name "${m.name}" — composed twice; check your modules array.`);
420
+ byName.set(m.name, m);
421
+ }
422
+ for (const m of modules) for (const dep of m.dependsOn ?? []) {
423
+ if (dep === m.name) throw new Error(`[arc] module "${m.name}" dependsOn itself — remove the self-reference.`);
424
+ 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)"}.`);
425
+ }
426
+ if (modules.every((m) => !m.dependsOn || m.dependsOn.length === 0)) return [...modules];
427
+ const originalIndex = /* @__PURE__ */ new Map();
428
+ let nextIndex = 0;
429
+ for (const m of modules) originalIndex.set(m.name, nextIndex++);
430
+ const pendingDeps = new Map(modules.map((m) => [m.name, (m.dependsOn ?? []).length]));
431
+ const dependents = /* @__PURE__ */ new Map();
432
+ for (const m of modules) for (const dep of m.dependsOn ?? []) {
433
+ const list = dependents.get(dep);
434
+ if (list) list.push(m.name);
435
+ else dependents.set(dep, [m.name]);
436
+ }
437
+ const ordered = [];
438
+ const remaining = new Set(modules.map((m) => m.name));
439
+ while (remaining.size > 0) {
440
+ let pick;
441
+ let pickIndex = Number.POSITIVE_INFINITY;
442
+ for (const name of remaining) {
443
+ if (pendingDeps.get(name) !== 0) continue;
444
+ const index = originalIndex.get(name) ?? Number.POSITIVE_INFINITY;
445
+ if (index < pickIndex) {
446
+ pick = name;
447
+ pickIndex = index;
448
+ }
449
+ }
450
+ if (pick === void 0) throw new Error(describeModuleCycle(remaining, byName));
451
+ const picked = byName.get(pick);
452
+ if (!picked) throw new Error(`[arc] internal: ordered module "${pick}" is missing from the index`);
453
+ ordered.push(picked);
454
+ remaining.delete(pick);
455
+ for (const dependent of dependents.get(pick) ?? []) pendingDeps.set(dependent, (pendingDeps.get(dependent) ?? 1) - 1);
456
+ }
457
+ return ordered;
458
+ }
459
+ /** Walk the still-unordered subgraph for one concrete cycle path. */
460
+ function describeModuleCycle(remaining, byName) {
461
+ const stack = [];
462
+ const onStack = /* @__PURE__ */ new Set();
463
+ const done = /* @__PURE__ */ new Set();
464
+ let cycle = null;
465
+ const walk = (name) => {
466
+ if (cycle) return;
467
+ stack.push(name);
468
+ onStack.add(name);
469
+ for (const dep of byName.get(name)?.dependsOn ?? []) {
470
+ if (!remaining.has(dep)) continue;
471
+ if (onStack.has(dep)) {
472
+ cycle = [...stack.slice(stack.indexOf(dep)), dep];
473
+ return;
474
+ }
475
+ if (!done.has(dep)) walk(dep);
476
+ if (cycle) return;
477
+ }
478
+ onStack.delete(name);
479
+ stack.pop();
480
+ done.add(name);
481
+ };
482
+ for (const name of remaining) {
483
+ if (!done.has(name)) walk(name);
484
+ if (cycle) break;
485
+ }
486
+ 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.`;
487
+ }
488
+ /**
489
+ * Identity helper for authoring a typed module — mirrors `defineResource`.
490
+ * Gives inference + a single obvious construction site; does no work.
491
+ *
492
+ * `TExports` is inferred from the `bootstrap` return value, so a module
493
+ * author gets a typed public export for free:
494
+ *
495
+ * ```ts
496
+ * export const accountingModule = (deps: Deps) =>
497
+ * defineModule({
498
+ * name: "accounting",
499
+ * bootstrap: async () => createAccountingEngine(deps), // TExports inferred
500
+ * });
501
+ * ```
502
+ */
503
+ function defineModule(module) {
504
+ return module;
505
+ }
506
+ function getModuleExports(fastify, name) {
507
+ const modules = fastify.arc?.modules;
508
+ if (!modules || !Object.hasOwn(modules, name)) {
509
+ const available = modules ? Object.keys(modules) : [];
510
+ 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)"}.`);
511
+ }
512
+ return modules[name];
513
+ }
514
+ //#endregion
387
515
  //#region src/factory/registerResources.ts
388
516
  function isResourcesFactory(value) {
389
517
  return typeof value === "function";
@@ -401,15 +529,29 @@ async function registerOne(parent, resource) {
401
529
  }
402
530
  }
403
531
  /**
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
532
+ * Execute the full resource lifecycle. The module graph is resolved + ordered
533
+ * by `dependsOn` (stable topological sort) once, up FRONT — before any
534
+ * side-effecting plugin — so a bad graph fails fast with no half-initialised
535
+ * infra. Every phase below iterates that order; each module phase runs BEFORE
536
+ * its app-level counterpart:
537
+ *
538
+ * 1. resolve + orderModules — validate the graph (dup names, missing/self
539
+ * deps, cycles fail fast) BEFORE side effects
540
+ * 2. plugins() — app infra (DB, data, webhooks)
541
+ * 3. module.plugins — module infra, dependsOn order, before bootstraps
542
+ * 4a. module.bootstrap — engine init (return value → arc.modules[name])
543
+ * 4b. bootstrap[] — app-level domain init
544
+ * 5. resources factory (if any) — resolved AFTER bootstrap, so engine-backed
408
545
  * adapters can `await ensureEngine()` and pass
409
546
  * 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
547
+ * 5b. module resources — RESOLVED after the app factory, but PREPENDED
548
+ * so they REGISTER before app resources (two
549
+ * orderings, both intentional — see §5b)
550
+ * 6. module.afterResources → afterResources() — post-registration wiring
551
+ * 7. onReady — lifecycle hook
552
+ * 7b. onClose — module.onClose (reverse composition order)
553
+ * THEN app onClose, in ONE hook so module
554
+ * teardown runs before shared infra closes.
413
555
  */
414
556
  async function registerResources(fastify, config) {
415
557
  if (config.preset === "production") {
@@ -422,10 +564,37 @@ async function registerResources(fastify, config) {
422
564
  strictResourceDir: true
423
565
  };
424
566
  }
567
+ const modules = orderModules(await Promise.all((config.modules ?? []).map((m) => resolveModule(m))));
425
568
  if (config.plugins) {
426
569
  await config.plugins(fastify);
427
570
  fastify.log.debug("Custom plugins registered");
428
571
  }
572
+ for (const m of modules) {
573
+ if (!m.plugins) continue;
574
+ try {
575
+ await m.plugins(fastify);
576
+ } catch (err) {
577
+ const msg = err instanceof Error ? err.message : String(err);
578
+ throw new Error(`[arc] module "${m.name}" plugins() threw: ${msg}`, { cause: err });
579
+ }
580
+ }
581
+ for (const m of modules) {
582
+ if (!m.bootstrap) continue;
583
+ let exported;
584
+ try {
585
+ exported = await m.bootstrap(fastify);
586
+ } catch (err) {
587
+ const msg = err instanceof Error ? err.message : String(err);
588
+ throw new Error(`[arc] module "${m.name}" bootstrap threw: ${msg}`, { cause: err });
589
+ }
590
+ if (exported !== void 0) {
591
+ const arc = fastify.arc;
592
+ if (!arc) throw new Error(`[arc] module "${m.name}" returned a public export but fastify.arc is not decorated — arcCorePlugin must register first.`);
593
+ if (!arc.modules) arc.modules = Object.create(null);
594
+ arc.modules[m.name] = exported;
595
+ }
596
+ }
597
+ if (modules.length) fastify.log.debug(`${modules.length} module(s) composed (in dependency order): ${modules.map((m) => m.name).join(" → ")}`);
429
598
  if (config.bootstrap?.length) {
430
599
  for (const init of config.bootstrap) await init(fastify);
431
600
  fastify.log.debug(`${config.bootstrap.length} bootstrap function(s) executed`);
@@ -462,6 +631,20 @@ async function registerResources(fastify, config) {
462
631
  }
463
632
  resolvedResources = discovered;
464
633
  }
634
+ if (modules.length) {
635
+ const moduleResources = [];
636
+ for (const m of modules) {
637
+ if (!m.resources) continue;
638
+ try {
639
+ const rs = typeof m.resources === "function" ? await m.resources(fastify) : m.resources;
640
+ moduleResources.push(...rs);
641
+ } catch (err) {
642
+ const msg = err instanceof Error ? err.message : String(err);
643
+ 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 });
644
+ }
645
+ }
646
+ if (moduleResources.length) resolvedResources = [...moduleResources, ...resolvedResources ?? []];
647
+ }
465
648
  if (resolvedResources && resolvedResources.length > 0) {
466
649
  const seen = /* @__PURE__ */ new Set();
467
650
  for (const resource of resolvedResources) if (resource.name) {
@@ -490,6 +673,15 @@ async function registerResources(fastify, config) {
490
673
  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
674
  fastify.log.warn(`0 resources registered${prefix}${scanned}${hints}`);
492
675
  }
676
+ for (const m of modules) {
677
+ if (!m.afterResources) continue;
678
+ try {
679
+ await m.afterResources(fastify);
680
+ } catch (err) {
681
+ const msg = err instanceof Error ? err.message : String(err);
682
+ throw new Error(`[arc] module "${m.name}" afterResources() threw: ${msg}`, { cause: err });
683
+ }
684
+ }
493
685
  if (config.afterResources) {
494
686
  await config.afterResources(fastify);
495
687
  fastify.log.debug("afterResources hook executed");
@@ -500,12 +692,12 @@ async function registerResources(fastify, config) {
500
692
  await onReady(fastify);
501
693
  });
502
694
  }
503
- if (config.onClose) {
504
- const onClose = config.onClose;
505
- fastify.addHook("onClose", async () => {
506
- await onClose(fastify);
507
- });
508
- }
695
+ const closers = modules.filter((m) => m.onClose).reverse();
696
+ const appOnClose = config.onClose;
697
+ if (closers.length || appOnClose) fastify.addHook("onClose", async () => {
698
+ for (const m of closers) await m.onClose?.(fastify);
699
+ if (appOnClose) await appOnClose(fastify);
700
+ });
509
701
  }
510
702
  //#endregion
511
703
  //#region src/factory/registerSecurity.ts
@@ -522,7 +714,8 @@ function buildRateLimitOpts(input) {
522
714
  };
523
715
  const matchesPath = compilePathMatcher(skipPaths);
524
716
  const combined = async (req, key) => {
525
- if (matchesPath((req.url ?? "").split("?", 1)[0] ?? "")) return true;
717
+ const path = (req.url ?? "").split("?", 1)[0] ?? "";
718
+ if (matchesPath(path)) return true;
526
719
  if (typeof allowList === "function") return await allowList(req, key);
527
720
  if (Array.isArray(allowList)) return allowList.includes(key);
528
721
  return false;
@@ -711,7 +904,7 @@ var createApp_exports = /* @__PURE__ */ __exportAll({
711
904
  createApp: () => createApp,
712
905
  resolveLoggerConfig: () => resolveLoggerConfig
713
906
  });
714
- const MEMORY_STORE_NAMES = new Set(["memory", "memory-cache"]);
907
+ const MEMORY_STORE_NAMES = /* @__PURE__ */ new Set(["memory", "memory-cache"]);
715
908
  /**
716
909
  * Default redact paths layered into Fastify's pino logger when the host
717
910
  * doesn't supply a `logger.redact` of their own. Covers the common token /
@@ -947,4 +1140,4 @@ const ArcFactory = {
947
1140
  }
948
1141
  };
949
1142
  //#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 };
1143
+ export { getModuleExports as a, developmentPreset as c, productionPreset as d, testingPreset as f, defineModule as i, edgePreset as l, createApp as n, orderModules as o, createApp_exports as r, resolveModule as s, ArcFactory as t, getPreset as u };
@@ -1,4 +1,4 @@
1
- import { p as RegistryEntry } from "../index-CHNQIhcb.mjs";
1
+ import { p as RegistryEntry } from "../index-_--GyzcO.mjs";
2
2
  import { t as ExternalOpenApiPaths } from "../externalPaths-BE6wFpYv.mjs";
3
3
  import { FastifyPluginAsync } from "fastify";
4
4
 
@@ -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-xVJibapr.mjs";
2
+ import { n as openApiPlugin, r as openapi_default, t as buildOpenApiSpec } from "../openapi-Dq6RbEGK.mjs";
3
3
  import fp from "fastify-plugin";
4
4
  //#region src/docs/scalar.ts
5
5
  const scalarPlugin = async (fastify, opts = {}) => {
@@ -0,0 +1,222 @@
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
133
+ //#region src/encryption/encryptionPlugin.d.ts
134
+ declare const encryptionPlugin: FastifyPluginAsync<EncryptionOptions>;
135
+ declare const _default: FastifyPluginAsync<EncryptionOptions>;
136
+ //#endregion
137
+ //#region src/encryption/fieldCipher.d.ts
138
+ /**
139
+ * Field-level cipher — `node:crypto` AES-256-GCM, zero external deps.
140
+ *
141
+ * Used by `mode: 'fields'` to encrypt individual field values in place while
142
+ * leaving the JSON structure intact. The envelope is an authenticated,
143
+ * versioned, `kid`-tagged token:
144
+ *
145
+ * arc.v1.<b64url(kid)>.<b64url(iv)>.<b64url(ciphertext)>.<b64url(tag)>
146
+ *
147
+ * This is NOT a hand-rolled cipher — it's the correct use of a vetted AEAD
148
+ * primitive (AES-256-GCM): a fresh random 96-bit IV per operation (never
149
+ * caller-supplied — IV reuse under GCM is catastrophic), authentication via
150
+ * the GCM tag, and a version prefix so the format can evolve. Same shape, in
151
+ * spirit, as MongoDB CSFLE's BSON-binary subtype-6 envelope. For
152
+ * cross-vendor interop or asymmetric keys use `mode: 'jwe'` (RFC 7516)
153
+ * instead.
154
+ */
155
+ /** Encrypt a UTF-8 string into the `arc.v1` field envelope. */
156
+ declare function encryptField(plaintext: string, kid: string, key: unknown): string;
157
+ /** Parsed envelope header — exposes `kid` for key resolution before decrypt. */
158
+ interface ParsedFieldEnvelope {
159
+ readonly kid: string;
160
+ readonly iv: Buffer;
161
+ readonly ciphertext: Buffer;
162
+ readonly tag: Buffer;
163
+ }
164
+ /** Returns the parsed envelope, or `null` when the token isn't `arc.v1`. */
165
+ declare function parseFieldEnvelope(token: string): ParsedFieldEnvelope | null;
166
+ /** Decrypt a parsed `arc.v1` envelope with the resolved key. Throws on tamper. */
167
+ declare function decryptField(envelope: ParsedFieldEnvelope, key: unknown): string;
168
+ //#endregion
169
+ //#region src/encryption/keyProvider.d.ts
170
+ interface StaticKeyProviderOptions {
171
+ /**
172
+ * Active key used to ENCRYPT outbound responses. For `jwe` mode this is the
173
+ * recipient's PUBLIC key; for `fields` mode a 32-byte symmetric secret.
174
+ */
175
+ readonly encryptionKey: ActiveKey;
176
+ /**
177
+ * Keys used to DECRYPT inbound requests, indexed by `kid`. For `jwe` mode
178
+ * these are PRIVATE keys; for `fields` mode the same symmetric secrets.
179
+ *
180
+ * Defaults to `{ [encryptionKey.kid]: encryptionKey.key }` — correct for
181
+ * symmetric field mode where one key both encrypts and decrypts. For
182
+ * asymmetric JWE you MUST pass the private key(s) here; the public
183
+ * `encryptionKey` cannot decrypt. Keep 2–3 entries during a rotation so
184
+ * in-flight messages signed with the previous `kid` still decrypt.
185
+ */
186
+ readonly decryptionKeys?: Readonly<Record<string, KeyMaterial>>;
187
+ }
188
+ /**
189
+ * A static, in-memory key provider with `kid`-based rotation. Keys are held
190
+ * for the process lifetime — resolve them from env / a secrets file at boot
191
+ * and pass the imported key material in (don't re-parse PEM per request).
192
+ *
193
+ * @example symmetric field mode
194
+ * ```ts
195
+ * const provider = createStaticKeyProvider({
196
+ * encryptionKey: { kid: "2026-06", key: Buffer.from(process.env.FIELD_KEY!, "base64") },
197
+ * });
198
+ * ```
199
+ *
200
+ * @example asymmetric JWE (jose key material)
201
+ * ```ts
202
+ * const provider = createStaticKeyProvider({
203
+ * encryptionKey: { kid: "client-1", key: await importSPKI(clientPubPem, "RSA-OAEP-256") },
204
+ * decryptionKeys: { "server-1": await importPKCS8(serverPrivPem, "RSA-OAEP-256") },
205
+ * });
206
+ * ```
207
+ */
208
+ declare function createStaticKeyProvider(options: StaticKeyProviderOptions): KeyProvider;
209
+ //#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
+ 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 };