@classytic/arc 2.11.2 → 2.11.4

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 (113) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +20 -21
  3. package/bin/arc.js +2 -2
  4. package/dist/{BaseController-JNV08qOT.mjs → BaseController-swXruJ2_.mjs} +2 -2
  5. package/dist/EventTransport-BFQjw9pB.mjs +133 -0
  6. package/dist/{QueryCache-DOBNHBE0.d.mts → QueryCache-D41bfdBB.d.mts} +1 -1
  7. package/dist/{actionPermissions-C8YYU92K.mjs → actionPermissions-sUUKDhtP.mjs} +4 -2
  8. package/dist/adapters/index.d.mts +3 -3
  9. package/dist/adapters/index.mjs +2 -2
  10. package/dist/{adapters-D0tT2Tyo.mjs → adapters-DUUiiimH.mjs} +17 -2
  11. package/dist/audit/index.d.mts +2 -2
  12. package/dist/auth/index.d.mts +4 -4
  13. package/dist/auth/index.mjs +1 -1
  14. package/dist/auth/redis-session.d.mts +1 -1
  15. package/dist/cache/index.d.mts +3 -3
  16. package/dist/cli/commands/docs.mjs +1 -1
  17. package/dist/cli/commands/generate.d.mts +0 -2
  18. package/dist/cli/commands/generate.mjs +16 -16
  19. package/dist/cli/commands/init.mjs +149 -65
  20. package/dist/context/index.mjs +1 -1
  21. package/dist/core/index.d.mts +2 -2
  22. package/dist/core/index.mjs +3 -3
  23. package/dist/{core-DXdSSFW-.mjs → core-CbcQRIch.mjs} +25 -8
  24. package/dist/{createActionRouter-BwaSM0No.mjs → createActionRouter-CIKOcNA7.mjs} +74 -14
  25. package/dist/{createApp-P1d6rjPy.mjs → createApp-C9bRrqlX.mjs} +4 -6
  26. package/dist/defineEvent-D1Ky9M1D.mjs +188 -0
  27. package/dist/docs/index.d.mts +2 -2
  28. package/dist/docs/index.mjs +1 -1
  29. package/dist/{eventPlugin--5HIkdPU.mjs → eventPlugin-Cts2-Tfj.mjs} +9 -135
  30. package/dist/{eventPlugin-CUNjYYRY.d.mts → eventPlugin-DDJoNEPL.d.mts} +34 -7
  31. package/dist/events/index.d.mts +164 -5
  32. package/dist/events/index.mjs +138 -182
  33. package/dist/events/transports/redis-stream-entry.d.mts +1 -1
  34. package/dist/events/transports/redis-stream-entry.mjs +204 -31
  35. package/dist/events/transports/redis.d.mts +1 -1
  36. package/dist/factory/index.d.mts +1 -1
  37. package/dist/factory/index.mjs +1 -1
  38. package/dist/{fields-C8Y0XLAu.d.mts → fields-BRjxOAFp.d.mts} +1 -1
  39. package/dist/hooks/index.d.mts +1 -1
  40. package/dist/idempotency/index.d.mts +3 -3
  41. package/dist/idempotency/index.mjs +1 -1
  42. package/dist/idempotency/redis.d.mts +1 -1
  43. package/dist/{index-6u4_Gg6G.d.mts → index-CXXRbnf8.d.mts} +51 -5
  44. package/dist/{index-DdQ3O9Pg.d.mts → index-D9t1KNaB.d.mts} +2 -2
  45. package/dist/{index-BbMrcvGp.d.mts → index-Rg8axYPz.d.mts} +12 -4
  46. package/dist/{index-BdXnTPRj.d.mts → index-m8mOOlFW.d.mts} +3 -3
  47. package/dist/{index-BYCqHCVu.d.mts → index-rHjXmJar.d.mts} +3 -3
  48. package/dist/index.d.mts +7 -7
  49. package/dist/index.mjs +7 -7
  50. package/dist/integrations/event-gateway.d.mts +2 -2
  51. package/dist/integrations/index.d.mts +2 -2
  52. package/dist/integrations/mcp/index.d.mts +2 -2
  53. package/dist/integrations/mcp/index.mjs +1 -1
  54. package/dist/integrations/mcp/testing.d.mts +1 -1
  55. package/dist/integrations/mcp/testing.mjs +1 -1
  56. package/dist/integrations/websocket-redis.d.mts +1 -1
  57. package/dist/integrations/websocket.d.mts +1 -1
  58. package/dist/middleware/index.d.mts +1 -1
  59. package/dist/{openapi-C0L9ar7m.mjs → openapi-D7G1V7ex.mjs} +2 -2
  60. package/dist/org/index.d.mts +2 -2
  61. package/dist/permissions/index.d.mts +2 -2
  62. package/dist/permissions/index.mjs +1 -1
  63. package/dist/{permissions-B4vU9L0Q.mjs → permissions-gd_aUWrR.mjs} +42 -0
  64. package/dist/pipeline/index.d.mts +1 -1
  65. package/dist/plugins/index.d.mts +5 -5
  66. package/dist/plugins/index.mjs +1 -1
  67. package/dist/plugins/tracing-entry.d.mts +1 -1
  68. package/dist/plugins/tracing-entry.mjs +1 -1
  69. package/dist/presets/filesUpload.d.mts +4 -4
  70. package/dist/presets/filesUpload.mjs +1 -1
  71. package/dist/presets/index.d.mts +1 -1
  72. package/dist/presets/index.mjs +1 -1
  73. package/dist/presets/multiTenant.d.mts +1 -1
  74. package/dist/presets/search.d.mts +2 -2
  75. package/dist/presets/search.mjs +1 -1
  76. package/dist/{presets-k604Lj99.mjs → presets-Z7P5w4gF.mjs} +1 -1
  77. package/dist/{queryCachePlugin-BUXBSm4F.d.mts → queryCachePlugin-CqMdLI2-.d.mts} +2 -2
  78. package/dist/{redis-Cm1gnRDf.d.mts → redis-DiMkdHEl.d.mts} +1 -1
  79. package/dist/redis-stream-xTGxB2bm.d.mts +232 -0
  80. package/dist/registry/index.d.mts +1 -1
  81. package/dist/{requestContext-CfRkaxwf.mjs → requestContext-C5XeK3VA.mjs} +15 -0
  82. package/dist/{resourceToTools--okX6QBr.mjs → resourceToTools-CxNmI6xF.mjs} +7 -6
  83. package/dist/{routerShared-DeESFp4a.mjs → routerShared-BqLRb5l7.mjs} +60 -3
  84. package/dist/scope/index.d.mts +2 -2
  85. package/dist/testing/index.d.mts +2 -2
  86. package/dist/testing/index.mjs +1 -1
  87. package/dist/testing/storageContract.d.mts +1 -1
  88. package/dist/types/index.d.mts +4 -4
  89. package/dist/types/storage.d.mts +1 -1
  90. package/dist/{types-9beEMe25.d.mts → types-BQ9TJQNy.d.mts} +1 -1
  91. package/dist/{types-BH7dEGvU.d.mts → types-D7KpfiL1.d.mts} +10 -10
  92. package/dist/utils/index.d.mts +1 -1
  93. package/dist/utils/index.mjs +1 -1
  94. package/dist/{utils-D3Yxnrwr.mjs → utils-CcYTj09l.mjs} +1 -1
  95. package/dist/{versioning-M9lNLhO8.d.mts → versioning-DsglKfM_.d.mts} +1 -1
  96. package/package.json +3 -1
  97. package/skills/arc/SKILL.md +409 -769
  98. package/skills/arc/references/events.md +489 -489
  99. package/dist/redis-stream-CM8TXTix.d.mts +0 -110
  100. /package/dist/{EventTransport-CfVEGaEl.d.mts → EventTransport-CYNUXdCJ.d.mts} +0 -0
  101. /package/dist/{elevation-s5ykdNHr.d.mts → elevation-BQQXZ_VR.d.mts} +0 -0
  102. /package/dist/{errorHandler-Co3lnVmJ.d.mts → errorHandler-DEWmGWPz.d.mts} +0 -0
  103. /package/dist/{externalPaths-Bapitwvd.d.mts → externalPaths-BD5nw6St.d.mts} +0 -0
  104. /package/dist/{interface-CkkWm5uR.d.mts → interface-DfLGcus7.d.mts} +0 -0
  105. /package/dist/{interface-Da0r7Lna.d.mts → interface-beEtJyWM.d.mts} +0 -0
  106. /package/dist/{pluralize-BneOJkpi.mjs → pluralize-CWP6MB39.mjs} +0 -0
  107. /package/dist/{schemaIR-BlG9bY7v.mjs → schemaIR-Dy2p4MxS.mjs} +0 -0
  108. /package/dist/{sessionManager-D-oNWHz3.d.mts → sessionManager-C4Le_UB3.d.mts} +0 -0
  109. /package/dist/{storage-BwGQXUpd.d.mts → storage-Dfzt4VTl.d.mts} +0 -0
  110. /package/dist/{store-helpers-BhrzxvyQ.mjs → store-helpers-Cp4uKC1U.mjs} +0 -0
  111. /package/dist/{tracing-DokiEsuz.d.mts → tracing-QJVprktp.d.mts} +0 -0
  112. /package/dist/{types-tgR4Pt8F.d.mts → types-DDyTPc6y.d.mts} +0 -0
  113. /package/dist/{websocket-CyJ1VIFI.d.mts → websocket-ChC2rqe1.d.mts} +0 -0
@@ -26,7 +26,7 @@ async function init(options = {}) {
26
26
  `);
27
27
  const config = await gatherConfig(options);
28
28
  console.log(`\nCreating project: ${config.name}`);
29
- console.log(` Adapter: ${config.adapter === "mongokit" ? "MongoKit (MongoDB)" : "Custom"}`);
29
+ console.log(` Adapter: ${config.adapter === "mongokit" ? "MongoKit (MongoDB)" : "Custom / Drizzle-ready"}`);
30
30
  console.log(` Auth: ${config.auth === "better-auth" ? "Better Auth (recommended)" : "Arc JWT"}`);
31
31
  console.log(` Tenant: ${config.tenant === "multi" ? "Multi-tenant" : "Single-tenant"}`);
32
32
  console.log(` Language: ${config.typescript ? "TypeScript" : "JavaScript"}`);
@@ -87,42 +87,103 @@ function existsSync$1(filePath) {
87
87
  }
88
88
  }
89
89
  /**
90
- * Install dependencies using the detected package manager
90
+ * Single source of truth for scaffolded project dependencies.
91
+ *
92
+ * Versions are pinned to the floor each subsystem requires — peer-dep
93
+ * minimums on Arc, kit minimums (mongokit ≥ 3.11, repo-core ≥ 0.2,
94
+ * mongoose ≥ 9.4.1), and major-version stable for the rest. The carets
95
+ * allow minor + patch upgrades without breaking arc's contract, while
96
+ * preventing the silent breakage of `@latest` on a kit floor bump.
97
+ *
98
+ * Used by both `packageJsonTemplate` (declares the deps in the generated
99
+ * `package.json` so `npm install` works without a pre-pass) and
100
+ * `installDependencies` (runs the package manager's `install` against
101
+ * the declared ranges). One source — no drift.
91
102
  */
92
- async function installDependencies(projectPath, config, pm) {
93
- const deps = [
94
- "@classytic/arc@latest",
95
- "fastify@latest",
96
- "@fastify/cors@latest",
97
- "@fastify/helmet@latest",
98
- "@fastify/rate-limit@latest",
99
- "@fastify/sensible@latest",
100
- "@fastify/under-pressure@latest",
101
- "dotenv@latest"
102
- ];
103
- if (config.auth === "better-auth") deps.push("better-auth@^1.6.0", "mongodb@latest");
104
- else deps.push("@fastify/jwt@latest", "bcryptjs@latest");
105
- if (config.adapter === "mongokit") deps.push("@classytic/mongokit@^3.11.0", "@classytic/repo-core@^0.2.0", "mongoose@^9.4.1");
106
- const devDeps = ["vitest@latest", "pino-pretty@latest"];
107
- if (config.typescript) devDeps.push("typescript@latest", "@types/node@latest", "tsx@latest");
108
- const installCmd = getInstallCommand(pm, deps, false);
109
- const installDevCmd = getInstallCommand(pm, devDeps, true);
103
+ const SCAFFOLD_DEP_VERSIONS = {
104
+ core: {
105
+ "@classytic/arc": "^2.11.3",
106
+ "@fastify/cors": "^11.0.0",
107
+ "@fastify/helmet": "^13.0.0",
108
+ "@fastify/rate-limit": "^10.0.0",
109
+ "@fastify/sensible": "^6.0.0",
110
+ "@fastify/under-pressure": "^9.0.0",
111
+ dotenv: "^17.0.0",
112
+ fastify: "^5.8.0"
113
+ },
114
+ authJwt: {
115
+ "@fastify/jwt": "^10.0.0",
116
+ bcryptjs: "^3.0.0"
117
+ },
118
+ authBetterAuth: {
119
+ "better-auth": "^1.6.0",
120
+ mongodb: "^6.10.0"
121
+ },
122
+ adapterMongokit: {
123
+ "@classytic/mongokit": "^3.11.0",
124
+ "@classytic/repo-core": "^0.2.0",
125
+ mongoose: "^9.4.1"
126
+ },
127
+ devCommon: {
128
+ "pino-pretty": "^13.0.0",
129
+ vitest: "^4.0.0"
130
+ },
131
+ devTypescript: {
132
+ "@types/node": "^22.0.0",
133
+ tsx: "^4.21.0",
134
+ typescript: "^5.6.0"
135
+ },
136
+ typesJwt: { "@types/bcryptjs": "^3.0.0" }
137
+ };
138
+ /**
139
+ * Resolve the dependency manifest for a scaffold configuration.
140
+ *
141
+ * Returns sorted records (alphabetical by package name) so the generated
142
+ * `package.json` is deterministic — diffs across re-runs stay clean.
143
+ */
144
+ function resolveScaffoldDependencies(config) {
145
+ const dependencies = { ...SCAFFOLD_DEP_VERSIONS.core };
146
+ const devDependencies = { ...SCAFFOLD_DEP_VERSIONS.devCommon };
147
+ if (config.auth === "better-auth") Object.assign(dependencies, SCAFFOLD_DEP_VERSIONS.authBetterAuth);
148
+ else {
149
+ Object.assign(dependencies, SCAFFOLD_DEP_VERSIONS.authJwt);
150
+ if (config.typescript) Object.assign(devDependencies, SCAFFOLD_DEP_VERSIONS.typesJwt);
151
+ }
152
+ if (config.adapter === "mongokit") Object.assign(dependencies, SCAFFOLD_DEP_VERSIONS.adapterMongokit);
153
+ if (config.typescript) Object.assign(devDependencies, SCAFFOLD_DEP_VERSIONS.devTypescript);
154
+ return {
155
+ dependencies: sortByKey(dependencies),
156
+ devDependencies: sortByKey(devDependencies)
157
+ };
158
+ }
159
+ /**
160
+ * Sort a record alphabetically by key — package.json convention.
161
+ */
162
+ function sortByKey(record) {
163
+ return Object.fromEntries(Object.entries(record).sort(([a], [b]) => a.localeCompare(b)));
164
+ }
165
+ /**
166
+ * Install dependencies using the detected package manager.
167
+ *
168
+ * Dependencies are already declared in the generated `package.json` (see
169
+ * `packageJsonTemplate`), so a single plain `install` resolves the full
170
+ * tree. No two-pass `npm add` flow — the manifest is the source of truth.
171
+ */
172
+ async function installDependencies(projectPath, _config, pm) {
110
173
  console.log(` Installing dependencies...`);
111
- await runCommand(installCmd, projectPath);
112
- console.log(` Installing dev dependencies...`);
113
- await runCommand(installDevCmd, projectPath);
174
+ await runCommand(getInstallCommand(pm), projectPath);
114
175
  console.log(`\nDependencies installed successfully.`);
115
176
  }
116
177
  /**
117
- * Get the install command for a package manager
178
+ * Get the plain `install` command for a package manager. Reads the declared
179
+ * dependencies from the project's `package.json`.
118
180
  */
119
- function getInstallCommand(pm, packages, isDev) {
120
- const pkgList = packages.join(" ");
181
+ function getInstallCommand(pm) {
121
182
  switch (pm) {
122
- case "pnpm": return `pnpm add ${isDev ? "-D" : ""} ${pkgList}`;
123
- case "yarn": return `yarn add ${isDev ? "-D" : ""} ${pkgList}`;
124
- case "bun": return `bun add ${isDev ? "-d" : ""} ${pkgList}`;
125
- default: return `npm install ${isDev ? "--save-dev" : ""} ${pkgList}`;
183
+ case "pnpm": return "pnpm install";
184
+ case "yarn": return "yarn install";
185
+ case "bun": return "bun install";
186
+ default: return "npm install";
126
187
  }
127
188
  }
128
189
  /**
@@ -156,7 +217,7 @@ async function gatherConfig(options) {
156
217
  try {
157
218
  const name = options.name || await question("Project name: ") || "my-arc-app";
158
219
  let adapter = options.adapter || "mongokit";
159
- if (!options.adapter && !nonInteractive) adapter = await question("Database adapter [1=MongoKit (recommended), 2=Custom]: ") === "2" ? "custom" : "mongokit";
220
+ if (!options.adapter && !nonInteractive) adapter = await question("Database adapter [1=MongoKit (recommended), 2=Custom / Drizzle-ready]: ") === "2" ? "custom" : "mongokit";
160
221
  let auth = options.auth || "better-auth";
161
222
  if (!options.auth && !nonInteractive) auth = await question("Auth strategy [1=Better Auth (recommended), 2=Arc JWT]: ") === "2" ? "jwt" : "better-auth";
162
223
  let tenant = options.tenant || "single";
@@ -171,12 +232,12 @@ async function gatherConfig(options) {
171
232
  console.log(" MongoDB Atlas works with the raw driver (mongodb 6.15+ with nodejs_compat_v2),");
172
233
  console.log(" but MongoKit depends on Mongoose. Options:");
173
234
  console.log(" 1. Use AWS Lambda / Vercel Serverless (Node.js) — Mongoose works normally");
174
- console.log(" 2. Use Cloudflare Hyperdrive + PostgreSQL (switch to Prisma/Drizzle adapter)");
235
+ console.log(" 2. Use Cloudflare Hyperdrive + PostgreSQL (wire sqlitekit/Drizzle via custom adapter)");
175
236
  console.log(" 3. Continue with MongoKit — works on Lambda/Vercel, NOT on Cloudflare Workers");
176
237
  console.log("");
177
238
  if ((await question("Continue with MongoKit? [y/N]: ")).toLowerCase() !== "y") {
178
239
  adapter = "custom";
179
- console.log(" Switched to custom adapter. You can wire Drizzle, Prisma, or the raw MongoDB driver.");
240
+ console.log(" Switched to custom adapter. Wire sqlitekit/Drizzle here; Prisma remains experimental.");
180
241
  }
181
242
  }
182
243
  return {
@@ -267,6 +328,7 @@ async function createProjectStructure(projectPath, config) {
267
328
  }
268
329
  }
269
330
  function packageJsonTemplate(config) {
331
+ const { dependencies, devDependencies } = resolveScaffoldDependencies(config);
270
332
  const scripts = config.typescript ? config.edge ? {
271
333
  dev: "tsx watch src/index.ts",
272
334
  build: "tsc",
@@ -309,6 +371,8 @@ function packageJsonTemplate(config) {
309
371
  "#utils/*": "./src/utils/*"
310
372
  },
311
373
  scripts,
374
+ dependencies,
375
+ devDependencies,
312
376
  engines: { node: ">=22" }
313
377
  }, null, 2);
314
378
  }
@@ -465,7 +529,7 @@ src/
465
529
  │ ├── env.${ext} # Env loader (import first!)
466
530
  │ └── index.${ext} # App config
467
531
  ├── shared/ # Shared utilities
468
- │ ├── adapter.${ext} # ${config.adapter === "mongokit" ? "MongoKit adapter factory" : "Custom adapter"}
532
+ │ ├── adapter.${ext} # ${config.adapter === "mongokit" ? "MongoKit adapter factory" : "Custom / Drizzle-ready adapter"}
469
533
  │ ├── permissions.${ext} # Permission helpers
470
534
  │ └── presets/ # ${config.tenant === "multi" ? "Multi-tenant presets" : "Standard presets"}
471
535
  ├── plugins/ # App-specific plugins
@@ -995,29 +1059,31 @@ function customAdapterTemplate(config) {
995
1059
  return `/**
996
1060
  * Custom Adapter Factory
997
1061
  *
998
- * Implement your own database adapter here.
1062
+ * Use this for sqlitekit/Drizzle, Prisma experiments, or any repository
1063
+ * that satisfies Arc's RepositoryLike contract.
999
1064
  */
1000
1065
 
1001
- import { createMongooseAdapter } from '@classytic/arc';
1002
- ${ts ? "import type { Model } from 'mongoose';" : ""}
1066
+ ${ts ? "import type { DataAdapter, RepositoryLike } from '@classytic/arc/adapters';" : ""}
1003
1067
 
1004
1068
  /**
1005
- * Create a custom adapter for a resource
1069
+ * Create a custom adapter for a resource.
1006
1070
  *
1007
- * Implement this based on your database choice:
1008
- * - Prisma: Use @classytic/prismakit (coming soon)
1009
- * - Drizzle: Create custom adapter
1010
- * - Raw SQL: Create custom adapter
1011
- */
1012
- export function createAdapter${ts ? "<TDoc>" : ""}(
1013
- model${ts ? ": Model<TDoc>" : ""},
1014
- repository${ts ? ": any" : ""}
1015
- )${ts ? ": ReturnType<typeof createMongooseAdapter>" : ""} {
1016
- // SCAFFOLD: Replace with your custom adapter implementation
1017
- return createMongooseAdapter({
1018
- model,
1071
+ * Recommended SQL path:
1072
+ * - sqlitekit repository + Arc's createDrizzleAdapter for Drizzle tables
1073
+ *
1074
+ * Experimental path:
1075
+ * - Prisma can be wired with createPrismaAdapter, but keep it opt-in until
1076
+ * your app has integration coverage.
1077
+ */
1078
+ export function createAdapter${ts ? "<TDoc = unknown>" : ""}(
1079
+ _source${ts ? ": unknown" : ""},
1080
+ repository${ts ? ": RepositoryLike<TDoc>" : ""}
1081
+ )${ts ? ": DataAdapter<TDoc>" : ""} {
1082
+ return {
1083
+ type: 'custom',
1084
+ name: 'custom-repository',
1019
1085
  repository,
1020
- });
1086
+ };
1021
1087
  }
1022
1088
  `;
1023
1089
  }
@@ -1806,8 +1872,10 @@ describe('Example Resource', () => {
1806
1872
  authMode: 'jwt',
1807
1873
  ${config.adapter === "mongokit" ? " connectMongoose: true,\n" : ""} });
1808
1874
 
1875
+ // Arc's permission engine reads singular user.role — string,
1876
+ // comma-separated string, or array all normalise via getUserRoles().
1809
1877
  ctx.auth${ts ? "!" : ""}.register('admin', {
1810
- user: { id: '1', roles: ['admin'] },
1878
+ user: { id: '1', role: 'admin' },
1811
1879
  orgId: 'org-1',
1812
1880
  });
1813
1881
  });
@@ -1911,13 +1979,19 @@ userSchema.methods.removeOrganization = function(organizationId${ts ? ": Types.O
1911
1979
  userSchema.index({ 'organizations.organizationId': 1 });
1912
1980
  ` : "";
1913
1981
  const userType = ts ? `
1914
- type PlatformRole = 'user' | 'admin' | 'superadmin';
1982
+ const PLATFORM_ROLES = ['user', 'admin', 'superadmin'] as const;
1983
+ type PlatformRole = typeof PLATFORM_ROLES[number];
1915
1984
 
1985
+ /**
1986
+ * Comma-separated list of platform roles (Better Auth admin-plugin convention).
1987
+ * Single role: 'admin'. Multiple: 'admin,trainer'. Arc's permission engine
1988
+ * normalises both forms via getUserRoles() — see @classytic/arc/scope.
1989
+ */
1916
1990
  type User = {
1917
1991
  name: string;
1918
1992
  email: string;
1919
1993
  password: string;
1920
- roles: PlatformRole[];${config.tenant === "multi" ? `
1994
+ role: string;${config.tenant === "multi" ? `
1921
1995
  organizations: UserOrganization[];` : ""}
1922
1996
  resetPasswordToken?: string;
1923
1997
  resetPasswordExpires?: Date;
@@ -1956,11 +2030,21 @@ const userSchema = new Schema${ts ? "<User, UserModel, UserMethods>" : ""}(
1956
2030
  },
1957
2031
  password: { type: String, required: true },
1958
2032
 
1959
- // Platform roles
1960
- roles: {
1961
- type: [String],
1962
- enum: ['user', 'admin', 'superadmin'],
1963
- default: ['user'],
2033
+ // Platform role — singular field, matches Arc's permission engine
2034
+ // (req.user.role) and Better Auth's admin-plugin convention.
2035
+ // Comma-separated for multi-role users (e.g. 'admin,trainer');
2036
+ // getUserRoles() in @classytic/arc/scope normalises both forms.
2037
+ role: {
2038
+ type: String,
2039
+ required: true,
2040
+ default: 'user',
2041
+ index: true,
2042
+ validate: {
2043
+ validator: (v${ts ? ": string" : ""}) =>
2044
+ /^(user|admin|superadmin)(,(user|admin|superadmin))*$/.test(v),
2045
+ message: (props${ts ? ": { value: string }" : ""}) =>
2046
+ \`Invalid role "\${props.value}" — expected one or more of user|admin|superadmin\`,
2047
+ },
1964
2048
  },
1965
2049
  ${orgSchema}
1966
2050
  // Password reset
@@ -2379,7 +2463,7 @@ export async function register(request${ts ? ": FastifyRequest" : ""}, reply${ts
2379
2463
  }
2380
2464
 
2381
2465
  // Create user
2382
- await userRepository.create({ name, email, password, roles: ['user'] });
2466
+ await userRepository.create({ name, email, password, role: 'user' });
2383
2467
 
2384
2468
  return reply.code(201).send({ success: true, message: 'User registered successfully' });
2385
2469
  } catch (error) {
@@ -2504,10 +2588,10 @@ export async function updateUserProfile(request${ts ? ": FastifyRequest" : ""},
2504
2588
  const userId = (request${ts ? " as any" : ""}).user?._id || (request${ts ? " as any" : ""}).user?.id;
2505
2589
  const updates = { ...request.body${ts ? " as any" : ""} };
2506
2590
 
2507
- // Prevent updating protected fields
2508
- if ('password' in updates) delete updates.password;
2509
- if ('roles' in updates) delete updates.roles;
2510
- if ('organizations' in updates) delete updates.organizations;
2591
+ // Prevent updating protected fields — auth-managed only
2592
+ delete updates.password;
2593
+ delete updates.role;
2594
+ delete updates.organizations;
2511
2595
 
2512
2596
  const user = await userRepository.Model.findByIdAndUpdate(userId, updates, { new: true });
2513
2597
  return reply.send({ success: true, data: user });
@@ -2596,7 +2680,7 @@ export const loginResponse = {
2596
2680
  id: { type: 'string' },
2597
2681
  name: { type: 'string' },
2598
2682
  email: { type: 'string' },
2599
- roles: { type: 'array', items: { type: 'string' } },
2683
+ role: { type: 'string' },
2600
2684
  },
2601
2685
  },
2602
2686
  accessToken: { type: 'string' },
@@ -1,2 +1,2 @@
1
- import { t as requestContext } from "../requestContext-CfRkaxwf.mjs";
1
+ import { t as requestContext } from "../requestContext-C5XeK3VA.mjs";
2
2
  export { requestContext };
@@ -1,3 +1,3 @@
1
- import { B as ResourceDefinition, Gt as TreeMixin, Ht as SoftDeleteExt, Jt as BulkExt, Kt as SlugExt, Ut as SoftDeleteMixin, V as defineResource, Vt as BaseController, Wt as TreeExt, Yt as BulkMixin, an as QueryResolverConfig, cn as AccessControl, in as QueryResolver, ln as AccessControlConfig, nn as BaseCrudController, on as BodySanitizer, qt as SlugMixin, rn as ListResult, sn as BodySanitizerConfig, tn as BaseControllerOptions } from "../index-6u4_Gg6G.mjs";
2
- import { C as MAX_REGEX_LENGTH, D as RESERVED_QUERY_PARAMS, E as MutationOperation, O as SYSTEM_FIELDS, S as MAX_FILTER_DEPTH, T as MUTATION_OPERATIONS, _ as DEFAULT_UPDATE_METHOD, a as getControllerScope, b as HookOperation, c as createCrudRouter, d as CrudOperation, f as DEFAULT_ID_FIELD, g as DEFAULT_TENANT_FIELD, h as DEFAULT_SORT, i as getControllerContext, l as createPermissionMiddleware, m as DEFAULT_MAX_LIMIT, n as createFastifyHandler, o as sendControllerResponse, p as DEFAULT_LIMIT, r as createRequestContext, s as defineResourceVariants, t as createCrudHandlers, u as CRUD_OPERATIONS, v as HOOK_OPERATIONS, w as MAX_SEARCH_LENGTH, x as HookPhase, y as HOOK_PHASES } from "../index-BdXnTPRj.mjs";
1
+ import { B as ResourceDefinition, Gt as TreeMixin, Ht as SoftDeleteExt, Jt as BulkExt, Kt as SlugExt, Ut as SoftDeleteMixin, V as defineResource, Vt as BaseController, Wt as TreeExt, Yt as BulkMixin, an as QueryResolverConfig, cn as AccessControl, in as QueryResolver, ln as AccessControlConfig, nn as BaseCrudController, on as BodySanitizer, qt as SlugMixin, rn as ListResult, sn as BodySanitizerConfig, tn as BaseControllerOptions } from "../index-CXXRbnf8.mjs";
2
+ import { C as MAX_REGEX_LENGTH, D as RESERVED_QUERY_PARAMS, E as MutationOperation, O as SYSTEM_FIELDS, S as MAX_FILTER_DEPTH, T as MUTATION_OPERATIONS, _ as DEFAULT_UPDATE_METHOD, a as getControllerScope, b as HookOperation, c as createCrudRouter, d as CrudOperation, f as DEFAULT_ID_FIELD, g as DEFAULT_TENANT_FIELD, h as DEFAULT_SORT, i as getControllerContext, l as createPermissionMiddleware, m as DEFAULT_MAX_LIMIT, n as createFastifyHandler, o as sendControllerResponse, p as DEFAULT_LIMIT, r as createRequestContext, s as defineResourceVariants, t as createCrudHandlers, u as CRUD_OPERATIONS, v as HOOK_OPERATIONS, w as MAX_SEARCH_LENGTH, x as HookPhase, y as HOOK_PHASES } from "../index-m8mOOlFW.mjs";
3
3
  export { AccessControl, AccessControlConfig, BaseController, BaseControllerOptions, BaseCrudController, BodySanitizer, BodySanitizerConfig, BulkExt, BulkMixin, CRUD_OPERATIONS, CrudOperation, DEFAULT_ID_FIELD, DEFAULT_LIMIT, DEFAULT_MAX_LIMIT, DEFAULT_SORT, DEFAULT_TENANT_FIELD, DEFAULT_UPDATE_METHOD, HOOK_OPERATIONS, HOOK_PHASES, HookOperation, HookPhase, ListResult, MAX_FILTER_DEPTH, MAX_REGEX_LENGTH, MAX_SEARCH_LENGTH, MUTATION_OPERATIONS, MutationOperation, QueryResolver, QueryResolverConfig, RESERVED_QUERY_PARAMS, ResourceDefinition, SYSTEM_FIELDS, SlugExt, SlugMixin, SoftDeleteExt, SoftDeleteMixin, TreeExt, TreeMixin, createCrudHandlers, createCrudRouter, createFastifyHandler, createPermissionMiddleware, createRequestContext, defineResource, defineResourceVariants, getControllerContext, getControllerScope, sendControllerResponse };
@@ -1,5 +1,5 @@
1
1
  import { a as DEFAULT_SORT, c as HOOK_OPERATIONS, d as MAX_REGEX_LENGTH, f as MAX_SEARCH_LENGTH, h as SYSTEM_FIELDS, i as DEFAULT_MAX_LIMIT, l as HOOK_PHASES, m as RESERVED_QUERY_PARAMS, n as DEFAULT_ID_FIELD, o as DEFAULT_TENANT_FIELD, p as MUTATION_OPERATIONS, r as DEFAULT_LIMIT, s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS, u as MAX_FILTER_DEPTH } from "../constants-BhY1OHoH.mjs";
2
- import { a as BulkMixin, c as BodySanitizer, i as SlugMixin, l as AccessControl, n as TreeMixin, o as BaseCrudController, r as SoftDeleteMixin, s as QueryResolver, t as BaseController } from "../BaseController-JNV08qOT.mjs";
3
- import { _ as getControllerScope, g as getControllerContext, h as createRequestContext, m as createFastifyHandler, p as createCrudHandlers, v as sendControllerResponse } from "../routerShared-DeESFp4a.mjs";
4
- import { a as createPermissionMiddleware, i as createCrudRouter, n as ResourceDefinition, r as defineResource, t as defineResourceVariants } from "../core-DXdSSFW-.mjs";
2
+ import { a as BulkMixin, c as BodySanitizer, i as SlugMixin, l as AccessControl, n as TreeMixin, o as BaseCrudController, r as SoftDeleteMixin, s as QueryResolver, t as BaseController } from "../BaseController-swXruJ2_.mjs";
3
+ import { _ as getControllerContext, g as createRequestContext, h as createFastifyHandler, m as createCrudHandlers, v as getControllerScope, y as sendControllerResponse } from "../routerShared-BqLRb5l7.mjs";
4
+ import { a as createPermissionMiddleware, i as createCrudRouter, n as ResourceDefinition, r as defineResource, t as defineResourceVariants } from "../core-CbcQRIch.mjs";
5
5
  export { AccessControl, BaseController, BaseCrudController, BodySanitizer, BulkMixin, CRUD_OPERATIONS, DEFAULT_ID_FIELD, DEFAULT_LIMIT, DEFAULT_MAX_LIMIT, DEFAULT_SORT, DEFAULT_TENANT_FIELD, DEFAULT_UPDATE_METHOD, HOOK_OPERATIONS, HOOK_PHASES, MAX_FILTER_DEPTH, MAX_REGEX_LENGTH, MAX_SEARCH_LENGTH, MUTATION_OPERATIONS, QueryResolver, RESERVED_QUERY_PARAMS, ResourceDefinition, SYSTEM_FIELDS, SlugMixin, SoftDeleteMixin, TreeMixin, createCrudHandlers, createCrudRouter, createFastifyHandler, createPermissionMiddleware, createRequestContext, defineResource, defineResourceVariants, getControllerContext, getControllerScope, sendControllerResponse };
@@ -1,12 +1,12 @@
1
1
  import { s as DEFAULT_UPDATE_METHOD, t as CRUD_OPERATIONS } from "./constants-BhY1OHoH.mjs";
2
2
  import { arcLog } from "./logger/index.mjs";
3
- import { m as assertValidConfig, y as getDefaultCrudSchemas } from "./utils-D3Yxnrwr.mjs";
4
- import { t as BaseController } from "./BaseController-JNV08qOT.mjs";
3
+ import { m as assertValidConfig, y as getDefaultCrudSchemas } from "./utils-CcYTj09l.mjs";
4
+ import { t as BaseController } from "./BaseController-swXruJ2_.mjs";
5
5
  import { n as convertRouteSchema, t as convertOpenApiSchemas } from "./schemaConverter-B0oKLuqI.mjs";
6
- import { c as buildPreHandlerChain, d as resolveRouterPluginMw, f as selectPluginMw, i as buildAuthMiddleware, l as buildRateLimitConfig, m as createFastifyHandler, o as buildCrudPermissionMw, p as createCrudHandlers, r as buildArcDecorator, s as buildPipelineHandler, u as resolvePipelineSteps, y as buildRequestScopeProjection } from "./routerShared-DeESFp4a.mjs";
7
- import { t as applyPresets } from "./presets-k604Lj99.mjs";
6
+ import { b as buildRequestScopeProjection, c as buildPreHandlerChain, d as resolveRoutePreHandlers, f as resolveRouterPluginMw, h as createFastifyHandler, i as buildAuthMiddleware, l as buildRateLimitConfig, m as createCrudHandlers, o as buildCrudPermissionMw, p as selectPluginMw, r as buildArcDecorator, s as buildPipelineHandler, u as resolvePipelineSteps } from "./routerShared-BqLRb5l7.mjs";
7
+ import { t as applyPresets } from "./presets-Z7P5w4gF.mjs";
8
8
  import { t as hasEvents } from "./typeGuards-CcFZXgU7.mjs";
9
- import { t as resolveActionPermission } from "./actionPermissions-C8YYU92K.mjs";
9
+ import { t as resolveActionPermission } from "./actionPermissions-sUUKDhtP.mjs";
10
10
  //#region src/core/createCrudRouter.ts
11
11
  /**
12
12
  * Mount custom routes (from presets or user-defined `routes`) on Fastify.
@@ -39,7 +39,7 @@ function createCustomRoutes(fastify, routes, controller, options) {
39
39
  ...route.description ? { description: route.description } : {},
40
40
  ...convertedSchema ?? {}
41
41
  };
42
- const customPreHandlers = typeof route.preHandler === "function" ? route.preHandler(fastify) : route.preHandler ?? [];
42
+ const customPreHandlers = resolveRoutePreHandlers(route.preHandler, fastify, `${route.method} ${route.path}`);
43
43
  const preHandler = buildPreHandlerChain({
44
44
  preAuth: route.preAuth ?? [],
45
45
  arcDecorator,
@@ -469,7 +469,24 @@ function resolveOrAutoCreateController(resolvedConfig, adapter, repository, hasC
469
469
  if (typeof ctrl.setQueryParser === "function") ctrl.setQueryParser(resolvedConfig.queryParser);
470
470
  else arcLog("defineResource").warn(`Resource "${resolvedConfig.name}" declares a custom \`queryParser\` but its controller does not expose \`setQueryParser(qp)\`. The parser will NOT be threaded into the controller's query resolution — operator filters (\`[contains]\`, \`[like]\`, etc.) may fall back to the controller's internal default. Extend \`BaseController\` / \`BaseCrudController\` (both implement \`setQueryParser\`) OR add the method to your custom controller to honor the resource-level parser.`);
471
471
  }
472
- if (controller || !hasCrudRoutes || !repository) return controller;
472
+ if (controller) {
473
+ const authorOptions = [];
474
+ if (resolvedConfig.tenantField !== void 0) authorOptions.push("tenantField");
475
+ if (resolvedConfig.schemaOptions !== void 0 && Object.keys(resolvedConfig.schemaOptions).length > 0) authorOptions.push("schemaOptions");
476
+ if (resolvedConfig.idField !== void 0) authorOptions.push("idField");
477
+ if (resolvedConfig.defaultSort !== void 0) authorOptions.push("defaultSort");
478
+ if (resolvedConfig.cache !== void 0) authorOptions.push("cache");
479
+ if (resolvedConfig.onFieldWriteDenied !== void 0) authorOptions.push("onFieldWriteDenied");
480
+ if (authorOptions.length > 0) arcLog("defineResource").warn(`Resource "${resolvedConfig.name}" declares a custom controller AND resource-level option(s) [${authorOptions.join(", ")}]. Arc only threads these when it auto-builds the controller — when you pass your own, they are dropped silently and the controller falls back to its own defaults (e.g. tenantField → 'organizationId'). Forward them to your controller's \`super(repo, { ... })\` call. Same root cause as the \`queryParser\` warn above.`);
481
+ if (resolvedConfig._controllerOptions !== void 0) {
482
+ const presetFields = [];
483
+ if (resolvedConfig._controllerOptions.slugField) presetFields.push("slugField");
484
+ if (resolvedConfig._controllerOptions.parentField) presetFields.push("parentField");
485
+ arcLog("defineResource").warn(`Resource "${resolvedConfig.name}" applies a preset that injects controller field(s) [${presetFields.join(", ") || "preset metadata"}] (e.g. slugLookup / softDelete / parent), but the resource also declares a custom controller. Preset metadata only reaches arc's auto-built BaseController — your custom controller will not see \`slugField\`/\`parentField\`/etc. Either (a) drop the preset on this resource (\`presets: [...]\` without it), or (b) extend \`BaseController\` / \`BaseCrudController\` so arc auto-builds the controller and threads the preset fields automatically.`);
486
+ }
487
+ return controller;
488
+ }
489
+ if (!hasCrudRoutes || !repository) return controller;
473
490
  const qp = resolvedConfig.queryParser;
474
491
  let maxLimitFromParser;
475
492
  if (qp?.getQuerySchema) {
@@ -869,7 +886,7 @@ var ResourceDefinition = class {
869
886
  fields: self.fields
870
887
  });
871
888
  if (self.actions && Object.keys(self.actions).length > 0) {
872
- const { createActionRouter } = await import("./createActionRouter-BwaSM0No.mjs").then((n) => n.n);
889
+ const { createActionRouter } = await import("./createActionRouter-CIKOcNA7.mjs").then((n) => n.n);
873
890
  createActionRouter(typedInstance, {
874
891
  ...normalizeActionsToRouterConfig(self.actions, self.actionPermissions, self.tag, self.permissions, self.name, typedInstance.log),
875
892
  resourceName: self.name,
@@ -1,6 +1,6 @@
1
1
  import { t as __exportAll } from "./chunk-BpYLSNr0.mjs";
2
- import { a as buildAuthMiddlewareForPermissions, c as buildPreHandlerChain, d as resolveRouterPluginMw, f as selectPluginMw, l as buildRateLimitConfig, n as buildActionPipelineHandler, r as buildArcDecorator, t as buildActionPermissionMw, u as resolvePipelineSteps, v as sendControllerResponse } from "./routerShared-DeESFp4a.mjs";
3
- import { n as schemaIRToJsonSchemaBranch, t as normalizeSchemaIR } from "./schemaIR-BlG9bY7v.mjs";
2
+ import { a as buildAuthMiddlewareForPermissions, c as buildPreHandlerChain, f as resolveRouterPluginMw, l as buildRateLimitConfig, n as buildActionPipelineHandler, p as selectPluginMw, r as buildArcDecorator, t as buildActionPermissionMw, u as resolvePipelineSteps, y as sendControllerResponse } from "./routerShared-BqLRb5l7.mjs";
3
+ import { n as schemaIRToJsonSchemaBranch, t as normalizeSchemaIR } from "./schemaIR-Dy2p4MxS.mjs";
4
4
  //#region src/core/createActionRouter.ts
5
5
  var createActionRouter_exports = /* @__PURE__ */ __exportAll({
6
6
  buildActionBodySchema: () => buildActionBodySchema,
@@ -118,34 +118,94 @@ function createActionRouter(fastify, config) {
118
118
  * {
119
119
  * "type": "object",
120
120
  * "required": ["action"],
121
+ * "properties": {
122
+ * "action": { "type": "string", "enum": ["dispatch", "approve"] },
123
+ * "carrier": { "type": "string" }
124
+ * },
121
125
  * "oneOf": [
122
- * { "properties": { "action": { "const": "dispatch" }, "carrier": {...} }, "required": ["action", "carrier"] },
123
- * { "properties": { "action": { "const": "approve" } }, "required": ["action"] }
126
+ * {
127
+ * "properties": {
128
+ * "action": { "const": "dispatch" },
129
+ * "carrier": { "type": "string" } // ← every branch lists the union
130
+ * },
131
+ * "required": ["action", "carrier"]
132
+ * },
133
+ * {
134
+ * "properties": {
135
+ * "action": { "const": "approve" },
136
+ * "carrier": { "type": "string" } // ← even though approve doesn't use it
137
+ * },
138
+ * "required": ["action"]
139
+ * }
124
140
  * ]
125
141
  * }
126
142
  * ```
127
143
  *
128
- * AJV validates this natively, so an action call missing required fields is
129
- * rejected with HTTP 400 before the handler ever runs.
144
+ * **Why every branch carries the full property union.** AJV's
145
+ * `removeAdditional: 'all'` (Fastify's framework default) interacts badly
146
+ * with `oneOf`: when a branch's `properties` lacks a field, AJV strips it
147
+ * from the body during that branch's evaluation — *even if a different
148
+ * branch would have allowed it*. The strip mutates the body before
149
+ * `oneOf` finishes discriminating, so by the time the matching branch
150
+ * wins, the body has already lost fields. Concretely: `actions: { verify:
151
+ * {}, hold: { schema: z.object({ amount, reason }.optional()) } }` +
152
+ * `POST { action: 'hold', amount: 1, reason }` lands at the handler as
153
+ * `{ action: 'hold' }`. Empirically reproduced and locked at
154
+ * [tests/core/action-discriminator-strip.test.ts](../../tests/core/action-discriminator-strip.test.ts).
155
+ *
156
+ * Listing every action's properties on every branch makes per-branch
157
+ * removeAdditional walks see every caller field as "in this branch's
158
+ * properties," so nothing gets stripped during oneOf evaluation. The
159
+ * `required` array stays per-action, so the handler still gets called
160
+ * only when the matching branch's required-field contract is satisfied.
161
+ * Per-branch `additionalProperties: false` (Zod v4 default) carries
162
+ * through but, under host removeAdditional: 'all', it can no longer
163
+ * reject sibling-action fields — those become silently stripped at top
164
+ * level instead. That's the host's opt-in to stripping; arc's job is to
165
+ * stop accidentally losing the action's *own* declared fields.
166
+ *
167
+ * Under arc's own `createApp` (`removeAdditional: false`), strict-mode
168
+ * rejection still functions normally — see
169
+ * [tests/core/action-strict-schema-parity.test.ts](../../tests/core/action-strict-schema-parity.test.ts).
130
170
  *
131
171
  * Exported so OpenAPI generation and MCP tool generation can reuse the same
132
172
  * schema shape (single source of truth).
133
173
  */
134
174
  function buildActionBodySchema(actionEnum, actionSchemas = {}) {
135
- const branches = [];
175
+ const unionProperties = {};
176
+ const irs = [];
136
177
  for (const actionName of actionEnum) {
137
178
  const ir = normalizeSchemaIR(actionSchemas[actionName]);
138
- branches.push(schemaIRToJsonSchemaBranch(ir, {
139
- properties: { action: {
140
- type: "string",
141
- const: actionName
142
- } },
143
- required: ["action"]
144
- }));
179
+ irs.push({
180
+ name: actionName,
181
+ ir
182
+ });
183
+ for (const [key, val] of Object.entries(ir.properties)) unionProperties[key] = val;
145
184
  }
185
+ const branches = [];
186
+ for (const { name, ir } of irs) branches.push(schemaIRToJsonSchemaBranch({
187
+ ...ir,
188
+ properties: {
189
+ ...unionProperties,
190
+ ...ir.properties
191
+ }
192
+ }, {
193
+ properties: { action: {
194
+ type: "string",
195
+ const: name
196
+ } },
197
+ required: ["action"]
198
+ }));
146
199
  return {
147
200
  type: "object",
148
201
  required: ["action"],
202
+ properties: {
203
+ action: {
204
+ type: "string",
205
+ enum: [...actionEnum]
206
+ },
207
+ ...unionProperties
208
+ },
149
209
  oneOf: branches
150
210
  };
151
211
  }
@@ -117,10 +117,7 @@ const developmentPreset = {
117
117
  ]
118
118
  },
119
119
  rateLimit: false,
120
- underPressure: {
121
- exposeStatusRoute: true,
122
- maxEventLoopDelay: 5e3
123
- }
120
+ underPressure: false
124
121
  };
125
122
  /**
126
123
  * Testing preset - minimal setup, fast startup
@@ -204,7 +201,7 @@ async function registerArcCore(fastify, config, trackPlugin) {
204
201
  await fastify.register(arcCorePlugin, { emitEvents: config.arcPlugins?.emitEvents !== false });
205
202
  trackPlugin("arc-core");
206
203
  if (config.arcPlugins?.events !== false) {
207
- const { default: eventPlugin } = await import("./eventPlugin--5HIkdPU.mjs").then((n) => n.n);
204
+ const { default: eventPlugin } = await import("./eventPlugin-Cts2-Tfj.mjs").then((n) => n.n);
208
205
  const eventOpts = typeof config.arcPlugins?.events === "object" ? config.arcPlugins.events : {};
209
206
  await fastify.register(eventPlugin, {
210
207
  ...eventOpts,
@@ -605,7 +602,8 @@ async function registerSecurityPlugins(fastify, config) {
605
602
  if (config.cors !== false) {
606
603
  const cors = await loadPlugin("cors");
607
604
  const corsOptions = { ...config.cors ?? {} };
608
- if (config.preset === "production" && corsOptions && !("origin" in corsOptions)) fastify.log.warn("CORS origin is not explicitly configured in production. Set cors.origin to allowed domains, cors: { origin: '*' }, or cors: false to disable.");
605
+ const originDeclared = "origin" in corsOptions && corsOptions.origin !== void 0;
606
+ 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.");
609
607
  if (corsOptions.credentials && corsOptions.origin === "*") corsOptions.origin = true;
610
608
  await fastify.register(cors, corsOptions);
611
609
  fastify.log.debug("CORS enabled");