@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,463 @@
1
+ /**
2
+ * `/admin/settings/*` — super_admin-only deployment-wide configuration.
3
+ *
4
+ * Five endpoints:
5
+ *
6
+ * - `GET /admin/settings` — Letta connection (api key **masked**), theme,
7
+ * metrics + signing-key rotation timestamps. Encrypted Letta API key
8
+ * surfaces only as `letta_api_key_masked` (`••••••••<last4>`); plaintext
9
+ * never leaves the server outside the `Reveal once` flow (Step 12).
10
+ * - `PATCH /admin/settings` — body `{ letta_base_url?, letta_ade_url?, letta_api_key?, theme?: { default?, switcher_enabled? } }`.
11
+ * API key, when supplied, is encrypted under `AI_HUB_ENCRYPTION_KEY`. Theme
12
+ * fields live as **separate columns**, not in `feature_toggles`.
13
+ * - `GET /admin/settings/feature-toggles` — flat JSON blob from §8.4.
14
+ * - `PATCH /admin/settings/feature-toggles` — merge-patch the same blob.
15
+ * Unknown keys are accepted (forward-compat for future toggles).
16
+ * - `POST /admin/settings/rotate-signing-key` — replace
17
+ * `service_token_signing_key` with random 64 bytes; bump
18
+ * `signing_key_rotated_at`. Returns nothing — the key never leaves the
19
+ * server. After rotation every outstanding JWT fails signature
20
+ * verification (no grace period in v1; the calling super-admin is logged
21
+ * out alongside everyone else).
22
+ * - `POST /admin/settings/rotate-metrics-token` — generate a new 32-byte
23
+ * hex token, bump `metrics_scrape_token_rotated_at`, return
24
+ * `{ token: "<new>" }` exactly once in the response. Same one-time-reveal
25
+ * pattern as user password reset; subsequent `GET /admin/settings` calls
26
+ * do not echo it back.
27
+ *
28
+ * @see SPECIFICATIONS.md §6.3.4 — settings endpoints
29
+ * @see SPECIFICATIONS.md §6.7 — signing key rotation
30
+ * @see SPECIFICATIONS.md §6.10 — metrics token rotation
31
+ * @see ui-sketches/08-admin-settings.md
32
+ */
33
+
34
+ import { randomBytes } from "node:crypto";
35
+
36
+ import { eq } from "drizzle-orm";
37
+ import { z } from "zod";
38
+
39
+ import { encryptString } from "../../auth/encryption.js";
40
+ import { protectedRoute } from "../../auth/middleware.js";
41
+ import { adminSetting } from "../../db/schema.js";
42
+ import type { AdminSettingRow } from "../../db/schema.js";
43
+ import {
44
+ internalError,
45
+ jsonResponse,
46
+ validationError,
47
+ } from "../../handlers/responses.js";
48
+ import type { RouteHandler } from "../../handlers/types.js";
49
+
50
+ /* ────────────────────────────────────────────────────────────────────────── */
51
+ /* Helpers */
52
+ /* ────────────────────────────────────────────────────────────────────────── */
53
+
54
+ /** ISO-8601 timestamp with millisecond precision. */
55
+ function nowIso(): string {
56
+ return new Date().toISOString();
57
+ }
58
+
59
+ /**
60
+ * Computes a privacy-preserving display of the encrypted Letta API key.
61
+ *
62
+ * Per [08-admin-settings.md], the UI shows `••••••••` while the value is in
63
+ * the field — operators reveal-once if they need to copy. The server's
64
+ * `GET /admin/settings` never returns plaintext.
65
+ *
66
+ * @param row - the admin_setting row.
67
+ * @returns a masked string, or `null` when no key is stored.
68
+ */
69
+ function maskLettaApiKey(row: AdminSettingRow): string | null {
70
+ if (!row.lettaApiKeyEncrypted) return null;
71
+ // We can't decrypt without the key, and decrypting just to show "<last 4>"
72
+ // would defeat the purpose of encrypting at rest. Use a constant placeholder
73
+ // so the UI knows a key is configured without revealing length / contents.
74
+ return "••••••••";
75
+ }
76
+
77
+ /** Possible sources for the effective Letta API key at runtime. */
78
+ type LettaApiKeySource = "db" | "env" | "none";
79
+
80
+ /**
81
+ * Determines the *effective* source of the Letta API key at this moment.
82
+ * Mirrors `letta/client.ts:resolveApiKey` (DB → env → throw) so the admin
83
+ * UI can show the truth about which source the runtime will use, not just
84
+ * what's stored in the DB.
85
+ *
86
+ * Returning the SOURCE — not the value — keeps plaintext off the wire.
87
+ *
88
+ * @param row - the admin_setting row (may be missing).
89
+ * @param envSource - env-var record from the server context. Lets tests
90
+ * exercise the source-resolution without touching the real `process.env`.
91
+ * @returns `"db"` when DB column has a non-empty value (the runtime will
92
+ * use it); `"env"` when DB is empty AND `LETTA_API_KEY` in `envSource`
93
+ * is set and non-empty; `"none"` otherwise.
94
+ */
95
+ function resolveApiKeySource(
96
+ row: AdminSettingRow | null,
97
+ envSource: Record<string, string | undefined>,
98
+ ): LettaApiKeySource {
99
+ // We deliberately can't tell here whether the DB-encrypted bytes decrypt
100
+ // to a non-whitespace string without doing the decrypt — but the rule is
101
+ // "non-empty bytes ⇒ candidate." The runtime resolver does the actual
102
+ // whitespace check; the UI source is just "is there a DB value worth
103
+ // showing?" The mismatch (DB has whitespace-only after decrypt) is rare
104
+ // enough that we don't pay the cost of decrypting here just to detect
105
+ // it; `letta_api_key_set` aligned with `lettaApiKeyEncrypted IS NOT NULL`
106
+ // matches the existing PATCH semantics (we never write whitespace).
107
+ if (row?.lettaApiKeyEncrypted) return "db";
108
+ const envKey = envSource.LETTA_API_KEY;
109
+ if (envKey && envKey.trim().length > 0) return "env";
110
+ return "none";
111
+ }
112
+
113
+ /**
114
+ * Reads the singleton admin-settings row. Throws if the row hasn't been
115
+ * seeded — should not happen after first-boot.
116
+ *
117
+ * @param ctx - minimal server-context slice carrying the Drizzle client.
118
+ * @param ctx.db - the Drizzle client used for the read.
119
+ * @returns the row, or `null` if absent.
120
+ */
121
+ function readSettings(ctx: {
122
+ db: import("../../db/connection.js").DrizzleDB;
123
+ }): AdminSettingRow | null {
124
+ return ctx.db.select().from(adminSetting).where(eq(adminSetting.id, 1)).get() ?? null;
125
+ }
126
+
127
+ /**
128
+ * Wire-format view of the settings row for `GET /admin/settings`.
129
+ *
130
+ * @param row - the admin_setting row.
131
+ * @returns the JSON-safe response shape.
132
+ */
133
+ function settingsView(
134
+ row: AdminSettingRow,
135
+ envSource: Record<string, string | undefined>,
136
+ ): Record<string, unknown> {
137
+ const source = resolveApiKeySource(row, envSource);
138
+ return {
139
+ letta_base_url: row.lettaBaseUrl ?? null,
140
+ letta_ade_url: row.lettaAdeUrl ?? null,
141
+ letta_api_key_masked: maskLettaApiKey(row),
142
+ // True when EITHER the DB column is populated OR env.LETTA_API_KEY is
143
+ // set — matches the runtime resolver's "is the chat path going to
144
+ // work?" answer. The admin UI uses this to decide whether to render a
145
+ // "configured" badge vs. a "missing" warning, regardless of which
146
+ // source supplies the value.
147
+ letta_api_key_set: source !== "none",
148
+ // Source the runtime is *currently* using (per resolveApiKey precedence
149
+ // in letta/client.ts). Drives the admin UI's source-indicator text —
150
+ // "(from env)" vs "(from admin panel)" vs "(not configured)" — so
151
+ // operators don't get confused when the DB row is empty but chat
152
+ // still works (env fallback) or vice versa.
153
+ letta_api_key_source: source,
154
+ theme: {
155
+ default: row.themeDefault,
156
+ switcher_enabled: row.themeSwitcherEnabled,
157
+ },
158
+ feature_toggles: row.featureToggles,
159
+ signing_key_rotated_at: row.signingKeyRotatedAt ?? null,
160
+ metrics_scrape_token_rotated_at: row.metricsScrapeTokenRotatedAt ?? null,
161
+ updated_at: row.updatedAt,
162
+ // `metrics_scrape_token` and `service_token_signing_key` are deliberately
163
+ // omitted — see file-level comment.
164
+ };
165
+ }
166
+
167
+ /* ────────────────────────────────────────────────────────────────────────── */
168
+ /* GET /admin/settings */
169
+ /* ────────────────────────────────────────────────────────────────────────── */
170
+
171
+ /**
172
+ * `GET /admin/settings` — super_admin-only deployment configuration.
173
+ *
174
+ * Encrypted Letta API key surfaces only as the masked placeholder
175
+ * (`letta_api_key_masked: "••••••••"`) + the boolean `letta_api_key_set`;
176
+ * plaintext never leaves the server through this endpoint. Step 12 wires the
177
+ * `Reveal once` flow as a separate UI affordance.
178
+ *
179
+ * @see SPECIFICATIONS.md §6.3.4
180
+ * @see ui-sketches/08-admin-settings.md
181
+ */
182
+ export const handleGetSettings: RouteHandler = protectedRoute(
183
+ { requiredRole: "super_admin" },
184
+ async (_req, ctx) => {
185
+ const row = readSettings(ctx);
186
+ if (!row) return internalError("Admin settings row is missing — server misconfigured.");
187
+ return jsonResponse(200, settingsView(row, ctx.envSource));
188
+ },
189
+ );
190
+
191
+ /* ────────────────────────────────────────────────────────────────────────── */
192
+ /* PATCH /admin/settings */
193
+ /* ────────────────────────────────────────────────────────────────────────── */
194
+
195
+ /** Body for `PATCH /admin/settings`. Every field optional; empty body is a no-op. */
196
+ const patchSettingsSchema = z
197
+ .object({
198
+ letta_base_url: z.string().url().nullable().optional(),
199
+ letta_ade_url: z.string().url().nullable().optional(),
200
+ // The API key accepts:
201
+ // - a non-empty string → encrypt + store; runtime will use this DB
202
+ // value over env on the next request.
203
+ // - an empty/whitespace string → clear the DB column to NULL; the
204
+ // runtime then falls back to env.LETTA_API_KEY (the operator-spec'd
205
+ // "if admin panel is empty, use env" precedence).
206
+ // - omitted → no change.
207
+ letta_api_key: z.string().max(2048).optional(),
208
+ theme: z
209
+ .object({
210
+ default: z.enum(["light", "dark"]).optional(),
211
+ switcher_enabled: z.boolean().optional(),
212
+ })
213
+ .strict()
214
+ .optional(),
215
+ })
216
+ .strict();
217
+
218
+ /**
219
+ * `PATCH /admin/settings` — partial update of deployment configuration.
220
+ *
221
+ * The Letta API key, when supplied, is encrypted under
222
+ * `AI_HUB_ENCRYPTION_KEY` (env-only) before write. Theme fields live as
223
+ * separate columns (`theme_default`, `theme_switcher_enabled`) per §8.4.
224
+ *
225
+ * @see SPECIFICATIONS.md §6.3.4
226
+ * @see SPECIFICATIONS.md §8.4 — theme settings are explicit columns
227
+ */
228
+ export const handlePatchSettings: RouteHandler = protectedRoute(
229
+ { requiredRole: "super_admin" },
230
+ async (req, ctx, auth) => {
231
+ let raw: unknown;
232
+ try {
233
+ raw = await req.json();
234
+ } catch {
235
+ return validationError("Request body must be valid JSON.");
236
+ }
237
+ const parsed = patchSettingsSchema.safeParse(raw);
238
+ if (!parsed.success) {
239
+ return validationError(parsed.error.issues[0]?.message ?? "Invalid request body.");
240
+ }
241
+ const body = parsed.data;
242
+
243
+ const update: Partial<typeof adminSetting.$inferInsert> = { updatedAt: nowIso() };
244
+ if (body.letta_base_url !== undefined) update.lettaBaseUrl = body.letta_base_url;
245
+ if (body.letta_ade_url !== undefined) update.lettaAdeUrl = body.letta_ade_url;
246
+ if (body.letta_api_key !== undefined) {
247
+ // Empty / whitespace-only string clears the DB column to NULL. The
248
+ // runtime then falls back to env.LETTA_API_KEY (see
249
+ // letta/client.ts:resolveApiKey). Non-empty values are encrypted +
250
+ // stored as the operator's override of env.
251
+ const trimmed = body.letta_api_key.trim();
252
+ update.lettaApiKeyEncrypted =
253
+ trimmed.length === 0 ? null : encryptString(ctx.encryptionKey, body.letta_api_key);
254
+ }
255
+ if (body.theme?.default !== undefined) update.themeDefault = body.theme.default;
256
+ if (body.theme?.switcher_enabled !== undefined)
257
+ update.themeSwitcherEnabled = body.theme.switcher_enabled;
258
+
259
+ if (Object.keys(update).length > 1) {
260
+ // More than just `updatedAt` — write.
261
+ ctx.db.update(adminSetting).set(update).where(eq(adminSetting.id, 1)).run();
262
+ ctx.logger.info(
263
+ {
264
+ actor_id: auth.user.id,
265
+ patch_keys: Object.keys(body),
266
+ event: "admin_settings_patched",
267
+ },
268
+ "Admin updated deployment settings.",
269
+ );
270
+ }
271
+
272
+ const fresh = readSettings(ctx);
273
+ if (!fresh) return internalError();
274
+ return jsonResponse(200, settingsView(fresh, ctx.envSource));
275
+ },
276
+ );
277
+
278
+ /* ────────────────────────────────────────────────────────────────────────── */
279
+ /* GET /admin/settings/feature-toggles */
280
+ /* ────────────────────────────────────────────────────────────────────────── */
281
+
282
+ /**
283
+ * `GET /admin/settings/feature-toggles` — the §8.4 flat JSON blob.
284
+ *
285
+ * Missing keys fall back to the React side's built-in defaults at render
286
+ * time; the server doesn't merge defaults in here (so the admin UI can show
287
+ * "default" vs. "explicitly set").
288
+ *
289
+ * @see SPECIFICATIONS.md §8.4
290
+ */
291
+ export const handleGetFeatureToggles: RouteHandler = protectedRoute(
292
+ { requiredRole: "super_admin" },
293
+ async (_req, ctx) => {
294
+ const row = readSettings(ctx);
295
+ if (!row) return internalError();
296
+ return jsonResponse(200, { feature_toggles: row.featureToggles ?? {} });
297
+ },
298
+ );
299
+
300
+ /* ────────────────────────────────────────────────────────────────────────── */
301
+ /* PATCH /admin/settings/feature-toggles */
302
+ /* ────────────────────────────────────────────────────────────────────────── */
303
+
304
+ /**
305
+ * `PATCH /admin/settings/feature-toggles` body. Strict — only known toggles
306
+ * are accepted. **Adding a new toggle requires bumping this schema** so
307
+ * accidental typos in the UI surface as 400s instead of silently being saved
308
+ * into the blob.
309
+ */
310
+ const featureTogglesSchema = z
311
+ .object({
312
+ sessions_panel_enabled: z.boolean().optional(),
313
+ sessions_panel_resizable: z.boolean().optional(),
314
+ sessions_panel_collapsible: z.boolean().optional(),
315
+ create_session_button_enabled: z.boolean().optional(),
316
+ user_input_enabled: z.boolean().optional(),
317
+ stop_button_enabled: z.boolean().optional(),
318
+ tool_calls_visible: z.boolean().optional(),
319
+ reasoning_visible: z.boolean().optional(),
320
+ system_messages_visible: z.boolean().optional(),
321
+ tpm_notice_visible: z.boolean().optional(),
322
+ effort_control_visible: z.boolean().optional(),
323
+ scroll_to_present_visible: z.boolean().optional(),
324
+ turn_status_visible: z.boolean().optional(),
325
+ })
326
+ .strict();
327
+
328
+ /**
329
+ * `PATCH /admin/settings/feature-toggles` — merge-patch the §8.4 blob.
330
+ *
331
+ * Unknown keys → 400. Each successful PATCH replaces the entire stored blob
332
+ * with the merge of (old state) + (incoming patch). Setting a key to `false`
333
+ * makes it explicit-false; omitting a key leaves the current value.
334
+ *
335
+ * @see SPECIFICATIONS.md §8.4
336
+ */
337
+ export const handlePatchFeatureToggles: RouteHandler = protectedRoute(
338
+ { requiredRole: "super_admin" },
339
+ async (req, ctx, auth) => {
340
+ let raw: unknown;
341
+ try {
342
+ raw = await req.json();
343
+ } catch {
344
+ return validationError("Request body must be valid JSON.");
345
+ }
346
+ const parsed = featureTogglesSchema.safeParse(raw);
347
+ if (!parsed.success) {
348
+ return validationError(parsed.error.issues[0]?.message ?? "Invalid request body.");
349
+ }
350
+ const body = parsed.data;
351
+
352
+ const row = readSettings(ctx);
353
+ if (!row) return internalError();
354
+ const current = (row.featureToggles ?? {}) as Record<string, unknown>;
355
+ const next = { ...current };
356
+ for (const [k, v] of Object.entries(body)) {
357
+ if (v !== undefined) next[k] = v;
358
+ }
359
+
360
+ ctx.db
361
+ .update(adminSetting)
362
+ .set({ featureToggles: next, updatedAt: nowIso() })
363
+ .where(eq(adminSetting.id, 1))
364
+ .run();
365
+ ctx.logger.info(
366
+ {
367
+ actor_id: auth.user.id,
368
+ patch_keys: Object.keys(body),
369
+ event: "admin_feature_toggles_patched",
370
+ },
371
+ "Admin updated feature toggles.",
372
+ );
373
+
374
+ return jsonResponse(200, { feature_toggles: next });
375
+ },
376
+ );
377
+
378
+ /* ────────────────────────────────────────────────────────────────────────── */
379
+ /* POST /admin/settings/rotate-signing-key */
380
+ /* ────────────────────────────────────────────────────────────────────────── */
381
+
382
+ /**
383
+ * `POST /admin/settings/rotate-signing-key` — replace the JWT signing key.
384
+ *
385
+ * Generates 64 new random bytes (matches seed bytes), writes to
386
+ * `admin_setting.service_token_signing_key`, bumps `signing_key_rotated_at`.
387
+ * Returns 204 No Content — the key never leaves the server.
388
+ *
389
+ * **Every outstanding JWT fails signature verification immediately after this
390
+ * call** (no grace period in v1). The calling super-admin's own token is
391
+ * invalidated alongside everyone else's; the React UI handles the 401 on the
392
+ * next request by routing back to login.
393
+ *
394
+ * @see SPECIFICATIONS.md §6.7 — signing key
395
+ * @see ui-sketches/08-admin-settings.md
396
+ */
397
+ export const handleRotateSigningKey: RouteHandler = protectedRoute(
398
+ { requiredRole: "super_admin" },
399
+ async (_req, ctx, auth) => {
400
+ const newKey = randomBytes(64);
401
+ ctx.db
402
+ .update(adminSetting)
403
+ .set({
404
+ serviceTokenSigningKey: newKey,
405
+ signingKeyRotatedAt: nowIso(),
406
+ updatedAt: nowIso(),
407
+ })
408
+ .where(eq(adminSetting.id, 1))
409
+ .run();
410
+ ctx.logger.info(
411
+ {
412
+ actor_id: auth.user.id,
413
+ event: "admin_signing_key_rotated",
414
+ },
415
+ "Admin rotated the JWT signing key. All outstanding tokens are now invalid.",
416
+ );
417
+ return new Response(null, { status: 204 });
418
+ },
419
+ );
420
+
421
+ /* ────────────────────────────────────────────────────────────────────────── */
422
+ /* POST /admin/settings/rotate-metrics-token */
423
+ /* ────────────────────────────────────────────────────────────────────────── */
424
+
425
+ /**
426
+ * `POST /admin/settings/rotate-metrics-token` — replace the `/metrics` bearer
427
+ * token.
428
+ *
429
+ * Generates a fresh 32-byte hex token, writes it, bumps
430
+ * `metrics_scrape_token_rotated_at`, and returns `{ token: "<new>" }` exactly
431
+ * once. The plaintext never appears in subsequent `GET /admin/settings`
432
+ * responses (same one-time-reveal pattern as user password resets).
433
+ *
434
+ * Rotating does **not** invalidate the calling super-admin's JWT — only the
435
+ * metrics-scraper bearer changes. Operators paste the new value into their
436
+ * Prometheus / Alloy config; old scrapers start failing immediately.
437
+ *
438
+ * @see SPECIFICATIONS.md §6.10
439
+ * @see ui-sketches/08-admin-settings.md
440
+ */
441
+ export const handleRotateMetricsToken: RouteHandler = protectedRoute(
442
+ { requiredRole: "super_admin" },
443
+ async (_req, ctx, auth) => {
444
+ const newToken = randomBytes(32).toString("hex");
445
+ ctx.db
446
+ .update(adminSetting)
447
+ .set({
448
+ metricsScrapeToken: newToken,
449
+ metricsScrapeTokenRotatedAt: nowIso(),
450
+ updatedAt: nowIso(),
451
+ })
452
+ .where(eq(adminSetting.id, 1))
453
+ .run();
454
+ ctx.logger.info(
455
+ {
456
+ actor_id: auth.user.id,
457
+ event: "admin_metrics_token_rotated",
458
+ },
459
+ "Admin rotated the /metrics scrape token.",
460
+ );
461
+ return jsonResponse(200, { token: newToken });
462
+ },
463
+ );