feat(contracts): shared server, exercise, ai, import and web interfaces

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas WintherandClaude Opus 5.5 committed 2026-10-30 02:48:10 +01:00
1 parent 9f582b99d2
commit 3b15d87a5f
7 files changed
+103

No files matched your search

+3
View File
@@ -0,0 +1,3 @@
export interface ChatMessage { role: "system" | "user" | "assistant" | "tool"; content: string; toolCallId?: string; name?: string }
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 } }> }
+7
View File
@@ -0,0 +1,7 @@
// Browser-runnable exercises (spec 4.3 type 6)
export type ExerciseLanguage = "js" | "ts";
export interface Assert { equal(actual: unknown, expected: unknown, message?: string): void; deepEqual(actual: unknown, expected: unknown, message?: string): void; ok(value: unknown, message?: string): void }
export interface ExerciseTest { name: string; run(mod: Record<string, unknown>, assert: Assert): void | Promise<void> }
// A tests file is an ES module: `export const tests: ExerciseTest[]`. Learner code is an ES module.
export interface TestResult { name: string; passed: boolean; message?: string; expected?: string; actual?: string }
export interface ExerciseRunner { language: ExerciseLanguage; run(learnerCode: string, testsModule: string, timeoutMs: number): Promise<TestResult[]> }
+2
View File
@@ -0,0 +1,2 @@
export interface ImportSource { kind: "packt-video" | "pdf-deck" | "code-repo" | "video-folder"; path: string; options?: Record<string, unknown> }
export interface ImportResult { courseId: string; manifestPath: string; warnings: string[] }
+5
View File
@@ -0,0 +1,5 @@
export * from "./server";
export * from "./exercise";
export * from "./ai";
export * from "./import";
export * from "./web";
+28
View File
@@ -0,0 +1,28 @@
export type Role = "owner" | "author" | "learner";
export interface AuthUser { id: string; email: string; name: string; roles: Role[]; groups: string[] }
export interface RequestContext { user: AuthUser | null; params: Record<string, string>; url: URL }
export type RouteHandler = (req: Request, rc: RequestContext) => Response | Promise<Response>;
export interface Feature {
name: string; // "learner" | "import" | "studio" | "ai" | "core"
routes: Record<string, Partial<Record<"GET"|"POST"|"PUT"|"PATCH"|"DELETE", RouteHandler>>>; // paths start with /api/
jobs?: Record<string, JobHandler>; // job kind -> handler
}
export interface JobContext { reportProgress(fraction: number, message?: string): Promise<void>; signal: AbortSignal }
export type JobHandler = (payload: unknown, jc: JobContext) => Promise<unknown>;
export type JobStatus = "queued" | "running" | "done" | "failed";
export interface JobRecord { id: string; kind: string; status: JobStatus; progress: number; message: string | null; payload: unknown; result: unknown; error: string | null; createdAt: string; updatedAt: string }
export interface JobQueue { enqueue(kind: string, payload: unknown): Promise<string>; get(id: string): Promise<JobRecord | null>; list(kind?: string): Promise<JobRecord[]> }
export interface AuthService { userFromRequest(req: Request): Promise<AuthUser | null> }
// Placeholders until Task 4 (@stc/db) and Task 7 (@stc/course-format) exist; switch to the real imports in Task 7.
type Db = unknown;
type CourseRepository = unknown;
export interface ServerContext {
config: AppConfig; db: Db; courses: CourseRepository;
jobs: JobQueue; auth: AuthService;
}
export interface AppConfig {
port: number; databaseUrl: string; dataDir: string; // dataDir holds cache/, media/, worktrees/
courseDirs: string[]; // folders scanned for stcourse.yaml (course repos and overlays/)
secret: string; // STC_SECRET, used to encrypt BYOK keys
publicUrl: string;
}
+4
View File
@@ -0,0 +1,4 @@
import type React from "react";
export interface WebRoute { path: string; element: () => Promise<{ default: React.ComponentType }>; nav?: { label: string; area: "header" | "admin" } }
export interface WebFeature { name: string; routes: WebRoute[] }
+54
View File
@@ -0,0 +1,54 @@
import { expect, test } from "bun:test";
import type {
AiProvider, AppConfig, AuthService, AuthUser, Assert, ChatMessage, ExerciseRunner, ExerciseTest,
Feature, ImportResult, ImportSource, JobContext, JobHandler, JobQueue, JobRecord, RequestContext,
RouteHandler, ServerContext, TestResult, ToolSpec, WebFeature, WebRoute,
} from "../src/index";
test("contract interfaces accept well-formed literals", async () => {
const user: AuthUser = { id: "u1", email: "a@b.c", name: "A", roles: ["owner", "learner"], groups: ["g"] };
const rc: RequestContext = { user, params: { id: "1" }, url: new URL("http://localhost/api/x") };
const handler: RouteHandler = async () => new Response("ok");
const jobHandler: JobHandler = async (_p, jc) => { await jc.reportProgress(0.5, "half"); return null; };
const feature: Feature = {
name: "core",
routes: { "/api/health": { GET: handler, POST: handler } },
jobs: { noop: jobHandler },
};
const jc: JobContext = { reportProgress: async () => {}, signal: new AbortController().signal };
const record: JobRecord = {
id: "j1", kind: "noop", status: "queued", progress: 0, message: null, payload: {}, result: null,
error: null, createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z",
};
const queue: JobQueue = { enqueue: async () => "j1", get: async () => record, list: async () => [record] };
const auth: AuthService = { userFromRequest: async () => user };
const config: AppConfig = {
port: 3000, databaseUrl: "sqlite://./data/stcourses.db", dataDir: "./data", courseDirs: ["./overlays"],
secret: "s", publicUrl: "http://localhost:3000",
};
const ctx: Pick<ServerContext, "config" | "jobs" | "auth"> = { config, jobs: queue, auth };
const assert: Assert = { equal() {}, deepEqual() {}, ok() {} };
const t: ExerciseTest = { name: "t", run: (_mod, a) => a.ok(true) };
const runner: ExerciseRunner = {
language: "ts",
run: async () => [{ name: "t", passed: true } satisfies TestResult],
};
const msg: ChatMessage = { role: "user", content: "hi" };
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: {} } }; },
};
const source: ImportSource = { kind: "pdf-deck", path: "/tmp/a.pdf", options: {} };
const result: ImportResult = { courseId: "c", manifestPath: "/tmp/stcourse.yaml", warnings: [] };
const route: WebRoute = {
path: "/", element: async () => ({ default: () => null }), nav: { label: "Home", area: "header" },
};
const web: WebFeature = { name: "learner", routes: [route] };
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];
});