merge: v1.1 AI schema work (tool calls, mentor table, citations, sealed MCP secrets)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -35,17 +35,30 @@ provider keeps each response's raw content blocks (thinking with signature, text
|
||||
and replays them unchanged. Proposal: `ChatMessage.toolCalls?: { id, name, input }[]` and an opaque
|
||||
`providerData?: unknown` the provider can round-trip.
|
||||
|
||||
**Settled in v1.1 (`feat/v11-ai`):** `ChatMessage.toolCalls?: ToolCall[]` and `providerData?: unknown` are in
|
||||
`@stc/contracts`, and providers may yield `{ providerData }` once per turn. The assistant loop appends one assistant
|
||||
message per model turn. Anthropic returns `{ anthropic: <raw blocks> }` and replays it verbatim (no in-memory map);
|
||||
OpenAI maps `toolCalls` to `tool_calls`.
|
||||
|
||||
## 4. Per-course mentor toggle in `ai_settings`
|
||||
|
||||
The frozen key is `(scope, owner_id)` with one `mentor_off` column, so a per-course toggle cannot be a row. The flag
|
||||
lives in the user row's `config_json.mentorOff[courseId]`; `mentor_off` stays 0 (reserved for "off everywhere").
|
||||
Proposal: add `course_id TEXT NOT NULL DEFAULT ''` to the key, or a small `ai_mentor_prefs(user_id, course_id, off)` table.
|
||||
|
||||
**Settled in v1.1:** table `ai_mentor_prefs(user_id, course_id, off, updated_at)` from
|
||||
`packages/feature-ai/migrations/0002_ai_mentor_prefs.sql`. `upgradeAiData` (src/upgrade.ts) copies v1
|
||||
`config_json.mentorOff` toggles once (marker `ai_data_v11` in `schema_migrations`).
|
||||
|
||||
## 5. Citations on `ai_messages`
|
||||
|
||||
`ai_messages` has no column for an assistant message's sources. They are stored as an extra row with role `sources`
|
||||
(JSON) right after the assistant row and folded back when reading. Proposal: `ai_messages.meta_json TEXT`.
|
||||
|
||||
**Settled in v1.1:** column `ai_messages.citations_json` from `0001_ai_message_citations.sql`, which also folds v1
|
||||
`sources` rows into the assistant row before them. (MCP `env`/`headers` are now encrypted at rest too, masked in the
|
||||
admin API.)
|
||||
|
||||
## 6. Derived text cache layout (shared with WP-B)
|
||||
|
||||
Retrieval reads the importer's layout:
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# STCourses v1.1 — Work package A: deferred AI contract requests 3–5
|
||||
|
||||
> **For agentic workers:** executed inline with superpowers:executing-plans and TDD. Steps use checkbox (`- [ ]`) syntax. One commit per task.
|
||||
|
||||
**Goal:** settle AI contract requests 3, 4 and 5 (`docs/contract-requests/ai.md`) and the v1 deferred minor "MCP `env`/`headers` in plaintext":
|
||||
- (3) structured tool calls on `ChatMessage`, with the Anthropic and OpenAI providers mapping them natively;
|
||||
- (4) per-course mentor toggles in their own table, migrated from `ai_settings.config_json`;
|
||||
- (5) a citations column on `ai_messages`, migrated from the v1 `sources` rows;
|
||||
- MCP `env` and `headers` encrypted at rest (same AES-GCM/HKDF box as BYOK keys) and only ever returned masked.
|
||||
|
||||
**Spec:** section 9.2. **Parent plan:** `docs/superpowers/plans/2026-10-30-v1-1.md` (Global Constraints, package A).
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Everything in `CLAUDE.md` "Project rules" and the v1.1 Global Constraints applies.
|
||||
- Every DB change is a new portable migration in `packages/feature-ai/migrations/` that runs unchanged on SQLite and Postgres, and upgrades a database already on the v1 schema. File names carry the `ai` prefix (core `migrate()` requires unique names across packages).
|
||||
- An upgrade test seeds v1 rows (core `0001_init.sql` only), runs the AI migrations and the data upgrade, and asserts the result, on SQLite and on Postgres (`STC_TEST_PG_URL`).
|
||||
- v1 safeguards that must keep holding (each already has tests; they stay green and unchanged in meaning):
|
||||
- BYOK keys only reach the official endpoints or allowlisted origins, without redirects;
|
||||
- error bodies from BYOK calls are never echoed to the user;
|
||||
- stdio MCP processes get PATH, HOME, LANG plus exactly the configured variables;
|
||||
- the solution guard works on canonical paths;
|
||||
- the banner text is exactly "Mentor mode off: I'll give full answers";
|
||||
- tests use fakes only, no network.
|
||||
- Gates per task: `bun test`, `bun run typecheck`, and the Postgres run with `STC_TEST_PG_URL=postgres://postgres:[email protected]:55433/postgres`.
|
||||
- Manual check: dev server on port 3493 with `STC_DATA_DIR=/tmp/stc-v11-ai`. Never port 3480 or `/home/user/STCourses/data`.
|
||||
|
||||
## Design decisions
|
||||
|
||||
1. **Schema vs data migrations.** Pure-SQL migrations cover what portable SQL can do: the new table, the new column, and folding v1 `sources` rows into `citations_json` (a correlated subquery that runs on both engines). Two upgrades cannot be portable SQL: reading `config_json.mentorOff` needs engine-specific JSON functions, and encrypting MCP secrets needs `STC_SECRET`. These run in code, in `upgradeAiData(db, box)`: one transaction, idempotent, recorded in `schema_migrations` under the name `ai_data_v11` (no `.sql` suffix, so it cannot clash with a file). The settings repo awaits it once (memoised; retried after a failure) before its first read or write, and `aiFeature` starts it at construction so plaintext does not linger until the first request.
|
||||
2. **Citations column name.** `ai_messages.citations_json TEXT NULL` (the parent plan asks for "a citations column"; a typed name beats a generic `meta_json`). v1 `sources` rows are folded into the assistant row immediately before them (by id order, the order v1 reads in) and then deleted.
|
||||
3. **Mentor table.** `ai_mentor_prefs (user_id, course_id, off INTEGER, updated_at, PK (user_id, course_id))`. A row exists only while the mentor is off (turning it back on deletes the row). `ai_settings.mentor_off` stays reserved for "off everywhere" and is not read. The upgrade copies every `mentorOff[courseId] === true` and removes the `mentorOff` key from the user's `config_json`.
|
||||
4. **Tool calls on `ChatMessage`.** Contract change in `@stc/contracts`:
|
||||
- `ToolCall = { id, name, input }`;
|
||||
- `ChatMessage.toolCalls?: ToolCall[]` (assistant turn) and `ChatMessage.providerData?: unknown` (opaque, round-tripped by the provider that produced it);
|
||||
- `AiProvider.chat` may also yield `{ providerData }` once per turn.
|
||||
The assistant loop appends one assistant message per model turn (`content` = the turn's text, `toolCalls`, `providerData`), then one `tool` message per call (`toolCallId`, `name`). The v1 one-message-per-call encoding (`assistant` + `toolCallId`) is removed.
|
||||
- Anthropic: `providerData = { anthropic: <raw content blocks> }` (thinking with signature, text, tool_use). An assistant message carrying it is replayed verbatim; otherwise it maps to text + `tool_use` blocks. The in-memory `rawByToolId` map goes away, so a provider instance holds no conversation state.
|
||||
- OpenAI: assistant `toolCalls` map to `tool_calls` (`function.arguments` = JSON of input) with `content` = text or null. No `providerData`.
|
||||
5. **MCP secrets at rest.** In `config_json` each MCP server keeps its non-secret fields; `env` and `headers` move into `secretCipher` = `box.encrypt(JSON.stringify({ env, headers }))`. The admin GET returns `env` and `headers` with the keys and masked values: `••••` plus the last 4 characters when the value has at least 12 characters, otherwise `••••••••`. On save, a posted value equal to the mask of the stored value for the same server id and key keeps the stored value; any other value starting with `••••` is refused with 400 ("re-enter the value"), so a mask is never stored as a secret. The MCP test endpoint resolves masks the same way. Only server code gets full values (`mcpServersWithSecrets()`). If the secret cannot be decrypted (changed `STC_SECRET`), the server is listed without `env`/`headers` and the client starts it without them.
|
||||
|
||||
## Tasks
|
||||
|
||||
### Task 1: AI migrations folder, test helper and citations column
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/feature-ai/migrations/0001_ai_message_citations.sql`
|
||||
- Modify: `packages/feature-ai/src/index.ts` (`migrationsDirs`), `packages/feature-ai/scripts/dev.ts` (migrate with the AI folder), `packages/feature-ai/src/feature.ts` (write/read `citations_json`, no more `sources` rows)
|
||||
- Test: `packages/feature-ai/test/upgrade.test.ts` (new), `test/helpers.ts` (`forEachAiDb`), `test/feature.test.ts`
|
||||
|
||||
- [ ] Write `upgrade.test.ts`: on a v1 database (core migrations only) seed a conversation with user, assistant, sources, user, assistant (no sources), and an orphan sources row; run `migrate(db, { "feature-ai": migrationsDirs })`; assert the first assistant row has the citations JSON, the second has NULL, no `sources` rows remain, and a second migrate applies nothing. Add a feature test that a stored assistant message has `citations_json` set and no `sources` row is written.
|
||||
- [ ] Run: fails (no migrations folder).
|
||||
- [ ] Implement the SQL (`ALTER TABLE ai_messages ADD COLUMN citations_json TEXT`; correlated `UPDATE` picking the next message by id when it is a `sources` row; `DELETE ... WHERE role = 'sources'`), export the folder, switch `feature.ts` to the column, switch AI tests that touch the DB to `forEachAiDb`.
|
||||
- [ ] Run tests on SQLite and Postgres; typecheck; commit `feat(ai): citations column on ai_messages (contract request 5)`.
|
||||
|
||||
### Task 2: Mentor preferences table and the data upgrade runner
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/feature-ai/migrations/0002_ai_mentor_prefs.sql`, `packages/feature-ai/src/upgrade.ts`
|
||||
- Modify: `packages/feature-ai/src/settings.ts`, `src/feature.ts`, `src/index.ts`
|
||||
- Test: `test/upgrade.test.ts`, `test/settings.test.ts`
|
||||
|
||||
- [ ] Tests: v1 user rows with `mentorOff: { sample: true, other: false }` plus a BYOK provider become one `ai_mentor_prefs` row (`u1`, `sample`) and a `config_json` without `mentorOff` but with `byok` intact; `isMentorOff` reads the table; running the upgrade twice changes nothing; a row with invalid JSON is left alone. Settings test: toggling writes and deletes table rows and never touches `config_json.mentorOff`.
|
||||
- [ ] Run: fails.
|
||||
- [ ] Implement table SQL, `upgradeAiData(db, box)` (mentor part plus the `ai_data_v11` marker), repo `ready()` memo, mentor methods on the table, `getUser().mentorOff` from the table.
|
||||
- [ ] Gates; commit `feat(ai): per-course mentor preferences table (contract request 4)`.
|
||||
|
||||
### Task 3: Encrypt MCP env and headers at rest, masked in the UI
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/settings.ts`, `src/upgrade.ts`, `src/feature.ts` (admin PUT 400 on stale masks, chat uses `mcpServersWithSecrets`, MCP test route resolves masks), `src/web/AdminAiPage.tsx` (one-line hint)
|
||||
- Test: `test/upgrade.test.ts`, `test/settings.test.ts`, `test/feature.test.ts`
|
||||
|
||||
- [ ] Tests: v1 server row with plaintext `env`/`headers` is encrypted by the upgrade (raw `config_json` no longer contains the values; `mcpServersWithSecrets()` returns them); `getServer()` returns masked values only; saving the masked values back keeps the stored secrets; saving a new value replaces it; a stray `••••` value is refused; the admin config API never contains a secret value; the MCP test route with masked values reaches the server with the real header (fake fetch).
|
||||
- [ ] Run: fails.
|
||||
- [ ] Implement.
|
||||
- [ ] Gates; commit `feat(ai): encrypt MCP env and headers at rest and mask them in the admin API`.
|
||||
|
||||
### Task 4: Structured tool calls on ChatMessage
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/contracts/src/ai.ts`, `packages/contracts/test/types.test.ts`, `packages/feature-ai/src/assistant/loop.ts`, `src/providers/anthropic.ts`, `src/providers/openai.ts`
|
||||
- Test: `test/anthropic.test.ts`, `test/openai.test.ts`, `test/assistant.test.ts`, `test/helpers.ts`
|
||||
|
||||
- [ ] Tests: the loop appends one assistant message with `toolCalls` (two calls in one turn) and the provider's `providerData`, then a tool message per call; Anthropic replays `providerData` blocks verbatim (thinking signature intact) and maps a message without it to text + `tool_use` blocks; a fresh provider instance replays history it did not produce (no in-memory state); OpenAI maps `toolCalls` to one assistant message with `tool_calls` and text content.
|
||||
- [ ] Run: fails.
|
||||
- [ ] Implement contract fields, provider mapping, loop change; remove `rawByToolId` and the legacy encoding.
|
||||
- [ ] Gates; commit `feat(ai): structured tool calls on ChatMessage (contract request 3)`.
|
||||
|
||||
### Task 5: Docs and manual check
|
||||
|
||||
- [ ] Mark requests 3–5 settled in `docs/contract-requests/ai.md`; update `settings.ts`/`index.ts` comments.
|
||||
- [ ] Dev server on 3493 with `STC_DATA_DIR=/tmp/stc-v11-ai`: migrations apply on a fresh DB, admin config shows masked MCP values.
|
||||
- [ ] Full gates; commit `docs(ai): contract requests 3-5 settled in v1.1`.
|
||||
@@ -1,3 +1,19 @@
|
||||
export interface ChatMessage { role: "system" | "user" | "assistant" | "tool"; content: string; toolCallId?: string; name?: string }
|
||||
/** One tool call the model asked for in an assistant turn. */
|
||||
export interface ToolCall { id: string; name: string; input: unknown }
|
||||
/**
|
||||
* A chat message. An assistant turn that calls tools carries them in `toolCalls` (v1.1, contract request 3), with
|
||||
* its text in `content`; each result follows as a `tool` message with `toolCallId` (and `name`).
|
||||
* `providerData` is opaque: the provider that produced the turn yields it and gets it back unchanged (for example
|
||||
* Anthropic's raw content blocks with thinking signatures). Other providers ignore it.
|
||||
*/
|
||||
export interface ChatMessage {
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
toolCallId?: string;
|
||||
name?: string;
|
||||
toolCalls?: ToolCall[];
|
||||
providerData?: unknown;
|
||||
}
|
||||
export interface ToolSpec { name: string; description: string; inputSchema: Record<string, unknown> }
|
||||
export interface AiProvider { id: string; chat(messages: ChatMessage[], opts: { tools?: ToolSpec[]; signal?: AbortSignal }): AsyncIterable<{ text?: string; toolCall?: { id: string; name: string; input: unknown } }> }
|
||||
/** `chat` streams text and tool calls, and may yield `providerData` once per turn for the assistant message. */
|
||||
export interface AiProvider { id: string; chat(messages: ChatMessage[], opts: { tools?: ToolSpec[]; signal?: AbortSignal }): AsyncIterable<{ text?: string; toolCall?: ToolCall; providerData?: unknown }> }
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import type {
|
||||
AiProvider, AppConfig, AuthService, AuthUser, Assert, ChatMessage, ExerciseRunner, ExerciseTest,
|
||||
AiProvider, AppConfig, AuthService, AuthUser, Assert, ChatMessage, ToolCall, ExerciseRunner, ExerciseTest,
|
||||
Feature, ImportResult, ImportSource, JobContext, JobHandler, JobQueue, JobRecord, RequestContext,
|
||||
RouteHandler, ServerContext, TestResult, ToolSpec, WebFeature, WebRoute,
|
||||
} from "../src/index";
|
||||
@@ -38,10 +38,12 @@ test("contract interfaces accept well-formed literals", async () => {
|
||||
run: async () => [{ name: "t", passed: true } satisfies TestResult],
|
||||
};
|
||||
const msg: ChatMessage = { role: "user", content: "hi" };
|
||||
const call: ToolCall = { id: "1", name: "x", input: {} };
|
||||
const turn: ChatMessage = { role: "assistant", content: "", toolCalls: [call], providerData: { any: "thing" } };
|
||||
const tool: ToolSpec = { name: "x", description: "d", inputSchema: {} };
|
||||
const provider: AiProvider = {
|
||||
id: "p",
|
||||
async *chat() { yield { text: "hi" }; yield { toolCall: { id: "1", name: "x", input: {} } }; },
|
||||
async *chat() { yield { text: "hi" }; yield { toolCall: { id: "1", name: "x", input: {} } }; yield { providerData: [] }; },
|
||||
};
|
||||
const source: ImportSource = { kind: "pdf-deck", path: "/tmp/a.pdf", options: {} };
|
||||
const result: ImportResult = { courseId: "c", manifestPath: "/tmp/stcourse.yaml", warnings: [] };
|
||||
@@ -53,5 +55,5 @@ test("contract interfaces accept well-formed literals", async () => {
|
||||
expect(typeof feature.routes).toBe("object");
|
||||
expect(await queue.enqueue("noop", {})).toBe("j1");
|
||||
expect((await runner.run("", "", 1000))[0]!.passed).toBe(true);
|
||||
void [rc, jc, ctx, assert, t, msg, tool, provider, source, result, web];
|
||||
void [rc, jc, ctx, assert, t, msg, turn, tool, provider, source, result, web];
|
||||
});
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
-- v1.1 (contract request 5): an assistant message's citations live in its own column.
|
||||
-- v1 stored them as an extra row with role 'sources' right after the assistant row (ids sort in write order).
|
||||
-- Fold each such row into the assistant row before it, then drop every 'sources' row.
|
||||
ALTER TABLE ai_messages ADD COLUMN citations_json TEXT;
|
||||
UPDATE ai_messages SET citations_json = (
|
||||
SELECT s.content FROM ai_messages s
|
||||
WHERE s.conversation_id = ai_messages.conversation_id AND s.role = 'sources'
|
||||
AND s.id = (SELECT MIN(n.id) FROM ai_messages n WHERE n.conversation_id = ai_messages.conversation_id AND n.id > ai_messages.id)
|
||||
) WHERE role = 'assistant';
|
||||
DELETE FROM ai_messages WHERE role = 'sources';
|
||||
@@ -0,0 +1,7 @@
|
||||
-- v1.1 (contract request 4): per-course mentor toggles get their own table. A row exists only while the learner
|
||||
-- has switched the course's mentor rules off. v1 kept them in ai_settings.config_json.mentorOff; reading that JSON
|
||||
-- is not portable SQL, so upgradeAiData (src/upgrade.ts) copies it on first start.
|
||||
CREATE TABLE ai_mentor_prefs (
|
||||
user_id TEXT NOT NULL, course_id TEXT NOT NULL, off INTEGER NOT NULL, updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, course_id)
|
||||
);
|
||||
@@ -7,6 +7,7 @@ import { createCourseRepository } from "@stc/course-format";
|
||||
import { connect, migrate } from "@stc/db";
|
||||
import indexHtml from "../../../apps/web/index.html";
|
||||
import { aiFeature } from "../src/feature";
|
||||
import { migrationsDirs } from "../src/migrations";
|
||||
|
||||
const config = loadConfig();
|
||||
const who = process.env.STC_AI_DEV_USER ?? "owner";
|
||||
@@ -32,7 +33,7 @@ const fake: AiProvider = {
|
||||
};
|
||||
|
||||
const db = await connect(config.databaseUrl);
|
||||
await migrate(db);
|
||||
await migrate(db, { "feature-ai": migrationsDirs });
|
||||
const courses = await createCourseRepository(config.courseDirs);
|
||||
const ctx = { config, db, courses, auth } as ServerContext;
|
||||
const feature = aiFeature(ctx, process.env.STC_AI_DEV_FAKE ? { providerFactory: () => fake } : {});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AiProvider, ChatMessage } from "@stc/contracts";
|
||||
import type { AiProvider, ChatMessage, ToolCall } from "@stc/contracts";
|
||||
import type { ToolSet } from "./tools";
|
||||
|
||||
export type AssistantEvent =
|
||||
@@ -9,7 +9,8 @@ export type AssistantEvent =
|
||||
|
||||
/**
|
||||
* Runs the model with tools until it answers without calling a tool, or until maxSteps model calls.
|
||||
* Mutates `messages` by appending the tool round trips, so callers can inspect them.
|
||||
* Mutates `messages` by appending the tool round trips (an assistant message with `toolCalls`, then one `tool`
|
||||
* message per call), so callers can inspect them.
|
||||
*/
|
||||
export async function* runAssistant(o: {
|
||||
provider: AiProvider;
|
||||
@@ -22,11 +23,13 @@ export async function* runAssistant(o: {
|
||||
let answer = "";
|
||||
for (let step = 0; step < maxSteps; step++) {
|
||||
let text = "";
|
||||
const calls: { id: string; name: string; input: unknown }[] = [];
|
||||
const calls: ToolCall[] = [];
|
||||
let providerData: unknown;
|
||||
try {
|
||||
for await (const ev of o.provider.chat(o.messages, { tools: o.tools.specs, signal: o.signal })) {
|
||||
if (ev.text) { text += ev.text; yield { type: "text", text: ev.text }; }
|
||||
if (ev.toolCall) calls.push(ev.toolCall);
|
||||
if (ev.providerData !== undefined) providerData = ev.providerData;
|
||||
}
|
||||
} catch (e) {
|
||||
yield { type: "error", message: (e as Error).message };
|
||||
@@ -37,8 +40,10 @@ export async function* runAssistant(o: {
|
||||
yield { type: "done", text: answer };
|
||||
return;
|
||||
}
|
||||
if (text) o.messages.push({ role: "assistant", content: text });
|
||||
for (const c of calls) o.messages.push({ role: "assistant", content: JSON.stringify(c.input ?? {}), toolCallId: c.id, name: c.name });
|
||||
// One assistant message per model turn: its text, every tool call, and the provider's opaque data.
|
||||
const turn: ChatMessage = { role: "assistant", content: text, toolCalls: calls };
|
||||
if (providerData !== undefined) turn.providerData = providerData;
|
||||
o.messages.push(turn);
|
||||
for (const c of calls) {
|
||||
yield { type: "tool", name: c.name };
|
||||
let result: string;
|
||||
|
||||
@@ -1,41 +1,82 @@
|
||||
/**
|
||||
* Secret box for API keys at rest: AES-GCM-256 with a key derived by HKDF-SHA-256 from STC_SECRET.
|
||||
* Token format: `v1.<base64 iv>.<base64 ciphertext+tag>`.
|
||||
* Secret box for secrets at rest (API keys, MCP env and headers): AES-GCM-256 with keys derived by HKDF-SHA-256
|
||||
* from STC_SECRET.
|
||||
*
|
||||
* - `v2.<base64 iv>.<base64 ciphertext+tag>` (v1.1): each purpose has its own HKDF key (`info` =
|
||||
* `stcourses/<purpose>/v2`), and the AES-GCM additional data binds the token to the row it belongs to (for
|
||||
* example the user id and provider of a BYOK key, or the MCP server id and field). A token copied to another
|
||||
* row or purpose does not open, so the server cannot be used to decrypt one secret as another.
|
||||
* - `v1.<iv>.<ct>` (v1): one key for everything (info `aes-gcm-256/v1`), no additional data. Only the v1.1 data
|
||||
* upgrade opens these, to re-seal them as v2 (`openLegacy`); `open` refuses them.
|
||||
*/
|
||||
export type SecretPurpose = "byok" | "server-api-key" | "mcp";
|
||||
|
||||
export interface SecretBox {
|
||||
encrypt(plain: string): Promise<string>;
|
||||
decrypt(token: string): Promise<string>;
|
||||
seal(plain: string, purpose: SecretPurpose, aad: string): Promise<string>;
|
||||
open(token: string, purpose: SecretPurpose, aad: string): Promise<string>;
|
||||
/** v1 tokens only; for the data upgrade. */
|
||||
openLegacy(token: string): Promise<string>;
|
||||
}
|
||||
|
||||
const enc = new TextEncoder();
|
||||
const dec = new TextDecoder();
|
||||
const b64 = (u: Uint8Array) => Buffer.from(u).toString("base64");
|
||||
const unb64 = (s: string) => new Uint8Array(Buffer.from(s, "base64"));
|
||||
const unb64 = (s: string): Uint8Array<ArrayBuffer> => new Uint8Array(Buffer.from(s, "base64"));
|
||||
const SALT = enc.encode("stcourses/byok");
|
||||
|
||||
export const isLegacyToken = (token: string): boolean => token.startsWith("v1.");
|
||||
|
||||
function deriveKey(secret: string, info: string): Promise<CryptoKey> {
|
||||
return (async () => {
|
||||
const ikm = await crypto.subtle.importKey("raw", enc.encode(secret), "HKDF", false, ["deriveKey"]);
|
||||
return crypto.subtle.deriveKey({ name: "HKDF", hash: "SHA-256", salt: SALT, info: enc.encode(info) }, ikm, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]);
|
||||
})();
|
||||
}
|
||||
|
||||
function parseToken(token: string, version: "v1" | "v2"): { iv: Uint8Array<ArrayBuffer>; ct: Uint8Array<ArrayBuffer> } {
|
||||
const [v, iv, ct, extra] = token.split(".");
|
||||
if (v !== version || !iv || !ct || extra !== undefined) throw new Error("Unrecognised secret format");
|
||||
return { iv: unb64(iv), ct: unb64(ct) };
|
||||
}
|
||||
|
||||
/** Additional data: purpose and row identity, length-prefixed so no two (purpose, aad) pairs encode alike. */
|
||||
const additionalData = (purpose: SecretPurpose, aad: string) => enc.encode(`stc/${purpose.length}:${purpose}/${aad.length}:${aad}`);
|
||||
|
||||
export function createSecretBox(secret: string): SecretBox {
|
||||
let keyP: Promise<CryptoKey> | null = null;
|
||||
const key = () =>
|
||||
(keyP ??= (async () => {
|
||||
const ikm = await crypto.subtle.importKey("raw", enc.encode(secret), "HKDF", false, ["deriveKey"]);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: "HKDF", hash: "SHA-256", salt: enc.encode("stcourses/byok"), info: enc.encode("aes-gcm-256/v1") },
|
||||
ikm,
|
||||
{ name: "AES-GCM", length: 256 },
|
||||
false,
|
||||
["encrypt", "decrypt"],
|
||||
);
|
||||
})());
|
||||
const keys = new Map<string, Promise<CryptoKey>>();
|
||||
const key = (info: string) => {
|
||||
let k = keys.get(info);
|
||||
if (!k) keys.set(info, (k = deriveKey(secret, info)));
|
||||
return k;
|
||||
};
|
||||
return {
|
||||
async encrypt(plain) {
|
||||
async seal(plain, purpose, aad) {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, await key(), enc.encode(plain)));
|
||||
return `v1.${b64(iv)}.${b64(ct)}`;
|
||||
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: additionalData(purpose, aad) }, await key(`stcourses/${purpose}/v2`), enc.encode(plain)));
|
||||
return `v2.${b64(iv)}.${b64(ct)}`;
|
||||
},
|
||||
async decrypt(token) {
|
||||
const [v, iv, ct] = token.split(".");
|
||||
if (v !== "v1" || !iv || !ct) throw new Error("Unrecognised secret format");
|
||||
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: unb64(iv) }, await key(), unb64(ct));
|
||||
async open(token, purpose, aad) {
|
||||
const { iv, ct } = parseToken(token, "v2");
|
||||
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv, additionalData: additionalData(purpose, aad) }, await key(`stcourses/${purpose}/v2`), ct);
|
||||
return dec.decode(plain);
|
||||
},
|
||||
async openLegacy(token) {
|
||||
const { iv, ct } = parseToken(token, "v1");
|
||||
return dec.decode(await crypto.subtle.decrypt({ name: "AES-GCM", iv }, await key("aes-gcm-256/v1"), ct));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The v1 token format, exactly as v1 wrote it. Only upgrade tests use it, to build v1 data. */
|
||||
export async function legacyV1Encrypt(secret: string, plain: string): Promise<string> {
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, await deriveKey(secret, "aes-gcm-256/v1"), enc.encode(plain)));
|
||||
return `v1.${b64(iv)}.${b64(ct)}`;
|
||||
}
|
||||
|
||||
/** AAD for a user's own key: user id and provider kind (the user_api_keys row). */
|
||||
export const byokAad = (userId: string, provider: string) => `${userId}\n${provider}`;
|
||||
/** AAD for the admin's provider key (one per server). */
|
||||
export const SERVER_KEY_AAD = "server";
|
||||
/** AAD for an MCP server's env or headers: server id and field. */
|
||||
export const mcpAad = (serverId: string, field: "env" | "headers") => `${serverId}\n${field}`;
|
||||
|
||||
@@ -14,6 +14,7 @@ import { connectMcp, mcpServersFor } from "./mcp/client";
|
||||
import { createProvider } from "./providers";
|
||||
import { PRESET_BASE_URLS, byokBaseUrl, noRedirectFetch, parseAllowedOrigins, publicProviderError, sanitizeProviderErrors } from "./endpoints";
|
||||
import { getCourseIndex, noteChunks } from "./retrieval/course-index";
|
||||
import { MaskedSecretError } from "./mcp/secrets";
|
||||
import { aiSettingsRepo, type McpServerConfig, type ProviderConfig } from "./settings";
|
||||
|
||||
export interface AiFeatureOptions {
|
||||
@@ -90,6 +91,10 @@ function parseMcp(v: unknown): McpServerConfig[] | "invalid" {
|
||||
if (s.headers && typeof s.headers === "object") cfg.headers = Object.fromEntries(Object.entries(s.headers).map(([k, val]) => [k, String(val)]));
|
||||
}
|
||||
if (Array.isArray(s.courseIds)) cfg.courseIds = s.courseIds.map(String).filter(Boolean);
|
||||
if (s.clearSecrets !== undefined) {
|
||||
if (!Array.isArray(s.clearSecrets) || !s.clearSecrets.every((f) => f === "env" || f === "headers")) return "invalid";
|
||||
if (s.clearSecrets.length) cfg.clearSecrets = [...new Set(s.clearSecrets as ("env" | "headers")[])];
|
||||
}
|
||||
out.push(cfg);
|
||||
}
|
||||
return out;
|
||||
@@ -145,9 +150,17 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
|
||||
const progress = progressRepo(ctx.db);
|
||||
const sink = opts.draftSink ?? createFsDraftSink(ctx.config.dataDir);
|
||||
const log = opts.log ?? ((m: string) => console.error(`[ai] ${m}`));
|
||||
// The v1 data upgrade (mentor toggles, MCP secrets) starts now; every settings call also waits for it.
|
||||
// Unreadable secrets are kept, never dropped; say so once at startup (the admin page shows the details).
|
||||
settings.ready()
|
||||
.then(() => settings.getServer())
|
||||
.then((s) => {
|
||||
if (s.unreadableSecrets.length) log(`${s.unreadableSecrets.length} stored AI secret(s) cannot be decrypted with the current STC_SECRET; they are kept until re-entered or the previous secret is restored`);
|
||||
})
|
||||
.catch((e) => log(`v1.1 data upgrade failed (retried on next use): ${(e as Error).message}`));
|
||||
// AppConfig.aiAllowedBaseUrls (STC_AI_ALLOWED_BASE_URLS) plus the admin's list in the server AI settings.
|
||||
const configOrigins = parseAllowedOrigins((ctx.config.aiAllowedBaseUrls ?? []).join(","));
|
||||
const allowedOrigins = async () => [...new Set([...configOrigins, ...(await settings.getServer()).allowedBaseUrls])];
|
||||
const allowedOrigins = async () => [...new Set([...configOrigins, ...(await settings.allowedBaseUrls())])];
|
||||
/**
|
||||
* Providers for a user's own key never follow redirects, and their errors reach the user only as the HTTP
|
||||
* status plus a fixed sentence (the full text is logged here). Server-scope providers are the admin's own.
|
||||
@@ -169,12 +182,12 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
|
||||
if (key && base) return { source: "byok", cfg: { ...u.byok, baseUrl: base }, key };
|
||||
}
|
||||
}
|
||||
const s = await settings.getServer();
|
||||
if (!s.provider) return null;
|
||||
if (s.provider.kind === "anthropic" && !s.provider.hasApiKey) return null;
|
||||
const key = s.provider.hasApiKey ? await settings.serverApiKey().catch(() => null) : null;
|
||||
if (s.provider.kind === "anthropic" && !key) return null;
|
||||
return { source: "server", cfg: s.provider, key };
|
||||
const provider = await settings.serverProvider();
|
||||
if (!provider) return null;
|
||||
if (provider.kind === "anthropic" && !provider.hasApiKey) return null;
|
||||
const key = provider.hasApiKey ? await settings.serverApiKey().catch(() => null) : null;
|
||||
if (provider.kind === "anthropic" && !key) return null;
|
||||
return { source: "server", cfg: provider, key };
|
||||
};
|
||||
|
||||
const viewable = async (user: AuthUser | null, courseId: string): Promise<LoadedCourse | null> => {
|
||||
@@ -198,22 +211,23 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
|
||||
const rows = await ctx.db.sql`SELECT id, user_id, course_id, title, created_at FROM ai_conversations WHERE id = ${id} AND user_id = ${userId}`;
|
||||
return rows[0] as { id: string; user_id: string; course_id: string; title: string | null; created_at: string } | undefined;
|
||||
};
|
||||
// Roles: "user", "assistant", and "sources" (JSON citations for the assistant message before it).
|
||||
const addMessage = async (conversationId: string, role: "user" | "assistant" | "sources", content: string) => {
|
||||
await ctx.db.sql`INSERT INTO ai_messages (id, conversation_id, role, content, created_at) VALUES (${Bun.randomUUIDv7()}, ${conversationId}, ${role}, ${content}, ${new Date().toISOString()})`;
|
||||
/** Roles "user" and "assistant"; an assistant message's citations go in `citations_json` (contract request 5). */
|
||||
const addMessage = async (conversationId: string, role: "user" | "assistant", content: string, citations?: unknown[]) => {
|
||||
const cites = citations ? JSON.stringify(citations) : null;
|
||||
await ctx.db.sql`INSERT INTO ai_messages (id, conversation_id, role, content, citations_json, created_at) VALUES (${Bun.randomUUIDv7()}, ${conversationId}, ${role}, ${content}, ${cites}, ${new Date().toISOString()})`;
|
||||
};
|
||||
const messagesOf = async (conversationId: string) =>
|
||||
(await ctx.db.sql`SELECT id, role, content, created_at FROM ai_messages WHERE conversation_id = ${conversationId}`)
|
||||
.map((r: { id: string; role: string; content: string; created_at: string }) => ({ id: r.id, role: r.role, content: r.content, createdAt: r.created_at }))
|
||||
.sort((a: { id: string }, b: { id: string }) => (a.id < b.id ? -1 : 1)) as { id: string; role: "user" | "assistant" | "sources"; content: string; createdAt: string }[];
|
||||
/** Chat turns with each assistant message's stored citations attached. */
|
||||
/** Chat turns in write order (UUIDv7 ids), each assistant message with its stored citations. */
|
||||
const turnsOf = async (conversationId: string) => {
|
||||
const rows = (await ctx.db.sql`SELECT id, role, content, citations_json, created_at FROM ai_messages WHERE conversation_id = ${conversationId}`) as
|
||||
{ id: string; role: string; content: string; citations_json: string | null; created_at: string }[];
|
||||
const out: { id: string; role: "user" | "assistant"; content: string; createdAt: string; citations?: unknown[] }[] = [];
|
||||
for (const m of await messagesOf(conversationId)) {
|
||||
if (m.role === "sources") {
|
||||
const last = out[out.length - 1];
|
||||
if (last?.role === "assistant") { try { last.citations = JSON.parse(m.content); } catch {} }
|
||||
} else out.push({ ...m, role: m.role });
|
||||
for (const r of rows.sort((a, b) => (a.id < b.id ? -1 : 1))) {
|
||||
if (r.role !== "user" && r.role !== "assistant") continue;
|
||||
const turn: (typeof out)[number] = { id: r.id, role: r.role, content: r.content, createdAt: r.created_at };
|
||||
if (r.role === "assistant" && r.citations_json) {
|
||||
try { const c = JSON.parse(r.citations_json); if (Array.isArray(c)) turn.citations = c; } catch {}
|
||||
}
|
||||
out.push(turn);
|
||||
}
|
||||
return out;
|
||||
};
|
||||
@@ -264,7 +278,7 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
|
||||
let tools: ToolSet | null = null;
|
||||
try {
|
||||
const course_ = courseTools({ repo: ctx.courses, course, index, listNotes: (lid) => notes.list(user.id, course.id, lid), getProgress: () => progress.get(user.id, course.id) });
|
||||
const servers = mcpServersFor(course.id, (await settings.getServer()).mcpServers);
|
||||
const servers = mcpServersFor(course.id, await settings.mcpServersWithSecrets());
|
||||
const mcp = servers.length ? await mcpTools(servers, { fetch: opts.fetch, timeoutMs: opts.mcpTimeoutMs }) : null;
|
||||
for (const w of mcp?.warnings ?? []) send({ type: "notice", message: w });
|
||||
tools = combineTools(mcp ? [course_, mcp] : [course_]);
|
||||
@@ -282,10 +296,7 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
|
||||
send({ type: "done", text: ev.text, citations: cited });
|
||||
} else send(ev);
|
||||
}
|
||||
if (answer) {
|
||||
await addMessage(convId, "assistant", answer);
|
||||
await addMessage(convId, "sources", JSON.stringify(cited));
|
||||
}
|
||||
if (answer) await addMessage(convId, "assistant", answer, cited);
|
||||
} catch (e) {
|
||||
const m = (e as Error).message;
|
||||
if (resolved.source === "byok") log(`chat error for user ${user.id} (own key): ${m}`);
|
||||
@@ -344,7 +355,12 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
|
||||
allowedBaseUrls = parseAllowedOrigins(list);
|
||||
if (allowedBaseUrls.length !== new Set(list.map((x) => x.replace(/\/+$/, ""))).size) return err(400, "Each allowed base URL must be an origin such as http://ollama.internal:11434.");
|
||||
}
|
||||
await settings.saveServer({ provider, mcpServers, allowedBaseUrls });
|
||||
try {
|
||||
await settings.saveServer({ provider, mcpServers, allowedBaseUrls });
|
||||
} catch (e) {
|
||||
if (e instanceof MaskedSecretError) return err(400, e.message);
|
||||
throw e;
|
||||
}
|
||||
return json(await settings.getServer());
|
||||
},
|
||||
},
|
||||
@@ -355,8 +371,13 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
|
||||
const b = await body(req);
|
||||
const parsed = parseMcp(b ? [b] : null);
|
||||
if (parsed === "invalid" || !parsed.length) return err(400, "Invalid MCP server.");
|
||||
let cfg: McpServerConfig;
|
||||
try { cfg = await settings.resolveMcpMasks(parsed[0]!); } catch (e) {
|
||||
if (e instanceof MaskedSecretError) return err(400, e.message);
|
||||
throw e;
|
||||
}
|
||||
try {
|
||||
const c = await connectMcp(parsed[0]!, { fetch: opts.fetch, timeoutMs: opts.mcpTimeoutMs ?? 15_000 });
|
||||
const c = await connectMcp(cfg, { fetch: opts.fetch, timeoutMs: opts.mcpTimeoutMs ?? 15_000 });
|
||||
try { return json({ ok: true, tools: (await c.tools()).map((t) => ({ name: t.name, description: t.description })) }); } finally { await c.close(); }
|
||||
} catch (e) {
|
||||
return json({ ok: false, error: (e as Error).message });
|
||||
@@ -367,8 +388,8 @@ export function aiFeature(ctx: ServerContext, opts: AiFeatureOptions = {}): Feat
|
||||
GET: async (_req, rc) => {
|
||||
if (!rc.user) return err(401, "Sign in first.");
|
||||
const u = await settings.getUser(rc.user.id);
|
||||
const s = await settings.getServer();
|
||||
return json({ byok: u.byok, serverProvider: s.provider ? { kind: s.provider.kind, model: s.provider.model ?? null } : null });
|
||||
const p = await settings.serverProvider();
|
||||
return json({ byok: u.byok, serverProvider: p ? { kind: p.kind, model: p.model ?? null } : null });
|
||||
},
|
||||
},
|
||||
"/api/ai/account/key": {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
export { createSecretBox, type SecretBox } from "./crypto";
|
||||
|
||||
/** Migration folders for this package. Empty: the AI package uses the ai_* tables from 0001_init.sql. */
|
||||
export const migrationsDirs: string[] = [];
|
||||
export { migrationsDirs } from "./migrations";
|
||||
export * from "./settings";
|
||||
export { upgradeAiData, AI_DATA_UPGRADE, AI_SECRETS_UPGRADE } from "./upgrade";
|
||||
export * from "./providers";
|
||||
export * from "./retrieval/course-index";
|
||||
export { Bm25, tokenize } from "./retrieval/bm25";
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { mcpAad, type SecretBox } from "../crypto";
|
||||
import type { McpServerConfig } from "../settings";
|
||||
|
||||
/**
|
||||
* MCP `env` and `headers` at rest: each field is sealed on its own (`envCipher`, `headersCipher`) with the "mcp"
|
||||
* purpose key and the server id plus field name as additional data, so a cipher moved to another server, field or
|
||||
* table does not open. The admin API only ever sees masked values; server code opens them to start or call a server.
|
||||
*
|
||||
* Older shapes, re-sealed by the data upgrade (src/upgrade.ts): plaintext `env`/`headers` (v1) and one v1-token
|
||||
* `secretCipher` holding `{ env, headers }` (the first v1.1 draft).
|
||||
*/
|
||||
export type SecretField = "env" | "headers";
|
||||
export const SECRET_FIELDS: SecretField[] = ["env", "headers"];
|
||||
const CIPHER: Record<SecretField, "envCipher" | "headersCipher"> = { env: "envCipher", headers: "headersCipher" };
|
||||
|
||||
export type StoredMcpServer = Omit<McpServerConfig, "env" | "headers" | "clearSecrets"> & {
|
||||
envCipher?: string;
|
||||
headersCipher?: string;
|
||||
/** First v1.1 draft: v1 token of `{ env, headers }`. */
|
||||
secretCipher?: string;
|
||||
/** v1: plaintext. */
|
||||
env?: Record<string, string>;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
export interface OpenedMcpServer {
|
||||
cfg: McpServerConfig;
|
||||
/** Fields with a stored cipher that cannot be opened (STC_SECRET changed, or a cipher moved from elsewhere). */
|
||||
unreadable: SecretField[];
|
||||
}
|
||||
|
||||
export const MASK_PREFIX = "••••";
|
||||
|
||||
/** `••••` plus the last 4 characters for values of 12 characters or more, otherwise `••••••••`. */
|
||||
export function maskSecret(value: string): string {
|
||||
return value.length >= 12 ? `${MASK_PREFIX}${value.slice(-4)}` : `${MASK_PREFIX}${MASK_PREFIX}`;
|
||||
}
|
||||
|
||||
/** A posted value looks like a mask but matches no stored value for that server and key. */
|
||||
export class MaskedSecretError extends Error {
|
||||
constructor(serverId: string, key: string) {
|
||||
super(`Re-enter the value of ${key} for MCP server "${serverId}": a masked value cannot be saved.`);
|
||||
this.name = "MaskedSecretError";
|
||||
}
|
||||
}
|
||||
|
||||
const nonEmpty = (r: Record<string, string> | undefined) =>
|
||||
(r && typeof r === "object" && !Array.isArray(r) && Object.keys(r).length ? r : undefined);
|
||||
|
||||
const isRecordOfStrings = (v: unknown): v is Record<string, string> =>
|
||||
!!v && typeof v === "object" && !Array.isArray(v) && Object.values(v).every((x) => typeof x === "string");
|
||||
|
||||
/** The non-secret part of a server config (also drops `clearSecrets`, which is a save instruction only). */
|
||||
function baseOf(s: McpServerConfig | StoredMcpServer): Omit<StoredMcpServer, "envCipher" | "headersCipher" | "secretCipher" | "env" | "headers"> {
|
||||
const { env: _e, headers: _h, envCipher: _ec, headersCipher: _hc, secretCipher: _sc, clearSecrets: _cs, ...rest } = s as StoredMcpServer & McpServerConfig;
|
||||
return rest;
|
||||
}
|
||||
|
||||
/** Opens a stored server for server code. Unreadable ciphers are reported, never thrown and never discarded. */
|
||||
export async function openMcpServer(box: SecretBox, s: StoredMcpServer): Promise<OpenedMcpServer> {
|
||||
const cfg: McpServerConfig = { ...baseOf(s) };
|
||||
const unreadable: SecretField[] = [];
|
||||
for (const f of SECRET_FIELDS) {
|
||||
const token = s[CIPHER[f]];
|
||||
if (token) {
|
||||
try {
|
||||
const v = JSON.parse(await box.open(token, "mcp", mcpAad(s.id, f)));
|
||||
if (!isRecordOfStrings(v)) throw new Error("not a record");
|
||||
if (nonEmpty(v)) cfg[f] = v;
|
||||
} catch { unreadable.push(f); }
|
||||
} else if (nonEmpty(s[f])) cfg[f] = s[f]; // v1 plaintext not yet upgraded
|
||||
}
|
||||
// A draft-format cipher the upgrade could not open: report the field this transport uses.
|
||||
if (s.secretCipher) {
|
||||
const f: SecretField = s.transport === "stdio" ? "env" : "headers";
|
||||
if (!s[CIPHER[f]] && !unreadable.includes(f)) unreadable.push(f);
|
||||
}
|
||||
return { cfg, unreadable };
|
||||
}
|
||||
|
||||
/**
|
||||
* Seals a posted (already unmasked) config. For each field: `clearSecrets` drops it; posted values replace it; with
|
||||
* nothing posted, a stored cipher that could not be opened is kept as it is, so a temporary STC_SECRET mismatch
|
||||
* never turns into data loss on the next save.
|
||||
*/
|
||||
export async function sealMcpServer(box: SecretBox, cfg: McpServerConfig, previous?: { stored: StoredMcpServer; unreadable: SecretField[] }): Promise<StoredMcpServer> {
|
||||
const out: StoredMcpServer = { ...baseOf(cfg) };
|
||||
for (const f of SECRET_FIELDS) {
|
||||
if (cfg.clearSecrets?.includes(f)) continue;
|
||||
const values = nonEmpty(cfg[f]);
|
||||
if (values) out[CIPHER[f]] = await box.seal(JSON.stringify(values), "mcp", mcpAad(cfg.id, f));
|
||||
else if (previous?.unreadable.includes(f)) {
|
||||
if (previous.stored[CIPHER[f]]) out[CIPHER[f]] = previous.stored[CIPHER[f]];
|
||||
else if (previous.stored.secretCipher) out.secretCipher = previous.stored.secretCipher;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Re-seals v1 plaintext and a readable draft `secretCipher` into per-field v2 ciphers. `pending`: something legacy stays. */
|
||||
export async function upgradeStoredMcpServer(box: SecretBox, s: StoredMcpServer): Promise<{ server: StoredMcpServer; changed: boolean; pending: boolean }> {
|
||||
let legacy: { env?: Record<string, string>; headers?: Record<string, string> } = {};
|
||||
let pending = false;
|
||||
let changed = false;
|
||||
if (s.secretCipher) {
|
||||
try {
|
||||
const v = JSON.parse(await box.openLegacy(s.secretCipher));
|
||||
legacy = v && typeof v === "object" ? v : {};
|
||||
} catch { pending = true; }
|
||||
}
|
||||
const out: StoredMcpServer = { ...baseOf(s) };
|
||||
for (const f of SECRET_FIELDS) {
|
||||
if (s[CIPHER[f]]) { out[CIPHER[f]] = s[CIPHER[f]]; continue; }
|
||||
const values = nonEmpty(isRecordOfStrings(s[f]) ? s[f] : undefined) ?? nonEmpty(isRecordOfStrings(legacy[f]) ? legacy[f] : undefined);
|
||||
if (values) out[CIPHER[f]] = await box.seal(JSON.stringify(values), "mcp", mcpAad(s.id, f));
|
||||
}
|
||||
if (pending) out.secretCipher = s.secretCipher;
|
||||
if (s.env !== undefined || s.headers !== undefined || (s.secretCipher && !pending)) changed = true;
|
||||
return { server: out, changed, pending };
|
||||
}
|
||||
|
||||
const maskRecord = (r: Record<string, string> | undefined) => (r ? Object.fromEntries(Object.entries(r).map(([k, v]) => [k, maskSecret(v)])) : undefined);
|
||||
|
||||
/** What the admin API returns: keys visible, values masked. */
|
||||
export function maskMcpServer(s: McpServerConfig): McpServerConfig {
|
||||
const out: McpServerConfig = { ...s };
|
||||
if (s.env) out.env = maskRecord(s.env);
|
||||
if (s.headers) out.headers = maskRecord(s.headers);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces each posted value equal to the mask of the stored value (same server id, same key) with the stored
|
||||
* value. Any other value starting with the mask prefix throws MaskedSecretError, so a mask is never saved or sent.
|
||||
*/
|
||||
export function unmaskMcpServer(posted: McpServerConfig, previous: McpServerConfig[]): McpServerConfig {
|
||||
const prev = previous.find((p) => p.id === posted.id);
|
||||
const fill = (field: SecretField) => {
|
||||
const r = posted[field];
|
||||
if (!r) return undefined;
|
||||
return Object.fromEntries(Object.entries(r).map(([k, v]) => {
|
||||
if (!v.startsWith(MASK_PREFIX)) return [k, v];
|
||||
const old = prev?.[field]?.[k];
|
||||
if (old !== undefined && maskSecret(old) === v) return [k, old];
|
||||
throw new MaskedSecretError(posted.id, k);
|
||||
}));
|
||||
};
|
||||
const out: McpServerConfig = { ...posted };
|
||||
const env = fill("env");
|
||||
const headers = fill("headers");
|
||||
if (env) out.env = env;
|
||||
if (headers) out.headers = headers;
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
/**
|
||||
* Migration folders for this package; the server passes them to core `migrate()`. The files upgrade the v1 ai_*
|
||||
* tables of core's 0001_init.sql. Data that portable SQL cannot move runs in `upgradeAiData` (src/upgrade.ts).
|
||||
*/
|
||||
export const migrationsDirs: string[] = [join(import.meta.dir, "..", "migrations")];
|
||||
@@ -17,19 +17,24 @@ export interface AnthropicOptions {
|
||||
type Block = Record<string, unknown> & { type: string };
|
||||
type ApiMessage = { role: "user" | "assistant"; content: Block[] };
|
||||
|
||||
/** This provider's own `providerData` (`{ anthropic: Block[] }`); anything else is ignored. */
|
||||
const rawBlocks = (data: unknown): Block[] | null => {
|
||||
const blocks = data && typeof data === "object" ? (data as { anthropic?: unknown }).anthropic : undefined;
|
||||
return Array.isArray(blocks) && blocks.length && blocks.every((b) => b && typeof b === "object" && typeof (b as Block).type === "string") ? (blocks as Block[]) : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Anthropic Messages API over plain fetch with streaming and tool use.
|
||||
* - Thinking is left to the model default (adaptive on current models); no temperature or forced tool_choice.
|
||||
* - The frozen ChatMessage contract has no slot for thinking blocks, so the provider remembers every
|
||||
* response's raw content blocks by tool_use id and replays them unchanged when the caller sends back
|
||||
* the matching assistant tool-call message (required for thinking + tool use).
|
||||
* - Each turn ends with `providerData = { anthropic: <raw content blocks> }` (thinking with signature, text,
|
||||
* tool_use). An assistant message carrying it is replayed verbatim, as thinking + tool use requires; one
|
||||
* without it maps to text plus tool_use blocks from `toolCalls`. The provider keeps no conversation state.
|
||||
* - On the first-party endpoint, server-side refusal fallbacks are on ("default" routing).
|
||||
*/
|
||||
export function createAnthropicProvider(o: AnthropicOptions): AiProvider {
|
||||
const doFetch = o.fetch ?? fetch;
|
||||
const base = (o.baseUrl || FIRST_PARTY).replace(/\/+$/, "");
|
||||
const firstParty = base === FIRST_PARTY;
|
||||
const rawByToolId = new Map<string, Block[]>();
|
||||
|
||||
const toApi = (messages: ChatMessage[]): { system: string; messages: ApiMessage[] } => {
|
||||
const system = messages.filter((m) => m.role === "system").map((m) => m.content).join("\n\n");
|
||||
@@ -42,18 +47,12 @@ export function createAnthropicProvider(o: AnthropicOptions): AiProvider {
|
||||
for (const m of messages) {
|
||||
if (m.role === "system") continue;
|
||||
if (m.role === "tool") push("user", { type: "tool_result", tool_use_id: m.toolCallId ?? "", content: m.content });
|
||||
else if (m.role === "assistant" && m.toolCallId) {
|
||||
let input: unknown = {};
|
||||
try { input = JSON.parse(m.content || "{}"); } catch { input = { value: m.content }; }
|
||||
push("assistant", { type: "tool_use", id: m.toolCallId, name: m.name ?? "", input });
|
||||
} else if (m.content) push(m.role === "assistant" ? "assistant" : "user", { type: "text", text: m.content });
|
||||
}
|
||||
// Replace assistant turns we produced with their exact original blocks (thinking signatures intact).
|
||||
for (const msg of out) {
|
||||
if (msg.role !== "assistant") continue;
|
||||
const id = msg.content.find((b) => b.type === "tool_use")?.id as string | undefined;
|
||||
const raw = id ? rawByToolId.get(id) : undefined;
|
||||
if (raw) msg.content = structuredClone(raw);
|
||||
else if (m.role === "assistant") {
|
||||
const raw = rawBlocks(m.providerData);
|
||||
if (raw) { for (const b of structuredClone(raw)) push("assistant", b); continue; }
|
||||
if (m.content) push("assistant", { type: "text", text: m.content });
|
||||
for (const c of m.toolCalls ?? []) push("assistant", { type: "tool_use", id: c.id, name: c.name, input: c.input ?? {} });
|
||||
} else if (m.content) push("user", { type: "text", text: m.content });
|
||||
}
|
||||
return { system, messages: out };
|
||||
};
|
||||
@@ -85,7 +84,6 @@ export function createAnthropicProvider(o: AnthropicOptions): AiProvider {
|
||||
|
||||
const blocks: Block[] = [];
|
||||
const partialJson = new Map<number, string>();
|
||||
const toolIds: string[] = [];
|
||||
let stopReason: string | null = null;
|
||||
for await (const { data } of readSse(res.body)) {
|
||||
let ev: any;
|
||||
@@ -117,7 +115,6 @@ export function createAnthropicProvider(o: AnthropicOptions): AiProvider {
|
||||
let input: unknown = {};
|
||||
try { input = json ? JSON.parse(json) : (b.input ?? {}); } catch { input = { _invalidJson: json }; }
|
||||
b.input = input;
|
||||
toolIds.push(b.id as string);
|
||||
yield { toolCall: { id: b.id as string, name: b.name as string, input } };
|
||||
}
|
||||
break;
|
||||
@@ -130,7 +127,7 @@ export function createAnthropicProvider(o: AnthropicOptions): AiProvider {
|
||||
}
|
||||
}
|
||||
const content = blocks.filter(Boolean);
|
||||
for (const id of toolIds) rawByToolId.set(id, content);
|
||||
if (content.length) yield { providerData: { anthropic: content } };
|
||||
if (stopReason === "refusal" && !content.some((b) => b.type === "text" && b.text)) {
|
||||
yield { text: "The model declined to answer this request." };
|
||||
}
|
||||
|
||||
@@ -11,11 +11,9 @@ type OaMessage =
|
||||
function toOpenAi(messages: ChatMessage[]): OaMessage[] {
|
||||
const out: OaMessage[] = [];
|
||||
for (const m of messages) {
|
||||
if (m.role === "assistant" && m.toolCallId) {
|
||||
const call = { id: m.toolCallId, type: "function" as const, function: { name: m.name ?? "", arguments: m.content || "{}" } };
|
||||
const last = out[out.length - 1];
|
||||
if (last && last.role === "assistant") (last.tool_calls ??= []).push(call);
|
||||
else out.push({ role: "assistant", content: null, tool_calls: [call] });
|
||||
if (m.role === "assistant" && m.toolCalls?.length) {
|
||||
const tool_calls = m.toolCalls.map((c) => ({ id: c.id, type: "function" as const, function: { name: c.name, arguments: JSON.stringify(c.input ?? {}) } }));
|
||||
out.push({ role: "assistant", content: m.content || null, tool_calls });
|
||||
} else if (m.role === "tool") out.push({ role: "tool", tool_call_id: m.toolCallId ?? "", content: m.content });
|
||||
else out.push({ role: m.role, content: m.content } as OaMessage);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { Db } from "@stc/db";
|
||||
import type { SecretBox } from "./crypto";
|
||||
import { byokAad, SERVER_KEY_AAD, type SecretBox } from "./crypto";
|
||||
import { maskMcpServer, openMcpServer, sealMcpServer, unmaskMcpServer, type OpenedMcpServer, type SecretField, type StoredMcpServer } from "./mcp/secrets";
|
||||
import { upgradeAiData } from "./upgrade";
|
||||
|
||||
export { maskSecret, MaskedSecretError } from "./mcp/secrets";
|
||||
|
||||
export type ProviderKind = "anthropic" | "openai";
|
||||
export type Effort = "low" | "medium" | "high" | "xhigh" | "max";
|
||||
@@ -17,20 +21,31 @@ export interface McpServerConfig {
|
||||
headers?: Record<string, string>;
|
||||
/** Courses this server is offered in. Absent or empty: every course (global). */
|
||||
courseIds?: string[];
|
||||
/** Save only: drop these stored fields, even if they cannot be decrypted. Never stored or returned. */
|
||||
clearSecrets?: SecretField[];
|
||||
}
|
||||
|
||||
/** `allowedBaseUrls`: origins learners' own keys (BYOK) may be sent to, besides the official endpoints. */
|
||||
export interface ServerSettings { provider: PublicProviderConfig | null; mcpServers: McpServerConfig[]; allowedBaseUrls: string[] }
|
||||
/** A stored secret the current STC_SECRET cannot open. It is kept until the admin replaces or clears it. */
|
||||
export type UnreadableSecret =
|
||||
| { kind: "api-key" }
|
||||
| { kind: "mcp"; serverId: string; name: string; field: SecretField };
|
||||
|
||||
/**
|
||||
* `allowedBaseUrls`: origins learners' own keys (BYOK) may be sent to, besides the official endpoints.
|
||||
* `mcpServers` here carry masked `env`/`headers` values (see mcp/secrets.ts); full values: mcpServersWithSecrets().
|
||||
*/
|
||||
export interface ServerSettings { provider: PublicProviderConfig | null; mcpServers: McpServerConfig[]; allowedBaseUrls: string[]; unreadableSecrets: UnreadableSecret[] }
|
||||
export interface ServerSettingsInput {
|
||||
provider: (ProviderConfig & { apiKey?: string; clearApiKey?: boolean }) | null;
|
||||
mcpServers: McpServerConfig[];
|
||||
/** Omitted: keep the saved list. */
|
||||
allowedBaseUrls?: string[];
|
||||
}
|
||||
export interface UserSettings { byok: PublicProviderConfig | null; mentorOff: Record<string, boolean> }
|
||||
/** `keyUnreadable`: the saved key cannot be decrypted with the current STC_SECRET; the user must re-enter it. */
|
||||
export interface UserSettings { byok: (PublicProviderConfig & { keyUnreadable?: true }) | null; mentorOff: Record<string, boolean> }
|
||||
|
||||
type ServerRow = { provider: ProviderConfig | null; apiKeyCipher: string | null; mcpServers: McpServerConfig[]; allowedBaseUrls?: string[] };
|
||||
type UserRow = { byok: ProviderConfig | null; mentorOff: Record<string, boolean> };
|
||||
type ServerRow = { provider: ProviderConfig | null; apiKeyCipher: string | null; mcpServers: StoredMcpServer[]; allowedBaseUrls?: string[] };
|
||||
type UserRow = { byok: ProviderConfig | null };
|
||||
|
||||
const pickProvider = (p: ProviderConfig): ProviderConfig => {
|
||||
const out: ProviderConfig = { kind: p.kind };
|
||||
@@ -46,14 +61,19 @@ const parse = <T>(s: string | undefined, fallback: T): T => {
|
||||
};
|
||||
|
||||
/**
|
||||
* AI settings in the ai_settings and user_api_keys tables of 0001_init.sql.
|
||||
* - ('server', '') holds the admin provider (key encrypted) and MCP servers.
|
||||
* - ('user', <userId>) holds the BYOK provider choice and per-course mentor toggles. The frozen primary key
|
||||
* is (scope, owner_id), so the per-course flag lives in config_json; the mentor_off column means
|
||||
* "off for every course" and stays 0 (see docs/contract-requests/ai.md).
|
||||
* Keys never leave this module except through serverApiKey/userApiKey, which only server code calls.
|
||||
* AI settings in ai_settings and user_api_keys (core 0001_init.sql) and ai_mentor_prefs (this package's 0002).
|
||||
* - ('server', '') holds the admin provider (key encrypted) and MCP servers (env and headers encrypted).
|
||||
* - ('user', <userId>) holds the BYOK provider choice. The mentor_off column ("off for every course") stays 0.
|
||||
* - ai_mentor_prefs has a row per (user, course) whose mentor rules the learner switched off.
|
||||
* Keys and MCP secrets leave this module only through serverApiKey, userApiKey and mcpServersWithSecrets, which
|
||||
* only server code calls.
|
||||
* Every method first awaits the one-time v1 data upgrade (upgradeAiData); a failed attempt is retried next call.
|
||||
*/
|
||||
export function aiSettingsRepo(db: Db, box: SecretBox) {
|
||||
let upgraded: Promise<unknown> | null = null;
|
||||
const ready = () =>
|
||||
(upgraded ??= upgradeAiData(db, box).catch((e) => { upgraded = null; throw e; }));
|
||||
|
||||
const read = async (scope: "server" | "user", owner: string): Promise<string | undefined> => {
|
||||
const rows = await db.sql`SELECT config_json FROM ai_settings WHERE scope = ${scope} AND owner_id = ${owner}`;
|
||||
return rows[0]?.config_json;
|
||||
@@ -68,40 +88,107 @@ export function aiSettingsRepo(db: Db, box: SecretBox) {
|
||||
});
|
||||
};
|
||||
const serverRow = async (): Promise<ServerRow> => parse(await read("server", ""), { provider: null, apiKeyCipher: null, mcpServers: [] });
|
||||
const userRow = async (userId: string): Promise<UserRow> => parse(await read("user", userId), { byok: null, mentorOff: {} });
|
||||
const userRow = async (userId: string): Promise<UserRow> => {
|
||||
const { mentorOff: _legacy, ...row } = parse(await read("user", userId), { byok: null } as UserRow & { mentorOff?: unknown });
|
||||
return row;
|
||||
};
|
||||
const storedServers = (r: ServerRow): StoredMcpServer[] =>
|
||||
(Array.isArray(r.mcpServers) ? r.mcpServers : []).filter((m) => m && typeof m === "object" && typeof m.id === "string");
|
||||
const openServers = async (r: ServerRow): Promise<OpenedMcpServer[]> => Promise.all(storedServers(r).map((m) => openMcpServer(box, m)));
|
||||
const openServerKey = async (r: ServerRow): Promise<string | null> =>
|
||||
r.apiKeyCipher ? box.open(r.apiKeyCipher, "server-api-key", SERVER_KEY_AAD).catch(() => null) : null;
|
||||
const openUserKey = async (userId: string, k: { provider: string; ciphertext: string }): Promise<string | null> =>
|
||||
box.open(k.ciphertext, "byok", byokAad(userId, k.provider)).catch(() => null);
|
||||
const keyRow = async (userId: string) =>
|
||||
(await db.sql`SELECT provider, ciphertext FROM user_api_keys WHERE user_id = ${userId}`)[0] as { provider: string; ciphertext: string } | undefined;
|
||||
|
||||
return {
|
||||
/** Resolves once the v1 data upgrade is done (aiFeature starts it at construction). */
|
||||
ready: async (): Promise<void> => { await ready(); },
|
||||
async getServer(): Promise<ServerSettings> {
|
||||
await ready();
|
||||
const r = await serverRow();
|
||||
return { provider: r.provider ? { ...r.provider, hasApiKey: !!r.apiKeyCipher } : null, mcpServers: r.mcpServers ?? [], allowedBaseUrls: r.allowedBaseUrls ?? [] };
|
||||
const opened = await openServers(r);
|
||||
const unreadableSecrets: UnreadableSecret[] = [];
|
||||
if (r.provider && r.apiKeyCipher && (await openServerKey(r)) === null) unreadableSecrets.push({ kind: "api-key" });
|
||||
for (const o of opened) for (const field of o.unreadable) unreadableSecrets.push({ kind: "mcp", serverId: o.cfg.id, name: o.cfg.name, field });
|
||||
return {
|
||||
provider: r.provider ? { ...r.provider, hasApiKey: !!r.apiKeyCipher } : null,
|
||||
mcpServers: opened.map((o) => maskMcpServer(o.cfg)),
|
||||
allowedBaseUrls: r.allowedBaseUrls ?? [],
|
||||
unreadableSecrets,
|
||||
};
|
||||
},
|
||||
/** The admin provider without opening any secret (for resolving which provider a request uses). */
|
||||
async serverProvider(): Promise<PublicProviderConfig | null> {
|
||||
await ready();
|
||||
const r = await serverRow();
|
||||
return r.provider ? { ...r.provider, hasApiKey: !!r.apiKeyCipher } : null;
|
||||
},
|
||||
/** The admin's BYOK allowlist, without opening any secret. */
|
||||
async allowedBaseUrls(): Promise<string[]> {
|
||||
await ready();
|
||||
return (await serverRow()).allowedBaseUrls ?? [];
|
||||
},
|
||||
/** Server code only: MCP servers with their real env and headers (unreadable fields left out). */
|
||||
async mcpServersWithSecrets(): Promise<McpServerConfig[]> {
|
||||
await ready();
|
||||
return (await openServers(await serverRow())).map((o) => o.cfg);
|
||||
},
|
||||
/** A posted (masked) MCP config with stored values filled in; throws MaskedSecretError for a stray mask. */
|
||||
async resolveMcpMasks(cfg: McpServerConfig): Promise<McpServerConfig> {
|
||||
await ready();
|
||||
const { clearSecrets: _c, ...rest } = unmaskMcpServer(cfg, (await openServers(await serverRow())).map((o) => o.cfg));
|
||||
return rest;
|
||||
},
|
||||
/**
|
||||
* Saves the admin settings. Secrets that cannot be decrypted are never discarded implicitly: the API key stays
|
||||
* unless replaced or cleared (clearApiKey), and an MCP field stays unless new values are posted or the field is
|
||||
* listed in that server's clearSecrets.
|
||||
*/
|
||||
async saveServer(input: ServerSettingsInput): Promise<void> {
|
||||
await ready();
|
||||
const prev = await serverRow();
|
||||
let apiKeyCipher: string | null = null;
|
||||
if (input.provider) {
|
||||
if (input.provider.apiKey) apiKeyCipher = await box.encrypt(input.provider.apiKey);
|
||||
if (input.provider.apiKey) apiKeyCipher = await box.seal(input.provider.apiKey, "server-api-key", SERVER_KEY_AAD);
|
||||
else if (!input.provider.clearApiKey) apiKeyCipher = prev.apiKeyCipher;
|
||||
}
|
||||
const stored = storedServers(prev);
|
||||
const opened = await openServers(prev);
|
||||
const previous = opened.map((o) => o.cfg);
|
||||
const mcpServers: StoredMcpServer[] = [];
|
||||
for (const m of input.mcpServers ?? []) {
|
||||
const i = stored.findIndex((x) => x.id === m.id);
|
||||
mcpServers.push(await sealMcpServer(box, unmaskMcpServer(m, previous), i >= 0 ? { stored: stored[i]!, unreadable: opened[i]!.unreadable } : undefined));
|
||||
}
|
||||
await write("server", "", {
|
||||
provider: input.provider ? pickProvider(input.provider) : null,
|
||||
apiKeyCipher,
|
||||
mcpServers: input.mcpServers ?? [],
|
||||
mcpServers,
|
||||
allowedBaseUrls: input.allowedBaseUrls ?? prev.allowedBaseUrls ?? [],
|
||||
});
|
||||
},
|
||||
async serverApiKey(): Promise<string | null> {
|
||||
await ready();
|
||||
const r = await serverRow();
|
||||
return r.provider && r.apiKeyCipher ? box.decrypt(r.apiKeyCipher) : null;
|
||||
return r.provider ? openServerKey(r) : null;
|
||||
},
|
||||
async getUser(userId: string): Promise<UserSettings> {
|
||||
await ready();
|
||||
const r = await userRow(userId);
|
||||
const k = await keyRow(userId);
|
||||
return { byok: r.byok && k ? { ...r.byok, hasApiKey: true } : null, mentorOff: r.mentorOff ?? {} };
|
||||
const prefs = (await db.sql`SELECT course_id FROM ai_mentor_prefs WHERE user_id = ${userId} AND off = 1`) as { course_id: string }[];
|
||||
let byok: UserSettings["byok"] = null;
|
||||
if (r.byok && k) {
|
||||
byok = { ...r.byok, hasApiKey: true };
|
||||
if ((await openUserKey(userId, k)) === null) byok.keyUnreadable = true;
|
||||
}
|
||||
return { byok, mentorOff: Object.fromEntries(prefs.map((p) => [p.course_id, true])) };
|
||||
},
|
||||
async saveUserKey(userId: string, cfg: ProviderConfig & { apiKey: string }): Promise<void> {
|
||||
const ciphertext = await box.encrypt(cfg.apiKey);
|
||||
await ready();
|
||||
const ciphertext = await box.seal(cfg.apiKey, "byok", byokAad(userId, cfg.kind));
|
||||
const now = new Date().toISOString();
|
||||
await db.sql.begin(async (tx) => {
|
||||
await tx`DELETE FROM user_api_keys WHERE user_id = ${userId}`;
|
||||
@@ -111,23 +198,26 @@ export function aiSettingsRepo(db: Db, box: SecretBox) {
|
||||
await write("user", userId, { ...r, byok: pickProvider(cfg) });
|
||||
},
|
||||
async removeUserKey(userId: string): Promise<void> {
|
||||
await ready();
|
||||
await db.sql`DELETE FROM user_api_keys WHERE user_id = ${userId}`;
|
||||
const r = await userRow(userId);
|
||||
await write("user", userId, { ...r, byok: null });
|
||||
},
|
||||
async userApiKey(userId: string): Promise<string | null> {
|
||||
await ready();
|
||||
const k = await keyRow(userId);
|
||||
return k ? box.decrypt(k.ciphertext) : null;
|
||||
return k ? openUserKey(userId, k) : null;
|
||||
},
|
||||
async setMentorOff(userId: string, courseId: string, off: boolean): Promise<void> {
|
||||
const r = await userRow(userId);
|
||||
const mentorOff = { ...r.mentorOff };
|
||||
if (off) mentorOff[courseId] = true;
|
||||
else delete mentorOff[courseId];
|
||||
await write("user", userId, { ...r, mentorOff });
|
||||
await ready();
|
||||
await db.sql.begin(async (tx) => {
|
||||
await tx`DELETE FROM ai_mentor_prefs WHERE user_id = ${userId} AND course_id = ${courseId}`;
|
||||
if (off) await tx`INSERT INTO ai_mentor_prefs (user_id, course_id, off, updated_at) VALUES (${userId}, ${courseId}, 1, ${new Date().toISOString()})`;
|
||||
});
|
||||
},
|
||||
async isMentorOff(userId: string, courseId: string): Promise<boolean> {
|
||||
return (await userRow(userId)).mentorOff?.[courseId] === true;
|
||||
await ready();
|
||||
return (await db.sql`SELECT 1 AS x FROM ai_mentor_prefs WHERE user_id = ${userId} AND course_id = ${courseId} AND off = 1`).length > 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { Db } from "@stc/db";
|
||||
import { byokAad, isLegacyToken, SERVER_KEY_AAD, type SecretBox } from "./crypto";
|
||||
import { upgradeStoredMcpServer, type StoredMcpServer } from "./mcp/secrets";
|
||||
|
||||
/** Recorded in schema_migrations once the data upgrade has run (no `.sql` suffix, so no file can share it). */
|
||||
export const AI_DATA_UPGRADE = "ai_data_v11";
|
||||
/** Recorded once no secret is left in a pre-v2 format. */
|
||||
export const AI_SECRETS_UPGRADE = "ai_secrets_v2";
|
||||
|
||||
type Tx = Bun.TransactionSQL;
|
||||
|
||||
const parseObject = (s: string): Record<string, unknown> | null => {
|
||||
try {
|
||||
const v = JSON.parse(s);
|
||||
return v && typeof v === "object" && !Array.isArray(v) ? v : null;
|
||||
} catch { return null; }
|
||||
};
|
||||
|
||||
/** v1 kept per-course toggles in the user row's config_json.mentorOff; move them to ai_mentor_prefs. */
|
||||
async function moveMentorToggles(tx: Tx, now: string) {
|
||||
const rows = (await tx`SELECT owner_id, config_json FROM ai_settings WHERE scope = 'user'`) as { owner_id: string; config_json: string }[];
|
||||
for (const r of rows) {
|
||||
const cfg = parseObject(r.config_json);
|
||||
if (!cfg || !("mentorOff" in cfg)) continue;
|
||||
const off = cfg.mentorOff && typeof cfg.mentorOff === "object" ? (cfg.mentorOff as Record<string, unknown>) : {};
|
||||
for (const [courseId, v] of Object.entries(off)) {
|
||||
if (v !== true || !courseId) continue;
|
||||
const exists = await tx`SELECT 1 AS x FROM ai_mentor_prefs WHERE user_id = ${r.owner_id} AND course_id = ${courseId}`;
|
||||
if (!exists.length) await tx`INSERT INTO ai_mentor_prefs (user_id, course_id, off, updated_at) VALUES (${r.owner_id}, ${courseId}, 1, ${now})`;
|
||||
}
|
||||
const { mentorOff: _drop, ...rest } = cfg;
|
||||
await tx`UPDATE ai_settings SET config_json = ${JSON.stringify(rest)}, updated_at = ${now} WHERE scope = 'user' AND owner_id = ${r.owner_id}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-seals every secret still in an older format as a v2 token (own HKDF key per purpose, AAD bound to the row):
|
||||
* BYOK keys, the server API key, and MCP env/headers (v1 plaintext or the draft `secretCipher`). A v1 token that
|
||||
* does not open (STC_SECRET changed) is left untouched and the step stays pending, so it is retried on every start
|
||||
* and completes once the previous secret is restored. Returns { changed, pending }.
|
||||
*/
|
||||
async function resealSecrets(tx: Tx, box: SecretBox, now: string): Promise<{ changed: boolean; pending: boolean }> {
|
||||
let changed = false;
|
||||
let pending = false;
|
||||
const keys = (await tx`SELECT user_id, provider, ciphertext FROM user_api_keys`) as { user_id: string; provider: string; ciphertext: string }[];
|
||||
for (const k of keys) {
|
||||
if (!isLegacyToken(k.ciphertext)) continue;
|
||||
let plain: string;
|
||||
try { plain = await box.openLegacy(k.ciphertext); } catch { pending = true; continue; }
|
||||
const sealed = await box.seal(plain, "byok", byokAad(k.user_id, k.provider));
|
||||
await tx`UPDATE user_api_keys SET ciphertext = ${sealed} WHERE user_id = ${k.user_id} AND provider = ${k.provider}`;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const rows = (await tx`SELECT config_json FROM ai_settings WHERE scope = 'server' AND owner_id = ''`) as { config_json: string }[];
|
||||
const cfg = rows[0] ? parseObject(rows[0].config_json) : null;
|
||||
if (!cfg) return { changed, pending };
|
||||
let rowChanged = false;
|
||||
if (typeof cfg.apiKeyCipher === "string" && isLegacyToken(cfg.apiKeyCipher)) {
|
||||
try {
|
||||
cfg.apiKeyCipher = await box.seal(await box.openLegacy(cfg.apiKeyCipher), "server-api-key", SERVER_KEY_AAD);
|
||||
rowChanged = true;
|
||||
} catch { pending = true; }
|
||||
}
|
||||
if (Array.isArray(cfg.mcpServers)) {
|
||||
const servers: unknown[] = [];
|
||||
for (const s of cfg.mcpServers) {
|
||||
// Malformed entries are kept as they are (the settings code skips them); they must not stop the upgrade.
|
||||
if (!s || typeof s !== "object" || typeof (s as StoredMcpServer).id !== "string") { servers.push(s); continue; }
|
||||
const r = await upgradeStoredMcpServer(box, s as StoredMcpServer);
|
||||
servers.push(r.server);
|
||||
if (r.changed) rowChanged = true;
|
||||
if (r.pending) pending = true;
|
||||
}
|
||||
cfg.mcpServers = servers;
|
||||
}
|
||||
if (rowChanged) {
|
||||
await tx`UPDATE ai_settings SET config_json = ${JSON.stringify(cfg)}, updated_at = ${now} WHERE scope = 'server' AND owner_id = ''`;
|
||||
changed = true;
|
||||
}
|
||||
return { changed, pending };
|
||||
}
|
||||
|
||||
/**
|
||||
* Data the v1.1 SQL migrations cannot move portably, in one transaction. Each step is recorded in schema_migrations
|
||||
* when complete (names without `.sql`, so no file can share them):
|
||||
* - `ai_data_v11`: per-course mentor toggles from config_json to ai_mentor_prefs;
|
||||
* - `ai_secrets_v2`: every secret re-sealed in the v2 format (marked only when nothing legacy is left).
|
||||
* Returns true if it changed anything now. The settings repo awaits it before first use.
|
||||
*/
|
||||
export async function upgradeAiData(db: Db, box: SecretBox): Promise<boolean> {
|
||||
return db.sql.begin(async (tx) => {
|
||||
const done = new Set(((await tx`SELECT name FROM schema_migrations WHERE name IN (${AI_DATA_UPGRADE}, ${AI_SECRETS_UPGRADE})`) as { name: string }[]).map((r) => r.name));
|
||||
const now = new Date().toISOString();
|
||||
let changed = false;
|
||||
if (!done.has(AI_DATA_UPGRADE)) {
|
||||
await moveMentorToggles(tx, now);
|
||||
await tx`INSERT INTO schema_migrations (name, applied_at) VALUES (${AI_DATA_UPGRADE}, ${now})`;
|
||||
changed = true;
|
||||
}
|
||||
if (!done.has(AI_SECRETS_UPGRADE)) {
|
||||
const r = await resealSecrets(tx, box, now);
|
||||
if (r.changed) changed = true;
|
||||
if (!r.pending) {
|
||||
await tx`INSERT INTO schema_migrations (name, applied_at) VALUES (${AI_SECRETS_UPGRADE}, ${now})`;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
});
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Pill } from "@stc/ui";
|
||||
import { api, ApiError } from "./api";
|
||||
import { Field, PageHead } from "./components";
|
||||
import { Field, PageHead, UNREADABLE_KEY_TEXT } from "./components";
|
||||
|
||||
interface Account {
|
||||
byok: { kind: "anthropic" | "openai"; model?: string; baseUrl?: string; hasApiKey: boolean } | null;
|
||||
byok: { kind: "anthropic" | "openai"; model?: string; baseUrl?: string; hasApiKey: boolean; keyUnreadable?: boolean } | null;
|
||||
serverProvider: { kind: string; model: string | null } | null;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,8 @@ export default function AccountAiPage() {
|
||||
</p>
|
||||
</PageHead>
|
||||
|
||||
{acct.byok ? <p className="ai-banner" role="status">A {acct.byok.kind === "anthropic" ? "Anthropic" : "OpenAI-compatible"} key is saved{acct.byok.model ? ` (model ${acct.byok.model})` : ""}.</p> : null}
|
||||
{acct.byok?.keyUnreadable ? <p className="ai-error" role="alert">{UNREADABLE_KEY_TEXT}</p> : null}
|
||||
{acct.byok && !acct.byok.keyUnreadable ? <p className="ai-banner" role="status">A {acct.byok.kind === "anthropic" ? "Anthropic" : "OpenAI-compatible"} key is saved{acct.byok.model ? ` (model ${acct.byok.model})` : ""}.</p> : null}
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); save(); }}>
|
||||
<div className="ai-grid">
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Pill } from "@stc/ui";
|
||||
import { api, ApiError } from "./api";
|
||||
import { Field, PageHead } from "./components";
|
||||
import { Field, PageHead, UnreadableSecretsWarning, type UnreadableSecretItem } from "./components";
|
||||
|
||||
type Kind = "" | "anthropic" | "openai";
|
||||
interface ProviderOut { kind: "anthropic" | "openai"; model?: string; baseUrl?: string; effort?: string; hasApiKey: boolean }
|
||||
interface McpServer { id: string; name: string; transport: "stdio" | "http"; command?: string; args?: string[]; env?: Record<string, string>; url?: string; headers?: Record<string, string>; courseIds?: string[] }
|
||||
interface Config { provider: ProviderOut | null; mcpServers: McpServer[]; allowedBaseUrls: string[] }
|
||||
interface McpServer { id: string; name: string; transport: "stdio" | "http"; command?: string; args?: string[]; env?: Record<string, string>; url?: string; headers?: Record<string, string>; courseIds?: string[]; clearSecrets?: ("env" | "headers")[] }
|
||||
interface Config { provider: ProviderOut | null; mcpServers: McpServer[]; allowedBaseUrls: string[]; unreadableSecrets?: UnreadableSecretItem[] }
|
||||
|
||||
type ServerDraft = { id: string; name: string; transport: "stdio" | "http"; command: string; args: string; env: string; url: string; headers?: Record<string, string>; courses: string; test?: string };
|
||||
type ServerDraft = { id: string; name: string; transport: "stdio" | "http"; command: string; args: string; env: string; url: string; headers?: Record<string, string>; courses: string; test?: string; clearSecrets?: ("env" | "headers")[] };
|
||||
const toDraft = (s: McpServer): ServerDraft => ({
|
||||
id: s.id, name: s.name, transport: s.transport, command: s.command ?? "", args: (s.args ?? []).join(" "),
|
||||
env: Object.entries(s.env ?? {}).map(([k, v]) => `${k}=${v}`).join("\n"), url: s.url ?? "", headers: s.headers, courses: (s.courseIds ?? []).join(", "),
|
||||
@@ -19,6 +19,7 @@ const fromDraft = (d: ServerDraft): McpServer => ({
|
||||
id: d.id, name: d.name || d.id, transport: d.transport,
|
||||
...(d.transport === "stdio" ? { command: d.command, args: d.args.trim() ? d.args.trim().split(/\s+/) : [], env: parseEnv(d.env) } : { url: d.url, ...(d.headers ? { headers: d.headers } : {}) }),
|
||||
courseIds: d.courses.split(",").map((s) => s.trim()).filter(Boolean),
|
||||
...(d.clearSecrets?.length ? { clearSecrets: d.clearSecrets } : {}),
|
||||
});
|
||||
|
||||
export default function AdminAiPage() {
|
||||
@@ -80,6 +81,19 @@ export default function AdminAiPage() {
|
||||
<p>AI is off until you choose a provider here. Learners and authors can also add their own key, which is used only for their own requests. Keys are encrypted on the server and never shown again.</p>
|
||||
</PageHead>
|
||||
|
||||
<UnreadableSecretsWarning
|
||||
items={loaded.unreadableSecrets ?? []}
|
||||
action={(item) => {
|
||||
if (item.kind !== "mcp") return <span className="ai-hint">Paste the key again below, or tick "Remove the saved key".</span>;
|
||||
const i = servers.findIndex((x) => x.id === item.serverId);
|
||||
const marked = i >= 0 && !!servers[i]!.clearSecrets?.includes(item.field);
|
||||
if (i < 0) return null;
|
||||
return marked ? <span className="ai-hint">Will be discarded when you save.</span> : (
|
||||
<Pill variant="ghost" onClick={() => setServer(i, { clearSecrets: [...(servers[i]!.clearSecrets ?? []), item.field] })}>Discard stored value</Pill>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<form onSubmit={(e) => { e.preventDefault(); save(); }}>
|
||||
<section className="ai-section">
|
||||
<h2>Server provider</h2>
|
||||
@@ -157,7 +171,7 @@ export default function AdminAiPage() {
|
||||
</div>
|
||||
)}
|
||||
{s.transport === "stdio" ? (
|
||||
<Field label="Environment" hint="One NAME=value per line. The command gets only PATH, HOME, LANG and these; server secrets are never passed on.">
|
||||
<Field label="Environment" hint="One NAME=value per line. The command gets only PATH, HOME, LANG and these; server secrets are never passed on. Values are stored encrypted and shown masked; leave a masked value as it is to keep it.">
|
||||
<textarea className="ai-input ai-mono" rows={2} value={s.env} onChange={(e) => setServer(i, { env: e.target.value })} placeholder="API_TOKEN=…" />
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
@@ -75,3 +75,25 @@ export function Markdown({ text }: { text: string }) {
|
||||
}
|
||||
return <div className="ai-md">{blocks}</div>;
|
||||
}
|
||||
|
||||
/** A stored secret the server cannot decrypt (mirrors UnreadableSecret in settings.ts). */
|
||||
export type UnreadableSecretItem = { kind: "api-key" } | { kind: "mcp"; serverId: string; name: string; field: "env" | "headers" };
|
||||
|
||||
export const UNREADABLE_SECRETS_TEXT = "Some stored secrets can't be decrypted with the current STC_SECRET. Re-enter them or restore the previous secret.";
|
||||
export const UNREADABLE_KEY_TEXT = "Your saved key can't be used; please re-enter it.";
|
||||
|
||||
const describeSecret = (s: UnreadableSecretItem) =>
|
||||
s.kind === "api-key" ? "Server provider: API key" : `MCP server "${s.name}" (${s.serverId}): ${s.field === "env" ? "environment" : "headers"}`;
|
||||
|
||||
/** Admin warning listing each affected server and field. Nothing is discarded until the admin replaces or clears it. */
|
||||
export function UnreadableSecretsWarning({ items, action }: { items: UnreadableSecretItem[]; action?: (item: UnreadableSecretItem) => ReactNode }) {
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div className="ai-error" role="alert">
|
||||
<p>{UNREADABLE_SECRETS_TEXT}</p>
|
||||
<ul>
|
||||
{items.map((s, i) => <li key={i}>{describeSecret(s)}{action ? <> {action(s)}</> : null}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,40 +56,48 @@ test("streams text and sends a well-formed request", async () => {
|
||||
expect(h.get("anthropic-beta")).toBe("server-side-fallback-2026-07-01");
|
||||
});
|
||||
|
||||
test("assembles tool input and replays thinking blocks unchanged on the next call", async () => {
|
||||
test("assembles tool input and replays thinking blocks unchanged from providerData", async () => {
|
||||
const ff = fakeFetch([ok(toolTurn), ok(textTurn)]);
|
||||
const p = createAnthropicProvider({ apiKey: "k", fetch: ff.fetch });
|
||||
const msgs: ChatMessage[] = [{ role: "user", content: "How do I add?" }];
|
||||
const first = await collect(p.chat(msgs, {}));
|
||||
const first = await collect(createAnthropicProvider({ apiKey: "k", fetch: ff.fetch }).chat(msgs, {}));
|
||||
expect(first.text).toBe("Let me look.");
|
||||
expect(first.toolCalls).toEqual([{ id: "toolu_1", name: "search_course", input: { query: "add" } }]);
|
||||
const raw = [
|
||||
{ type: "thinking", thinking: "", signature: "SIG-abc" },
|
||||
{ type: "text", text: "Let me look." },
|
||||
{ type: "tool_use", id: "toolu_1", name: "search_course", input: { query: "add" } },
|
||||
];
|
||||
expect(first.providerData).toEqual({ anthropic: raw });
|
||||
|
||||
msgs.push({ role: "assistant", content: "Let me look." });
|
||||
msgs.push({ role: "assistant", content: JSON.stringify({ query: "add" }), toolCallId: "toolu_1", name: "search_course" });
|
||||
msgs.push({ role: "assistant", content: first.text, toolCalls: first.toolCalls, providerData: first.providerData });
|
||||
msgs.push({ role: "tool", content: "lessons/04-exercise.md: ...", toolCallId: "toolu_1", name: "search_course" });
|
||||
await collect(p.chat(msgs, {}));
|
||||
// A new provider instance: the replay comes from the message, not from memory.
|
||||
await collect(createAnthropicProvider({ apiKey: "k", fetch: ff.fetch }).chat(msgs, {}));
|
||||
const sent = ff.calls[1]!.body.messages;
|
||||
expect(sent).toHaveLength(3);
|
||||
expect(sent[1]).toEqual({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "", signature: "SIG-abc" },
|
||||
{ type: "text", text: "Let me look." },
|
||||
{ type: "tool_use", id: "toolu_1", name: "search_course", input: { query: "add" } },
|
||||
],
|
||||
});
|
||||
expect(sent[1]).toEqual({ role: "assistant", content: raw });
|
||||
expect(sent[2]).toEqual({ role: "user", content: [{ type: "tool_result", tool_use_id: "toolu_1", content: "lessons/04-exercise.md: ..." }] });
|
||||
});
|
||||
|
||||
test("tool calls it did not produce are mapped from the message fields", async () => {
|
||||
test("tool calls without Anthropic providerData are mapped from the message fields", async () => {
|
||||
const ff = fakeFetch([ok(textTurn)]);
|
||||
const p = createAnthropicProvider({ apiKey: "k", fetch: ff.fetch });
|
||||
await collect(p.chat([
|
||||
{ role: "user", content: "q" },
|
||||
{ role: "assistant", content: "{\"x\":1}", toolCallId: "t9", name: "list_notes" },
|
||||
{ role: "assistant", content: "Checking.", toolCalls: [{ id: "t9", name: "list_notes", input: { x: 1 } }, { id: "t10", name: "my_progress", input: {} }], providerData: { other: true } },
|
||||
{ role: "tool", content: "none", toolCallId: "t9" },
|
||||
{ role: "tool", content: "50%", toolCallId: "t10" },
|
||||
], {}));
|
||||
expect(ff.calls[0]!.body.messages[1]).toEqual({ role: "assistant", content: [{ type: "tool_use", id: "t9", name: "list_notes", input: { x: 1 } }] });
|
||||
const sent = ff.calls[0]!.body.messages;
|
||||
expect(sent[1]).toEqual({ role: "assistant", content: [
|
||||
{ type: "text", text: "Checking." },
|
||||
{ type: "tool_use", id: "t9", name: "list_notes", input: { x: 1 } },
|
||||
{ type: "tool_use", id: "t10", name: "my_progress", input: {} },
|
||||
] });
|
||||
expect(sent[2]).toEqual({ role: "user", content: [
|
||||
{ type: "tool_result", tool_use_id: "t9", content: "none" },
|
||||
{ type: "tool_result", tool_use_id: "t10", content: "50%" },
|
||||
] });
|
||||
});
|
||||
|
||||
test("errors carry the API message", async () => {
|
||||
|
||||
@@ -69,7 +69,7 @@ test("the loop runs tools and feeds results back until the model answers", async
|
||||
const done = events.find((e) => e.type === "done") as { type: "done"; text: string };
|
||||
expect(done.text).toBe("Searching.Found it in lessons/04-exercise.md. (1 tool result)");
|
||||
const second = seen[1]!.messages;
|
||||
expect(second.at(-2)).toMatchObject({ role: "assistant", toolCallId: "c1", name: "search_course" });
|
||||
expect(second.at(-2)).toEqual({ role: "assistant", content: "Searching.", toolCalls: [{ id: "c1", name: "search_course", input: { query: "add arguments" } }] });
|
||||
expect(second.at(-1)!.role).toBe("tool");
|
||||
expect(second.at(-1)!.content).toContain("lessons/04-exercise.md");
|
||||
expect(seen[0]!.tools!.map((x) => x.name)).toContain("search_course");
|
||||
@@ -169,3 +169,21 @@ test("read_file refuses solutions however the path is written, including through
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("one assistant message per model turn carries every tool call and the provider's data", async () => {
|
||||
const t = courseTools({ repo, course, index, listNotes: async () => [] });
|
||||
const calls = [
|
||||
{ id: "c1", name: "search_course", input: { query: "add" } },
|
||||
{ id: "c2", name: "list_notes", input: {} },
|
||||
];
|
||||
const { provider, seen } = fakeProvider([
|
||||
() => ({ toolCalls: calls, providerData: { fake: ["opaque", 1] } }),
|
||||
() => ({ text: "Done." }),
|
||||
]);
|
||||
const messages: ChatMessage[] = [{ role: "user", content: "q" }];
|
||||
for await (const _ of runAssistant({ provider, messages, tools: combineTools([t]) })) { /* drain */ }
|
||||
const sent = seen[1]!.messages;
|
||||
expect(sent.slice(1).map((m) => [m.role, m.toolCallId ?? null])).toEqual([["assistant", null], ["tool", "c1"], ["tool", "c2"]]);
|
||||
expect(sent[1]).toEqual({ role: "assistant", content: "", toolCalls: calls, providerData: { fake: ["opaque", 1] } });
|
||||
expect(sent[2]).toMatchObject({ role: "tool", toolCallId: "c1", name: "search_course" });
|
||||
});
|
||||
|
||||
@@ -1,27 +1,51 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { createSecretBox } from "../src/crypto";
|
||||
import { byokAad, createSecretBox, legacyV1Encrypt, mcpAad, SERVER_KEY_AAD } from "../src/crypto";
|
||||
|
||||
test("round trips and uses a fresh IV each time", async () => {
|
||||
const box = createSecretBox("s3cret");
|
||||
const a = await box.encrypt("sk-ant-123");
|
||||
const b = await box.encrypt("sk-ant-123");
|
||||
const a = await box.seal("sk-ant-123", "byok", byokAad("u1", "anthropic"));
|
||||
const b = await box.seal("sk-ant-123", "byok", byokAad("u1", "anthropic"));
|
||||
expect(a).not.toBe(b);
|
||||
expect(a).not.toContain("sk-ant-123");
|
||||
expect(a.startsWith("v1.")).toBe(true);
|
||||
expect(await box.decrypt(a)).toBe("sk-ant-123");
|
||||
expect(await box.decrypt(b)).toBe("sk-ant-123");
|
||||
expect(a.startsWith("v2.")).toBe(true);
|
||||
expect(await box.open(a, "byok", byokAad("u1", "anthropic"))).toBe("sk-ant-123");
|
||||
expect(await box.open(b, "byok", byokAad("u1", "anthropic"))).toBe("sk-ant-123");
|
||||
});
|
||||
|
||||
test("another secret cannot decrypt", async () => {
|
||||
const token = await createSecretBox("one").encrypt("key");
|
||||
await expect(createSecretBox("two").decrypt(token)).rejects.toThrow();
|
||||
const token = await createSecretBox("one").seal("key", "byok", "a");
|
||||
await expect(createSecretBox("two").open(token, "byok", "a")).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("a tampered token is rejected", async () => {
|
||||
const token = await createSecretBox("one").encrypt("key");
|
||||
const box = createSecretBox("one");
|
||||
const token = await box.seal("key", "byok", "a");
|
||||
const parts = token.split(".");
|
||||
const ct = Buffer.from(parts[2]!, "base64");
|
||||
ct[0] = ct[0]! ^ 1;
|
||||
await expect(createSecretBox("one").decrypt(`${parts[0]}.${parts[1]}.${ct.toString("base64")}`)).rejects.toThrow();
|
||||
await expect(createSecretBox("one").decrypt("garbage")).rejects.toThrow();
|
||||
await expect(box.open(`${parts[0]}.${parts[1]}.${ct.toString("base64")}`, "byok", "a")).rejects.toThrow();
|
||||
await expect(box.open("garbage", "byok", "a")).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("a token opens only for its own purpose and row (domain separation and AAD)", async () => {
|
||||
const box = createSecretBox("one");
|
||||
const byok = await box.seal("sk-user", "byok", byokAad("u1", "anthropic"));
|
||||
await expect(box.open(byok, "byok", byokAad("u2", "anthropic"))).rejects.toThrow(); // another user
|
||||
await expect(box.open(byok, "byok", byokAad("u1", "openai"))).rejects.toThrow(); // another provider
|
||||
await expect(box.open(byok, "server-api-key", SERVER_KEY_AAD)).rejects.toThrow(); // another purpose
|
||||
await expect(box.open(byok, "mcp", byokAad("u1", "anthropic"))).rejects.toThrow(); // same AAD, other HKDF key
|
||||
const env = await box.seal("{}", "mcp", mcpAad("fs", "env"));
|
||||
await expect(box.open(env, "mcp", mcpAad("fs", "headers"))).rejects.toThrow();
|
||||
await expect(box.open(env, "mcp", mcpAad("web", "env"))).rejects.toThrow();
|
||||
await expect(box.open(env, "byok", mcpAad("fs", "env"))).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("v1 tokens open only through openLegacy, and v2 tokens never do", async () => {
|
||||
const box = createSecretBox("one");
|
||||
const v1 = await legacyV1Encrypt("one", "old-key");
|
||||
expect(v1.startsWith("v1.")).toBe(true);
|
||||
expect(await box.openLegacy(v1)).toBe("old-key");
|
||||
await expect(box.open(v1, "byok", byokAad("u1", "anthropic"))).rejects.toThrow(/format/);
|
||||
await expect(box.openLegacy(await box.seal("x", "byok", "a"))).rejects.toThrow(/format/);
|
||||
await expect(createSecretBox("two").openLegacy(v1)).rejects.toThrow();
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { AppConfig, AuthUser, JobRecord, ServerContext } from "@stc/contrac
|
||||
import { createApp, createJobQueue, resolveConfig } from "@stc/core";
|
||||
import { createCourseRepository } from "@stc/course-format";
|
||||
import type { Db } from "@stc/db";
|
||||
import { forEachDb } from "../../db/test/helpers";
|
||||
import { forEachAiDb as forEachDb } from "./helpers";
|
||||
import { aiFeature, type AiFeatureOptions } from "../src/feature";
|
||||
import { fakeProvider } from "./helpers";
|
||||
|
||||
@@ -104,6 +104,11 @@ forEachDb("chat cites course files, applies mentor rules, and stores the convers
|
||||
expect(conv.messages.map((m: { role: string }) => m.role)).toEqual(["user", "assistant"]);
|
||||
expect(conv.messages[1].citations.some((c: { path: string }) => c.path === "lessons/04-exercise.md")).toBe(true);
|
||||
expect((await call("GET", `/api/ai/conversations/${convs[0].id}`, "bob")).status).toBe(404);
|
||||
// citations live in the assistant row's column, not in a separate "sources" row
|
||||
const stored = await db.sql`SELECT role, citations_json FROM ai_messages WHERE conversation_id = ${convs[0].id}`;
|
||||
expect(stored.map((m: { role: string }) => m.role).sort()).toEqual(["assistant", "user"]);
|
||||
const assistantRow = stored.find((m: { role: string }) => m.role === "assistant");
|
||||
expect(JSON.parse(assistantRow.citations_json).some((c: { path: string }) => c.path === "lessons/04-exercise.md")).toBe(true);
|
||||
|
||||
// Learner switches mentor mode off for this course: rules leave the prompt.
|
||||
expect((await call("PUT", "/api/ai/courses/sample/mentor", "ann", { off: true })).status).toBe(200);
|
||||
@@ -240,3 +245,71 @@ forEachDb("authoring uses core canView: the global author role reaches restricte
|
||||
await db.sql`INSERT INTO course_acl (course_id, principal_type, principal_id) VALUES ('secret', 'user', 'ann')`;
|
||||
expect((await call("POST", "/api/ai/studio/generate", "ann", body)).status).toBe(403);
|
||||
});
|
||||
|
||||
forEachDb("the admin API masks MCP secrets, and the test button and chat use the stored values", async (db) => {
|
||||
const seen: (string | null)[] = [];
|
||||
const mcpFetch = (async (_url: string | URL | Request, init?: RequestInit) => {
|
||||
seen.push(new Headers(init?.headers).get("authorization"));
|
||||
const msg = JSON.parse(String(init?.body ?? "{}"));
|
||||
if (msg.id === undefined) return new Response(null, { status: 202 });
|
||||
const result = msg.method === "initialize" ? { protocolVersion: "2025-06-18", capabilities: {}, serverInfo: { name: "f", version: "1" } }
|
||||
: msg.method === "tools/list" ? { tools: [{ name: "ping", description: "Ping", inputSchema: { type: "object" } }] }
|
||||
: {};
|
||||
return Response.json({ jsonrpc: "2.0", id: msg.id, result });
|
||||
}) as unknown as typeof fetch;
|
||||
const { call } = await setup(db, [() => ({ text: "ok" })], { fetch: mcpFetch });
|
||||
const web = { id: "web", name: "Web", transport: "http", url: "https://mcp.example.com/mcp", headers: { authorization: "Bearer hdr-SECRET-abcdef" } };
|
||||
expect((await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "anthropic", apiKey: "k" }, mcpServers: [web] })).status).toBe(200);
|
||||
const text = await (await call("GET", "/api/ai/admin/config", "owner")).text();
|
||||
expect(text).not.toContain("SECRET");
|
||||
const shown = JSON.parse(text).mcpServers[0];
|
||||
expect(shown.headers).toEqual({ authorization: "••••cdef" });
|
||||
|
||||
// Saving the page unchanged keeps the secret; a stale mask is a 400 and changes nothing.
|
||||
expect((await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "anthropic" }, mcpServers: [shown] })).status).toBe(200);
|
||||
const stale = await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "anthropic" }, mcpServers: [{ ...shown, id: "web2" }] });
|
||||
expect(stale.status).toBe(400);
|
||||
expect((await stale.json()).error).toContain("Re-enter");
|
||||
|
||||
// The test button posts the masked draft; the MCP server receives the stored header.
|
||||
const t = await (await call("POST", "/api/ai/admin/mcp/test", "owner", shown)).json();
|
||||
expect(t).toMatchObject({ ok: true, tools: [{ name: "ping" }] });
|
||||
expect(seen.filter(Boolean).every((h) => h === "Bearer hdr-SECRET-abcdef")).toBe(true);
|
||||
expect(seen.length).toBeGreaterThan(0);
|
||||
|
||||
// Chat connects with the stored header too.
|
||||
seen.length = 0;
|
||||
await (await call("POST", "/api/ai/courses/sample/chat", "ann", { message: "hello" })).text();
|
||||
expect(seen.length).toBeGreaterThan(0);
|
||||
expect(seen.filter(Boolean).every((h) => h === "Bearer hdr-SECRET-abcdef")).toBe(true);
|
||||
});
|
||||
|
||||
forEachDb("after an STC_SECRET change the admin and the user are told, and an unchanged save keeps every secret", async (db) => {
|
||||
const before = await setup(db, [() => ({ text: "ok" })], {}, { secret: "old-secret" });
|
||||
const web = { id: "web", name: "Web", transport: "http", url: "https://mcp.example.com/mcp", headers: { authorization: "Bearer hdr-SECRET-abcdef" } };
|
||||
await before.call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "anthropic", apiKey: "sk-ant-OLD" }, mcpServers: [web] });
|
||||
await before.call("PUT", "/api/ai/account/key", "ann", { kind: "anthropic", apiKey: "sk-ann-OLD" });
|
||||
const logs: string[] = [];
|
||||
const { call } = await setup(db, [() => ({ text: "ok" })], { log: (m) => logs.push(m) }, { secret: "new-secret" });
|
||||
|
||||
const cfg = await (await call("GET", "/api/ai/admin/config", "owner")).json();
|
||||
expect(cfg.unreadableSecrets).toEqual([{ kind: "api-key" }, { kind: "mcp", serverId: "web", name: "Web", field: "headers" }]);
|
||||
expect(cfg.mcpServers[0].headers).toBeUndefined();
|
||||
for (let i = 0; i < 100 && !logs.length; i++) await Bun.sleep(5); // the startup check runs in the background
|
||||
expect(logs.some((l) => l.includes("cannot be decrypted"))).toBe(true);
|
||||
const acct = await (await call("GET", "/api/ai/account", "ann")).json();
|
||||
expect(acct.byok).toEqual({ kind: "anthropic", hasApiKey: true, keyUnreadable: true });
|
||||
|
||||
// Saving the page as shown (the admin only changes the model) keeps the API key and the MCP header.
|
||||
expect((await call("PUT", "/api/ai/admin/config", "owner", { provider: { kind: "anthropic", model: "claude-sonnet-5-5" }, mcpServers: cfg.mcpServers })).status).toBe(200);
|
||||
const back = await setup(db, [() => ({ text: "ok" })], {}, { secret: "old-secret" });
|
||||
const restored = await (await back.call("GET", "/api/ai/admin/config", "owner")).json();
|
||||
expect(restored.unreadableSecrets).toEqual([]);
|
||||
expect(restored.mcpServers[0].headers).toEqual({ authorization: "••••cdef" });
|
||||
expect((await (await back.call("GET", "/api/ai/account", "ann")).json()).byok.keyUnreadable).toBeUndefined();
|
||||
|
||||
// An explicit clear is accepted; a malformed one is refused.
|
||||
expect((await call("PUT", "/api/ai/admin/config", "owner", { provider: null, mcpServers: [{ ...cfg.mcpServers[0], clearSecrets: ["cookies"] }] })).status).toBe(400);
|
||||
expect((await call("PUT", "/api/ai/admin/config", "owner", { provider: null, mcpServers: [{ ...cfg.mcpServers[0], clearSecrets: ["headers"] }] })).status).toBe(200);
|
||||
expect((await (await call("GET", "/api/ai/admin/config", "owner")).json()).unreadableSecrets).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { AiProvider, ChatMessage, ToolSpec } from "@stc/contracts";
|
||||
import type { AiProvider, ChatMessage, ToolCall, ToolSpec } from "@stc/contracts";
|
||||
import { migrate, type Db } from "@stc/db";
|
||||
import { forEachDb } from "../../db/test/helpers";
|
||||
import { migrationsDirs } from "../src/migrations";
|
||||
|
||||
/** SSE body from a list of [event, data] pairs, split into awkward chunks to exercise buffering. */
|
||||
export function sseBody(events: [string | null, unknown][], chunk = 7): ReadableStream<Uint8Array> {
|
||||
@@ -27,18 +30,20 @@ export function fakeFetch(responses: (() => Response)[]) {
|
||||
return { fetch: f, calls };
|
||||
}
|
||||
|
||||
export async function collect(it: AsyncIterable<{ text?: string; toolCall?: { id: string; name: string; input: unknown } }>) {
|
||||
export async function collect(it: AsyncIterable<{ text?: string; toolCall?: ToolCall; providerData?: unknown }>) {
|
||||
let text = "";
|
||||
const toolCalls: { id: string; name: string; input: unknown }[] = [];
|
||||
const toolCalls: ToolCall[] = [];
|
||||
let providerData: unknown;
|
||||
for await (const ev of it) {
|
||||
if (ev.text) text += ev.text;
|
||||
if (ev.toolCall) toolCalls.push(ev.toolCall);
|
||||
if (ev.providerData !== undefined) providerData = ev.providerData;
|
||||
}
|
||||
return { text, toolCalls };
|
||||
return { text, toolCalls, providerData };
|
||||
}
|
||||
|
||||
/** Scripted fake provider: each call to chat() plays the next turn and records the messages it got. */
|
||||
export function fakeProvider(turns: ((messages: ChatMessage[], tools?: ToolSpec[]) => { text?: string; toolCalls?: { id: string; name: string; input: unknown }[] })[]) {
|
||||
export function fakeProvider(turns: ((messages: ChatMessage[], tools?: ToolSpec[]) => { text?: string; toolCalls?: ToolCall[]; providerData?: unknown })[]) {
|
||||
const seen: { messages: ChatMessage[]; tools?: ToolSpec[] }[] = [];
|
||||
const provider: AiProvider = {
|
||||
id: "fake",
|
||||
@@ -47,7 +52,16 @@ export function fakeProvider(turns: ((messages: ChatMessage[], tools?: ToolSpec[
|
||||
const turn = turns[Math.min(seen.length - 1, turns.length - 1)]!(messages, opts.tools);
|
||||
if (turn.text) for (const part of turn.text.match(/.{1,8}/gs) ?? []) yield { text: part };
|
||||
for (const tc of turn.toolCalls ?? []) yield { toolCall: tc };
|
||||
if (turn.providerData !== undefined) yield { providerData: turn.providerData };
|
||||
},
|
||||
};
|
||||
return { provider, seen };
|
||||
}
|
||||
|
||||
/** forEachDb with the AI package's migrations applied on top of core (the schema the server runs). */
|
||||
export function forEachAiDb(name: string, fn: (db: Db) => Promise<void>): void {
|
||||
forEachDb(name, async (db) => {
|
||||
await migrate(db, { "feature-ai": migrationsDirs });
|
||||
await fn(db);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,8 +37,7 @@ test("assembles fragmented tool calls and maps a tool round back", async () => {
|
||||
|
||||
await collect(p.chat([
|
||||
{ role: "user", content: "q" },
|
||||
{ role: "assistant", content: "Reading." },
|
||||
{ role: "assistant", content: "{\"lessonId\":\"m1-intro\"}", toolCallId: "call_1", name: "read_lesson" },
|
||||
{ role: "assistant", content: "Reading.", toolCalls: [{ id: "call_1", name: "read_lesson", input: { lessonId: "m1-intro" } }] },
|
||||
{ role: "tool", content: "# Introduction", toolCallId: "call_1", name: "read_lesson" },
|
||||
], { tools }));
|
||||
expect(ff.calls[1]!.body.messages).toEqual([
|
||||
@@ -59,3 +58,24 @@ test("createProvider picks the implementation and defaults", () => {
|
||||
expect(() => createProvider({ kind: "anthropic" }, null)).toThrow(/key/);
|
||||
expect(() => createProvider({ kind: "openai" }, null)).toThrow(/base URL|model/);
|
||||
});
|
||||
|
||||
test("several tool calls in one assistant turn map to one message with tool_calls", async () => {
|
||||
const ff = fakeFetch([ok([chunk({ content: "ok" }), [null, "[DONE]"]])]);
|
||||
const p = createOpenAiProvider({ baseUrl: "http://x/v1", model: "m", fetch: ff.fetch });
|
||||
const r = await collect(p.chat([
|
||||
{ role: "user", content: "q" },
|
||||
{ role: "assistant", content: "", toolCalls: [{ id: "a", name: "search_course", input: { query: "x" } }, { id: "b", name: "list_notes", input: {} }], providerData: { anthropic: [] } },
|
||||
{ role: "tool", content: "r1", toolCallId: "a", name: "search_course" },
|
||||
{ role: "tool", content: "r2", toolCallId: "b", name: "list_notes" },
|
||||
], {}));
|
||||
expect(r.providerData).toBeUndefined();
|
||||
expect(ff.calls[0]!.body.messages).toEqual([
|
||||
{ role: "user", content: "q" },
|
||||
{ role: "assistant", content: null, tool_calls: [
|
||||
{ id: "a", type: "function", function: { name: "search_course", arguments: "{\"query\":\"x\"}" } },
|
||||
{ id: "b", type: "function", function: { name: "list_notes", arguments: "{}" } },
|
||||
] },
|
||||
{ role: "tool", tool_call_id: "a", content: "r1" },
|
||||
{ role: "tool", tool_call_id: "b", content: "r2" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { expect } from "bun:test";
|
||||
import { forEachDb } from "../../db/test/helpers";
|
||||
import { forEachAiDb as forEachDb } from "./helpers";
|
||||
import { createSecretBox } from "../src/crypto";
|
||||
import { aiSettingsRepo } from "../src/settings";
|
||||
import { aiSettingsRepo, maskSecret, MaskedSecretError } from "../src/settings";
|
||||
|
||||
const box = createSecretBox("test-secret");
|
||||
|
||||
forEachDb("server config is empty by default and hides the key", async (db) => {
|
||||
const r = aiSettingsRepo(db, box);
|
||||
expect(await r.getServer()).toEqual({ provider: null, mcpServers: [], allowedBaseUrls: [] });
|
||||
expect(await r.getServer()).toEqual({ provider: null, mcpServers: [], allowedBaseUrls: [], unreadableSecrets: [] });
|
||||
expect(await r.serverApiKey()).toBeNull();
|
||||
|
||||
await r.saveServer({ provider: { kind: "anthropic", model: "claude-opus-5-5", apiKey: "sk-ant-SECRET" }, mcpServers: [] });
|
||||
@@ -61,3 +61,148 @@ forEachDb("mentor mode is on by default and toggles per course", async (db) => {
|
||||
expect(await r.isMentorOff("u1", "sample")).toBe(false);
|
||||
expect((await r.getUser("u1")).byok?.kind).toBe("anthropic");
|
||||
});
|
||||
|
||||
forEachDb("mentor toggles are rows in ai_mentor_prefs, not config_json", async (db) => {
|
||||
const r = aiSettingsRepo(db, box);
|
||||
await r.saveUserKey("u1", { kind: "anthropic", apiKey: "k" });
|
||||
await r.setMentorOff("u1", "sample", true);
|
||||
await r.setMentorOff("u1", "sample", true); // twice is fine
|
||||
const rows = await db.sql`SELECT user_id, course_id, off FROM ai_mentor_prefs`;
|
||||
expect(rows.map((x: { user_id: string; course_id: string; off: number }) => [x.user_id, x.course_id, Number(x.off)])).toEqual([["u1", "sample", 1]]);
|
||||
const cfg = (await db.sql`SELECT config_json FROM ai_settings WHERE scope = 'user' AND owner_id = 'u1'`)[0].config_json;
|
||||
expect(cfg).not.toContain("mentorOff");
|
||||
expect((await r.getUser("u1")).mentorOff).toEqual({ sample: true });
|
||||
await r.setMentorOff("u1", "sample", false);
|
||||
expect(await db.sql`SELECT user_id FROM ai_mentor_prefs`).toHaveLength(0);
|
||||
});
|
||||
|
||||
forEachDb("MCP env and headers are encrypted, masked when read and kept when the mask is sent back", async (db) => {
|
||||
const r = aiSettingsRepo(db, box);
|
||||
await r.saveServer({ provider: null, mcpServers: [
|
||||
{ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "tok-SECRET-123456", SHORT: "abc" } },
|
||||
{ id: "web", name: "Web", transport: "http", url: "https://mcp.example.com/mcp", headers: { authorization: "Bearer hdr-SECRET-abcdef" } },
|
||||
] });
|
||||
const raw = (await db.sql`SELECT config_json FROM ai_settings WHERE scope = 'server'`)[0].config_json;
|
||||
expect(raw).not.toContain("SECRET");
|
||||
expect(raw).not.toContain("abc\"");
|
||||
|
||||
const shown = await r.getServer();
|
||||
expect(JSON.stringify(shown)).not.toContain("SECRET");
|
||||
expect(shown.mcpServers[0]!.env).toEqual({ FS_TOKEN: "••••3456", SHORT: "••••••••" });
|
||||
expect(shown.mcpServers[1]!.headers).toEqual({ authorization: "••••cdef" });
|
||||
expect(maskSecret("abc")).toBe("••••••••");
|
||||
|
||||
// The admin page sends the masks back unchanged (plus a new variable): stored values are kept.
|
||||
await r.saveServer({ provider: null, mcpServers: [
|
||||
{ ...shown.mcpServers[0]!, env: { ...shown.mcpServers[0]!.env, NEW: "plain-new-value" } },
|
||||
shown.mcpServers[1]!,
|
||||
] });
|
||||
const full = await r.mcpServersWithSecrets();
|
||||
expect(full[0]!.env).toEqual({ FS_TOKEN: "tok-SECRET-123456", SHORT: "abc", NEW: "plain-new-value" });
|
||||
expect(full[1]!.headers).toEqual({ authorization: "Bearer hdr-SECRET-abcdef" });
|
||||
|
||||
// A new value replaces the old one; a removed key is gone.
|
||||
await r.saveServer({ provider: null, mcpServers: [{ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "tok-ROTATED-999999" } }] });
|
||||
expect((await r.mcpServersWithSecrets())).toEqual([{ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "tok-ROTATED-999999" } }]);
|
||||
|
||||
// A mask that does not match the stored value (other server id, or a stale mask) is refused, never stored.
|
||||
await expect(r.saveServer({ provider: null, mcpServers: [{ id: "fs2", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "••••9999" } }] })).rejects.toThrow(MaskedSecretError);
|
||||
await expect(r.saveServer({ provider: null, mcpServers: [{ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "••••0000" } }] })).rejects.toThrow(/Re-enter/);
|
||||
expect((await r.mcpServersWithSecrets())[0]!.env).toEqual({ FS_TOKEN: "tok-ROTATED-999999" });
|
||||
|
||||
// resolveMcpMasks: the admin "test" button sends a masked draft; the server fills in the stored values.
|
||||
expect(await r.resolveMcpMasks({ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "••••9999" } }))
|
||||
.toEqual({ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", env: { FS_TOKEN: "tok-ROTATED-999999" } });
|
||||
});
|
||||
|
||||
forEachDb("unreadable MCP secrets are reported, kept on save, and recover when the old STC_SECRET returns", async (db) => {
|
||||
const old = aiSettingsRepo(db, createSecretBox("old-secret"));
|
||||
await old.saveServer({ provider: null, mcpServers: [
|
||||
{ id: "fs", name: "Files", transport: "stdio", command: "x", env: { T: "tok-SECRET-123456" } },
|
||||
{ id: "web", name: "Web", transport: "http", url: "https://mcp.example.com/mcp", headers: { authorization: "Bearer hdr-SECRET-abcdef" } },
|
||||
] });
|
||||
const r = aiSettingsRepo(db, box); // STC_SECRET changed
|
||||
const shown = await r.getServer();
|
||||
expect(shown.mcpServers).toEqual([
|
||||
{ id: "fs", name: "Files", transport: "stdio", command: "x" },
|
||||
{ id: "web", name: "Web", transport: "http", url: "https://mcp.example.com/mcp" },
|
||||
]);
|
||||
expect(shown.unreadableSecrets).toEqual([
|
||||
{ kind: "mcp", serverId: "fs", name: "Files", field: "env" },
|
||||
{ kind: "mcp", serverId: "web", name: "Web", field: "headers" },
|
||||
]);
|
||||
expect(await r.mcpServersWithSecrets()).toEqual(shown.mcpServers);
|
||||
|
||||
// The admin saves the page for another reason: the unreadable ciphers survive.
|
||||
const before = JSON.parse((await db.sql`SELECT config_json FROM ai_settings WHERE scope = 'server'`)[0].config_json);
|
||||
await r.saveServer({ provider: { kind: "openai", baseUrl: "http://ollama:11434/v1", model: "llama3" }, mcpServers: shown.mcpServers });
|
||||
const after = JSON.parse((await db.sql`SELECT config_json FROM ai_settings WHERE scope = 'server'`)[0].config_json);
|
||||
expect(after.mcpServers[0].envCipher).toBe(before.mcpServers[0].envCipher);
|
||||
expect(after.mcpServers[1].headersCipher).toBe(before.mcpServers[1].headersCipher);
|
||||
expect((await r.getServer()).unreadableSecrets).toHaveLength(2);
|
||||
|
||||
// Restoring the previous secret brings them back.
|
||||
expect((await aiSettingsRepo(db, createSecretBox("old-secret")).mcpServersWithSecrets()).map((m) => m.env ?? m.headers)).toEqual([
|
||||
{ T: "tok-SECRET-123456" }, { authorization: "Bearer hdr-SECRET-abcdef" },
|
||||
]);
|
||||
|
||||
// Explicit replace (new values) and explicit clear (clearSecrets) are the only ways to drop them.
|
||||
await r.saveServer({ provider: null, mcpServers: [
|
||||
{ id: "fs", name: "Files", transport: "stdio", command: "x", env: { T: "tok-NEW-0000000000" } },
|
||||
{ id: "web", name: "Web", transport: "http", url: "https://mcp.example.com/mcp", clearSecrets: ["headers"] },
|
||||
] });
|
||||
expect(await r.mcpServersWithSecrets()).toEqual([
|
||||
{ id: "fs", name: "Files", transport: "stdio", command: "x", env: { T: "tok-NEW-0000000000" } },
|
||||
{ id: "web", name: "Web", transport: "http", url: "https://mcp.example.com/mcp" },
|
||||
]);
|
||||
expect((await r.getServer()).unreadableSecrets).toEqual([]);
|
||||
expect((await db.sql`SELECT config_json FROM ai_settings WHERE scope = 'server'`)[0].config_json).not.toContain("clearSecrets");
|
||||
});
|
||||
|
||||
forEachDb("an MCP cipher swapped onto another server or field does not open", async (db) => {
|
||||
const r = aiSettingsRepo(db, box);
|
||||
await r.saveServer({ provider: null, mcpServers: [
|
||||
{ id: "a", name: "A", transport: "http", url: "https://a.example.com/mcp", headers: { authorization: "Bearer A-SECRET-111111" } },
|
||||
{ id: "b", name: "B", transport: "http", url: "https://b.example.com/mcp", headers: { authorization: "Bearer B-SECRET-222222" } },
|
||||
{ id: "c", name: "C", transport: "stdio", command: "x", env: { T: "C-SECRET-33333333" } },
|
||||
] });
|
||||
const row = JSON.parse((await db.sql`SELECT config_json FROM ai_settings WHERE scope = 'server'`)[0].config_json);
|
||||
// swap a <-> b, and move c's env cipher into a headers slot of c
|
||||
[row.mcpServers[0].headersCipher, row.mcpServers[1].headersCipher] = [row.mcpServers[1].headersCipher, row.mcpServers[0].headersCipher];
|
||||
row.mcpServers[2].headersCipher = row.mcpServers[2].envCipher;
|
||||
delete row.mcpServers[2].envCipher;
|
||||
await db.sql`UPDATE ai_settings SET config_json = ${JSON.stringify(row)} WHERE scope = 'server'`;
|
||||
const full = await r.mcpServersWithSecrets();
|
||||
expect(full.every((m) => !m.headers && !m.env)).toBe(true);
|
||||
expect((await r.getServer()).unreadableSecrets.map((u) => u.kind === "mcp" && `${u.serverId}.${u.field}`)).toEqual(["a.headers", "b.headers", "c.headers"]);
|
||||
});
|
||||
|
||||
forEachDb("a BYOK ciphertext copied to another user, or into an MCP slot, does not open", async (db) => {
|
||||
const r = aiSettingsRepo(db, box);
|
||||
await r.saveUserKey("u1", { kind: "anthropic", apiKey: "sk-u1-SECRET" });
|
||||
await r.saveUserKey("u2", { kind: "anthropic", apiKey: "sk-u2-other" });
|
||||
const u1 = (await db.sql`SELECT ciphertext FROM user_api_keys WHERE user_id = 'u1'`)[0].ciphertext as string;
|
||||
await db.sql`UPDATE user_api_keys SET ciphertext = ${u1} WHERE user_id = 'u2'`;
|
||||
expect(await r.userApiKey("u2")).toBeNull();
|
||||
expect((await r.getUser("u2")).byok).toEqual({ kind: "anthropic", hasApiKey: true, keyUnreadable: true });
|
||||
expect(await r.userApiKey("u1")).toBe("sk-u1-SECRET");
|
||||
expect((await r.getUser("u1")).byok).toEqual({ kind: "anthropic", hasApiKey: true });
|
||||
// into the server API key and an MCP field: no oracle
|
||||
await r.saveServer({ provider: { kind: "anthropic", apiKey: "sk-server" }, mcpServers: [{ id: "m", name: "M", transport: "http", url: "https://m.example.com/mcp", headers: { x: "y-123456789012" } }] });
|
||||
const row = JSON.parse((await db.sql`SELECT config_json FROM ai_settings WHERE scope = 'server'`)[0].config_json);
|
||||
row.apiKeyCipher = u1;
|
||||
row.mcpServers[0].headersCipher = u1;
|
||||
await db.sql`UPDATE ai_settings SET config_json = ${JSON.stringify(row)} WHERE scope = 'server'`;
|
||||
expect(await r.serverApiKey()).toBeNull();
|
||||
expect((await r.mcpServersWithSecrets())[0]!.headers).toBeUndefined();
|
||||
expect((await r.getServer()).unreadableSecrets).toEqual([{ kind: "api-key" }, { kind: "mcp", serverId: "m", name: "M", field: "headers" }]);
|
||||
});
|
||||
|
||||
forEachDb("an unreadable server API key is reported and kept on save", async (db) => {
|
||||
await aiSettingsRepo(db, createSecretBox("old-secret")).saveServer({ provider: { kind: "anthropic", apiKey: "sk-ant-OLD" }, mcpServers: [] });
|
||||
const r = aiSettingsRepo(db, box);
|
||||
expect((await r.getServer()).unreadableSecrets).toEqual([{ kind: "api-key" }]);
|
||||
expect(await r.serverApiKey()).toBeNull();
|
||||
await r.saveServer({ provider: { kind: "anthropic", model: "claude-opus-5-5" }, mcpServers: [] });
|
||||
expect(await aiSettingsRepo(db, createSecretBox("old-secret")).serverApiKey()).toBe("sk-ant-OLD");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { expect } from "bun:test";
|
||||
import { migrate, type Db } from "@stc/db";
|
||||
// Core migrations only: the ai_* tables exactly as v1 shipped them.
|
||||
import { forEachDb as forEachV1Db } from "../../db/test/helpers";
|
||||
import { byokAad, createSecretBox, legacyV1Encrypt, mcpAad, SERVER_KEY_AAD } from "../src/crypto";
|
||||
import { migrationsDirs } from "../src/migrations";
|
||||
import { aiSettingsRepo } from "../src/settings";
|
||||
import { AI_SECRETS_UPGRADE, upgradeAiData } from "../src/upgrade";
|
||||
|
||||
const T = "2026-10-01T00:00:00.000Z";
|
||||
const SECRET = "test-secret";
|
||||
const box = createSecretBox(SECRET);
|
||||
/** A secret as v1 stored it (one key, no additional data). */
|
||||
const v1Token = (plain: string, secret = SECRET) => legacyV1Encrypt(secret, plain);
|
||||
const aiMigrations = { "feature-ai": migrationsDirs };
|
||||
|
||||
async function seedMessage(db: Db, id: string, conv: string, role: string, content: string) {
|
||||
await db.sql`INSERT INTO ai_messages (id, conversation_id, role, content, created_at) VALUES (${id}, ${conv}, ${role}, ${content}, ${T})`;
|
||||
}
|
||||
|
||||
forEachV1Db("v1 upgrade: sources rows fold into citations_json on the assistant row before them", async (db) => {
|
||||
const cites = JSON.stringify([{ path: "lessons/04-exercise.md", title: "Adding numbers", lessonId: "m2-ex-add" }]);
|
||||
await db.sql`INSERT INTO ai_conversations (id, user_id, course_id, title, created_at) VALUES ('c1', 'u1', 'sample', 'Hi', ${T})`;
|
||||
await db.sql`INSERT INTO ai_conversations (id, user_id, course_id, title, created_at) VALUES ('c2', 'u1', 'sample', 'Two', ${T})`;
|
||||
// ids sort in the order v1 wrote them (UUIDv7); c1: user, assistant, sources, user, assistant (no sources)
|
||||
await seedMessage(db, "0001-a", "c1", "user", "How do I add?");
|
||||
await seedMessage(db, "0001-b", "c1", "assistant", "Which operator?");
|
||||
await seedMessage(db, "0001-c", "c1", "sources", cites);
|
||||
await seedMessage(db, "0001-d", "c1", "user", "And then?");
|
||||
await seedMessage(db, "0001-e", "c1", "assistant", "Try it.");
|
||||
// c2: an orphan sources row (no assistant before it), then an assistant with no row after it
|
||||
await seedMessage(db, "0002-a", "c2", "sources", "[]");
|
||||
await seedMessage(db, "0002-b", "c2", "assistant", "Alone.");
|
||||
|
||||
const applied = await migrate(db, aiMigrations);
|
||||
expect(applied).toContain("0001_ai_message_citations.sql");
|
||||
|
||||
const rows = await db.sql`SELECT id, role, content, citations_json FROM ai_messages ORDER BY id`;
|
||||
expect(rows.map((r: { id: string; role: string }) => `${r.id}:${r.role}`)).toEqual([
|
||||
"0001-a:user", "0001-b:assistant", "0001-d:user", "0001-e:assistant", "0002-b:assistant",
|
||||
]);
|
||||
const byId = Object.fromEntries(rows.map((r: { id: string; citations_json: string | null }) => [r.id, r.citations_json]));
|
||||
expect(JSON.parse(byId["0001-b"]!)).toEqual(JSON.parse(cites));
|
||||
expect(byId["0001-e"]).toBeNull();
|
||||
expect(byId["0002-b"]).toBeNull();
|
||||
expect(byId["0001-a"]).toBeNull();
|
||||
expect(await migrate(db, aiMigrations)).toEqual([]);
|
||||
});
|
||||
|
||||
async function seedSettings(db: Db, scope: "server" | "user", owner: string, config: unknown) {
|
||||
const json = typeof config === "string" ? config : JSON.stringify(config);
|
||||
await db.sql`INSERT INTO ai_settings (scope, owner_id, mentor_off, config_json, updated_at) VALUES (${scope}, ${owner}, 0, ${json}, ${T})`;
|
||||
}
|
||||
|
||||
forEachV1Db("v1 upgrade: per-course mentor toggles move from config_json to ai_mentor_prefs", async (db) => {
|
||||
await seedSettings(db, "user", "u1", { byok: { kind: "anthropic" }, mentorOff: { sample: true, other: false } });
|
||||
await seedSettings(db, "user", "u2", { byok: null, mentorOff: {} });
|
||||
await seedSettings(db, "user", "u3", "{not json");
|
||||
await db.sql`INSERT INTO user_api_keys (user_id, provider, ciphertext, created_at) VALUES ('u1', 'anthropic', ${await v1Token("sk-u1")}, ${T})`;
|
||||
|
||||
expect(await migrate(db, aiMigrations)).toContain("0002_ai_mentor_prefs.sql");
|
||||
expect(await upgradeAiData(db, box)).toBe(true);
|
||||
|
||||
const prefs = await db.sql`SELECT user_id, course_id, off FROM ai_mentor_prefs ORDER BY user_id, course_id`;
|
||||
expect(prefs.map((r: { user_id: string; course_id: string; off: number }) => [r.user_id, r.course_id, Number(r.off)])).toEqual([["u1", "sample", 1]]);
|
||||
const cfg = async (u: string) => (await db.sql`SELECT config_json FROM ai_settings WHERE scope = 'user' AND owner_id = ${u}`)[0].config_json as string;
|
||||
expect(JSON.parse(await cfg("u1"))).toEqual({ byok: { kind: "anthropic" } });
|
||||
expect(JSON.parse(await cfg("u2"))).toEqual({ byok: null });
|
||||
expect(await cfg("u3")).toBe("{not json");
|
||||
|
||||
const repo = aiSettingsRepo(db, box);
|
||||
expect(await repo.isMentorOff("u1", "sample")).toBe(true);
|
||||
expect(await repo.isMentorOff("u1", "other")).toBe(false);
|
||||
expect(await repo.getUser("u1")).toEqual({ byok: { kind: "anthropic", hasApiKey: true }, mentorOff: { sample: true } });
|
||||
|
||||
// idempotent: the marker stops a second run
|
||||
expect(await upgradeAiData(db, box)).toBe(false);
|
||||
expect((await db.sql`SELECT COUNT(*) AS n FROM ai_mentor_prefs`)[0].n.toString()).toBe("1");
|
||||
expect((await db.sql`SELECT name FROM schema_migrations WHERE name = 'ai_data_v11'`)).toHaveLength(1);
|
||||
});
|
||||
|
||||
forEachV1Db("v1 upgrade: the settings repo upgrades on first use", async (db) => {
|
||||
await seedSettings(db, "user", "u1", { byok: null, mentorOff: { sample: true } });
|
||||
await migrate(db, aiMigrations);
|
||||
expect(await aiSettingsRepo(db, box).isMentorOff("u1", "sample")).toBe(true);
|
||||
expect(JSON.parse((await db.sql`SELECT config_json FROM ai_settings WHERE owner_id = 'u1'`)[0].config_json)).toEqual({ byok: null });
|
||||
});
|
||||
|
||||
forEachV1Db("v1 upgrade: plaintext MCP env and headers are encrypted at rest", async (db) => {
|
||||
const apiKeyCipher = await v1Token("sk-ant-SERVER");
|
||||
await seedSettings(db, "server", "", {
|
||||
provider: { kind: "anthropic" }, apiKeyCipher, allowedBaseUrls: ["http://ollama:11434"],
|
||||
mcpServers: [
|
||||
{ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", args: ["--root", "/x"], env: { FS_TOKEN: "tok-PLAIN-123456" } },
|
||||
{ id: "web", name: "Web", transport: "http", url: "https://mcp.example.com/mcp", headers: { authorization: "Bearer hdr-PLAIN-abcdef" }, courseIds: ["sample"] },
|
||||
{ id: "bare", name: "Bare", transport: "http", url: "https://bare.example.com/mcp" },
|
||||
],
|
||||
});
|
||||
await migrate(db, aiMigrations);
|
||||
expect(await upgradeAiData(db, box)).toBe(true);
|
||||
|
||||
const raw = (await db.sql`SELECT config_json FROM ai_settings WHERE scope = 'server'`)[0].config_json as string;
|
||||
expect(raw).not.toContain("PLAIN");
|
||||
const stored = JSON.parse(raw);
|
||||
expect(stored.apiKeyCipher.startsWith("v2.")).toBe(true); // re-sealed under the v2 scheme
|
||||
expect(await box.open(stored.apiKeyCipher, "server-api-key", SERVER_KEY_AAD)).toBe("sk-ant-SERVER");
|
||||
expect(stored.allowedBaseUrls).toEqual(["http://ollama:11434"]);
|
||||
type S = { id: string; env?: unknown; headers?: unknown; envCipher?: string; headersCipher?: string; secretCipher?: string };
|
||||
expect(stored.mcpServers.map((s: S) => [s.id, !!s.env, !!s.headers, !!s.envCipher, !!s.headersCipher, !!s.secretCipher]))
|
||||
.toEqual([["fs", false, false, true, false, false], ["web", false, false, false, true, false], ["bare", false, false, false, false, false]]);
|
||||
expect(JSON.parse(await box.open(stored.mcpServers[0].envCipher, "mcp", mcpAad("fs", "env")))).toEqual({ FS_TOKEN: "tok-PLAIN-123456" });
|
||||
expect((await db.sql`SELECT name FROM schema_migrations WHERE name = ${AI_SECRETS_UPGRADE}`)).toHaveLength(1);
|
||||
|
||||
const repo = aiSettingsRepo(db, box);
|
||||
expect(await repo.serverApiKey()).toBe("sk-ant-SERVER");
|
||||
expect(await repo.mcpServersWithSecrets()).toEqual([
|
||||
{ id: "fs", name: "Files", transport: "stdio", command: "mcp-fs", args: ["--root", "/x"], env: { FS_TOKEN: "tok-PLAIN-123456" } },
|
||||
{ id: "web", name: "Web", transport: "http", url: "https://mcp.example.com/mcp", headers: { authorization: "Bearer hdr-PLAIN-abcdef" }, courseIds: ["sample"] },
|
||||
{ id: "bare", name: "Bare", transport: "http", url: "https://bare.example.com/mcp" },
|
||||
]);
|
||||
const shown = JSON.stringify(await repo.getServer());
|
||||
expect(shown).not.toContain("PLAIN");
|
||||
});
|
||||
|
||||
forEachV1Db("v1.1 draft upgrade: secretCipher, v1 API keys and BYOK keys are re-sealed as v2, once", async (db) => {
|
||||
// The state the first v1.1 draft left: mentor step done, MCP secrets in one v1-token secretCipher.
|
||||
await migrate(db, aiMigrations);
|
||||
await db.sql`INSERT INTO schema_migrations (name, applied_at) VALUES ('ai_data_v11', ${T})`;
|
||||
await seedSettings(db, "server", "", {
|
||||
provider: { kind: "anthropic" }, apiKeyCipher: await v1Token("sk-ant-SERVER"),
|
||||
mcpServers: [
|
||||
{ id: "fs", name: "Files", transport: "stdio", command: "x", secretCipher: await v1Token(JSON.stringify({ env: { T: "tok-DRAFT-123456" } })) },
|
||||
{ id: "web", name: "Web", transport: "http", url: "https://w.example.com/mcp", secretCipher: await v1Token(JSON.stringify({ headers: { authorization: "Bearer DRAFT-abcdef" } })) },
|
||||
"not a server",
|
||||
],
|
||||
});
|
||||
await db.sql`INSERT INTO user_api_keys (user_id, provider, ciphertext, created_at) VALUES ('u1', 'openai', ${await v1Token("sk-u1")}, ${T})`;
|
||||
await seedSettings(db, "user", "u1", { byok: { kind: "openai", model: "m" } });
|
||||
|
||||
expect(await upgradeAiData(db, box)).toBe(true);
|
||||
const raw = (await db.sql`SELECT config_json FROM ai_settings WHERE scope = 'server'`)[0].config_json as string;
|
||||
expect(raw).not.toContain("secretCipher");
|
||||
expect(raw).not.toContain("\"v1.");
|
||||
const ct = (await db.sql`SELECT ciphertext FROM user_api_keys WHERE user_id = 'u1'`)[0].ciphertext as string;
|
||||
expect(ct.startsWith("v2.")).toBe(true);
|
||||
expect(await box.open(ct, "byok", byokAad("u1", "openai"))).toBe("sk-u1");
|
||||
|
||||
const repo = aiSettingsRepo(db, box);
|
||||
expect(await repo.serverApiKey()).toBe("sk-ant-SERVER");
|
||||
expect(await repo.userApiKey("u1")).toBe("sk-u1");
|
||||
expect(await repo.mcpServersWithSecrets()).toEqual([
|
||||
{ id: "fs", name: "Files", transport: "stdio", command: "x", env: { T: "tok-DRAFT-123456" } },
|
||||
{ id: "web", name: "Web", transport: "http", url: "https://w.example.com/mcp", headers: { authorization: "Bearer DRAFT-abcdef" } },
|
||||
]);
|
||||
expect((await repo.getServer()).unreadableSecrets).toEqual([]);
|
||||
expect(JSON.parse(raw).mcpServers[2]).toBe("not a server"); // malformed entries are kept, not fatal
|
||||
|
||||
const snapshot = raw;
|
||||
expect(await upgradeAiData(db, box)).toBe(false);
|
||||
expect((await db.sql`SELECT config_json FROM ai_settings WHERE scope = 'server'`)[0].config_json).toBe(snapshot);
|
||||
});
|
||||
|
||||
forEachV1Db("v1 upgrade with a changed STC_SECRET keeps old tokens, stays pending, and finishes once the secret is restored", async (db) => {
|
||||
await seedSettings(db, "server", "", {
|
||||
provider: { kind: "anthropic" }, apiKeyCipher: await v1Token("sk-ant-SERVER", "old-secret"),
|
||||
mcpServers: [{ id: "fs", name: "Files", transport: "stdio", command: "x", env: { T: "tok-PLAIN-123456" } }],
|
||||
});
|
||||
await db.sql`INSERT INTO user_api_keys (user_id, provider, ciphertext, created_at) VALUES ('u1', 'anthropic', ${await v1Token("sk-u1", "old-secret")}, ${T})`;
|
||||
await seedSettings(db, "user", "u1", { byok: { kind: "anthropic" } });
|
||||
await migrate(db, aiMigrations);
|
||||
|
||||
const repo = aiSettingsRepo(db, box); // the new secret
|
||||
expect(await repo.userApiKey("u1")).toBeNull();
|
||||
expect((await repo.getUser("u1")).byok).toEqual({ kind: "anthropic", hasApiKey: true, keyUnreadable: true });
|
||||
expect((await repo.getServer()).unreadableSecrets).toEqual([{ kind: "api-key" }]);
|
||||
// plaintext MCP values do not need the old secret: sealed already
|
||||
expect((await repo.mcpServersWithSecrets())[0]!.env).toEqual({ T: "tok-PLAIN-123456" });
|
||||
expect((await db.sql`SELECT name FROM schema_migrations WHERE name = ${AI_SECRETS_UPGRADE}`)).toHaveLength(0);
|
||||
expect(((await db.sql`SELECT ciphertext FROM user_api_keys`)[0].ciphertext as string).startsWith("v1.")).toBe(true);
|
||||
|
||||
// The admin puts the previous secret back: the next start re-seals and completes.
|
||||
const restored = createSecretBox("old-secret");
|
||||
expect(await upgradeAiData(db, restored)).toBe(true);
|
||||
expect((await db.sql`SELECT name FROM schema_migrations WHERE name = ${AI_SECRETS_UPGRADE}`)).toHaveLength(1);
|
||||
const again = aiSettingsRepo(db, restored);
|
||||
expect(await again.userApiKey("u1")).toBe("sk-u1");
|
||||
expect(await again.serverApiKey()).toBe("sk-ant-SERVER");
|
||||
});
|
||||
@@ -54,3 +54,20 @@ test("citation links use the learner's registered lesson route", async () => {
|
||||
// matchPath leaves params encoded (the router decodes the pathname before matching).
|
||||
expect(Object.fromEntries(Object.entries(hit?.params ?? {}).map(([k, v]) => [k, decodeURIComponent(v!)]))).toEqual({ courseId: "pure snake", lessonId: "m1-ü/x" });
|
||||
});
|
||||
|
||||
test("the unreadable-secrets warnings name what is affected", async () => {
|
||||
const { UnreadableSecretsWarning, UNREADABLE_SECRETS_TEXT, UNREADABLE_KEY_TEXT } = await import("../src/web/components");
|
||||
expect(UNREADABLE_SECRETS_TEXT).toBe("Some stored secrets can't be decrypted with the current STC_SECRET. Re-enter them or restore the previous secret.");
|
||||
expect(UNREADABLE_KEY_TEXT).toBe("Your saved key can't be used; please re-enter it.");
|
||||
const html = renderToString(<UnreadableSecretsWarning items={[{ kind: "api-key" }, { kind: "mcp", serverId: "web", name: "Web", field: "headers" }, { kind: "mcp", serverId: "fs", name: "Files", field: "env" }]} />);
|
||||
expect(html).toContain("Some stored secrets can't be decrypted with the current STC_SECRET.");
|
||||
expect(html).toContain("Server provider: API key");
|
||||
expect(html).toContain("MCP server "Web" (web): headers");
|
||||
expect(html).toContain("MCP server "Files" (fs): environment");
|
||||
expect(html).toContain('role="alert"');
|
||||
expect(renderToString(<UnreadableSecretsWarning items={[]} />)).toBe("");
|
||||
const account = await Bun.file(new URL("../src/web/AccountAiPage.tsx", import.meta.url)).text();
|
||||
expect(account).toContain("UNREADABLE_KEY_TEXT");
|
||||
const admin = await Bun.file(new URL("../src/web/AdminAiPage.tsx", import.meta.url)).text();
|
||||
expect(admin).toContain("<UnreadableSecretsWarning");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user