@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.
- package/dist/auth/argon2.d.ts +72 -0
- package/dist/auth/argon2.d.ts.map +1 -0
- package/dist/auth/argon2.js +145 -0
- package/dist/auth/argon2.js.map +1 -0
- package/dist/auth/encryption.d.ts +52 -0
- package/dist/auth/encryption.d.ts.map +1 -0
- package/dist/auth/encryption.js +98 -0
- package/dist/auth/encryption.js.map +1 -0
- package/dist/auth/jwt.d.ts +133 -0
- package/dist/auth/jwt.d.ts.map +1 -0
- package/dist/auth/jwt.js +147 -0
- package/dist/auth/jwt.js.map +1 -0
- package/dist/auth/middleware.d.ts +101 -0
- package/dist/auth/middleware.d.ts.map +1 -0
- package/dist/auth/middleware.js +161 -0
- package/dist/auth/middleware.js.map +1 -0
- package/dist/auth/passwordPolicy.d.ts +72 -0
- package/dist/auth/passwordPolicy.d.ts.map +1 -0
- package/dist/auth/passwordPolicy.js +88 -0
- package/dist/auth/passwordPolicy.js.map +1 -0
- package/dist/auth/roleRules.d.ts +200 -0
- package/dist/auth/roleRules.d.ts.map +1 -0
- package/dist/auth/roleRules.js +365 -0
- package/dist/auth/roleRules.js.map +1 -0
- package/dist/auth/userView.d.ts +55 -0
- package/dist/auth/userView.d.ts.map +1 -0
- package/dist/auth/userView.js +77 -0
- package/dist/auth/userView.js.map +1 -0
- package/dist/bootstrap.d.ts +104 -0
- package/dist/bootstrap.d.ts.map +1 -0
- package/dist/bootstrap.js +160 -0
- package/dist/bootstrap.js.map +1 -0
- package/dist/db/connection.d.ts +39 -0
- package/dist/db/connection.d.ts.map +1 -0
- package/dist/db/connection.js +62 -0
- package/dist/db/connection.js.map +1 -0
- package/dist/db/migrate.d.ts +31 -0
- package/dist/db/migrate.d.ts.map +1 -0
- package/dist/db/migrate.js +197 -0
- package/dist/db/migrate.js.map +1 -0
- package/dist/db/schema.d.ts +1902 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +662 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/db/seed.d.ts +92 -0
- package/dist/db/seed.d.ts.map +1 -0
- package/dist/db/seed.js +160 -0
- package/dist/db/seed.js.map +1 -0
- package/dist/handlers/fetch.d.ts +89 -0
- package/dist/handlers/fetch.d.ts.map +1 -0
- package/dist/handlers/fetch.js +173 -0
- package/dist/handlers/fetch.js.map +1 -0
- package/dist/handlers/nextjs.d.ts +40 -0
- package/dist/handlers/nextjs.d.ts.map +1 -0
- package/dist/handlers/nextjs.js +42 -0
- package/dist/handlers/nextjs.js.map +1 -0
- package/dist/handlers/responses.d.ts +46 -0
- package/dist/handlers/responses.d.ts.map +1 -0
- package/dist/handlers/responses.js +74 -0
- package/dist/handlers/responses.js.map +1 -0
- package/dist/handlers/routeTable.d.ts +21 -0
- package/dist/handlers/routeTable.d.ts.map +1 -0
- package/dist/handlers/routeTable.js +133 -0
- package/dist/handlers/routeTable.js.map +1 -0
- package/dist/handlers/router.d.ts +37 -0
- package/dist/handlers/router.d.ts.map +1 -0
- package/dist/handlers/router.js +82 -0
- package/dist/handlers/router.js.map +1 -0
- package/dist/handlers/sse.d.ts +38 -0
- package/dist/handlers/sse.d.ts.map +1 -0
- package/dist/handlers/sse.js +52 -0
- package/dist/handlers/sse.js.map +1 -0
- package/dist/handlers/types.d.ts +96 -0
- package/dist/handlers/types.d.ts.map +1 -0
- package/dist/handlers/types.js +14 -0
- package/dist/handlers/types.js.map +1 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/letta/client.d.ts +355 -0
- package/dist/letta/client.d.ts.map +1 -0
- package/dist/letta/client.js +529 -0
- package/dist/letta/client.js.map +1 -0
- package/dist/letta/eventTranslator.d.ts +166 -0
- package/dist/letta/eventTranslator.d.ts.map +1 -0
- package/dist/letta/eventTranslator.js +532 -0
- package/dist/letta/eventTranslator.js.map +1 -0
- package/dist/letta/index.d.ts +13 -0
- package/dist/letta/index.d.ts.map +1 -0
- package/dist/letta/index.js +12 -0
- package/dist/letta/index.js.map +1 -0
- package/dist/letta/modelSettings.d.ts +176 -0
- package/dist/letta/modelSettings.d.ts.map +1 -0
- package/dist/letta/modelSettings.js +314 -0
- package/dist/letta/modelSettings.js.map +1 -0
- package/dist/letta/promptWrapping.d.ts +141 -0
- package/dist/letta/promptWrapping.d.ts.map +1 -0
- package/dist/letta/promptWrapping.js +213 -0
- package/dist/letta/promptWrapping.js.map +1 -0
- package/dist/letta/sandbox.d.ts +100 -0
- package/dist/letta/sandbox.d.ts.map +1 -0
- package/dist/letta/sandbox.js +151 -0
- package/dist/letta/sandbox.js.map +1 -0
- package/dist/letta/settingsFileSeed.d.ts +146 -0
- package/dist/letta/settingsFileSeed.d.ts.map +1 -0
- package/dist/letta/settingsFileSeed.js +177 -0
- package/dist/letta/settingsFileSeed.js.map +1 -0
- package/dist/letta/subprocessThrottle.d.ts +151 -0
- package/dist/letta/subprocessThrottle.d.ts.map +1 -0
- package/dist/letta/subprocessThrottle.js +195 -0
- package/dist/letta/subprocessThrottle.js.map +1 -0
- package/dist/letta/tpmRecovery.d.ts +202 -0
- package/dist/letta/tpmRecovery.d.ts.map +1 -0
- package/dist/letta/tpmRecovery.js +314 -0
- package/dist/letta/tpmRecovery.js.map +1 -0
- package/dist/letta/types.d.ts +54 -0
- package/dist/letta/types.d.ts.map +1 -0
- package/dist/letta/types.js +16 -0
- package/dist/letta/types.js.map +1 -0
- package/dist/observability/logger.d.ts +48 -0
- package/dist/observability/logger.d.ts.map +1 -0
- package/dist/observability/logger.js +81 -0
- package/dist/observability/logger.js.map +1 -0
- package/dist/observability/metrics.d.ts +53 -0
- package/dist/observability/metrics.d.ts.map +1 -0
- package/dist/observability/metrics.js +92 -0
- package/dist/observability/metrics.js.map +1 -0
- package/dist/routes/admin/agents.d.ts +63 -0
- package/dist/routes/admin/agents.d.ts.map +1 -0
- package/dist/routes/admin/agents.js +461 -0
- package/dist/routes/admin/agents.js.map +1 -0
- package/dist/routes/admin/rotatingMessages.d.ts +51 -0
- package/dist/routes/admin/rotatingMessages.d.ts.map +1 -0
- package/dist/routes/admin/rotatingMessages.js +292 -0
- package/dist/routes/admin/rotatingMessages.js.map +1 -0
- package/dist/routes/admin/sessions.d.ts +61 -0
- package/dist/routes/admin/sessions.d.ts.map +1 -0
- package/dist/routes/admin/sessions.js +232 -0
- package/dist/routes/admin/sessions.js.map +1 -0
- package/dist/routes/admin/settings.d.ts +110 -0
- package/dist/routes/admin/settings.d.ts.map +1 -0
- package/dist/routes/admin/settings.js +401 -0
- package/dist/routes/admin/settings.js.map +1 -0
- package/dist/routes/admin/users.d.ts +115 -0
- package/dist/routes/admin/users.d.ts.map +1 -0
- package/dist/routes/admin/users.js +374 -0
- package/dist/routes/admin/users.js.map +1 -0
- package/dist/routes/agents.d.ts +30 -0
- package/dist/routes/agents.d.ts.map +1 -0
- package/dist/routes/agents.js +55 -0
- package/dist/routes/agents.js.map +1 -0
- package/dist/routes/auth.d.ts +121 -0
- package/dist/routes/auth.d.ts.map +1 -0
- package/dist/routes/auth.js +422 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/chat.d.ts +97 -0
- package/dist/routes/chat.d.ts.map +1 -0
- package/dist/routes/chat.js +484 -0
- package/dist/routes/chat.js.map +1 -0
- package/dist/routes/featureToggles.d.ts +34 -0
- package/dist/routes/featureToggles.d.ts.map +1 -0
- package/dist/routes/featureToggles.js +47 -0
- package/dist/routes/featureToggles.js.map +1 -0
- package/dist/routes/health.d.ts +24 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +37 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/metrics.d.ts +35 -0
- package/dist/routes/metrics.d.ts.map +1 -0
- package/dist/routes/metrics.js +106 -0
- package/dist/routes/metrics.js.map +1 -0
- package/dist/routes/sessions.d.ts +161 -0
- package/dist/routes/sessions.d.ts.map +1 -0
- package/dist/routes/sessions.js +1501 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/routes/stubs.d.ts +25 -0
- package/dist/routes/stubs.d.ts.map +1 -0
- package/dist/routes/stubs.js +25 -0
- package/dist/routes/stubs.js.map +1 -0
- package/dist/state/cron.d.ts +58 -0
- package/dist/state/cron.d.ts.map +1 -0
- package/dist/state/cron.js +160 -0
- package/dist/state/cron.js.map +1 -0
- package/dist/state/rotationRunner.d.ts +96 -0
- package/dist/state/rotationRunner.d.ts.map +1 -0
- package/dist/state/rotationRunner.js +246 -0
- package/dist/state/rotationRunner.js.map +1 -0
- package/dist/state/rotationScheduler.d.ts +76 -0
- package/dist/state/rotationScheduler.d.ts.map +1 -0
- package/dist/state/rotationScheduler.js +124 -0
- package/dist/state/rotationScheduler.js.map +1 -0
- package/dist/state/turnRegistry.d.ts +95 -0
- package/dist/state/turnRegistry.d.ts.map +1 -0
- package/dist/state/turnRegistry.js +149 -0
- package/dist/state/turnRegistry.js.map +1 -0
- package/dist/state/turnRunner.d.ts +134 -0
- package/dist/state/turnRunner.d.ts.map +1 -0
- package/dist/state/turnRunner.js +476 -0
- package/dist/state/turnRunner.js.map +1 -0
- package/package.json +47 -0
- package/src/auth/argon2.ts +152 -0
- package/src/auth/encryption.ts +110 -0
- package/src/auth/jwt.ts +217 -0
- package/src/auth/middleware.ts +250 -0
- package/src/auth/passwordPolicy.ts +99 -0
- package/src/auth/roleRules.ts +497 -0
- package/src/auth/userView.ts +88 -0
- package/src/bootstrap.ts +254 -0
- package/src/db/connection.ts +75 -0
- package/src/db/migrate.ts +212 -0
- package/src/db/schema.ts +750 -0
- package/src/db/seed.ts +236 -0
- package/src/handlers/fetch.ts +251 -0
- package/src/handlers/nextjs.ts +49 -0
- package/src/handlers/responses.ts +95 -0
- package/src/handlers/routeTable.ts +188 -0
- package/src/handlers/router.ts +96 -0
- package/src/handlers/sse.ts +55 -0
- package/src/handlers/types.ts +104 -0
- package/src/index.ts +60 -0
- package/src/letta/client.ts +860 -0
- package/src/letta/eventTranslator.ts +667 -0
- package/src/letta/index.ts +46 -0
- package/src/letta/lettaCliShim.mjs +40 -0
- package/src/letta/sandbox.ts +167 -0
- package/src/letta/settingsFileSeed.ts +254 -0
- package/src/letta/subprocessThrottle.ts +251 -0
- package/src/letta/types.ts +65 -0
- package/src/observability/logger.ts +103 -0
- package/src/observability/metrics.ts +114 -0
- package/src/routes/admin/agents.ts +574 -0
- package/src/routes/admin/rotatingMessages.ts +351 -0
- package/src/routes/admin/sessions.ts +283 -0
- package/src/routes/admin/settings.ts +463 -0
- package/src/routes/admin/users.ts +455 -0
- package/src/routes/agents.ts +66 -0
- package/src/routes/auth.ts +513 -0
- package/src/routes/chat.ts +534 -0
- package/src/routes/featureToggles.ts +52 -0
- package/src/routes/health.ts +44 -0
- package/src/routes/metrics.ts +111 -0
- package/src/routes/sessions.ts +1878 -0
- package/src/state/cron.ts +179 -0
- package/src/state/rotationRunner.ts +326 -0
- package/src/state/rotationScheduler.ts +164 -0
- package/src/state/turnRegistry.ts +210 -0
- 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
|
+
);
|