@classytic/arc 1.1.0 → 2.1.3

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