@classytic/arc 1.1.0 → 2.1.2

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 (322) hide show
  1. package/README.md +247 -794
  2. package/bin/arc.js +91 -52
  3. package/dist/EventTransport-BD2U0BTc.d.mts +100 -0
  4. package/dist/EventTransport-BD2U0BTc.d.mts.map +1 -0
  5. package/dist/HookSystem-BsGV-j2l.mjs +405 -0
  6. package/dist/HookSystem-BsGV-j2l.mjs.map +1 -0
  7. package/dist/ResourceRegistry-DsN4KJjV.mjs +250 -0
  8. package/dist/ResourceRegistry-DsN4KJjV.mjs.map +1 -0
  9. package/dist/adapters/index.d.mts +5 -0
  10. package/dist/adapters/index.mjs +3 -0
  11. package/dist/audit/index.d.mts +82 -0
  12. package/dist/audit/index.d.mts.map +1 -0
  13. package/dist/audit/index.mjs +276 -0
  14. package/dist/audit/index.mjs.map +1 -0
  15. package/dist/audit/mongodb.d.mts +5 -0
  16. package/dist/audit/mongodb.mjs +3 -0
  17. package/dist/audited-C3T5DTUx.mjs +141 -0
  18. package/dist/audited-C3T5DTUx.mjs.map +1 -0
  19. package/dist/auth/index.d.mts +189 -0
  20. package/dist/auth/index.d.mts.map +1 -0
  21. package/dist/auth/index.mjs +1102 -0
  22. package/dist/auth/index.mjs.map +1 -0
  23. package/dist/auth/redis-session.d.mts +44 -0
  24. package/dist/auth/redis-session.d.mts.map +1 -0
  25. package/dist/auth/redis-session.mjs +76 -0
  26. package/dist/auth/redis-session.mjs.map +1 -0
  27. package/dist/betterAuthOpenApi-BrHKeSAx.mjs +250 -0
  28. package/dist/betterAuthOpenApi-BrHKeSAx.mjs.map +1 -0
  29. package/dist/cache/index.d.mts +146 -0
  30. package/dist/cache/index.d.mts.map +1 -0
  31. package/dist/cache/index.mjs +92 -0
  32. package/dist/cache/index.mjs.map +1 -0
  33. package/dist/caching-Bl28lYsR.mjs +94 -0
  34. package/dist/caching-Bl28lYsR.mjs.map +1 -0
  35. package/dist/chunk-C7Uep-_p.mjs +20 -0
  36. package/dist/circuitBreaker-DeY4FCjs.mjs +1097 -0
  37. package/dist/circuitBreaker-DeY4FCjs.mjs.map +1 -0
  38. package/dist/cli/commands/describe.d.mts +19 -0
  39. package/dist/cli/commands/describe.d.mts.map +1 -0
  40. package/dist/cli/commands/describe.mjs +239 -0
  41. package/dist/cli/commands/describe.mjs.map +1 -0
  42. package/dist/cli/commands/docs.d.mts +14 -0
  43. package/dist/cli/commands/docs.d.mts.map +1 -0
  44. package/dist/cli/commands/docs.mjs +53 -0
  45. package/dist/cli/commands/docs.mjs.map +1 -0
  46. package/dist/cli/commands/{generate.d.ts → generate.d.mts} +3 -1
  47. package/dist/cli/commands/generate.d.mts.map +1 -0
  48. package/dist/cli/commands/generate.mjs +358 -0
  49. package/dist/cli/commands/generate.mjs.map +1 -0
  50. package/dist/cli/commands/{init.d.ts → init.d.mts} +12 -8
  51. package/dist/cli/commands/init.d.mts.map +1 -0
  52. package/dist/cli/commands/{init.js → init.mjs} +807 -616
  53. package/dist/cli/commands/init.mjs.map +1 -0
  54. package/dist/cli/commands/introspect.d.mts +11 -0
  55. package/dist/cli/commands/introspect.d.mts.map +1 -0
  56. package/dist/cli/commands/introspect.mjs +76 -0
  57. package/dist/cli/commands/introspect.mjs.map +1 -0
  58. package/dist/cli/index.d.mts +17 -0
  59. package/dist/cli/index.d.mts.map +1 -0
  60. package/dist/cli/index.mjs +157 -0
  61. package/dist/cli/index.mjs.map +1 -0
  62. package/dist/constants-DdXFXQtN.mjs +85 -0
  63. package/dist/constants-DdXFXQtN.mjs.map +1 -0
  64. package/dist/core/index.d.mts +5 -0
  65. package/dist/core/index.mjs +4 -0
  66. package/dist/createApp-CUgNqegw.mjs +560 -0
  67. package/dist/createApp-CUgNqegw.mjs.map +1 -0
  68. package/dist/defineResource-k0_BDn8v.mjs +2197 -0
  69. package/dist/defineResource-k0_BDn8v.mjs.map +1 -0
  70. package/dist/discovery/index.d.mts +47 -0
  71. package/dist/discovery/index.d.mts.map +1 -0
  72. package/dist/discovery/index.mjs +110 -0
  73. package/dist/discovery/index.mjs.map +1 -0
  74. package/dist/docs/index.d.mts +163 -0
  75. package/dist/docs/index.d.mts.map +1 -0
  76. package/dist/docs/index.mjs +73 -0
  77. package/dist/docs/index.mjs.map +1 -0
  78. package/dist/elevation-BRy3yFWT.mjs +113 -0
  79. package/dist/elevation-BRy3yFWT.mjs.map +1 -0
  80. package/dist/elevation-B_2dRLVP.d.mts +88 -0
  81. package/dist/elevation-B_2dRLVP.d.mts.map +1 -0
  82. package/dist/errorHandler-BbcgBmIH.d.mts +73 -0
  83. package/dist/errorHandler-BbcgBmIH.d.mts.map +1 -0
  84. package/dist/errorHandler-C1okiriz.mjs +109 -0
  85. package/dist/errorHandler-C1okiriz.mjs.map +1 -0
  86. package/dist/errors-B9bZok84.mjs +212 -0
  87. package/dist/errors-B9bZok84.mjs.map +1 -0
  88. package/dist/errors-ChKiFz62.d.mts +125 -0
  89. package/dist/errors-ChKiFz62.d.mts.map +1 -0
  90. package/dist/eventPlugin-CTrLH3mt.d.mts +125 -0
  91. package/dist/eventPlugin-CTrLH3mt.d.mts.map +1 -0
  92. package/dist/eventPlugin-DGR_B2on.mjs +230 -0
  93. package/dist/eventPlugin-DGR_B2on.mjs.map +1 -0
  94. package/dist/events/index.d.mts +54 -0
  95. package/dist/events/index.d.mts.map +1 -0
  96. package/dist/events/index.mjs +52 -0
  97. package/dist/events/index.mjs.map +1 -0
  98. package/dist/events/transports/redis-stream-entry.d.mts +2 -0
  99. package/dist/events/transports/redis-stream-entry.mjs +178 -0
  100. package/dist/events/transports/redis-stream-entry.mjs.map +1 -0
  101. package/dist/events/transports/redis.d.mts +77 -0
  102. package/dist/events/transports/redis.d.mts.map +1 -0
  103. package/dist/events/transports/redis.mjs +125 -0
  104. package/dist/events/transports/redis.mjs.map +1 -0
  105. package/dist/externalPaths-DlINfKbP.d.mts +51 -0
  106. package/dist/externalPaths-DlINfKbP.d.mts.map +1 -0
  107. package/dist/factory/index.d.mts +64 -0
  108. package/dist/factory/index.d.mts.map +1 -0
  109. package/dist/factory/index.mjs +3 -0
  110. package/dist/fastifyAdapter-BkrGrlFi.d.mts +217 -0
  111. package/dist/fastifyAdapter-BkrGrlFi.d.mts.map +1 -0
  112. package/dist/fields-DyaDVX4J.d.mts +110 -0
  113. package/dist/fields-DyaDVX4J.d.mts.map +1 -0
  114. package/dist/fields-iagOozy0.mjs +115 -0
  115. package/dist/fields-iagOozy0.mjs.map +1 -0
  116. package/dist/hooks/index.d.mts +4 -0
  117. package/dist/hooks/index.mjs +3 -0
  118. package/dist/idempotency/index.d.mts +97 -0
  119. package/dist/idempotency/index.d.mts.map +1 -0
  120. package/dist/idempotency/index.mjs +320 -0
  121. package/dist/idempotency/index.mjs.map +1 -0
  122. package/dist/idempotency/mongodb.d.mts +2 -0
  123. package/dist/idempotency/mongodb.mjs +115 -0
  124. package/dist/idempotency/mongodb.mjs.map +1 -0
  125. package/dist/idempotency/redis.d.mts +2 -0
  126. package/dist/idempotency/redis.mjs +104 -0
  127. package/dist/idempotency/redis.mjs.map +1 -0
  128. package/dist/index.d.mts +261 -0
  129. package/dist/index.d.mts.map +1 -0
  130. package/dist/index.mjs +105 -0
  131. package/dist/index.mjs.map +1 -0
  132. package/dist/integrations/event-gateway.d.mts +47 -0
  133. package/dist/integrations/event-gateway.d.mts.map +1 -0
  134. package/dist/integrations/event-gateway.mjs +44 -0
  135. package/dist/integrations/event-gateway.mjs.map +1 -0
  136. package/dist/integrations/index.d.mts +5 -0
  137. package/dist/integrations/index.mjs +1 -0
  138. package/dist/integrations/jobs.d.mts +104 -0
  139. package/dist/integrations/jobs.d.mts.map +1 -0
  140. package/dist/integrations/jobs.mjs +124 -0
  141. package/dist/integrations/jobs.mjs.map +1 -0
  142. package/dist/integrations/streamline.d.mts +61 -0
  143. package/dist/integrations/streamline.d.mts.map +1 -0
  144. package/dist/integrations/streamline.mjs +126 -0
  145. package/dist/integrations/streamline.mjs.map +1 -0
  146. package/dist/integrations/websocket.d.mts +83 -0
  147. package/dist/integrations/websocket.d.mts.map +1 -0
  148. package/dist/integrations/websocket.mjs +289 -0
  149. package/dist/integrations/websocket.mjs.map +1 -0
  150. package/dist/interface-B01JvPVc.d.mts +78 -0
  151. package/dist/interface-B01JvPVc.d.mts.map +1 -0
  152. package/dist/interface-CZe8IkMf.d.mts +55 -0
  153. package/dist/interface-CZe8IkMf.d.mts.map +1 -0
  154. package/dist/interface-Ch8HU9uM.d.mts +1098 -0
  155. package/dist/interface-Ch8HU9uM.d.mts.map +1 -0
  156. package/dist/introspectionPlugin-rFdO8ZUa.mjs +54 -0
  157. package/dist/introspectionPlugin-rFdO8ZUa.mjs.map +1 -0
  158. package/dist/keys-BqNejWup.mjs +43 -0
  159. package/dist/keys-BqNejWup.mjs.map +1 -0
  160. package/dist/logger-Df2O2WsW.mjs +79 -0
  161. package/dist/logger-Df2O2WsW.mjs.map +1 -0
  162. package/dist/memory-cQgelFOj.mjs +144 -0
  163. package/dist/memory-cQgelFOj.mjs.map +1 -0
  164. package/dist/migrations/index.d.mts +157 -0
  165. package/dist/migrations/index.d.mts.map +1 -0
  166. package/dist/migrations/index.mjs +261 -0
  167. package/dist/migrations/index.mjs.map +1 -0
  168. package/dist/mongodb-BfJVlUJH.mjs +94 -0
  169. package/dist/mongodb-BfJVlUJH.mjs.map +1 -0
  170. package/dist/mongodb-CGzRbfAK.d.mts +119 -0
  171. package/dist/mongodb-CGzRbfAK.d.mts.map +1 -0
  172. package/dist/mongodb-JN-9JA7K.d.mts +72 -0
  173. package/dist/mongodb-JN-9JA7K.d.mts.map +1 -0
  174. package/dist/openapi-G3Cw7XuM.mjs +524 -0
  175. package/dist/openapi-G3Cw7XuM.mjs.map +1 -0
  176. package/dist/org/index.d.mts +69 -0
  177. package/dist/org/index.d.mts.map +1 -0
  178. package/dist/org/index.mjs +514 -0
  179. package/dist/org/index.mjs.map +1 -0
  180. package/dist/org/types.d.mts +83 -0
  181. package/dist/org/types.d.mts.map +1 -0
  182. package/dist/org/types.mjs +1 -0
  183. package/dist/permissions/index.d.mts +279 -0
  184. package/dist/permissions/index.d.mts.map +1 -0
  185. package/dist/permissions/index.mjs +579 -0
  186. package/dist/permissions/index.mjs.map +1 -0
  187. package/dist/plugins/index.d.mts +173 -0
  188. package/dist/plugins/index.d.mts.map +1 -0
  189. package/dist/plugins/index.mjs +523 -0
  190. package/dist/plugins/index.mjs.map +1 -0
  191. package/dist/plugins/response-cache.d.mts +88 -0
  192. package/dist/plugins/response-cache.d.mts.map +1 -0
  193. package/dist/plugins/response-cache.mjs +284 -0
  194. package/dist/plugins/response-cache.mjs.map +1 -0
  195. package/dist/plugins/tracing-entry.d.mts +2 -0
  196. package/dist/plugins/tracing-entry.mjs +186 -0
  197. package/dist/plugins/tracing-entry.mjs.map +1 -0
  198. package/dist/pluralize-CEweyOEm.mjs +87 -0
  199. package/dist/pluralize-CEweyOEm.mjs.map +1 -0
  200. package/dist/policies/{index.d.ts → index.d.mts} +204 -169
  201. package/dist/policies/index.d.mts.map +1 -0
  202. package/dist/policies/index.mjs +322 -0
  203. package/dist/policies/index.mjs.map +1 -0
  204. package/dist/presets/{index.d.ts → index.d.mts} +63 -131
  205. package/dist/presets/index.d.mts.map +1 -0
  206. package/dist/presets/index.mjs +144 -0
  207. package/dist/presets/index.mjs.map +1 -0
  208. package/dist/presets/multiTenant.d.mts +25 -0
  209. package/dist/presets/multiTenant.d.mts.map +1 -0
  210. package/dist/presets/multiTenant.mjs +114 -0
  211. package/dist/presets/multiTenant.mjs.map +1 -0
  212. package/dist/presets-BITljm96.mjs +120 -0
  213. package/dist/presets-BITljm96.mjs.map +1 -0
  214. package/dist/presets-DzSMwlKj.d.mts +58 -0
  215. package/dist/presets-DzSMwlKj.d.mts.map +1 -0
  216. package/dist/prisma-DJbMt3yf.mjs +628 -0
  217. package/dist/prisma-DJbMt3yf.mjs.map +1 -0
  218. package/dist/prisma-Dg9GoVdj.d.mts +275 -0
  219. package/dist/prisma-Dg9GoVdj.d.mts.map +1 -0
  220. package/dist/queryCachePlugin-7THaI5mt.d.mts +72 -0
  221. package/dist/queryCachePlugin-7THaI5mt.d.mts.map +1 -0
  222. package/dist/queryCachePlugin-DMBnp2Q0.mjs +139 -0
  223. package/dist/queryCachePlugin-DMBnp2Q0.mjs.map +1 -0
  224. package/dist/redis-D-JAeLtm.d.mts +50 -0
  225. package/dist/redis-D-JAeLtm.d.mts.map +1 -0
  226. package/dist/redis-stream-Bdh_vUU8.d.mts +104 -0
  227. package/dist/redis-stream-Bdh_vUU8.d.mts.map +1 -0
  228. package/dist/registry/index.d.mts +12 -0
  229. package/dist/registry/index.d.mts.map +1 -0
  230. package/dist/registry/index.mjs +4 -0
  231. package/dist/requestContext-QQD6ROJc.mjs +56 -0
  232. package/dist/requestContext-QQD6ROJc.mjs.map +1 -0
  233. package/dist/schemaConverter-BwrmWroW.mjs +99 -0
  234. package/dist/schemaConverter-BwrmWroW.mjs.map +1 -0
  235. package/dist/schemas/index.d.mts +64 -0
  236. package/dist/schemas/index.d.mts.map +1 -0
  237. package/dist/schemas/index.mjs +83 -0
  238. package/dist/schemas/index.mjs.map +1 -0
  239. package/dist/scope/index.d.mts +22 -0
  240. package/dist/scope/index.d.mts.map +1 -0
  241. package/dist/scope/index.mjs +66 -0
  242. package/dist/scope/index.mjs.map +1 -0
  243. package/dist/sessionManager-jPKLbHE0.d.mts +187 -0
  244. package/dist/sessionManager-jPKLbHE0.d.mts.map +1 -0
  245. package/dist/sse-B3c3_yZp.mjs +124 -0
  246. package/dist/sse-B3c3_yZp.mjs.map +1 -0
  247. package/dist/testing/index.d.mts +908 -0
  248. package/dist/testing/index.d.mts.map +1 -0
  249. package/dist/testing/index.mjs +1977 -0
  250. package/dist/testing/index.mjs.map +1 -0
  251. package/dist/tracing-Cc7vVQPp.d.mts +71 -0
  252. package/dist/tracing-Cc7vVQPp.d.mts.map +1 -0
  253. package/dist/typeGuards-DhMNLuvU.mjs +10 -0
  254. package/dist/typeGuards-DhMNLuvU.mjs.map +1 -0
  255. package/dist/types/index.d.mts +947 -0
  256. package/dist/types/index.d.mts.map +1 -0
  257. package/dist/types/index.mjs +15 -0
  258. package/dist/types/index.mjs.map +1 -0
  259. package/dist/types-Beqn1Un7.mjs +39 -0
  260. package/dist/types-Beqn1Un7.mjs.map +1 -0
  261. package/dist/types-CIgB7UUl.d.mts +446 -0
  262. package/dist/types-CIgB7UUl.d.mts.map +1 -0
  263. package/dist/types-aYB4V7uN.d.mts +87 -0
  264. package/dist/types-aYB4V7uN.d.mts.map +1 -0
  265. package/dist/utils/index.d.mts +748 -0
  266. package/dist/utils/index.d.mts.map +1 -0
  267. package/dist/utils/index.mjs +6 -0
  268. package/package.json +194 -68
  269. package/dist/BaseController-DVAiHxEQ.d.ts +0 -233
  270. package/dist/adapters/index.d.ts +0 -237
  271. package/dist/adapters/index.js +0 -668
  272. package/dist/arcCorePlugin-CsShQdyP.d.ts +0 -273
  273. package/dist/audit/index.d.ts +0 -195
  274. package/dist/audit/index.js +0 -319
  275. package/dist/auth/index.d.ts +0 -47
  276. package/dist/auth/index.js +0 -174
  277. package/dist/cli/commands/docs.d.ts +0 -11
  278. package/dist/cli/commands/docs.js +0 -474
  279. package/dist/cli/commands/generate.js +0 -334
  280. package/dist/cli/commands/introspect.d.ts +0 -8
  281. package/dist/cli/commands/introspect.js +0 -338
  282. package/dist/cli/index.d.ts +0 -4
  283. package/dist/cli/index.js +0 -3269
  284. package/dist/core/index.d.ts +0 -220
  285. package/dist/core/index.js +0 -2786
  286. package/dist/createApp-Ce9wl8W9.d.ts +0 -77
  287. package/dist/docs/index.d.ts +0 -166
  288. package/dist/docs/index.js +0 -658
  289. package/dist/errors-8WIxGS_6.d.ts +0 -122
  290. package/dist/events/index.d.ts +0 -117
  291. package/dist/events/index.js +0 -89
  292. package/dist/factory/index.d.ts +0 -38
  293. package/dist/factory/index.js +0 -1652
  294. package/dist/hooks/index.d.ts +0 -4
  295. package/dist/hooks/index.js +0 -199
  296. package/dist/idempotency/index.d.ts +0 -323
  297. package/dist/idempotency/index.js +0 -500
  298. package/dist/index-B4t03KQ0.d.ts +0 -1366
  299. package/dist/index.d.ts +0 -135
  300. package/dist/index.js +0 -4756
  301. package/dist/migrations/index.d.ts +0 -185
  302. package/dist/migrations/index.js +0 -274
  303. package/dist/org/index.d.ts +0 -129
  304. package/dist/org/index.js +0 -220
  305. package/dist/permissions/index.d.ts +0 -144
  306. package/dist/permissions/index.js +0 -103
  307. package/dist/plugins/index.d.ts +0 -46
  308. package/dist/plugins/index.js +0 -1069
  309. package/dist/policies/index.js +0 -196
  310. package/dist/presets/index.js +0 -384
  311. package/dist/presets/multiTenant.d.ts +0 -39
  312. package/dist/presets/multiTenant.js +0 -112
  313. package/dist/registry/index.d.ts +0 -16
  314. package/dist/registry/index.js +0 -253
  315. package/dist/testing/index.d.ts +0 -618
  316. package/dist/testing/index.js +0 -48020
  317. package/dist/types/index.d.ts +0 -4
  318. package/dist/types/index.js +0 -8
  319. package/dist/types-B99TBmFV.d.ts +0 -76
  320. package/dist/types-BvckRbs2.d.ts +0 -143
  321. package/dist/utils/index.d.ts +0 -679
  322. package/dist/utils/index.js +0 -931
@@ -0,0 +1,1102 @@
1
+ import { t as AUTHENTICATED_SCOPE } from "../types-Beqn1Un7.mjs";
2
+ import { t as ArcError } from "../errors-B9bZok84.mjs";
3
+ import { requireOrgMembership, requireOrgRole, requireTeamMembership } from "../permissions/index.mjs";
4
+ import { n as extractBetterAuthOpenApi } from "../betterAuthOpenApi-BrHKeSAx.mjs";
5
+ import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
6
+ import fp from "fastify-plugin";
7
+
8
+ //#region src/auth/authPlugin.ts
9
+ /**
10
+ * Auth Plugin - Flexible, Database-Agnostic Authentication
11
+ *
12
+ * Arc provides JWT infrastructure and calls your authenticator.
13
+ * You control ALL authentication logic.
14
+ *
15
+ * Design principles:
16
+ * - Arc handles plumbing (JWT sign/verify utilities)
17
+ * - App handles business logic (how to authenticate, where users live)
18
+ * - Works with any database (Prisma, MongoDB, Postgres, none)
19
+ * - Supports multiple auth strategies (JWT, API keys, sessions, etc.)
20
+ *
21
+ * @example
22
+ * ```typescript
23
+ * // In createApp
24
+ * auth: {
25
+ * jwt: { secret: process.env.JWT_SECRET },
26
+ * authenticate: async (request, { jwt }) => {
27
+ * // Your auth logic - Arc never touches your database
28
+ * const token = request.headers.authorization?.split(' ')[1];
29
+ * if (!token) return null;
30
+ * const decoded = jwt.verify(token);
31
+ * return userRepo.findById(decoded.id);
32
+ * },
33
+ * }
34
+ * ```
35
+ */
36
+ /**
37
+ * Parse expiration string to seconds
38
+ */
39
+ function parseExpiresIn(input, defaultValue) {
40
+ if (!input) return defaultValue;
41
+ if (/^\d+$/.test(input)) return parseInt(input, 10);
42
+ const match = /^(\d+)\s*([smhd])$/i.exec(input);
43
+ if (!match) return defaultValue;
44
+ return parseInt(match[1], 10) * ({
45
+ s: 1,
46
+ m: 60,
47
+ h: 3600,
48
+ d: 86400
49
+ }[match[2].toLowerCase()] ?? 1);
50
+ }
51
+ /**
52
+ * Extract Bearer token from Authorization header
53
+ */
54
+ function extractBearerToken(request) {
55
+ const auth = request.headers.authorization;
56
+ if (!auth?.startsWith("Bearer ")) return null;
57
+ return auth.slice(7);
58
+ }
59
+ const authPlugin = async (fastify, opts = {}) => {
60
+ const { jwt: jwtConfig, authenticate: appAuthenticator, onFailure, userProperty = "user", exposeAuthErrors = false } = opts;
61
+ let jwtContext = null;
62
+ if (jwtConfig?.secret) {
63
+ if (jwtConfig.secret.length < 32) throw new Error(`JWT secret must be at least 32 characters (current: ${jwtConfig.secret.length}).\nUse a strong random secret for production.`);
64
+ const jwtPlugin = await import("@fastify/jwt");
65
+ await fastify.register(jwtPlugin.default ?? jwtPlugin, {
66
+ secret: jwtConfig.secret,
67
+ sign: {
68
+ expiresIn: jwtConfig.expiresIn ?? "15m",
69
+ ...jwtConfig.sign ?? {}
70
+ },
71
+ verify: { ...jwtConfig.verify ?? {} }
72
+ });
73
+ const fastifyWithJwt = fastify;
74
+ jwtContext = {
75
+ verify: (token) => {
76
+ return fastifyWithJwt.jwt.verify(token);
77
+ },
78
+ sign: (payload, options) => {
79
+ return fastifyWithJwt.jwt.sign(payload, options);
80
+ },
81
+ decode: (token) => {
82
+ try {
83
+ return fastifyWithJwt.jwt.decode(token);
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+ };
89
+ fastify.log.debug("Auth: JWT infrastructure enabled");
90
+ }
91
+ const authContext = {
92
+ jwt: jwtContext,
93
+ fastify
94
+ };
95
+ /**
96
+ * Authenticate middleware
97
+ *
98
+ * Arc adds this to preHandler for non-public routes.
99
+ * Calls app's authenticator or falls back to default JWT verify.
100
+ */
101
+ const authenticate = async (request, reply) => {
102
+ try {
103
+ let user = null;
104
+ if (appAuthenticator) user = await appAuthenticator(request, authContext);
105
+ else if (jwtContext) {
106
+ const token = extractBearerToken(request);
107
+ if (token) {
108
+ const decoded = jwtContext.verify(token);
109
+ if (decoded.type === "refresh") throw new Error("Refresh tokens cannot be used for authentication");
110
+ user = decoded;
111
+ }
112
+ } else throw new Error("No authenticator configured. Provide auth.authenticate function or auth.jwt.secret.");
113
+ if (!user) throw new Error("Authentication required");
114
+ const reqRecord = request;
115
+ reqRecord.user = user;
116
+ reqRecord[userProperty] = user;
117
+ if (!request.scope || request.scope.kind === "public") {
118
+ const userRecord = user;
119
+ if (userRecord.organizationId) request.scope = {
120
+ kind: "member",
121
+ organizationId: String(userRecord.organizationId),
122
+ orgRoles: Array.isArray(userRecord.orgRoles) ? userRecord.orgRoles : []
123
+ };
124
+ else request.scope = AUTHENTICATED_SCOPE;
125
+ }
126
+ } catch (err) {
127
+ const error = err instanceof Error ? err : new Error(String(err));
128
+ if (onFailure) {
129
+ await onFailure(request, reply, error);
130
+ return;
131
+ }
132
+ const message = exposeAuthErrors ? error.message : "Authentication required";
133
+ reply.code(401).send({
134
+ success: false,
135
+ error: "Unauthorized",
136
+ message
137
+ });
138
+ }
139
+ };
140
+ /**
141
+ * Optional authenticate middleware
142
+ *
143
+ * Parses JWT if a Bearer token is present and populates request.user.
144
+ * Does NOT fail if no token or invalid token — treats as unauthenticated.
145
+ *
146
+ * Used on allowPublic() routes so that downstream middleware (e.g. multiTenant
147
+ * flexible filter) can apply org-scoped queries when a user IS authenticated.
148
+ */
149
+ const optionalAuthenticate = async (request, _reply) => {
150
+ try {
151
+ let user = null;
152
+ if (appAuthenticator) user = await appAuthenticator(request, authContext);
153
+ else if (jwtContext) {
154
+ const token = extractBearerToken(request);
155
+ if (token) {
156
+ const decoded = jwtContext.verify(token);
157
+ if (decoded.type === "refresh") return;
158
+ user = decoded;
159
+ }
160
+ }
161
+ if (user) {
162
+ const reqRecord = request;
163
+ reqRecord.user = user;
164
+ reqRecord[userProperty] = user;
165
+ if (!request.scope || request.scope.kind === "public") {
166
+ const userRecord = user;
167
+ if (userRecord.organizationId) request.scope = {
168
+ kind: "member",
169
+ organizationId: String(userRecord.organizationId),
170
+ orgRoles: Array.isArray(userRecord.orgRoles) ? userRecord.orgRoles : []
171
+ };
172
+ else request.scope = AUTHENTICATED_SCOPE;
173
+ }
174
+ }
175
+ } catch {}
176
+ };
177
+ const refreshSecret = jwtConfig?.refreshSecret ?? jwtConfig?.secret;
178
+ const accessExpiresIn = jwtConfig?.expiresIn ?? "15m";
179
+ const refreshExpiresIn = jwtConfig?.refreshExpiresIn ?? "7d";
180
+ /**
181
+ * Issue access + refresh tokens
182
+ * App calls this after validating credentials (login, OAuth, etc.)
183
+ */
184
+ const issueTokens = (payload, options) => {
185
+ if (!jwtContext) throw new Error("JWT not configured. Provide auth.jwt.secret to use issueTokens.");
186
+ const accessTtl = options?.expiresIn ?? accessExpiresIn;
187
+ const refreshTtl = options?.refreshExpiresIn ?? refreshExpiresIn;
188
+ const accessToken = jwtContext.sign({
189
+ ...payload,
190
+ type: "access"
191
+ }, { expiresIn: accessTtl });
192
+ const refreshPayload = payload.id ? {
193
+ id: payload.id,
194
+ type: "refresh"
195
+ } : payload._id ? {
196
+ id: payload._id,
197
+ type: "refresh"
198
+ } : {
199
+ ...payload,
200
+ type: "refresh"
201
+ };
202
+ let refreshToken;
203
+ if (refreshSecret) refreshToken = fastify.jwt.sign(refreshPayload, {
204
+ expiresIn: refreshTtl,
205
+ ...refreshSecret !== jwtConfig?.secret ? { key: refreshSecret } : {}
206
+ });
207
+ return {
208
+ accessToken,
209
+ refreshToken,
210
+ expiresIn: parseExpiresIn(accessTtl, 900),
211
+ refreshExpiresIn: refreshToken ? parseExpiresIn(refreshTtl, 604800) : void 0,
212
+ tokenType: "Bearer"
213
+ };
214
+ };
215
+ /**
216
+ * Verify refresh token
217
+ * App calls this in refresh endpoint
218
+ */
219
+ const verifyRefreshToken = (token) => {
220
+ if (!jwtContext) throw new Error("JWT not configured. Provide auth.jwt.secret to use verifyRefreshToken.");
221
+ const decoded = fastify.jwt.verify(token, { ...refreshSecret !== jwtConfig?.secret ? { key: refreshSecret } : {} });
222
+ if (decoded.type !== "refresh") throw new Error("Invalid token type: expected refresh token");
223
+ return decoded;
224
+ };
225
+ /**
226
+ * Authorize middleware factory
227
+ * Creates a middleware that checks if user has required roles
228
+ *
229
+ * @example
230
+ * preHandler: [fastify.authenticate, fastify.authorize('admin', 'superadmin')]
231
+ */
232
+ const authorize = (...allowedRoles) => {
233
+ return async (request, reply) => {
234
+ const reqRecord = request;
235
+ const user = reqRecord[userProperty] ?? reqRecord.user;
236
+ if (!user) {
237
+ reply.code(401).send({
238
+ success: false,
239
+ error: "Unauthorized",
240
+ message: "No user context"
241
+ });
242
+ return;
243
+ }
244
+ const userRoles = user.roles ?? [];
245
+ if (allowedRoles.length === 1 && allowedRoles[0] === "*") return;
246
+ if (!allowedRoles.some((role) => userRoles.includes(role))) {
247
+ reply.code(403).send({
248
+ success: false,
249
+ error: "Forbidden",
250
+ message: `Requires one of: ${allowedRoles.join(", ")}`
251
+ });
252
+ return;
253
+ }
254
+ };
255
+ };
256
+ const authHelpers = {
257
+ jwt: jwtContext,
258
+ issueTokens,
259
+ verifyRefreshToken
260
+ };
261
+ fastify.decorate("authenticate", authenticate);
262
+ fastify.decorate("optionalAuthenticate", optionalAuthenticate);
263
+ fastify.decorate("authorize", authorize);
264
+ fastify.decorate("auth", authHelpers);
265
+ fastify.log.debug(`Auth: Plugin registered (jwt=${!!jwtContext}, customAuth=${!!appAuthenticator})`);
266
+ };
267
+ var authPlugin_default = fp(authPlugin, {
268
+ name: "arc-auth",
269
+ fastify: "5.x"
270
+ });
271
+
272
+ //#endregion
273
+ //#region src/auth/betterAuth.ts
274
+ /**
275
+ * Better Auth Adapter for Arc/Fastify
276
+ *
277
+ * Bridges Fastify <-> Better Auth's Fetch API (Request/Response).
278
+ * Better Auth is the USER's dependency -- Arc only provides this thin adapter.
279
+ *
280
+ * @example
281
+ * import { betterAuth } from 'better-auth';
282
+ * import { createBetterAuthAdapter } from '@classytic/arc/auth';
283
+ *
284
+ * const auth = betterAuth({ ... });
285
+ *
286
+ * const app = await createApp({
287
+ * auth: { type: 'betterAuth', betterAuth: createBetterAuthAdapter({ auth }) },
288
+ * });
289
+ */
290
+ /**
291
+ * Convert a Fastify request into a Fetch API Request.
292
+ *
293
+ * Better Auth expects standard Web API Request objects.
294
+ * We reconstruct one from Fastify's request properties.
295
+ */
296
+ function toFetchRequest(request) {
297
+ const url = `${request.protocol ?? "http"}://${request.hostname ?? "localhost"}${request.url}`;
298
+ const headers = new Headers();
299
+ for (const [key, value] of Object.entries(request.headers)) {
300
+ if (value === void 0) continue;
301
+ if (Array.isArray(value)) for (const v of value) headers.append(key, v);
302
+ else headers.set(key, value);
303
+ }
304
+ const hasBody = request.method !== "GET" && request.method !== "HEAD";
305
+ let body;
306
+ if (hasBody && request.body != null) {
307
+ const contentType = (request.headers["content-type"] ?? "").toLowerCase();
308
+ if (request.rawBody) body = request.rawBody;
309
+ else if (contentType.includes("application/x-www-form-urlencoded")) {
310
+ const params = new URLSearchParams();
311
+ for (const [k, v] of Object.entries(request.body)) if (v != null) params.set(k, String(v));
312
+ body = params.toString();
313
+ } else if (typeof request.body === "string") body = request.body;
314
+ else if (contentType.includes("application/json") || contentType.includes("text/") || !contentType) body = JSON.stringify(request.body);
315
+ else request.log?.warn?.("toFetchRequest: cannot reconstruct %s body without rawBody plugin", contentType);
316
+ }
317
+ return new Request(url, {
318
+ method: request.method,
319
+ headers,
320
+ body
321
+ });
322
+ }
323
+ /**
324
+ * Pipe a Fetch API Response back into Fastify's reply.
325
+ *
326
+ * Transfers status code, all response headers, and the body.
327
+ * Handles both buffered (JSON) and streaming (SSE) responses.
328
+ */
329
+ async function sendFetchResponse(response, reply) {
330
+ reply.status(response.status);
331
+ response.headers.forEach((value, key) => {
332
+ if (key.toLowerCase() === "transfer-encoding") return;
333
+ reply.header(key, value);
334
+ });
335
+ const contentType = response.headers.get("content-type") ?? "";
336
+ if (response.body && (contentType.includes("text/event-stream") || contentType.includes("application/octet-stream"))) await reply.send(response.body);
337
+ else {
338
+ const body = await response.text();
339
+ await reply.send(body);
340
+ }
341
+ }
342
+ /**
343
+ * Try to get session via Better Auth's direct JS API.
344
+ * Returns null if the API method is not available (older Better Auth versions).
345
+ */
346
+ async function tryDirectGetSession(auth, headers) {
347
+ const api = auth.api;
348
+ if (!api || typeof api.getSession !== "function") return null;
349
+ try {
350
+ const result = await api.getSession({ headers });
351
+ if (result?.user) return result;
352
+ return null;
353
+ } catch {
354
+ return null;
355
+ }
356
+ }
357
+ /**
358
+ * Try to get active org member via direct JS API.
359
+ * Returns roles array or null if not available.
360
+ */
361
+ async function tryDirectGetActiveMember(auth, headers) {
362
+ const getActiveMember = auth.api?.organization?.getActiveMember;
363
+ if (typeof getActiveMember !== "function") return null;
364
+ try {
365
+ const memberData = await getActiveMember({ headers });
366
+ if (memberData) return extractRolesFromMembership(memberData);
367
+ return null;
368
+ } catch {
369
+ return null;
370
+ }
371
+ }
372
+ /**
373
+ * Look up member role by explicit organizationId (query param).
374
+ *
375
+ * Better Auth's `getActiveMemberRole` endpoint accepts an `organizationId`
376
+ * query parameter, bypassing the session's `activeOrganizationId`.
377
+ * This is essential for API key auth where the synthetic session has no
378
+ * active organization set — callers pass org context via `x-organization-id` header.
379
+ */
380
+ async function tryDirectGetMemberRole(auth, headers, organizationId) {
381
+ const getActiveMemberRole = auth.api?.organization?.getActiveMemberRole;
382
+ if (typeof getActiveMemberRole !== "function") return null;
383
+ try {
384
+ const result = await getActiveMemberRole({
385
+ headers,
386
+ query: { organizationId }
387
+ });
388
+ if (result?.role) return parseRoles(result.role);
389
+ return null;
390
+ } catch {
391
+ return null;
392
+ }
393
+ }
394
+ /**
395
+ * Try to list teams via direct JS API.
396
+ */
397
+ async function tryDirectListTeams(auth, headers) {
398
+ const listTeams = auth.api?.organization?.listTeams;
399
+ if (typeof listTeams !== "function") return null;
400
+ try {
401
+ const result = await listTeams({ headers });
402
+ const teams = Array.isArray(result) ? result : result?.teams;
403
+ return Array.isArray(teams) ? teams : null;
404
+ } catch {
405
+ return null;
406
+ }
407
+ }
408
+ /** Build a Headers object from Fastify request headers */
409
+ function buildHeaders(request) {
410
+ const headers = new Headers();
411
+ for (const [key, value] of Object.entries(request.headers)) {
412
+ if (value === void 0) continue;
413
+ if (Array.isArray(value)) for (const v of value) headers.append(key, v);
414
+ else headers.set(key, value);
415
+ }
416
+ return headers;
417
+ }
418
+ /** Normalize unknown ID-like values to comparable string form */
419
+ function normalizeId(value) {
420
+ if (value == null) return null;
421
+ if (typeof value === "string") return value;
422
+ if (typeof value === "number" || typeof value === "bigint") return String(value);
423
+ if (typeof value === "object") {
424
+ const obj = value;
425
+ const nested = obj._id ?? obj.id ?? obj.organizationId;
426
+ if (nested != null && nested !== value) return normalizeId(nested);
427
+ }
428
+ return String(value);
429
+ }
430
+ /** Parse role payload from Better Auth (string, csv, array) into normalized roles[] */
431
+ function parseRoles(value) {
432
+ if (Array.isArray(value)) return value.map((r) => String(r).trim()).filter(Boolean);
433
+ if (typeof value === "string") return value.split(",").map((r) => r.trim()).filter(Boolean);
434
+ return [];
435
+ }
436
+ /** Extract role field from heterogeneous org membership shapes */
437
+ function extractRolesFromMembership(membership) {
438
+ const direct = parseRoles(membership.role ?? membership.roles ?? membership.orgRole);
439
+ if (direct.length > 0) return direct;
440
+ const nestedMembership = membership.membership;
441
+ if (nestedMembership) {
442
+ const nested = parseRoles(nestedMembership.role ?? nestedMembership.roles);
443
+ if (nested.length > 0) return nested;
444
+ }
445
+ return [];
446
+ }
447
+ /** Match an organization membership entry against the active org id */
448
+ function membershipMatchesOrg(membership, activeOrgId) {
449
+ return [
450
+ normalizeId(membership.organizationId),
451
+ normalizeId(membership.orgId),
452
+ normalizeId(membership.id),
453
+ normalizeId(membership.organization?._id),
454
+ normalizeId(membership.organization?.id),
455
+ normalizeId(membership.organization?.organizationId)
456
+ ].filter(Boolean).includes(activeOrgId);
457
+ }
458
+ /**
459
+ * Resolve org roles with fallback chain:
460
+ * 1) GET /organization/get-active-member (requires activeOrganizationId in session)
461
+ * 2) GET /organization/get-active-member-role?organizationId=... (explicit org — works for API key auth)
462
+ * 3) GET /organization/list (fallback for type mismatch/legacy ID storage)
463
+ */
464
+ async function resolveOrgRoles(auth, protocol, host, normalizedBase, headers, activeOrgId) {
465
+ const memberUrl = `${protocol}://${host}${normalizedBase}/organization/get-active-member`;
466
+ const memberRequest = new Request(memberUrl, {
467
+ method: "GET",
468
+ headers
469
+ });
470
+ const memberResponse = await auth.handler(memberRequest);
471
+ if (memberResponse.ok) {
472
+ const memberData = await memberResponse.json();
473
+ if (memberData) return extractRolesFromMembership(memberData);
474
+ }
475
+ const roleUrl = `${protocol}://${host}${normalizedBase}/organization/get-active-member-role?organizationId=${encodeURIComponent(activeOrgId)}`;
476
+ const roleRequest = new Request(roleUrl, {
477
+ method: "GET",
478
+ headers
479
+ });
480
+ const roleResponse = await auth.handler(roleRequest);
481
+ if (roleResponse.ok) {
482
+ const roleData = await roleResponse.json();
483
+ if (roleData?.role) return parseRoles(roleData.role);
484
+ }
485
+ const listUrl = `${protocol}://${host}${normalizedBase}/organization/list`;
486
+ const listRequest = new Request(listUrl, {
487
+ method: "GET",
488
+ headers
489
+ });
490
+ const listResponse = await auth.handler(listRequest);
491
+ if (!listResponse.ok) return null;
492
+ const listData = await listResponse.json();
493
+ const memberships = Array.isArray(listData) ? listData : listData?.organizations ?? listData?.data ?? [];
494
+ if (!Array.isArray(memberships)) return null;
495
+ const target = memberships.find((entry) => {
496
+ if (!entry || typeof entry !== "object") return false;
497
+ return membershipMatchesOrg(entry, activeOrgId);
498
+ });
499
+ if (!target) return null;
500
+ return extractRolesFromMembership(target);
501
+ }
502
+ /**
503
+ * Create a Better Auth adapter for Arc/Fastify.
504
+ *
505
+ * Returns a Fastify plugin (registers catch-all auth routes) and an
506
+ * `authenticate` preHandler that validates sessions via Better Auth.
507
+ *
508
+ * @example
509
+ * ```typescript
510
+ * import { betterAuth } from 'better-auth';
511
+ * import { createBetterAuthAdapter } from '@classytic/arc/auth';
512
+ *
513
+ * const auth = betterAuth({
514
+ * database: ...,
515
+ * emailAndPassword: { enabled: true },
516
+ * });
517
+ *
518
+ * const { plugin, authenticate } = createBetterAuthAdapter({ auth });
519
+ *
520
+ * // Register the plugin (catch-all auth routes)
521
+ * await fastify.register(plugin);
522
+ *
523
+ * // Use authenticate as a preHandler on protected routes
524
+ * fastify.get('/me', { preHandler: [authenticate] }, handler);
525
+ * ```
526
+ */
527
+ function createBetterAuthAdapter(options) {
528
+ const { auth, basePath = "/api/auth", orgContext: orgContextOpt = false, openapi: openapiOpt = true, userFields, exposeAuthErrors = false } = options;
529
+ const normalizedBase = basePath.replace(/\/+$/, "");
530
+ const orgEnabled = !!orgContextOpt;
531
+ /**
532
+ * Validates the current session by forwarding cookies/headers
533
+ * to Better Auth's `GET /api/auth/get-session` endpoint.
534
+ *
535
+ * On success, sets `request.user` and `request.session`.
536
+ * When orgContext is enabled, also sets `request.scope` to
537
+ * `{ kind: 'member', organizationId, orgRoles, teamId? }`.
538
+ * On failure, replies with 401.
539
+ */
540
+ const authenticate = async (request, reply) => {
541
+ try {
542
+ const protocol = request.protocol ?? "http";
543
+ const host = request.hostname ?? "localhost";
544
+ const headers = buildHeaders(request);
545
+ let sessionData = null;
546
+ sessionData = await tryDirectGetSession(auth, headers);
547
+ if (!sessionData) {
548
+ const sessionUrl = `${protocol}://${host}${normalizedBase}/get-session`;
549
+ const sessionRequest = new Request(sessionUrl, {
550
+ method: "GET",
551
+ headers
552
+ });
553
+ const sessionResponse = await auth.handler(sessionRequest);
554
+ if (!sessionResponse.ok) {
555
+ reply.code(401).send({
556
+ success: false,
557
+ error: "Unauthorized",
558
+ message: "Invalid or expired session"
559
+ });
560
+ return;
561
+ }
562
+ sessionData = await sessionResponse.json();
563
+ }
564
+ if (!sessionData?.user) {
565
+ reply.code(401).send({
566
+ success: false,
567
+ error: "Unauthorized",
568
+ message: "No active session"
569
+ });
570
+ return;
571
+ }
572
+ const req = request;
573
+ req.user = sessionData.user;
574
+ req.session = sessionData.session;
575
+ req.scope = AUTHENTICATED_SCOPE;
576
+ if (orgEnabled) {
577
+ const session = sessionData.session;
578
+ const activeOrgId = session?.activeOrganizationId || request.headers["x-organization-id"];
579
+ if (activeOrgId) {
580
+ let orgRoles = await tryDirectGetActiveMember(auth, headers);
581
+ if (!orgRoles) orgRoles = await tryDirectGetMemberRole(auth, headers, activeOrgId);
582
+ if (!orgRoles) orgRoles = await resolveOrgRoles(auth, protocol, host, normalizedBase, headers, activeOrgId);
583
+ if (orgRoles) {
584
+ const scope = {
585
+ kind: "member",
586
+ organizationId: activeOrgId,
587
+ orgRoles
588
+ };
589
+ const activeTeamId = session?.activeTeamId;
590
+ if (activeTeamId) {
591
+ let teams = await tryDirectListTeams(auth, headers);
592
+ if (!teams) {
593
+ const teamsUrl = `${protocol}://${host}${normalizedBase}/organization/list-teams`;
594
+ const teamsRequest = new Request(teamsUrl, {
595
+ method: "GET",
596
+ headers
597
+ });
598
+ const teamsResponse = await auth.handler(teamsRequest);
599
+ if (teamsResponse.ok) {
600
+ const teamsData = await teamsResponse.json();
601
+ teams = Array.isArray(teamsData) ? teamsData : teamsData?.teams ?? [];
602
+ }
603
+ }
604
+ if (teams && teams.some((t) => t.id === activeTeamId)) scope.teamId = activeTeamId;
605
+ }
606
+ req.scope = scope;
607
+ }
608
+ }
609
+ }
610
+ } catch (err) {
611
+ const message = exposeAuthErrors ? err instanceof Error ? err.message : String(err) : "Authentication required";
612
+ reply.code(401).send({
613
+ success: false,
614
+ error: "Unauthorized",
615
+ message
616
+ });
617
+ }
618
+ };
619
+ /**
620
+ * Silently resolves session without failing.
621
+ * Populates request.user + request.scope if a valid session exists.
622
+ * On failure or missing session, continues as unauthenticated (scope stays 'public').
623
+ *
624
+ * Used by allowPublic() routes so downstream middleware (e.g. multiTenant
625
+ * flexible filter) can apply org-scoped queries when a user IS authenticated.
626
+ */
627
+ const optionalAuthenticate = async (request, _reply) => {
628
+ try {
629
+ const headers = buildHeaders(request);
630
+ let sessionData = null;
631
+ sessionData = await tryDirectGetSession(auth, headers);
632
+ if (!sessionData) {
633
+ const sessionUrl = `${request.protocol ?? "http"}://${request.hostname ?? "localhost"}${normalizedBase}/get-session`;
634
+ const sessionRequest = new Request(sessionUrl, {
635
+ method: "GET",
636
+ headers
637
+ });
638
+ const sessionResponse = await auth.handler(sessionRequest);
639
+ if (sessionResponse.ok) sessionData = await sessionResponse.json();
640
+ }
641
+ if (!sessionData?.user) return;
642
+ const req = request;
643
+ req.user = sessionData.user;
644
+ req.session = sessionData.session;
645
+ req.scope = AUTHENTICATED_SCOPE;
646
+ if (orgEnabled) {
647
+ const activeOrgId = sessionData.session?.activeOrganizationId || request.headers["x-organization-id"];
648
+ if (activeOrgId) {
649
+ let orgRoles = await tryDirectGetActiveMember(auth, headers);
650
+ if (!orgRoles) orgRoles = await tryDirectGetMemberRole(auth, headers, activeOrgId);
651
+ if (!orgRoles) orgRoles = await resolveOrgRoles(auth, request.protocol ?? "http", request.hostname ?? "localhost", normalizedBase, headers, activeOrgId);
652
+ if (orgRoles) req.scope = {
653
+ kind: "member",
654
+ organizationId: activeOrgId,
655
+ orgRoles
656
+ };
657
+ }
658
+ }
659
+ } catch {}
660
+ };
661
+ let extractedOpenApi;
662
+ if (openapiOpt === false) extractedOpenApi = void 0;
663
+ else if (typeof openapiOpt === "object") extractedOpenApi = openapiOpt;
664
+ const betterAuthPlugin = async (fastify) => {
665
+ fastify.all(`${normalizedBase}/*`, async (request, reply) => {
666
+ try {
667
+ const fetchRequest = toFetchRequest(request);
668
+ await sendFetchResponse(await auth.handler(fetchRequest), reply);
669
+ } catch (err) {
670
+ throw new ArcError("Authentication service error", {
671
+ code: "AUTH_SERVICE_ERROR",
672
+ statusCode: 500,
673
+ cause: err instanceof Error ? err : new Error(String(err))
674
+ });
675
+ }
676
+ });
677
+ if (!fastify.hasDecorator("authenticate")) fastify.decorate("authenticate", authenticate);
678
+ if (!fastify.hasDecorator("optionalAuthenticate")) fastify.decorate("optionalAuthenticate", optionalAuthenticate);
679
+ if (!extractedOpenApi && openapiOpt !== false && auth.api && typeof auth.api === "object") {
680
+ const { extractBetterAuthOpenApi } = await import("../betterAuthOpenApi-BrHKeSAx.mjs").then((n) => n.t);
681
+ extractedOpenApi = extractBetterAuthOpenApi(auth.api, {
682
+ basePath,
683
+ userFields
684
+ });
685
+ }
686
+ if (extractedOpenApi) {
687
+ const arc = fastify.arc;
688
+ if (arc?.externalOpenApiPaths) arc.externalOpenApiPaths.push(extractedOpenApi);
689
+ }
690
+ fastify.log.debug(`Better Auth: Routes registered at ${normalizedBase}/*`);
691
+ };
692
+ return {
693
+ plugin: fp(betterAuthPlugin, {
694
+ name: "arc-better-auth",
695
+ fastify: "5.x"
696
+ }),
697
+ authenticate,
698
+ optionalAuthenticate,
699
+ permissions: {
700
+ requireOrgRole: (...roles) => requireOrgRole(roles),
701
+ requireOrgMembership: () => requireOrgMembership(),
702
+ requireTeamMembership: () => requireTeamMembership()
703
+ },
704
+ openapi: extractedOpenApi
705
+ };
706
+ }
707
+
708
+ //#endregion
709
+ //#region src/auth/sessionManager.ts
710
+ /**
711
+ * Session Management for Arc
712
+ *
713
+ * Lightweight cookie-based session manager that coexists with JWT and Better Auth.
714
+ * Users pick their auth strategy — this is one option alongside authPlugin and
715
+ * createBetterAuthAdapter.
716
+ *
717
+ * Features:
718
+ * - Cookie-based session tokens (HMAC-signed)
719
+ * - Session refresh with throttling (updateAge)
720
+ * - Fresh session concept for sensitive operations (freshAge)
721
+ * - Session revocation (single, all, all-except-current)
722
+ * - Pluggable session stores (Memory, Redis, etc.)
723
+ *
724
+ * @example
725
+ * ```typescript
726
+ * import { createSessionManager, MemorySessionStore } from '@classytic/arc/auth';
727
+ *
728
+ * const sessions = createSessionManager({
729
+ * store: new MemorySessionStore(),
730
+ * secret: process.env.SESSION_SECRET,
731
+ * maxAge: 7 * 24 * 60 * 60, // 7 days
732
+ * updateAge: 24 * 60 * 60, // refresh every 24h
733
+ * freshAge: 10 * 60, // 10 min for sensitive ops
734
+ * });
735
+ *
736
+ * // Register plugin
737
+ * await fastify.register(sessions.plugin);
738
+ *
739
+ * // Protect sensitive routes
740
+ * fastify.post('/change-password', {
741
+ * preHandler: [fastify.authenticate, sessions.requireFresh],
742
+ * }, handler);
743
+ * ```
744
+ */
745
+ /**
746
+ * Sign a session ID using HMAC-SHA256.
747
+ * Returns `sessionId.signature` format.
748
+ */
749
+ function signSessionId(sessionId, secret) {
750
+ return `${sessionId}.${createHmac("sha256", secret).update(sessionId).digest("base64url")}`;
751
+ }
752
+ /**
753
+ * Verify and extract session ID from a signed cookie value.
754
+ * Returns the session ID if valid, null otherwise.
755
+ */
756
+ function verifySessionId(signedValue, secret) {
757
+ const lastDotIndex = signedValue.lastIndexOf(".");
758
+ if (lastDotIndex === -1) return null;
759
+ const sessionId = signedValue.slice(0, lastDotIndex);
760
+ const signature = signedValue.slice(lastDotIndex + 1);
761
+ if (!sessionId || !signature) return null;
762
+ const expectedSignature = createHmac("sha256", secret).update(sessionId).digest("base64url");
763
+ const sigBuf = Buffer.from(signature);
764
+ const expectedBuf = Buffer.from(expectedSignature);
765
+ if (sigBuf.length !== expectedBuf.length) return null;
766
+ return timingSafeEqual(sigBuf, expectedBuf) ? sessionId : null;
767
+ }
768
+ /**
769
+ * Parse cookies from a Cookie header string.
770
+ * Returns a map of cookie name to value.
771
+ */
772
+ function parseCookies(header) {
773
+ const cookies = /* @__PURE__ */ new Map();
774
+ if (!header) return cookies;
775
+ const pairs = header.split(";");
776
+ for (const pair of pairs) {
777
+ const eqIndex = pair.indexOf("=");
778
+ if (eqIndex === -1) continue;
779
+ const name = pair.slice(0, eqIndex).trim();
780
+ const value = pair.slice(eqIndex + 1).trim();
781
+ if (name) try {
782
+ cookies.set(name, decodeURIComponent(value));
783
+ } catch {
784
+ cookies.set(name, value);
785
+ }
786
+ }
787
+ return cookies;
788
+ }
789
+ /**
790
+ * Build a Set-Cookie header value.
791
+ */
792
+ function buildSetCookieHeader(name, value, maxAgeSeconds, options) {
793
+ const parts = [
794
+ `${name}=${encodeURIComponent(value)}`,
795
+ `Max-Age=${maxAgeSeconds}`,
796
+ `Path=${options.path ?? "/"}`
797
+ ];
798
+ if (options.httpOnly !== false) parts.push("HttpOnly");
799
+ if (options.secure ?? process.env.NODE_ENV === "production") parts.push("Secure");
800
+ parts.push(`SameSite=${capitalize(options.sameSite ?? "lax")}`);
801
+ if (options.domain) parts.push(`Domain=${options.domain}`);
802
+ return parts.join("; ");
803
+ }
804
+ /**
805
+ * Build a Set-Cookie header that clears (expires) the cookie.
806
+ */
807
+ function buildClearCookieHeader(name, options) {
808
+ return buildSetCookieHeader(name, "", 0, options);
809
+ }
810
+ function capitalize(s) {
811
+ return s.charAt(0).toUpperCase() + s.slice(1);
812
+ }
813
+ /**
814
+ * In-memory session store for development and single-instance deployments.
815
+ * NOT suitable for multi-instance/clustered deployments — use Redis or similar.
816
+ */
817
+ var MemorySessionStore = class {
818
+ sessions = /* @__PURE__ */ new Map();
819
+ /** Reverse index: userId -> Set<sessionId> for efficient bulk operations */
820
+ userIndex = /* @__PURE__ */ new Map();
821
+ cleanupInterval = null;
822
+ constructor(options = {}) {
823
+ const intervalMs = options.cleanupIntervalMs ?? 6e4;
824
+ this.cleanupInterval = setInterval(() => {
825
+ this.cleanup();
826
+ }, intervalMs);
827
+ if (this.cleanupInterval.unref) this.cleanupInterval.unref();
828
+ }
829
+ async get(sessionId) {
830
+ const session = this.sessions.get(sessionId);
831
+ if (!session) return null;
832
+ if (Date.now() > session.expiresAt) {
833
+ await this.delete(sessionId);
834
+ return null;
835
+ }
836
+ return session;
837
+ }
838
+ async set(sessionId, data) {
839
+ this.sessions.set(sessionId, data);
840
+ let userSessions = this.userIndex.get(data.userId);
841
+ if (!userSessions) {
842
+ userSessions = /* @__PURE__ */ new Set();
843
+ this.userIndex.set(data.userId, userSessions);
844
+ }
845
+ userSessions.add(sessionId);
846
+ }
847
+ async delete(sessionId) {
848
+ const session = this.sessions.get(sessionId);
849
+ if (session) {
850
+ const userSessions = this.userIndex.get(session.userId);
851
+ if (userSessions) {
852
+ userSessions.delete(sessionId);
853
+ if (userSessions.size === 0) this.userIndex.delete(session.userId);
854
+ }
855
+ }
856
+ this.sessions.delete(sessionId);
857
+ }
858
+ async deleteAll(userId) {
859
+ const userSessions = this.userIndex.get(userId);
860
+ if (!userSessions) return;
861
+ for (const sessionId of userSessions) this.sessions.delete(sessionId);
862
+ this.userIndex.delete(userId);
863
+ }
864
+ async deleteAllExcept(userId, currentSessionId) {
865
+ const userSessions = this.userIndex.get(userId);
866
+ if (!userSessions) return;
867
+ for (const sessionId of userSessions) if (sessionId !== currentSessionId) this.sessions.delete(sessionId);
868
+ if (userSessions.has(currentSessionId)) this.userIndex.set(userId, new Set([currentSessionId]));
869
+ else this.userIndex.delete(userId);
870
+ }
871
+ /**
872
+ * Close the store and clean up resources.
873
+ */
874
+ close() {
875
+ if (this.cleanupInterval) {
876
+ clearInterval(this.cleanupInterval);
877
+ this.cleanupInterval = null;
878
+ }
879
+ this.sessions.clear();
880
+ this.userIndex.clear();
881
+ }
882
+ /**
883
+ * Get current stats (for debugging/monitoring).
884
+ */
885
+ getStats() {
886
+ return {
887
+ sessions: this.sessions.size,
888
+ users: this.userIndex.size
889
+ };
890
+ }
891
+ /**
892
+ * Remove expired sessions.
893
+ */
894
+ cleanup() {
895
+ const now = Date.now();
896
+ for (const [sessionId, session] of this.sessions) if (now > session.expiresAt) {
897
+ const userSessions = this.userIndex.get(session.userId);
898
+ if (userSessions) {
899
+ userSessions.delete(sessionId);
900
+ if (userSessions.size === 0) this.userIndex.delete(session.userId);
901
+ }
902
+ this.sessions.delete(sessionId);
903
+ }
904
+ }
905
+ };
906
+ /**
907
+ * Create a session manager for Arc.
908
+ *
909
+ * Returns a Fastify plugin and a `requireFresh` preHandler.
910
+ *
911
+ * The plugin:
912
+ * - Parses session cookie on each request
913
+ * - Validates session against the store
914
+ * - Sets `request.user` and `request.session` from session data
915
+ * - Refreshes session token if older than `updateAge`
916
+ * - Provides `fastify.authenticate` decorator
917
+ * - Provides `fastify.sessionManager` decorator for session CRUD
918
+ *
919
+ * @example
920
+ * ```typescript
921
+ * import { createSessionManager, MemorySessionStore } from '@classytic/arc/auth';
922
+ *
923
+ * const sessions = createSessionManager({
924
+ * store: new MemorySessionStore(),
925
+ * secret: process.env.SESSION_SECRET!,
926
+ * maxAge: 7 * 24 * 60 * 60,
927
+ * updateAge: 24 * 60 * 60,
928
+ * freshAge: 10 * 60,
929
+ * });
930
+ *
931
+ * await fastify.register(sessions.plugin);
932
+ *
933
+ * // Login route
934
+ * fastify.post('/login', async (request, reply) => {
935
+ * const user = await authenticateUser(request.body);
936
+ * const { cookie } = await fastify.sessionManager.createSession(user.id);
937
+ * reply.header('Set-Cookie', cookie);
938
+ * return { success: true, user };
939
+ * });
940
+ *
941
+ * // Protected route
942
+ * fastify.get('/me', {
943
+ * preHandler: [fastify.authenticate],
944
+ * }, async (request) => {
945
+ * return { user: request.user };
946
+ * });
947
+ *
948
+ * // Sensitive route (requires fresh session)
949
+ * fastify.post('/change-password', {
950
+ * preHandler: [fastify.authenticate, sessions.requireFresh],
951
+ * }, handler);
952
+ * ```
953
+ */
954
+ function createSessionManager(options) {
955
+ const { store, secret, maxAge: maxAgeSeconds = 10080 * 60, updateAge: updateAgeSeconds = 1440 * 60, freshAge: freshAgeSeconds = 600, cookieName = "arc.session", cookie: cookieOptions = {} } = options;
956
+ if (secret.length < 32) throw new Error(`Session secret must be at least 32 characters (current: ${secret.length}). Use a strong random secret for production.`);
957
+ const maxAgeMs = maxAgeSeconds * 1e3;
958
+ const updateAgeMs = updateAgeSeconds * 1e3;
959
+ const freshAgeMs = freshAgeSeconds * 1e3;
960
+ /**
961
+ * Create a new session and return the signed cookie value.
962
+ */
963
+ async function createSession(userId, metadata) {
964
+ const sessionId = randomUUID();
965
+ const now = Date.now();
966
+ const sessionData = {
967
+ userId,
968
+ createdAt: now,
969
+ updatedAt: now,
970
+ expiresAt: now + maxAgeMs,
971
+ metadata
972
+ };
973
+ await store.set(sessionId, sessionData);
974
+ return {
975
+ sessionId,
976
+ cookie: buildSetCookieHeader(cookieName, signSessionId(sessionId, secret), maxAgeSeconds, cookieOptions)
977
+ };
978
+ }
979
+ /**
980
+ * Refresh a session: update the updatedAt timestamp and optionally extend expiry.
981
+ */
982
+ async function refreshSession(sessionId) {
983
+ const session = await store.get(sessionId);
984
+ if (!session) return null;
985
+ const now = Date.now();
986
+ const updatedSession = {
987
+ ...session,
988
+ updatedAt: now,
989
+ expiresAt: Math.max(session.expiresAt, now + maxAgeMs)
990
+ };
991
+ await store.set(sessionId, updatedSession);
992
+ return updatedSession;
993
+ }
994
+ /**
995
+ * PreHandler that rejects requests if the session is not "fresh".
996
+ * A session is fresh if it was last updated within `freshAge` seconds.
997
+ * Use this for sensitive operations like password changes, email changes, etc.
998
+ */
999
+ const requireFresh = async (request, reply) => {
1000
+ const session = request.session;
1001
+ if (!session) {
1002
+ reply.code(401).send({
1003
+ success: false,
1004
+ error: "Unauthorized",
1005
+ message: "Authentication required"
1006
+ });
1007
+ return;
1008
+ }
1009
+ if (Date.now() - session.updatedAt > freshAgeMs) {
1010
+ reply.code(403).send({
1011
+ success: false,
1012
+ error: "SessionNotFresh",
1013
+ message: "Session is not fresh. Please re-authenticate to perform this action.",
1014
+ code: "SESSION_NOT_FRESH"
1015
+ });
1016
+ return;
1017
+ }
1018
+ };
1019
+ const sessionPlugin = async (fastify) => {
1020
+ const authenticate = async (request, reply) => {
1021
+ const cookieHeader = request.headers.cookie;
1022
+ const signedValue = parseCookies(typeof cookieHeader === "string" ? cookieHeader : void 0).get(cookieName);
1023
+ if (!signedValue) {
1024
+ reply.code(401).send({
1025
+ success: false,
1026
+ error: "Unauthorized",
1027
+ message: "No session cookie"
1028
+ });
1029
+ return;
1030
+ }
1031
+ const sessionId = verifySessionId(signedValue, secret);
1032
+ if (!sessionId) {
1033
+ reply.header("Set-Cookie", buildClearCookieHeader(cookieName, cookieOptions));
1034
+ reply.code(401).send({
1035
+ success: false,
1036
+ error: "Unauthorized",
1037
+ message: "Invalid session"
1038
+ });
1039
+ return;
1040
+ }
1041
+ const session = await store.get(sessionId);
1042
+ if (!session) {
1043
+ reply.header("Set-Cookie", buildClearCookieHeader(cookieName, cookieOptions));
1044
+ reply.code(401).send({
1045
+ success: false,
1046
+ error: "Unauthorized",
1047
+ message: "Session expired or revoked"
1048
+ });
1049
+ return;
1050
+ }
1051
+ if (Date.now() > session.expiresAt) {
1052
+ await store.delete(sessionId);
1053
+ reply.header("Set-Cookie", buildClearCookieHeader(cookieName, cookieOptions));
1054
+ reply.code(401).send({
1055
+ success: false,
1056
+ error: "Unauthorized",
1057
+ message: "Session expired"
1058
+ });
1059
+ return;
1060
+ }
1061
+ request.user = {
1062
+ id: session.userId,
1063
+ ...session.metadata
1064
+ };
1065
+ request.session = {
1066
+ ...session,
1067
+ id: sessionId
1068
+ };
1069
+ if (Date.now() - session.updatedAt > updateAgeMs) {
1070
+ const updatedSession = await refreshSession(sessionId);
1071
+ if (updatedSession) {
1072
+ const newCookie = buildSetCookieHeader(cookieName, signSessionId(sessionId, secret), maxAgeSeconds, cookieOptions);
1073
+ reply.header("Set-Cookie", newCookie);
1074
+ request.session = {
1075
+ ...updatedSession,
1076
+ id: sessionId
1077
+ };
1078
+ }
1079
+ }
1080
+ };
1081
+ if (!fastify.hasDecorator("authenticate")) fastify.decorate("authenticate", authenticate);
1082
+ fastify.decorate("sessionManager", {
1083
+ createSession,
1084
+ revokeSession: (sessionId) => store.delete(sessionId),
1085
+ revokeAllSessions: (userId) => store.deleteAll(userId),
1086
+ revokeOtherSessions: (userId, currentSessionId) => store.deleteAllExcept(userId, currentSessionId),
1087
+ refreshSession
1088
+ });
1089
+ fastify.log.debug(`Session: Plugin registered (cookieName=${cookieName}, maxAge=${maxAgeSeconds}s, updateAge=${updateAgeSeconds}s, freshAge=${freshAgeSeconds}s)`);
1090
+ };
1091
+ return {
1092
+ plugin: fp(sessionPlugin, {
1093
+ name: "arc-session",
1094
+ fastify: "5.x"
1095
+ }),
1096
+ requireFresh
1097
+ };
1098
+ }
1099
+
1100
+ //#endregion
1101
+ export { MemorySessionStore, authPlugin_default as authPlugin, authPlugin as authPluginFn, createBetterAuthAdapter, createSessionManager, extractBetterAuthOpenApi };
1102
+ //# sourceMappingURL=index.mjs.map