@data-club/ai-hub-server 0.0.1

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 (248) hide show
  1. package/dist/auth/argon2.d.ts +72 -0
  2. package/dist/auth/argon2.d.ts.map +1 -0
  3. package/dist/auth/argon2.js +145 -0
  4. package/dist/auth/argon2.js.map +1 -0
  5. package/dist/auth/encryption.d.ts +52 -0
  6. package/dist/auth/encryption.d.ts.map +1 -0
  7. package/dist/auth/encryption.js +98 -0
  8. package/dist/auth/encryption.js.map +1 -0
  9. package/dist/auth/jwt.d.ts +133 -0
  10. package/dist/auth/jwt.d.ts.map +1 -0
  11. package/dist/auth/jwt.js +147 -0
  12. package/dist/auth/jwt.js.map +1 -0
  13. package/dist/auth/middleware.d.ts +101 -0
  14. package/dist/auth/middleware.d.ts.map +1 -0
  15. package/dist/auth/middleware.js +161 -0
  16. package/dist/auth/middleware.js.map +1 -0
  17. package/dist/auth/passwordPolicy.d.ts +72 -0
  18. package/dist/auth/passwordPolicy.d.ts.map +1 -0
  19. package/dist/auth/passwordPolicy.js +88 -0
  20. package/dist/auth/passwordPolicy.js.map +1 -0
  21. package/dist/auth/roleRules.d.ts +200 -0
  22. package/dist/auth/roleRules.d.ts.map +1 -0
  23. package/dist/auth/roleRules.js +365 -0
  24. package/dist/auth/roleRules.js.map +1 -0
  25. package/dist/auth/userView.d.ts +55 -0
  26. package/dist/auth/userView.d.ts.map +1 -0
  27. package/dist/auth/userView.js +77 -0
  28. package/dist/auth/userView.js.map +1 -0
  29. package/dist/bootstrap.d.ts +104 -0
  30. package/dist/bootstrap.d.ts.map +1 -0
  31. package/dist/bootstrap.js +160 -0
  32. package/dist/bootstrap.js.map +1 -0
  33. package/dist/db/connection.d.ts +39 -0
  34. package/dist/db/connection.d.ts.map +1 -0
  35. package/dist/db/connection.js +62 -0
  36. package/dist/db/connection.js.map +1 -0
  37. package/dist/db/migrate.d.ts +31 -0
  38. package/dist/db/migrate.d.ts.map +1 -0
  39. package/dist/db/migrate.js +197 -0
  40. package/dist/db/migrate.js.map +1 -0
  41. package/dist/db/schema.d.ts +1902 -0
  42. package/dist/db/schema.d.ts.map +1 -0
  43. package/dist/db/schema.js +662 -0
  44. package/dist/db/schema.js.map +1 -0
  45. package/dist/db/seed.d.ts +92 -0
  46. package/dist/db/seed.d.ts.map +1 -0
  47. package/dist/db/seed.js +160 -0
  48. package/dist/db/seed.js.map +1 -0
  49. package/dist/handlers/fetch.d.ts +89 -0
  50. package/dist/handlers/fetch.d.ts.map +1 -0
  51. package/dist/handlers/fetch.js +173 -0
  52. package/dist/handlers/fetch.js.map +1 -0
  53. package/dist/handlers/nextjs.d.ts +40 -0
  54. package/dist/handlers/nextjs.d.ts.map +1 -0
  55. package/dist/handlers/nextjs.js +42 -0
  56. package/dist/handlers/nextjs.js.map +1 -0
  57. package/dist/handlers/responses.d.ts +46 -0
  58. package/dist/handlers/responses.d.ts.map +1 -0
  59. package/dist/handlers/responses.js +74 -0
  60. package/dist/handlers/responses.js.map +1 -0
  61. package/dist/handlers/routeTable.d.ts +21 -0
  62. package/dist/handlers/routeTable.d.ts.map +1 -0
  63. package/dist/handlers/routeTable.js +133 -0
  64. package/dist/handlers/routeTable.js.map +1 -0
  65. package/dist/handlers/router.d.ts +37 -0
  66. package/dist/handlers/router.d.ts.map +1 -0
  67. package/dist/handlers/router.js +82 -0
  68. package/dist/handlers/router.js.map +1 -0
  69. package/dist/handlers/sse.d.ts +38 -0
  70. package/dist/handlers/sse.d.ts.map +1 -0
  71. package/dist/handlers/sse.js +52 -0
  72. package/dist/handlers/sse.js.map +1 -0
  73. package/dist/handlers/types.d.ts +96 -0
  74. package/dist/handlers/types.d.ts.map +1 -0
  75. package/dist/handlers/types.js +14 -0
  76. package/dist/handlers/types.js.map +1 -0
  77. package/dist/index.d.ts +25 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +24 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/letta/client.d.ts +355 -0
  82. package/dist/letta/client.d.ts.map +1 -0
  83. package/dist/letta/client.js +529 -0
  84. package/dist/letta/client.js.map +1 -0
  85. package/dist/letta/eventTranslator.d.ts +166 -0
  86. package/dist/letta/eventTranslator.d.ts.map +1 -0
  87. package/dist/letta/eventTranslator.js +532 -0
  88. package/dist/letta/eventTranslator.js.map +1 -0
  89. package/dist/letta/index.d.ts +13 -0
  90. package/dist/letta/index.d.ts.map +1 -0
  91. package/dist/letta/index.js +12 -0
  92. package/dist/letta/index.js.map +1 -0
  93. package/dist/letta/modelSettings.d.ts +176 -0
  94. package/dist/letta/modelSettings.d.ts.map +1 -0
  95. package/dist/letta/modelSettings.js +314 -0
  96. package/dist/letta/modelSettings.js.map +1 -0
  97. package/dist/letta/promptWrapping.d.ts +141 -0
  98. package/dist/letta/promptWrapping.d.ts.map +1 -0
  99. package/dist/letta/promptWrapping.js +213 -0
  100. package/dist/letta/promptWrapping.js.map +1 -0
  101. package/dist/letta/sandbox.d.ts +100 -0
  102. package/dist/letta/sandbox.d.ts.map +1 -0
  103. package/dist/letta/sandbox.js +151 -0
  104. package/dist/letta/sandbox.js.map +1 -0
  105. package/dist/letta/settingsFileSeed.d.ts +146 -0
  106. package/dist/letta/settingsFileSeed.d.ts.map +1 -0
  107. package/dist/letta/settingsFileSeed.js +177 -0
  108. package/dist/letta/settingsFileSeed.js.map +1 -0
  109. package/dist/letta/subprocessThrottle.d.ts +151 -0
  110. package/dist/letta/subprocessThrottle.d.ts.map +1 -0
  111. package/dist/letta/subprocessThrottle.js +195 -0
  112. package/dist/letta/subprocessThrottle.js.map +1 -0
  113. package/dist/letta/tpmRecovery.d.ts +202 -0
  114. package/dist/letta/tpmRecovery.d.ts.map +1 -0
  115. package/dist/letta/tpmRecovery.js +314 -0
  116. package/dist/letta/tpmRecovery.js.map +1 -0
  117. package/dist/letta/types.d.ts +54 -0
  118. package/dist/letta/types.d.ts.map +1 -0
  119. package/dist/letta/types.js +16 -0
  120. package/dist/letta/types.js.map +1 -0
  121. package/dist/observability/logger.d.ts +48 -0
  122. package/dist/observability/logger.d.ts.map +1 -0
  123. package/dist/observability/logger.js +81 -0
  124. package/dist/observability/logger.js.map +1 -0
  125. package/dist/observability/metrics.d.ts +53 -0
  126. package/dist/observability/metrics.d.ts.map +1 -0
  127. package/dist/observability/metrics.js +92 -0
  128. package/dist/observability/metrics.js.map +1 -0
  129. package/dist/routes/admin/agents.d.ts +63 -0
  130. package/dist/routes/admin/agents.d.ts.map +1 -0
  131. package/dist/routes/admin/agents.js +461 -0
  132. package/dist/routes/admin/agents.js.map +1 -0
  133. package/dist/routes/admin/rotatingMessages.d.ts +51 -0
  134. package/dist/routes/admin/rotatingMessages.d.ts.map +1 -0
  135. package/dist/routes/admin/rotatingMessages.js +292 -0
  136. package/dist/routes/admin/rotatingMessages.js.map +1 -0
  137. package/dist/routes/admin/sessions.d.ts +61 -0
  138. package/dist/routes/admin/sessions.d.ts.map +1 -0
  139. package/dist/routes/admin/sessions.js +232 -0
  140. package/dist/routes/admin/sessions.js.map +1 -0
  141. package/dist/routes/admin/settings.d.ts +110 -0
  142. package/dist/routes/admin/settings.d.ts.map +1 -0
  143. package/dist/routes/admin/settings.js +401 -0
  144. package/dist/routes/admin/settings.js.map +1 -0
  145. package/dist/routes/admin/users.d.ts +115 -0
  146. package/dist/routes/admin/users.d.ts.map +1 -0
  147. package/dist/routes/admin/users.js +374 -0
  148. package/dist/routes/admin/users.js.map +1 -0
  149. package/dist/routes/agents.d.ts +30 -0
  150. package/dist/routes/agents.d.ts.map +1 -0
  151. package/dist/routes/agents.js +55 -0
  152. package/dist/routes/agents.js.map +1 -0
  153. package/dist/routes/auth.d.ts +121 -0
  154. package/dist/routes/auth.d.ts.map +1 -0
  155. package/dist/routes/auth.js +422 -0
  156. package/dist/routes/auth.js.map +1 -0
  157. package/dist/routes/chat.d.ts +97 -0
  158. package/dist/routes/chat.d.ts.map +1 -0
  159. package/dist/routes/chat.js +484 -0
  160. package/dist/routes/chat.js.map +1 -0
  161. package/dist/routes/featureToggles.d.ts +34 -0
  162. package/dist/routes/featureToggles.d.ts.map +1 -0
  163. package/dist/routes/featureToggles.js +47 -0
  164. package/dist/routes/featureToggles.js.map +1 -0
  165. package/dist/routes/health.d.ts +24 -0
  166. package/dist/routes/health.d.ts.map +1 -0
  167. package/dist/routes/health.js +37 -0
  168. package/dist/routes/health.js.map +1 -0
  169. package/dist/routes/metrics.d.ts +35 -0
  170. package/dist/routes/metrics.d.ts.map +1 -0
  171. package/dist/routes/metrics.js +106 -0
  172. package/dist/routes/metrics.js.map +1 -0
  173. package/dist/routes/sessions.d.ts +161 -0
  174. package/dist/routes/sessions.d.ts.map +1 -0
  175. package/dist/routes/sessions.js +1501 -0
  176. package/dist/routes/sessions.js.map +1 -0
  177. package/dist/routes/stubs.d.ts +25 -0
  178. package/dist/routes/stubs.d.ts.map +1 -0
  179. package/dist/routes/stubs.js +25 -0
  180. package/dist/routes/stubs.js.map +1 -0
  181. package/dist/state/cron.d.ts +58 -0
  182. package/dist/state/cron.d.ts.map +1 -0
  183. package/dist/state/cron.js +160 -0
  184. package/dist/state/cron.js.map +1 -0
  185. package/dist/state/rotationRunner.d.ts +96 -0
  186. package/dist/state/rotationRunner.d.ts.map +1 -0
  187. package/dist/state/rotationRunner.js +246 -0
  188. package/dist/state/rotationRunner.js.map +1 -0
  189. package/dist/state/rotationScheduler.d.ts +76 -0
  190. package/dist/state/rotationScheduler.d.ts.map +1 -0
  191. package/dist/state/rotationScheduler.js +124 -0
  192. package/dist/state/rotationScheduler.js.map +1 -0
  193. package/dist/state/turnRegistry.d.ts +95 -0
  194. package/dist/state/turnRegistry.d.ts.map +1 -0
  195. package/dist/state/turnRegistry.js +149 -0
  196. package/dist/state/turnRegistry.js.map +1 -0
  197. package/dist/state/turnRunner.d.ts +134 -0
  198. package/dist/state/turnRunner.d.ts.map +1 -0
  199. package/dist/state/turnRunner.js +476 -0
  200. package/dist/state/turnRunner.js.map +1 -0
  201. package/package.json +47 -0
  202. package/src/auth/argon2.ts +152 -0
  203. package/src/auth/encryption.ts +110 -0
  204. package/src/auth/jwt.ts +217 -0
  205. package/src/auth/middleware.ts +250 -0
  206. package/src/auth/passwordPolicy.ts +99 -0
  207. package/src/auth/roleRules.ts +497 -0
  208. package/src/auth/userView.ts +88 -0
  209. package/src/bootstrap.ts +254 -0
  210. package/src/db/connection.ts +75 -0
  211. package/src/db/migrate.ts +212 -0
  212. package/src/db/schema.ts +750 -0
  213. package/src/db/seed.ts +236 -0
  214. package/src/handlers/fetch.ts +251 -0
  215. package/src/handlers/nextjs.ts +49 -0
  216. package/src/handlers/responses.ts +95 -0
  217. package/src/handlers/routeTable.ts +188 -0
  218. package/src/handlers/router.ts +96 -0
  219. package/src/handlers/sse.ts +55 -0
  220. package/src/handlers/types.ts +104 -0
  221. package/src/index.ts +60 -0
  222. package/src/letta/client.ts +860 -0
  223. package/src/letta/eventTranslator.ts +667 -0
  224. package/src/letta/index.ts +46 -0
  225. package/src/letta/lettaCliShim.mjs +40 -0
  226. package/src/letta/sandbox.ts +167 -0
  227. package/src/letta/settingsFileSeed.ts +254 -0
  228. package/src/letta/subprocessThrottle.ts +251 -0
  229. package/src/letta/types.ts +65 -0
  230. package/src/observability/logger.ts +103 -0
  231. package/src/observability/metrics.ts +114 -0
  232. package/src/routes/admin/agents.ts +574 -0
  233. package/src/routes/admin/rotatingMessages.ts +351 -0
  234. package/src/routes/admin/sessions.ts +283 -0
  235. package/src/routes/admin/settings.ts +463 -0
  236. package/src/routes/admin/users.ts +455 -0
  237. package/src/routes/agents.ts +66 -0
  238. package/src/routes/auth.ts +513 -0
  239. package/src/routes/chat.ts +534 -0
  240. package/src/routes/featureToggles.ts +52 -0
  241. package/src/routes/health.ts +44 -0
  242. package/src/routes/metrics.ts +111 -0
  243. package/src/routes/sessions.ts +1878 -0
  244. package/src/state/cron.ts +179 -0
  245. package/src/state/rotationRunner.ts +326 -0
  246. package/src/state/rotationScheduler.ts +164 -0
  247. package/src/state/turnRegistry.ts +210 -0
  248. package/src/state/turnRunner.ts +588 -0
@@ -0,0 +1,513 @@
1
+ /**
2
+ * `/auth/*` route handlers.
3
+ *
4
+ * Step 3 ships the full surface:
5
+ *
6
+ * - `POST /auth/login` (Step 2) — public.
7
+ * - `POST /auth/change-password` — accepts session OR password-change-only
8
+ * tokens. Voluntary path requires `current_password`; forced path doesn't
9
+ * (the change-only token IS the auth). Enforces the §8.5 password policy
10
+ * (length + complexity).
11
+ * - `POST /auth/logout` — server-side no-op in v1 (the React client just
12
+ * discards the token).
13
+ * - `GET /auth/me` — returns the `{ user, deployment }` envelope per §6.3.4.
14
+ * - `PATCH /auth/me/preferences` — updates `user.theme_preference` (rejects
15
+ * with 409 when the deployment-wide switcher is off).
16
+ *
17
+ * @see SPECIFICATIONS.md §6.3.4 — auth endpoints
18
+ * @see SPECIFICATIONS.md §7.6 — theme resolution
19
+ * @see SPECIFICATIONS.md §8.5 — Argon2id password verification + complexity
20
+ */
21
+
22
+ import { eq } from "drizzle-orm";
23
+ import { z } from "zod";
24
+
25
+ import {
26
+ hashPassword,
27
+ needsRehash,
28
+ verifyPassword,
29
+ } from "../auth/argon2.js";
30
+ import {
31
+ mintPasswordChangeToken,
32
+ mintSessionToken,
33
+ } from "../auth/jwt.js";
34
+ import { protectedRoute } from "../auth/middleware.js";
35
+ import { validatePasswordPolicy } from "../auth/passwordPolicy.js";
36
+ import {
37
+ buildAuthContext,
38
+ buildDeploymentView,
39
+ buildUserView,
40
+ } from "../auth/userView.js";
41
+ import { adminSetting, user } from "../db/schema.js";
42
+ import {
43
+ conflictError,
44
+ internalError,
45
+ jsonResponse,
46
+ unauthorized,
47
+ validationError,
48
+ } from "../handlers/responses.js";
49
+ import type { RouteHandler } from "../handlers/types.js";
50
+
51
+ /* ────────────────────────────────────────────────────────────────────────── */
52
+ /* POST /auth/login */
53
+ /* ────────────────────────────────────────────────────────────────────────── */
54
+
55
+ /** Login request body shape. Wire is snake_case (§6.3.4). */
56
+ const loginSchema = z
57
+ .object({
58
+ username: z.string().min(1).max(255),
59
+ password: z.string().min(1).max(1024),
60
+ })
61
+ .strict();
62
+
63
+ /**
64
+ * `POST /auth/login` — Argon2id-verifies username + password, mints either
65
+ * a session JWT or a 5-minute password-change-only JWT depending on the
66
+ * user's `is_initial_password` flag.
67
+ *
68
+ * **Generic 401 on every failure path** — wrong username, wrong password,
69
+ * NULL `password_hash` (the future-IdP seam test), or any malformed body.
70
+ * No enumeration leakage (§13.1, §8.6).
71
+ *
72
+ * On success returns `{ token, must_change_password, user, deployment }`.
73
+ * When `must_change_password = true`, the `deployment` block is omitted
74
+ * entirely (the change-only token doesn't grant deployment access) and
75
+ * `user.role` is omitted (the change-only token carries no role claim) —
76
+ * the client re-fetches `/auth/me` after the change-password handshake.
77
+ *
78
+ * Hash-upgrade-on-login (§8.5): if the stored hash was produced with weaker
79
+ * params than the current `ARGON2_PARAMS` baseline, we rehash and write
80
+ * back. Best-effort — a write failure does not fail the login.
81
+ *
82
+ * @see SPECIFICATIONS.md §6.3.4 — `POST /auth/login`
83
+ * @see SPECIFICATIONS.md §8.5 — auth + hash upgrade
84
+ */
85
+ export const handleLogin: RouteHandler = async (req, ctx) => {
86
+ let body: unknown;
87
+ try {
88
+ body = await req.json();
89
+ } catch {
90
+ // Malformed JSON — return a generic 401, not a 400. The endpoint should
91
+ // not distinguish "bad shape" from "wrong credentials" to clients.
92
+ return unauthorized();
93
+ }
94
+ const parsed = loginSchema.safeParse(body);
95
+ if (!parsed.success) return unauthorized();
96
+ const { username, password } = parsed.data;
97
+
98
+ const row = ctx.db.select().from(user).where(eq(user.username, username)).get();
99
+ if (!row || !row.passwordHash) {
100
+ // Either the user doesn't exist or it's an externally-authenticated row
101
+ // (NULL `password_hash` — §6.7 future-IdP seam). Same generic 401.
102
+ return unauthorized();
103
+ }
104
+
105
+ const ok = await verifyPassword(row.passwordHash, password);
106
+ if (!ok) {
107
+ ctx.logger.info(
108
+ { username, event: "login_failed" },
109
+ "Login attempt failed: invalid credentials.",
110
+ );
111
+ return unauthorized();
112
+ }
113
+
114
+ // Hash-upgrade-on-login (§8.5). Best-effort — never fail a login because
115
+ // the rehash itself failed.
116
+ if (needsRehash(row.passwordHash)) {
117
+ try {
118
+ const upgraded = await hashPassword(password);
119
+ ctx.db
120
+ .update(user)
121
+ .set({
122
+ passwordHash: upgraded,
123
+ updatedAt: nowIso(),
124
+ })
125
+ .where(eq(user.id, row.id))
126
+ .run();
127
+ ctx.logger.info(
128
+ { user_id: row.id, event: "password_hash_upgraded" },
129
+ "Upgraded stored password hash to current parameters.",
130
+ );
131
+ } catch (err) {
132
+ ctx.logger.warn(
133
+ { user_id: row.id, err: errMessage(err) },
134
+ "Hash-upgrade write failed; login still proceeds.",
135
+ );
136
+ }
137
+ }
138
+
139
+ const settings = ctx.db
140
+ .select()
141
+ .from(adminSetting)
142
+ .where(eq(adminSetting.id, 1))
143
+ .get();
144
+ const signingKey = settings?.serviceTokenSigningKey;
145
+ if (!signingKey) {
146
+ // Should not happen — the seed creates the row on first boot.
147
+ ctx.logger.error(
148
+ { event: "missing_signing_key" },
149
+ "admin_setting.service_token_signing_key is empty; cannot mint tokens.",
150
+ );
151
+ return internalError("Server misconfigured");
152
+ }
153
+
154
+ const must = row.isInitialPassword;
155
+ const token = must
156
+ ? await mintPasswordChangeToken(signingKey, { id: row.id })
157
+ : await mintSessionToken(signingKey, {
158
+ id: row.id,
159
+ username: row.username,
160
+ role: row.role,
161
+ });
162
+
163
+ ctx.logger.info(
164
+ {
165
+ user_id: row.id,
166
+ username: row.username,
167
+ role: row.role,
168
+ must_change_password: must,
169
+ event: "login_success",
170
+ },
171
+ "Login succeeded.",
172
+ );
173
+
174
+ // Per §6.3.4: on `must_change_password = true`:
175
+ // - `user.role` is omitted (the change-only token carries no role claim).
176
+ // - The `deployment` block is omitted entirely (no deployment access via
177
+ // the change-only token).
178
+ // The client re-fetches /auth/me after the change-password handshake.
179
+ if (must) {
180
+ const minimalUser = buildUserView(row);
181
+ const userWithoutRole: Record<string, unknown> = { ...minimalUser };
182
+ delete userWithoutRole.role;
183
+ return jsonResponse(200, {
184
+ token,
185
+ must_change_password: true,
186
+ user: userWithoutRole,
187
+ });
188
+ }
189
+
190
+ const context = buildAuthContext(row, settings ?? null);
191
+ return jsonResponse(200, {
192
+ token,
193
+ must_change_password: false,
194
+ user: context.user,
195
+ deployment: context.deployment,
196
+ });
197
+ };
198
+
199
+ /* ────────────────────────────────────────────────────────────────────────── */
200
+ /* POST /auth/change-password */
201
+ /* ────────────────────────────────────────────────────────────────────────── */
202
+
203
+ /** Body for change-password. `current_password` required only on voluntary path. */
204
+ const changePasswordSchema = z
205
+ .object({
206
+ current_password: z.string().min(1).max(1024).optional(),
207
+ new_password: z.string().min(1).max(1024),
208
+ })
209
+ .strict();
210
+
211
+ /**
212
+ * `POST /auth/change-password` — two distinct flows on one endpoint:
213
+ *
214
+ * - **Voluntary (session token):** caller supplies `current_password`; the
215
+ * server Argon2id-verifies it, then hashes + writes the new password. Does
216
+ * not touch `is_initial_password`. Returns a fresh session token (with the
217
+ * caller's current role).
218
+ * - **Forced (password-change-only token):** caller does not supply
219
+ * `current_password` (the change-only token already proves a successful
220
+ * prior login). The server hashes + writes the new password, clears
221
+ * `is_initial_password`, and returns a fresh session token. The client
222
+ * transitions to the normal chat at this point.
223
+ *
224
+ * Server-side validation: the new password must satisfy the §8.5 policy —
225
+ * at least 8 characters AND at least two of {letters, digits, special
226
+ * characters}. Length-only passwords (e.g. `"12345678"`) are rejected with
227
+ * 400 `password_too_simple`.
228
+ *
229
+ * Mismatched current password → 401 `unauthorized` with a "current
230
+ * password"-mentioning message. Voluntary path with no `current_password`
231
+ * is rejected as a validation error (the field IS required when the caller
232
+ * authenticated with a session token). Forced path with a `current_password`
233
+ * is rejected too — that signals client confusion.
234
+ *
235
+ * @see SPECIFICATIONS.md §6.3.4 — `POST /auth/change-password`
236
+ * @see SPECIFICATIONS.md §8.5 — change flows + password policy
237
+ */
238
+ export const handleChangePassword: RouteHandler = protectedRoute(
239
+ { allowPasswordChangeToken: true },
240
+ async (req, ctx, auth) => {
241
+ let raw: unknown;
242
+ try {
243
+ raw = await req.json();
244
+ } catch {
245
+ return validationError("Request body must be valid JSON.");
246
+ }
247
+ const parsed = changePasswordSchema.safeParse(raw);
248
+ if (!parsed.success) {
249
+ return validationError("Invalid request body.");
250
+ }
251
+ const body = parsed.data;
252
+
253
+ const policy = validatePasswordPolicy(body.new_password);
254
+ if (!policy.ok) {
255
+ return validationError(policy.message, policy.code);
256
+ }
257
+
258
+ if (auth.tokenKind === "session") {
259
+ // Voluntary path — current_password required.
260
+ const current = body.current_password;
261
+ if (!current) {
262
+ return validationError(
263
+ "current_password is required for the voluntary change-password flow.",
264
+ "current_password_required",
265
+ );
266
+ }
267
+ if (!auth.user.passwordHash) {
268
+ // The auth middleware already rejected NULL-password-hash users, but
269
+ // belt-and-braces here — if we somehow reached this line, log + 401.
270
+ ctx.logger.error(
271
+ { user_id: auth.user.id, event: "change_password_no_hash" },
272
+ "Reached change-password with a NULL password_hash on the user row.",
273
+ );
274
+ return unauthorized();
275
+ }
276
+ const ok = await verifyPassword(auth.user.passwordHash, current);
277
+ if (!ok) {
278
+ ctx.logger.info(
279
+ { user_id: auth.user.id, event: "change_password_wrong_current" },
280
+ "Change-password rejected: current password incorrect.",
281
+ );
282
+ return unauthorized("Current password is incorrect.");
283
+ }
284
+ } else {
285
+ // Forced path — current_password not accepted (would confuse callers).
286
+ if (body.current_password !== undefined) {
287
+ return validationError(
288
+ "current_password must not be supplied with a password-change-only token.",
289
+ "current_password_not_allowed",
290
+ );
291
+ }
292
+ }
293
+
294
+ // Hash + write the new password. Forced path also clears the flag.
295
+ // Bumping `passwordChangedAt` invalidates other sessions the user
296
+ // may have open (multi-device sign-out on password change) — see
297
+ // the middleware's iat-vs-passwordChangedAt gate.
298
+ const newHash = await hashPassword(body.new_password);
299
+ const clearInitialFlag = auth.tokenKind === "password_change";
300
+ const now = nowIso();
301
+ ctx.db
302
+ .update(user)
303
+ .set({
304
+ passwordHash: newHash,
305
+ isInitialPassword: clearInitialFlag ? false : auth.user.isInitialPassword,
306
+ passwordChangedAt: now,
307
+ updatedAt: now,
308
+ })
309
+ .where(eq(user.id, auth.user.id))
310
+ .run();
311
+
312
+ // Re-read for the role + theme bits (and to mint a session token with
313
+ // the freshest row state — Step 4's role-change endpoint can demote a
314
+ // user between login and change-password, in theory).
315
+ const freshRow = ctx.db.select().from(user).where(eq(user.id, auth.user.id)).get();
316
+ if (!freshRow) {
317
+ // Should not happen.
318
+ return internalError();
319
+ }
320
+
321
+ const settings = ctx.db
322
+ .select()
323
+ .from(adminSetting)
324
+ .where(eq(adminSetting.id, 1))
325
+ .get();
326
+ const signingKey = settings?.serviceTokenSigningKey;
327
+ if (!signingKey) {
328
+ ctx.logger.error(
329
+ { event: "missing_signing_key" },
330
+ "Cannot mint session token after password change — signing key empty.",
331
+ );
332
+ return internalError("Server misconfigured");
333
+ }
334
+
335
+ const sessionToken = await mintSessionToken(signingKey, {
336
+ id: freshRow.id,
337
+ username: freshRow.username,
338
+ role: freshRow.role,
339
+ });
340
+
341
+ ctx.logger.info(
342
+ {
343
+ user_id: freshRow.id,
344
+ username: freshRow.username,
345
+ role: freshRow.role,
346
+ token_kind: auth.tokenKind,
347
+ cleared_initial: clearInitialFlag,
348
+ event: "password_changed",
349
+ },
350
+ "Password changed successfully.",
351
+ );
352
+
353
+ return jsonResponse(200, { token: sessionToken });
354
+ },
355
+ );
356
+
357
+ /* ────────────────────────────────────────────────────────────────────────── */
358
+ /* POST /auth/logout */
359
+ /* ────────────────────────────────────────────────────────────────────────── */
360
+
361
+ /**
362
+ * `POST /auth/logout` — server-side no-op in v1.
363
+ *
364
+ * Per §6.3.4 and SPECIFICATIONS.md's "Implementer's call" note: v1's
365
+ * simplest implementation is for the client to discard its token; we don't
366
+ * maintain a blacklist. A future minor may add token revocation if a
367
+ * concrete threat model demands it. Returns 200 with a minimal body so the
368
+ * client has something to act on.
369
+ *
370
+ * Still routes through the protected middleware so an anonymous caller gets
371
+ * a 401 — that signals to the client that its token was already invalid (in
372
+ * which case it should already be on the login screen).
373
+ *
374
+ * @see SPECIFICATIONS.md §6.3.4
375
+ */
376
+ export const handleLogout: RouteHandler = protectedRoute({}, async () => {
377
+ return jsonResponse(200, { status: "ok" });
378
+ });
379
+
380
+ /* ────────────────────────────────────────────────────────────────────────── */
381
+ /* GET /auth/me */
382
+ /* ────────────────────────────────────────────────────────────────────────── */
383
+
384
+ /**
385
+ * `GET /auth/me` — returns the `{ user, deployment }` envelope.
386
+ *
387
+ * `deployment.letta_ade_url` is included only for callers with role >=
388
+ * `power_user` (handled inside `buildDeploymentView`); `user`-role callers
389
+ * don't even see the field exists — by design (§6.3.4).
390
+ *
391
+ * The resolved theme is computed client-side from
392
+ * `user.theme_preference + deployment.{theme_default, theme_switcher_enabled}`
393
+ * per §7.6 — the server does not pre-compute it.
394
+ *
395
+ * @see SPECIFICATIONS.md §6.3.4 — `GET /auth/me`
396
+ * @see SPECIFICATIONS.md §7.6 — theme resolution
397
+ */
398
+ export const handleMe: RouteHandler = protectedRoute({}, async (_req, ctx, auth) => {
399
+ const settings = ctx.db
400
+ .select()
401
+ .from(adminSetting)
402
+ .where(eq(adminSetting.id, 1))
403
+ .get();
404
+ const context = buildAuthContext(auth.user, settings ?? null);
405
+ return jsonResponse(200, {
406
+ user: context.user,
407
+ deployment: context.deployment,
408
+ });
409
+ });
410
+
411
+ /* ────────────────────────────────────────────────────────────────────────── */
412
+ /* PATCH /auth/me/preferences */
413
+ /* ────────────────────────────────────────────────────────────────────────── */
414
+
415
+ /** Preferences body. Only `theme` is supported in v1. Strict — extras rejected. */
416
+ const preferencesSchema = z
417
+ .object({
418
+ theme: z.enum(["light", "dark"]).optional(),
419
+ })
420
+ .strict();
421
+
422
+ /**
423
+ * `PATCH /auth/me/preferences` — updates per-user preferences.
424
+ *
425
+ * Body: `{ theme?: "light" | "dark" }`. Empty body is a 200 no-op (returns
426
+ * the current `{ user, deployment }` envelope unchanged) — by an explicit
427
+ * decision recorded in the Step-3 prompt. Unknown keys → 400 (§13.4).
428
+ *
429
+ * Returns 409 `theme_switcher_disabled` when the deployment-wide
430
+ * `admin_setting.theme_switcher_enabled = false`: per §7.6 the user's
431
+ * stored preference is ignored while the switcher is off, and accepting a
432
+ * write in that state would be silently swallowed. We surface the
433
+ * configuration mismatch rather than pretend the write applied.
434
+ *
435
+ * @see SPECIFICATIONS.md §6.3.4 — `PATCH /auth/me/preferences`
436
+ * @see SPECIFICATIONS.md §7.6 — theme resolution
437
+ */
438
+ export const handleMePreferences: RouteHandler = protectedRoute(
439
+ {},
440
+ async (req, ctx, auth) => {
441
+ let raw: unknown;
442
+ try {
443
+ raw = await req.json();
444
+ } catch {
445
+ return validationError("Request body must be valid JSON.");
446
+ }
447
+ const parsed = preferencesSchema.safeParse(raw);
448
+ if (!parsed.success) {
449
+ return validationError("Invalid request body.");
450
+ }
451
+ const body = parsed.data;
452
+
453
+ if (body.theme !== undefined) {
454
+ const settings = ctx.db
455
+ .select()
456
+ .from(adminSetting)
457
+ .where(eq(adminSetting.id, 1))
458
+ .get();
459
+ if (settings && !settings.themeSwitcherEnabled) {
460
+ return conflictError(
461
+ "theme_switcher_disabled",
462
+ "Theme switcher is disabled by the deployment administrator.",
463
+ );
464
+ }
465
+ ctx.db
466
+ .update(user)
467
+ .set({
468
+ themePreference: body.theme,
469
+ updatedAt: nowIso(),
470
+ })
471
+ .where(eq(user.id, auth.user.id))
472
+ .run();
473
+ ctx.logger.info(
474
+ {
475
+ user_id: auth.user.id,
476
+ theme: body.theme,
477
+ event: "theme_preference_updated",
478
+ },
479
+ "User theme preference updated.",
480
+ );
481
+ }
482
+
483
+ const freshRow = ctx.db
484
+ .select()
485
+ .from(user)
486
+ .where(eq(user.id, auth.user.id))
487
+ .get();
488
+ if (!freshRow) return internalError();
489
+ const settings = ctx.db
490
+ .select()
491
+ .from(adminSetting)
492
+ .where(eq(adminSetting.id, 1))
493
+ .get();
494
+ return jsonResponse(200, {
495
+ user: buildUserView(freshRow),
496
+ deployment: buildDeploymentView(settings ?? null, freshRow.role),
497
+ });
498
+ },
499
+ );
500
+
501
+ /* ────────────────────────────────────────────────────────────────────────── */
502
+ /* Helpers */
503
+ /* ────────────────────────────────────────────────────────────────────────── */
504
+
505
+ /** Returns the current ISO-8601 timestamp with millisecond precision. */
506
+ function nowIso(): string {
507
+ return new Date().toISOString();
508
+ }
509
+
510
+ /** Extracts a string for logging from any thrown value. */
511
+ function errMessage(err: unknown): string {
512
+ return err instanceof Error ? err.message : String(err);
513
+ }