@data-club/ai-hub-server 0.0.9 → 0.0.10

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 (83) hide show
  1. package/dist/auth/encryption.d.ts +5 -5
  2. package/dist/auth/encryption.js +5 -5
  3. package/dist/auth/userView.d.ts +8 -15
  4. package/dist/auth/userView.d.ts.map +1 -1
  5. package/dist/auth/userView.js +10 -23
  6. package/dist/auth/userView.js.map +1 -1
  7. package/dist/bootstrap.d.ts +2 -8
  8. package/dist/bootstrap.d.ts.map +1 -1
  9. package/dist/bootstrap.js +2 -35
  10. package/dist/bootstrap.js.map +1 -1
  11. package/dist/db/migrate.js +0 -17
  12. package/dist/db/migrate.js.map +1 -1
  13. package/dist/db/schema.d.ts +0 -263
  14. package/dist/db/schema.d.ts.map +1 -1
  15. package/dist/db/schema.js +0 -57
  16. package/dist/db/schema.js.map +1 -1
  17. package/dist/db/seed.d.ts +4 -19
  18. package/dist/db/seed.d.ts.map +1 -1
  19. package/dist/db/seed.js +6 -33
  20. package/dist/db/seed.js.map +1 -1
  21. package/dist/handlers/fetch.d.ts +6 -7
  22. package/dist/handlers/fetch.d.ts.map +1 -1
  23. package/dist/handlers/fetch.js +0 -2
  24. package/dist/handlers/fetch.js.map +1 -1
  25. package/dist/handlers/types.d.ts +5 -6
  26. package/dist/handlers/types.d.ts.map +1 -1
  27. package/dist/letta/client.d.ts +86 -263
  28. package/dist/letta/client.d.ts.map +1 -1
  29. package/dist/letta/client.js +99 -404
  30. package/dist/letta/client.js.map +1 -1
  31. package/dist/letta/index.d.ts +6 -8
  32. package/dist/letta/index.d.ts.map +1 -1
  33. package/dist/letta/index.js +5 -7
  34. package/dist/letta/index.js.map +1 -1
  35. package/dist/letta/sandbox.d.ts +45 -9
  36. package/dist/letta/sandbox.d.ts.map +1 -1
  37. package/dist/letta/sandbox.js +123 -11
  38. package/dist/letta/sandbox.js.map +1 -1
  39. package/dist/letta/types.d.ts +6 -49
  40. package/dist/letta/types.d.ts.map +1 -1
  41. package/dist/letta/types.js +5 -10
  42. package/dist/letta/types.js.map +1 -1
  43. package/dist/observability/logger.d.ts.map +1 -1
  44. package/dist/observability/logger.js +0 -2
  45. package/dist/observability/logger.js.map +1 -1
  46. package/dist/routes/admin/agents.d.ts.map +1 -1
  47. package/dist/routes/admin/agents.js +0 -40
  48. package/dist/routes/admin/agents.js.map +1 -1
  49. package/dist/routes/admin/settings.d.ts +6 -15
  50. package/dist/routes/admin/settings.d.ts.map +1 -1
  51. package/dist/routes/admin/settings.js +9 -107
  52. package/dist/routes/admin/settings.js.map +1 -1
  53. package/dist/routes/auth.d.ts +0 -4
  54. package/dist/routes/auth.d.ts.map +1 -1
  55. package/dist/routes/auth.js +1 -5
  56. package/dist/routes/auth.js.map +1 -1
  57. package/dist/routes/sessions.d.ts.map +1 -1
  58. package/dist/routes/sessions.js +9 -30
  59. package/dist/routes/sessions.js.map +1 -1
  60. package/dist/state/turnRunner.d.ts.map +1 -1
  61. package/dist/state/turnRunner.js +1 -75
  62. package/dist/state/turnRunner.js.map +1 -1
  63. package/package.json +4 -4
  64. package/src/auth/encryption.ts +5 -5
  65. package/src/auth/userView.ts +9 -22
  66. package/src/bootstrap.ts +4 -43
  67. package/src/db/migrate.ts +0 -17
  68. package/src/db/schema.ts +0 -68
  69. package/src/db/seed.ts +6 -47
  70. package/src/handlers/fetch.ts +6 -9
  71. package/src/handlers/types.ts +5 -6
  72. package/src/letta/client.ts +170 -634
  73. package/src/letta/index.ts +8 -21
  74. package/src/letta/sandbox.ts +151 -11
  75. package/src/letta/types.ts +9 -60
  76. package/src/observability/logger.ts +0 -2
  77. package/src/routes/admin/agents.ts +0 -49
  78. package/src/routes/admin/settings.ts +9 -113
  79. package/src/routes/auth.ts +1 -5
  80. package/src/routes/sessions.ts +11 -32
  81. package/src/state/turnRunner.ts +1 -77
  82. package/src/letta/lettaCliShim.mjs +0 -40
  83. package/src/letta/settingsFileSeed.ts +0 -254
@@ -1,15 +1,18 @@
1
1
  /**
2
2
  * Public exports for the `letta/` module — the single entry point the rest
3
- * of the server uses to reach Letta. Per §6.4, **every Letta interaction
4
- * goes through `LettaClient`**; no raw HTTP, no other entry points.
5
- *
6
- * @see SPECIFICATIONS.md §6.4
3
+ * of the server uses to reach Letta. Every Letta interaction goes through
4
+ * `LettaClient` (implemented over `@data-club/letta-runtime`'s server-less
5
+ * local backend); no raw HTTP, no other entry points.
7
6
  */
8
7
 
9
8
  export {
10
9
  createDefaultLettaClient,
10
+ ForkSourceNotFoundError,
11
+ ServerOverloadedError,
12
+ type ChatTurnHandle,
11
13
  type CreateLettaClientDeps,
12
14
  type LettaClient,
15
+ type LettaMessage,
13
16
  type StreamUserMessageOpts,
14
17
  } from "./client.js";
15
18
  export {
@@ -22,25 +25,9 @@ export {
22
25
  type UnsequencedEvent,
23
26
  } from "./eventTranslator.js";
24
27
  export {
25
- ServerOverloadedError,
26
28
  SubprocessThrottle,
27
29
  readThrottleConfigFromEnv,
28
30
  type ThrottleConfig,
29
31
  type ThrottleLease,
30
32
  } from "./subprocessThrottle.js";
31
- export {
32
- seedAgentToolsetNone,
33
- type SeedOutcome,
34
- type ToolsetPreference,
35
- } from "./settingsFileSeed.js";
36
- export type {
37
- LettaAssistantMessage,
38
- LettaHiddenReasoningMessage,
39
- LettaMessage,
40
- LettaReasoningMessage,
41
- LettaSdk,
42
- LettaStreamEvent,
43
- LettaToolCallMessage,
44
- LettaToolReturnMessage,
45
- LettaUserMessage,
46
- } from "./types.js";
33
+ export type { SDKMessage } from "./types.js";
@@ -16,8 +16,8 @@
16
16
  * @see docs/server/session-sandboxes.md
17
17
  */
18
18
 
19
- import { existsSync, mkdirSync } from "node:fs";
20
- import { cp, rm } from "node:fs/promises";
19
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
20
+ import { cp, link, mkdir, readdir, rm } from "node:fs/promises";
21
21
  import { join } from "node:path";
22
22
 
23
23
  /**
@@ -108,16 +108,150 @@ export function resolveSeedDir(filesDir: string, seedName: string): string {
108
108
  }
109
109
 
110
110
  /**
111
- * Recursively copies the seed folder into the (not-yet-existing) sandbox
112
- * target. The target's parent is created via `mkdir -p` if needed; the
113
- * target itself is created by `cp({ recursive: true })`. Throws on any
114
- * I/O failure the caller is responsible for rolling back the just-
115
- * created Letta conversation if this throws.
111
+ * Filename of the optional per-seed materialization manifest, looked up in
112
+ * the seed folder's root. When absent, `materializeSandbox` behaves exactly
113
+ * as before (full recursive copy). The manifest itself is never
114
+ * materialized into the sandbox.
115
+ */
116
+ export const SEED_MANIFEST_FILENAME = "seed.manifest.json";
117
+
118
+ /**
119
+ * Parsed shape of `seed.manifest.json`.
120
+ *
121
+ * `link` — glob patterns (relative to the seed root, `/`-separated) of
122
+ * files to **hardlink** into the sandbox instead of copying. Intended for
123
+ * large read-only assets (multi-hundred-MB SQLite databases) where a
124
+ * per-session copy would cost seconds of latency and gigabytes of disk.
125
+ *
126
+ * Operator contract for linked files (documented in
127
+ * docs/server/session-sandboxes.md):
128
+ * - make them read-only (`chmod 444`) — a hardlink shares the inode, so a
129
+ * write through any sandbox would mutate every session's view;
130
+ * - update them by replace-and-rename (new inode), never in place —
131
+ * running sessions then keep the old data, new sessions get the new;
132
+ * - SQLite seeds should be in `journal_mode=DELETE` (not WAL) so read-only
133
+ * opens need no `-shm`/`-wal` sidecar writes.
134
+ */
135
+ export interface SeedManifest {
136
+ link?: string[];
137
+ }
138
+
139
+ /**
140
+ * Converts a manifest glob into an anchored RegExp over the seed-relative
141
+ * posix path. Supported syntax (deliberately minimal, dependency-free):
142
+ * `**` matches any depth, `*` matches within one path segment, `?` matches
143
+ * a single non-separator char. Everything else is literal.
144
+ */
145
+ function globToRegExp(glob: string): RegExp {
146
+ let out = "";
147
+ for (let i = 0; i < glob.length; i++) {
148
+ const ch = glob[i] as string;
149
+ if (ch === "*") {
150
+ if (glob[i + 1] === "*") {
151
+ out += ".*";
152
+ i++;
153
+ } else {
154
+ out += "[^/]*";
155
+ }
156
+ } else if (ch === "?") {
157
+ out += "[^/]";
158
+ } else {
159
+ out += ch.replace(/[.+^${}()|[\]\\]/g, "\\$&");
160
+ }
161
+ }
162
+ return new RegExp(`^${out}$`);
163
+ }
164
+
165
+ /**
166
+ * Reads and validates the seed manifest, if present. Returns `null` when
167
+ * the seed has no manifest (the common case — plain full-copy seeds).
168
+ * Throws with an operator-actionable message on malformed JSON or shape —
169
+ * a broken manifest should fail session-create loudly, not silently fall
170
+ * back to copying a 500 MB database per session.
171
+ *
172
+ * @param seedDir - absolute seed folder path.
173
+ */
174
+ export function readSeedManifest(seedDir: string): SeedManifest | null {
175
+ const manifestPath = join(seedDir, SEED_MANIFEST_FILENAME);
176
+ if (!existsSync(manifestPath)) return null;
177
+ let parsed: unknown;
178
+ try {
179
+ parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
180
+ } catch (err) {
181
+ throw new Error(
182
+ `Seed manifest is not valid JSON: ${manifestPath} (${String(err)})`,
183
+ );
184
+ }
185
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
186
+ throw new Error(`Seed manifest must be a JSON object: ${manifestPath}`);
187
+ }
188
+ const linkRaw = (parsed as Record<string, unknown>).link;
189
+ if (linkRaw !== undefined) {
190
+ if (
191
+ !Array.isArray(linkRaw) ||
192
+ linkRaw.some((p) => typeof p !== "string" || p.length === 0)
193
+ ) {
194
+ throw new Error(
195
+ `Seed manifest "link" must be an array of non-empty glob strings: ${manifestPath}`,
196
+ );
197
+ }
198
+ }
199
+ return linkRaw === undefined ? {} : { link: linkRaw as string[] };
200
+ }
201
+
202
+ /**
203
+ * Recursively materializes one directory level of the seed into the
204
+ * sandbox: entries whose seed-relative path matches a `link` glob are
205
+ * hardlinked (zero-copy, instant — seeds and sandboxes live under the same
206
+ * `AI_HUB_FILES_DIR`, hence the same filesystem); everything else is
207
+ * copied. A hardlink attempt that fails with `EXDEV` (cross-device — some
208
+ * exotic bind-mount layout) degrades to a copy rather than failing the
209
+ * session.
210
+ */
211
+ async function materializeWithManifest(
212
+ seedDir: string,
213
+ targetDir: string,
214
+ relDir: string,
215
+ linkMatchers: RegExp[],
216
+ ): Promise<void> {
217
+ const srcDir = relDir === "" ? seedDir : join(seedDir, relDir);
218
+ const dstDir = relDir === "" ? targetDir : join(targetDir, relDir);
219
+ await mkdir(dstDir, { recursive: true });
220
+ const entries = await readdir(srcDir, { withFileTypes: true });
221
+ for (const entry of entries) {
222
+ const rel = relDir === "" ? entry.name : `${relDir}/${entry.name}`;
223
+ if (rel === SEED_MANIFEST_FILENAME) continue;
224
+ if (entry.isDirectory()) {
225
+ await materializeWithManifest(seedDir, targetDir, rel, linkMatchers);
226
+ continue;
227
+ }
228
+ const src = join(seedDir, rel);
229
+ const dst = join(targetDir, rel);
230
+ if (entry.isFile() && linkMatchers.some((m) => m.test(rel))) {
231
+ try {
232
+ await link(src, dst);
233
+ } catch (err) {
234
+ if ((err as NodeJS.ErrnoException).code !== "EXDEV") throw err;
235
+ await cp(src, dst);
236
+ }
237
+ continue;
238
+ }
239
+ // Non-linked files and symlinks: same per-entry semantics as the
240
+ // full-copy path below.
241
+ await cp(src, dst, { errorOnExist: false });
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Materializes the seed folder into the (not-yet-existing) sandbox target.
116
247
  *
117
- * Symlinks in the seed are followed (`{ verbatimSymlinks: false }` is
118
- * Node's default). Permissions are preserved. The implementation uses
119
- * `fs.promises.cp` so it's a single syscall sequence per file with no
120
- * intermediate I/O on our side.
248
+ * Without a `seed.manifest.json` in the seed root this is a full recursive
249
+ * copy byte-for-byte the pre-manifest behavior. With a manifest, files
250
+ * matching its `link` globs are hardlinked instead of copied (see
251
+ * {@link SeedManifest}); the manifest file itself is skipped.
252
+ *
253
+ * Throws on any I/O failure — the caller is responsible for rolling back
254
+ * the just-created Letta conversation if this throws.
121
255
  *
122
256
  * @param seedDir - source folder (must exist).
123
257
  * @param targetDir - destination folder (must NOT exist).
@@ -129,6 +263,12 @@ export async function materializeSandbox(
129
263
  if (!existsSync(seedDir)) {
130
264
  throw new Error(`Seed folder does not exist on disk: ${seedDir}`);
131
265
  }
266
+ const manifest = readSeedManifest(seedDir);
267
+ if (manifest !== null) {
268
+ const linkMatchers = (manifest.link ?? []).map(globToRegExp);
269
+ await materializeWithManifest(seedDir, targetDir, "", linkMatchers);
270
+ return;
271
+ }
132
272
  // `recursive: true` + `errorOnExist: false` matches `cp -R` semantics —
133
273
  // if `targetDir` somehow already exists (impossible in the happy path
134
274
  // since session ids are fresh UUIDs, but cheap to be safe), the
@@ -1,65 +1,14 @@
1
1
  /**
2
2
  * Project-shaped Letta types.
3
3
  *
4
- * Re-exports only the SDK surfaces ai-hub uses, so the rest of the server
5
- * code never has to traverse `@letta-ai/letta-client`'s deep namespace
6
- * structure. **No raw HTTP, ever** — these are aliases over the SDK's own
7
- * types, not parallel inventions (§6.4).
8
- *
9
- * If the SDK rev removes or renames one of these, the typechecker flags it
10
- * at upgrade time and `letta/client.ts` is the single place to adapt.
11
- *
12
- * @see SPECIFICATIONS.md §6.4 — SDK-only access pattern
13
- * @see SPECIFICATIONS.md §6.4.1 — first-class surfaces we depend on
14
- */
15
-
16
- import type Letta from "@letta-ai/letta-client";
17
- import type {
18
- AssistantMessage,
19
- HiddenReasoningMessage,
20
- Message as LettaSdkMessage,
21
- ReasoningMessage,
22
- ToolCallMessage,
23
- UserMessage,
24
- } from "@letta-ai/letta-client/resources/agents/messages.js";
25
- import type { LettaStreamingResponse } from "@letta-ai/letta-client/resources/agents/messages.js";
26
- import type { ToolReturnMessage } from "@letta-ai/letta-client/resources/tools.js";
27
-
28
- /**
29
- * The underlying SDK client. We `import type` everywhere except in
30
- * `letta/client.ts` (where the constructor actually runs) so the rest of the
31
- * codebase stays SDK-version-agnostic in shape.
32
- */
33
- export type LettaSdk = Letta;
34
-
35
- /**
36
- * One streaming event from `client.conversations.messages.create({ streaming: true })`.
37
- *
38
- * The union spans every variant Letta emits in an SSE stream — assistant
39
- * text, reasoning, tool calls, tool returns, errors, pings, stop reasons,
40
- * usage stats. `letta/eventTranslator.ts` is the pure function from this
41
- * union to ai-hub's wire-protocol events (§9).
42
- *
43
- * @see SPECIFICATIONS.md §9 — wire protocol
44
- */
45
- export type LettaStreamEvent = LettaStreamingResponse;
46
-
47
- /**
48
- * Alias for `AssistantMessage` — used by the translator + tests. See
49
- * `letta/eventTranslator.ts` for the streamed-delta vs. cumulative-content
50
- * note.
4
+ * Post-migration (server-less local backend), the message shape comes from
5
+ * `@data-club/letta-runtime` — the fork's `messages list` subcommand prints
6
+ * the backend's Letta-format records verbatim, and `LettaMessageRecord`
7
+ * types the fields AI Hub's renderers actually read. The old
8
+ * `@letta-ai/letta-client` aliases are gone with the server.
51
9
  */
52
- export type LettaAssistantMessage = AssistantMessage;
53
- /** Alias for `ReasoningMessage` — see `eventTranslator.ts`. */
54
- export type LettaReasoningMessage = ReasoningMessage;
55
- /** Alias for `HiddenReasoningMessage` — see `eventTranslator.ts`. */
56
- export type LettaHiddenReasoningMessage = HiddenReasoningMessage;
57
- /** Alias for `ToolCallMessage` — see `eventTranslator.ts`. */
58
- export type LettaToolCallMessage = ToolCallMessage;
59
- /** Alias for `ToolReturnMessage` — see `eventTranslator.ts`. */
60
- export type LettaToolReturnMessage = ToolReturnMessage;
61
- /** Alias for `UserMessage` — only appears in history reads, not in streams. */
62
- export type LettaUserMessage = UserMessage;
63
10
 
64
- /** Full assembled Letta message (used by `messages.list`). */
65
- export type LettaMessage = LettaSdkMessage;
11
+ export type {
12
+ LettaMessageRecord as LettaMessage,
13
+ SDKMessage,
14
+ } from "@data-club/letta-runtime";
@@ -46,8 +46,6 @@ const REDACT_PATHS: ReadonlyArray<string> = [
46
46
  "*.Authorization",
47
47
  "service_token_signing_key",
48
48
  "serviceTokenSigningKey",
49
- "letta_api_key",
50
- "lettaApiKey",
51
49
  "metrics_scrape_token",
52
50
  "metricsScrapeToken",
53
51
  "encryption_key",
@@ -39,7 +39,6 @@ import {
39
39
  } from "../../handlers/responses.js";
40
40
  import type { AIHubServerContext, RouteHandler } from "../../handlers/types.js";
41
41
  import { seedDirExists, validateSeedName } from "../../letta/sandbox.js";
42
- import { seedAgentToolsetNone } from "../../letta/settingsFileSeed.js";
43
42
 
44
43
  /* ────────────────────────────────────────────────────────────────────────── */
45
44
  /* Helpers */
@@ -247,42 +246,6 @@ function respondSpecializedFailure(
247
246
  }
248
247
  }
249
248
 
250
- /**
251
- * Best-effort seed of `~/.letta/settings.json` with `{toolset: "none"}` for
252
- * the given Letta agent id. Idempotent. Logs the outcome but **does not
253
- * throw**: if seeding fails (read-only home dir, permission denied, …) the
254
- * agent creation should still succeed — the toolset suppression is then
255
- * a runtime concern the operator notices when chats start producing weird
256
- * file-edit tool calls. The intent here is hands-off automation, not a
257
- * required precondition.
258
- *
259
- * @param ctx - server context (for logging).
260
- * @param lettaAgentId - id to seed the toolset preference for.
261
- *
262
- * @see ../../letta/settingsFileSeed.ts
263
- */
264
- function seedToolsetNoneBestEffort(
265
- ctx: AIHubServerContext,
266
- lettaAgentId: string,
267
- ): void {
268
- try {
269
- seedAgentToolsetNone({
270
- agentId: lettaAgentId,
271
- lettaBaseUrl: process.env.LETTA_BASE_URL,
272
- logger: ctx.logger,
273
- });
274
- } catch (err) {
275
- ctx.logger.warn(
276
- {
277
- agent_id: lettaAgentId,
278
- err: err instanceof Error ? err.message : String(err),
279
- event: "letta_code_settings_seed_failed",
280
- },
281
- "Failed to seed Letta Code toolset preference; continuing anyway.",
282
- );
283
- }
284
- }
285
-
286
249
  /* ────────────────────────────────────────────────────────────────────────── */
287
250
  /* GET /admin/agents */
288
251
  /* ────────────────────────────────────────────────────────────────────────── */
@@ -415,12 +378,6 @@ export const handleCreateAgent: RouteHandler = protectedRoute(
415
378
  const row = ctx.db.select().from(agent).where(eq(agent.id, id)).get();
416
379
  if (!row) throw new Error("Just-inserted agent vanished.");
417
380
 
418
- // Seed `~/.letta/settings.json` so Letta Code's bundled CLI suppresses
419
- // its auto-toolset (Read/Write/Bash/etc.) when chat sessions spawn
420
- // against this agent. ai-hub wants only the tools the ADE admin
421
- // explicitly attached. Best-effort: a write failure logs + continues.
422
- seedToolsetNoneBestEffort(ctx, row.lettaAgentId);
423
-
424
381
  ctx.logger.info(
425
382
  {
426
383
  actor_id: auth.user.id,
@@ -553,12 +510,6 @@ export const handlePatchAgent: RouteHandler = protectedRoute(
553
510
  // remove the old agent's seeded record — it may still be backing live
554
511
  // sessions per the §11 schema note: existing sessions keep their
555
512
  // original `letta_conversation_id` against the original Letta agent.)
556
- if (
557
- body.letta_agent_id !== undefined &&
558
- body.letta_agent_id !== existing.lettaAgentId
559
- ) {
560
- seedToolsetNoneBestEffort(ctx, body.letta_agent_id);
561
- }
562
513
 
563
514
  ctx.logger.info(
564
515
  {
@@ -3,13 +3,10 @@
3
3
  *
4
4
  * Five endpoints:
5
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`.
6
+ * - `GET /admin/settings` — theme / language / agents settings plus
7
+ * metrics + signing-key rotation timestamps.
8
+ * - `PATCH /admin/settings` body `{ theme?: { default?, switcher_enabled? }, language?, agents? }`.
9
+ * Theme fields live as **separate columns**, not in `feature_toggles`.
13
10
  * - `GET /admin/settings/feature-toggles` — flat JSON blob from §8.4.
14
11
  * - `PATCH /admin/settings/feature-toggles` — merge-patch the same blob.
15
12
  * Unknown keys are accepted (forward-compat for future toggles).
@@ -36,7 +33,6 @@ import { randomBytes } from "node:crypto";
36
33
  import { eq } from "drizzle-orm";
37
34
  import { z } from "zod";
38
35
 
39
- import { encryptString } from "../../auth/encryption.js";
40
36
  import { protectedRoute } from "../../auth/middleware.js";
41
37
  import { adminSetting } from "../../db/schema.js";
42
38
  import type { AdminSettingRow } from "../../db/schema.js";
@@ -56,60 +52,6 @@ function nowIso(): string {
56
52
  return new Date().toISOString();
57
53
  }
58
54
 
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
55
  /**
114
56
  * Reads the singleton admin-settings row. Throws if the row hasn't been
115
57
  * seeded — should not happen after first-boot.
@@ -130,27 +72,8 @@ function readSettings(ctx: {
130
72
  * @param row - the admin_setting row.
131
73
  * @returns the JSON-safe response shape.
132
74
  */
133
- function settingsView(
134
- row: AdminSettingRow,
135
- envSource: Record<string, string | undefined>,
136
- ): Record<string, unknown> {
137
- const source = resolveApiKeySource(row, envSource);
75
+ function settingsView(row: AdminSettingRow): Record<string, unknown> {
138
76
  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
77
  theme: {
155
78
  default: row.themeDefault,
156
79
  switcher_enabled: row.themeSwitcherEnabled,
@@ -179,11 +102,6 @@ function settingsView(
179
102
  /**
180
103
  * `GET /admin/settings` — super_admin-only deployment configuration.
181
104
  *
182
- * Encrypted Letta API key surfaces only as the masked placeholder
183
- * (`letta_api_key_masked: "••••••••"`) + the boolean `letta_api_key_set`;
184
- * plaintext never leaves the server through this endpoint. Step 12 wires the
185
- * `Reveal once` flow as a separate UI affordance.
186
- *
187
105
  * @see SPECIFICATIONS.md §6.3.4
188
106
  * @see ui-sketches/08-admin-settings.md
189
107
  */
@@ -192,7 +110,7 @@ export const handleGetSettings: RouteHandler = protectedRoute(
192
110
  async (_req, ctx) => {
193
111
  const row = readSettings(ctx);
194
112
  if (!row) return internalError("Admin settings row is missing — server misconfigured.");
195
- return jsonResponse(200, settingsView(row, ctx.envSource));
113
+ return jsonResponse(200, settingsView(row));
196
114
  },
197
115
  );
198
116
 
@@ -203,16 +121,6 @@ export const handleGetSettings: RouteHandler = protectedRoute(
203
121
  /** Body for `PATCH /admin/settings`. Every field optional; empty body is a no-op. */
204
122
  const patchSettingsSchema = z
205
123
  .object({
206
- letta_base_url: z.string().url().nullable().optional(),
207
- letta_ade_url: z.string().url().nullable().optional(),
208
- // The API key accepts:
209
- // - a non-empty string → encrypt + store; runtime will use this DB
210
- // value over env on the next request.
211
- // - an empty/whitespace string → clear the DB column to NULL; the
212
- // runtime then falls back to env.LETTA_API_KEY (the operator-spec'd
213
- // "if admin panel is empty, use env" precedence).
214
- // - omitted → no change.
215
- letta_api_key: z.string().max(2048).optional(),
216
124
  theme: z
217
125
  .object({
218
126
  default: z.enum(["light", "dark"]).optional(),
@@ -241,9 +149,8 @@ const patchSettingsSchema = z
241
149
  /**
242
150
  * `PATCH /admin/settings` — partial update of deployment configuration.
243
151
  *
244
- * The Letta API key, when supplied, is encrypted under
245
- * `AI_HUB_ENCRYPTION_KEY` (env-only) before write. Theme fields live as
246
- * separate columns (`theme_default`, `theme_switcher_enabled`) per §8.4.
152
+ * Theme fields live as separate columns (`theme_default`,
153
+ * `theme_switcher_enabled`) per §8.4.
247
154
  *
248
155
  * @see SPECIFICATIONS.md §6.3.4
249
156
  * @see SPECIFICATIONS.md §8.4 — theme settings are explicit columns
@@ -264,17 +171,6 @@ export const handlePatchSettings: RouteHandler = protectedRoute(
264
171
  const body = parsed.data;
265
172
 
266
173
  const update: Partial<typeof adminSetting.$inferInsert> = { updatedAt: nowIso() };
267
- if (body.letta_base_url !== undefined) update.lettaBaseUrl = body.letta_base_url;
268
- if (body.letta_ade_url !== undefined) update.lettaAdeUrl = body.letta_ade_url;
269
- if (body.letta_api_key !== undefined) {
270
- // Empty / whitespace-only string clears the DB column to NULL. The
271
- // runtime then falls back to env.LETTA_API_KEY (see
272
- // letta/client.ts:resolveApiKey). Non-empty values are encrypted +
273
- // stored as the operator's override of env.
274
- const trimmed = body.letta_api_key.trim();
275
- update.lettaApiKeyEncrypted =
276
- trimmed.length === 0 ? null : encryptString(ctx.encryptionKey, body.letta_api_key);
277
- }
278
174
  if (body.theme?.default !== undefined) update.themeDefault = body.theme.default;
279
175
  if (body.theme?.switcher_enabled !== undefined)
280
176
  update.themeSwitcherEnabled = body.theme.switcher_enabled;
@@ -305,7 +201,7 @@ export const handlePatchSettings: RouteHandler = protectedRoute(
305
201
 
306
202
  const fresh = readSettings(ctx);
307
203
  if (!fresh) return internalError();
308
- return jsonResponse(200, settingsView(fresh, ctx.envSource));
204
+ return jsonResponse(200, settingsView(fresh));
309
205
  },
310
206
  );
311
207
 
@@ -384,10 +384,6 @@ export const handleLogout: RouteHandler = protectedRoute({}, async () => {
384
384
  /**
385
385
  * `GET /auth/me` — returns the `{ user, deployment }` envelope.
386
386
  *
387
- * `deployment.letta_ade_url` is included only for callers with role >=
388
- * `power_user` (handled inside `buildDeploymentView`); `user`-role callers
389
- * don't even see the field exists — by design (§6.3.4).
390
- *
391
387
  * The resolved theme is computed client-side from
392
388
  * `user.theme_preference + deployment.{theme_default, theme_switcher_enabled}`
393
389
  * per §7.6 — the server does not pre-compute it.
@@ -493,7 +489,7 @@ export const handleMePreferences: RouteHandler = protectedRoute(
493
489
  .get();
494
490
  return jsonResponse(200, {
495
491
  user: buildUserView(freshRow),
496
- deployment: buildDeploymentView(settings ?? null, freshRow.role),
492
+ deployment: buildDeploymentView(settings ?? null),
497
493
  });
498
494
  },
499
495
  );