@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,455 @@
1
+ /**
2
+ * `/admin/users/*` — admin + super_admin user management.
3
+ *
4
+ * Five endpoints:
5
+ *
6
+ * - `GET /admin/users` — list every user in the deployment.
7
+ * - `POST /admin/users` — create a user; the server generates a 16-char
8
+ * alphanumeric password, hashes it, and returns the **plaintext exactly
9
+ * once** in the response. The caller delivers it to the user out-of-band.
10
+ * Sets `is_initial_password = true` so the user is forced to change on
11
+ * first login (§8.5).
12
+ * - `PATCH /admin/users/:id/role` — change a user's role. Uses
13
+ * `applyUserRoleChangeInTx` so the shield + min-count rules are enforced
14
+ * inside the transaction (no race possible).
15
+ * - `POST /admin/users/:id/reset-password` — regenerate a 16-char password
16
+ * for an existing user. Same return-once semantics; sets the initial-
17
+ * password flag back to true. Uses `assertPasswordResetInTx` to validate
18
+ * the shield + min-count rules, then performs the async Argon2 hash + write
19
+ * outside the transaction (better-sqlite3 transactions are synchronous so
20
+ * an Argon2 hash cannot live inside one). The narrow window between assert
21
+ * and write is documented in the helper's JSDoc and is acceptable in v1.
22
+ * - `DELETE /admin/users/:id` — hard-delete the user. Uses
23
+ * `applyUserDeletionInTx`; cascades to `session` via the FK.
24
+ *
25
+ * **Admin shield + min-count rules** are enforced by the helpers in
26
+ * `auth/roleRules.ts`; the handlers here just translate the helper's
27
+ * `TxResult` into a Response.
28
+ *
29
+ * **Admin cannot create / promote to `super_admin`.** The role enum on the
30
+ * POST body is filtered by caller role (admins see user / power_user /
31
+ * admin; super_admin sees all four). This mirrors the [05-admin-users.md]
32
+ * sketch's "Create User" modal.
33
+ *
34
+ * @see SPECIFICATIONS.md §6.3.4 — admin users endpoints
35
+ * @see SPECIFICATIONS.md §8.1 — shield + min-count rules
36
+ * @see SPECIFICATIONS.md §8.5 — password reset semantics
37
+ * @see ui-sketches/05-admin-users.md
38
+ */
39
+
40
+ import { randomBytes, randomUUID } from "node:crypto";
41
+
42
+ import { eq } from "drizzle-orm";
43
+ import { z } from "zod";
44
+
45
+ import type { AIHubRole } from "@data-club/ai-hub";
46
+
47
+ import { hashPassword } from "../../auth/argon2.js";
48
+ import { protectedRoute } from "../../auth/middleware.js";
49
+ import {
50
+ applyUserDeletionInTx,
51
+ applyUserRoleChangeInTx,
52
+ assertPasswordResetInTx,
53
+ } from "../../auth/roleRules.js";
54
+ import { user } from "../../db/schema.js";
55
+ import type { UserRow } from "../../db/schema.js";
56
+ import {
57
+ conflictError,
58
+ errorResponse,
59
+ forbidden,
60
+ jsonResponse,
61
+ notFound,
62
+ validationError,
63
+ } from "../../handlers/responses.js";
64
+ import type { RouteHandler } from "../../handlers/types.js";
65
+
66
+ /* ────────────────────────────────────────────────────────────────────────── */
67
+ /* Helpers */
68
+ /* ────────────────────────────────────────────────────────────────────────── */
69
+
70
+ /**
71
+ * Wire-format view of a `user` row for admin listings. Excludes
72
+ * `password_hash` and `identity_source` (server-internal — §6.3.4).
73
+ *
74
+ * @param row - the DB row.
75
+ * @returns the JSON-safe response shape.
76
+ */
77
+ function adminUserView(row: UserRow): Record<string, unknown> {
78
+ return {
79
+ id: row.id,
80
+ username: row.username,
81
+ display_name: row.displayName ?? null,
82
+ role: row.role,
83
+ is_initial_password: row.isInitialPassword,
84
+ theme_preference: row.themePreference ?? null,
85
+ created_at: row.createdAt,
86
+ updated_at: row.updatedAt,
87
+ };
88
+ }
89
+
90
+ /** ISO-8601 timestamp with millisecond precision. */
91
+ function nowIso(): string {
92
+ return new Date().toISOString();
93
+ }
94
+
95
+ /**
96
+ * Generates a 16-character alphanumeric password ([A-Za-z0-9]).
97
+ *
98
+ * Uses rejection sampling against the 62-char alphabet — every char is drawn
99
+ * uniformly via `randomBytes`, masked to 6 bits, then discarded if the value
100
+ * lands in the unused `[62, 64)` range. Average overhead is ~3%.
101
+ *
102
+ * The generated password trivially satisfies the §8.5 complexity policy
103
+ * (letters + digits = 2 categories), so no extra validation is needed before
104
+ * hashing. The 16-char length is also above the §8.5 8-char minimum.
105
+ *
106
+ * @returns a fresh 16-char alphanumeric password.
107
+ */
108
+ function generatePassword(): string {
109
+ const alphabet =
110
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
111
+ const out: string[] = [];
112
+ while (out.length < 16) {
113
+ const bytes = randomBytes(32);
114
+ for (const b of bytes) {
115
+ const v = b & 0b00111111;
116
+ if (v < alphabet.length) {
117
+ out.push(alphabet[v] as string);
118
+ if (out.length === 16) break;
119
+ }
120
+ }
121
+ }
122
+ return out.join("");
123
+ }
124
+
125
+ /**
126
+ * Returns `true` if the caller's role is allowed to create / promote a user
127
+ * to `targetRole`. Admins cannot touch `super_admin`; super-admins can do
128
+ * anything.
129
+ *
130
+ * @param caller - caller role.
131
+ * @param targetRole - the requested role for the new (or promoted) user.
132
+ * @returns `true` if the operation is permitted.
133
+ */
134
+ function callerCanGrantRole(caller: AIHubRole, targetRole: AIHubRole): boolean {
135
+ if (caller === "super_admin") return true;
136
+ if (caller === "admin") return targetRole !== "super_admin";
137
+ return false;
138
+ }
139
+
140
+ /* ────────────────────────────────────────────────────────────────────────── */
141
+ /* GET /admin/users */
142
+ /* ────────────────────────────────────────────────────────────────────────── */
143
+
144
+ /**
145
+ * `GET /admin/users` — list every user in the deployment.
146
+ *
147
+ * `admin` and `super_admin` only; gated by `protectedRoute({ requiredRole: "admin" })`.
148
+ * Sorted alphabetically by username for stable rendering in the admin table.
149
+ *
150
+ * @see SPECIFICATIONS.md §6.3.4
151
+ * @see ui-sketches/05-admin-users.md
152
+ */
153
+ export const handleListUsers: RouteHandler = protectedRoute(
154
+ { requiredRole: "admin" },
155
+ async (_req, ctx) => {
156
+ const rows = ctx.db.select().from(user).all();
157
+ // Sort client-side for a stable order regardless of insert order.
158
+ rows.sort((a, b) => a.username.localeCompare(b.username));
159
+ return jsonResponse(200, { users: rows.map(adminUserView) });
160
+ },
161
+ );
162
+
163
+ /* ────────────────────────────────────────────────────────────────────────── */
164
+ /* POST /admin/users */
165
+ /* ────────────────────────────────────────────────────────────────────────── */
166
+
167
+ /** Body for `POST /admin/users`. `role` defaults to `user` per §6.3.4. */
168
+ const createUserSchema = z
169
+ .object({
170
+ username: z.string().min(1).max(255),
171
+ display_name: z.string().min(1).max(255).nullish(),
172
+ role: z
173
+ .enum(["user", "power_user", "admin", "super_admin"])
174
+ .optional(),
175
+ })
176
+ .strict();
177
+
178
+ /**
179
+ * `POST /admin/users` — create a new user with a server-generated password.
180
+ *
181
+ * The 16-char alphanumeric plaintext is returned **exactly once** in the
182
+ * response (`{ user, generated_password }`); the caller (admin) is expected
183
+ * to deliver it out-of-band per §8.5. The plaintext never appears in logs
184
+ * (pino's redaction policy plus we never log the field).
185
+ *
186
+ * Caller-role gates:
187
+ *
188
+ * - `admin` can create role `user` / `power_user` / `admin`.
189
+ * - `super_admin` can additionally create `super_admin`.
190
+ *
191
+ * On username collision returns 409 `username_taken` (not 400) — the request
192
+ * itself was well-formed; the conflict is with existing data.
193
+ *
194
+ * @see SPECIFICATIONS.md §6.3.4 / §8.5
195
+ * @see ui-sketches/05-admin-users.md
196
+ */
197
+ export const handleCreateUser: RouteHandler = protectedRoute(
198
+ { requiredRole: "admin" },
199
+ async (req, ctx, auth) => {
200
+ let raw: unknown;
201
+ try {
202
+ raw = await req.json();
203
+ } catch {
204
+ return validationError("Request body must be valid JSON.");
205
+ }
206
+ const parsed = createUserSchema.safeParse(raw);
207
+ if (!parsed.success) {
208
+ return validationError(parsed.error.issues[0]?.message ?? "Invalid request body.");
209
+ }
210
+ const body = parsed.data;
211
+ const role: AIHubRole = body.role ?? "user";
212
+ if (!callerCanGrantRole(auth.role, role)) {
213
+ return forbidden(
214
+ `Caller role ${auth.role} cannot create a user with role ${role}.`,
215
+ );
216
+ }
217
+
218
+ const existing = ctx.db
219
+ .select({ id: user.id })
220
+ .from(user)
221
+ .where(eq(user.username, body.username))
222
+ .get();
223
+ if (existing) {
224
+ return conflictError("username_taken", "Username is already in use.");
225
+ }
226
+
227
+ const plaintext = generatePassword();
228
+ const passwordHash = await hashPassword(plaintext);
229
+ const id = randomUUID();
230
+ try {
231
+ ctx.db
232
+ .insert(user)
233
+ .values({
234
+ id,
235
+ username: body.username,
236
+ displayName: body.display_name ?? null,
237
+ passwordHash,
238
+ identitySource: "local",
239
+ isInitialPassword: true,
240
+ role,
241
+ })
242
+ .run();
243
+ } catch (err) {
244
+ // Race against another simultaneous create with the same username.
245
+ const message = err instanceof Error ? err.message : String(err);
246
+ if (message.includes("UNIQUE")) {
247
+ return conflictError("username_taken", "Username is already in use.");
248
+ }
249
+ throw err;
250
+ }
251
+
252
+ const row = ctx.db.select().from(user).where(eq(user.id, id)).get();
253
+ if (!row) throw new Error("Just-inserted user vanished.");
254
+
255
+ ctx.logger.info(
256
+ {
257
+ actor_id: auth.user.id,
258
+ new_user_id: row.id,
259
+ new_role: row.role,
260
+ event: "admin_user_created",
261
+ },
262
+ "Admin created a new user.",
263
+ );
264
+
265
+ return jsonResponse(201, {
266
+ user: adminUserView(row),
267
+ generated_password: plaintext,
268
+ });
269
+ },
270
+ );
271
+
272
+ /* ────────────────────────────────────────────────────────────────────────── */
273
+ /* PATCH /admin/users/:id/role */
274
+ /* ────────────────────────────────────────────────────────────────────────── */
275
+
276
+ /** Body for `PATCH /admin/users/:id/role`. */
277
+ const patchRoleSchema = z
278
+ .object({
279
+ role: z.enum(["user", "power_user", "admin", "super_admin"]),
280
+ })
281
+ .strict();
282
+
283
+ /**
284
+ * `PATCH /admin/users/:id/role` — change a user's role.
285
+ *
286
+ * Delegates the shield + min-count check to `applyUserRoleChangeInTx`, which
287
+ * re-reads the target row + super-admin count inside one transaction so a
288
+ * concurrent demote cannot bypass the invariants.
289
+ *
290
+ * Additional caller-role gate on top of the helper: an `admin` cannot grant
291
+ * the `super_admin` role on a target they could otherwise mutate (e.g.
292
+ * promoting a `power_user → super_admin`) — only super-admins can.
293
+ *
294
+ * @see SPECIFICATIONS.md §6.3.4 / §8.1
295
+ */
296
+ export const handlePatchUserRole: RouteHandler = protectedRoute(
297
+ { requiredRole: "admin" },
298
+ async (req, ctx, auth, params) => {
299
+ const id = params.id;
300
+ if (!id) return notFound();
301
+ let raw: unknown;
302
+ try {
303
+ raw = await req.json();
304
+ } catch {
305
+ return validationError("Request body must be valid JSON.");
306
+ }
307
+ const parsed = patchRoleSchema.safeParse(raw);
308
+ if (!parsed.success) {
309
+ return validationError(parsed.error.issues[0]?.message ?? "Invalid request body.");
310
+ }
311
+ const newRole: AIHubRole = parsed.data.role;
312
+ if (!callerCanGrantRole(auth.role, newRole)) {
313
+ return forbidden(
314
+ `Caller role ${auth.role} cannot promote a user to ${newRole}.`,
315
+ );
316
+ }
317
+
318
+ const result = applyUserRoleChangeInTx(
319
+ ctx.db,
320
+ { id: auth.user.id, role: auth.role },
321
+ id,
322
+ newRole,
323
+ );
324
+ if (!result.ok) {
325
+ return errorResponse(result.status, result.code, result.message);
326
+ }
327
+
328
+ ctx.logger.info(
329
+ {
330
+ actor_id: auth.user.id,
331
+ target_id: id,
332
+ new_role: newRole,
333
+ event: "admin_role_changed",
334
+ },
335
+ "Admin changed a user's role.",
336
+ );
337
+
338
+ return jsonResponse(200, { user: adminUserView(result.data.updated) });
339
+ },
340
+ );
341
+
342
+ /* ────────────────────────────────────────────────────────────────────────── */
343
+ /* POST /admin/users/:id/reset-password */
344
+ /* ────────────────────────────────────────────────────────────────────────── */
345
+
346
+ /**
347
+ * `POST /admin/users/:id/reset-password` — regenerate the user's password.
348
+ *
349
+ * Uses `assertPasswordResetInTx` to validate shield + min-count inside a
350
+ * transaction, then performs the async Argon2 hash + UPDATE *outside* the
351
+ * transaction (better-sqlite3 transactions are synchronous). The narrow
352
+ * window between assertion and write is acceptable in v1 — a concurrent
353
+ * role-mutation could in theory bypass the rule, but the worst-case is a
354
+ * single password reset on a user the admin shouldn't have been able to
355
+ * touch; the admin already had to authenticate and the new password is
356
+ * displayed only to them. Mitigation if this ever bites: re-verify the
357
+ * caller's role hasn't dropped between assertion and write.
358
+ *
359
+ * Returns `{ generated_password }` exactly once. Sets `is_initial_password =
360
+ * true` so the user is forced to change on their next login.
361
+ *
362
+ * @see SPECIFICATIONS.md §6.3.4 / §8.5
363
+ * @see ui-sketches/05-admin-users.md
364
+ */
365
+ export const handleResetUserPassword: RouteHandler = protectedRoute(
366
+ { requiredRole: "admin" },
367
+ async (_req, ctx, auth, params) => {
368
+ const id = params.id;
369
+ if (!id) return notFound();
370
+
371
+ const result = assertPasswordResetInTx(
372
+ ctx.db,
373
+ { id: auth.user.id, role: auth.role },
374
+ id,
375
+ );
376
+ if (!result.ok) {
377
+ return errorResponse(result.status, result.code, result.message);
378
+ }
379
+
380
+ const plaintext = generatePassword();
381
+ const newHash = await hashPassword(plaintext);
382
+ const now = nowIso();
383
+ ctx.db
384
+ .update(user)
385
+ .set({
386
+ passwordHash: newHash,
387
+ isInitialPassword: true,
388
+ // Bumping `passwordChangedAt` invalidates the target's existing
389
+ // session JWTs (the middleware compares `iat` against this column)
390
+ // — the user is signed out from every device the next time their
391
+ // browser fires a protected request. Step 11 bugfix per the
392
+ // SPECIFICATIONS.md §8.5 password-reset semantics.
393
+ passwordChangedAt: now,
394
+ updatedAt: now,
395
+ })
396
+ .where(eq(user.id, id))
397
+ .run();
398
+
399
+ ctx.logger.info(
400
+ {
401
+ actor_id: auth.user.id,
402
+ target_id: id,
403
+ event: "admin_password_reset",
404
+ },
405
+ "Admin reset a user's password.",
406
+ );
407
+
408
+ return jsonResponse(200, { generated_password: plaintext });
409
+ },
410
+ );
411
+
412
+ /* ────────────────────────────────────────────────────────────────────────── */
413
+ /* DELETE /admin/users/:id */
414
+ /* ────────────────────────────────────────────────────────────────────────── */
415
+
416
+ /**
417
+ * `DELETE /admin/users/:id` — hard-delete the user.
418
+ *
419
+ * Uses `applyUserDeletionInTx`; the FK on `session.user_id` cascades, so
420
+ * deleting a user removes all their sessions atomically. The shield + min-
421
+ * count invariants apply (an admin cannot delete another admin or super
422
+ * admin; nobody can delete the only super admin).
423
+ *
424
+ * @see SPECIFICATIONS.md §6.3.4 / §8.1
425
+ */
426
+ export const handleDeleteUser: RouteHandler = protectedRoute(
427
+ { requiredRole: "admin" },
428
+ async (_req, ctx, auth, params) => {
429
+ const id = params.id;
430
+ if (!id) return notFound();
431
+ if (id === auth.user.id) {
432
+ // Defensive: even though min-count would catch the only-super_admin
433
+ // self-delete, we block all self-deletes outright as a usability
434
+ // safeguard (deleting yourself logs you out and could brick the UI).
435
+ return forbidden("Cannot delete your own account.");
436
+ }
437
+ const result = applyUserDeletionInTx(
438
+ ctx.db,
439
+ { id: auth.user.id, role: auth.role },
440
+ id,
441
+ );
442
+ if (!result.ok) {
443
+ return errorResponse(result.status, result.code, result.message);
444
+ }
445
+ ctx.logger.info(
446
+ {
447
+ actor_id: auth.user.id,
448
+ target_id: id,
449
+ event: "admin_user_deleted",
450
+ },
451
+ "Admin deleted a user.",
452
+ );
453
+ return jsonResponse(200, { deleted: { id } });
454
+ },
455
+ );
@@ -0,0 +1,66 @@
1
+ /**
2
+ * User-facing `/agents` — the catalog the agents picker reads.
3
+ *
4
+ * Returns **non-archived** ai-hub agents only; each entry carries a
5
+ * `session_count` computed against the *caller's* non-archived sessions for
6
+ * that agent (one round-trip populates the "(N of your sessions)" subtitles
7
+ * on the picker — see [03-chat.md](../../../../docs/ui-sketches/03-chat.md)).
8
+ *
9
+ * The underlying Letta `agent_id` is **not** exposed here — it's an admin-only
10
+ * concern (`/admin/agents` returns it for editors).
11
+ *
12
+ * @see SPECIFICATIONS.md §6.3.3 — Agents (catalog)
13
+ */
14
+
15
+ import { and, asc, eq, sql } from "drizzle-orm";
16
+
17
+ import { protectedRoute } from "../auth/middleware.js";
18
+ import { agent, session } from "../db/schema.js";
19
+ import { jsonResponse } from "../handlers/responses.js";
20
+ import type { RouteHandler } from "../handlers/types.js";
21
+
22
+ /**
23
+ * `GET /agents` — every authenticated session.
24
+ *
25
+ * Returns `{ agents: [{ id, display_name, session_count }, ...] }`. Sorted by
26
+ * `display_name` for stable picker ordering. Excludes archived agents
27
+ * server-side so the user-facing picker stays focused on the working set.
28
+ *
29
+ * The `session_count` is **caller-scoped** — counted via a left-join filtered
30
+ * to `(session.user_id = caller.id AND session.is_archived = false)` so each
31
+ * user sees their own count, never another user's.
32
+ *
33
+ * @see SPECIFICATIONS.md §6.3.3 — Agents (catalog)
34
+ * @see ui-sketches/03-chat.md — agents picker
35
+ */
36
+ export const handleListAgents: RouteHandler = protectedRoute({}, async (_req, ctx, auth) => {
37
+ const rows = ctx.db
38
+ .select({
39
+ id: agent.id,
40
+ displayName: agent.displayName,
41
+ // Count caller's non-archived sessions against this agent. The
42
+ // double-condition LEFT JOIN keeps agents with 0 sessions in the result.
43
+ sessionCount: sql<number>`COUNT(${session.id})`.as("session_count"),
44
+ })
45
+ .from(agent)
46
+ .leftJoin(
47
+ session,
48
+ and(
49
+ eq(session.agentId, agent.id),
50
+ eq(session.userId, auth.user.id),
51
+ eq(session.isArchived, false),
52
+ ),
53
+ )
54
+ .where(eq(agent.isArchived, false))
55
+ .groupBy(agent.id)
56
+ .orderBy(asc(agent.displayName))
57
+ .all();
58
+
59
+ return jsonResponse(200, {
60
+ agents: rows.map((r) => ({
61
+ id: r.id,
62
+ display_name: r.displayName,
63
+ session_count: Number(r.sessionCount),
64
+ })),
65
+ });
66
+ });