diff --git a/apps/api/.env.example b/apps/api/.env.example index a26475c3..8d0995b9 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -8,6 +8,10 @@ WEB_ORIGIN=http://localhost:3000 # Shared parent domain for the session cookie when the API and dashboard are on # different subdomains, for example `medialit.cloud`. Leave unset otherwise. # AUTH_COOKIE_DOMAIN= +# Cloudflare Turnstile on the hosted login page. Set both keys or neither. +# The widget's hostname in Cloudflare must be PUBLIC_API_URL's host. +# TURNSTILE_SITE_KEY= +# TURNSTILE_SECRET_KEY= BETTER_AUTH_SECRET=medialit-local-better-auth-secret-32b-min EMAIL=admin@example.com # Optional. An API key the API creates for EMAIL's default app at startup, so diff --git a/apps/api/__tests__/mcp/call-tool.ts b/apps/api/__tests__/mcp/call-tool.ts new file mode 100644 index 00000000..242735fb --- /dev/null +++ b/apps/api/__tests__/mcp/call-tool.ts @@ -0,0 +1,26 @@ +import { medialitMcpTools } from "../../src/mcp/server"; +import type { MedialitMcpContext } from "../../src/mcp/context"; + +/** Runs a tool the way the kit does after authentication, with the args + * already parsed. */ +export async function callTool( + name: string, + context: Partial & { user: any }, + args: Record = {}, +): Promise { + const tool = medialitMcpTools().find( + (candidate) => candidate.name === name, + ); + if (!tool) throw new Error(`No MCP tool named ${name}`); + return tool.handler({ + context: { + authKind: "apikey", + userId: context.user._id, + appId: "test-app", + scopes: ["data:read", "data:write"], + ...context, + }, + args: tool.inputSchema.parse(args), + signal: new AbortController().signal, + }); +} diff --git a/apps/api/__tests__/mcp/media-tool.test.ts b/apps/api/__tests__/mcp/media-tool.test.ts index fedf017d..a5adafd9 100644 --- a/apps/api/__tests__/mcp/media-tool.test.ts +++ b/apps/api/__tests__/mcp/media-tool.test.ts @@ -1,6 +1,7 @@ import test, { afterEach, describe, mock } from "node:test"; import assert from "node:assert"; -import { handleGetTotalStorageTool } from "../../src/mcp/tools/media"; +import mediaQueries from "../../src/media/queries"; +import { callTool } from "./call-tool"; import { maxStorageAllowedSubscribed } from "../../src/config/constants"; import { billingStateSource } from "../../src/billing/entitlements"; @@ -35,28 +36,16 @@ describe("MCP get_total_storage", () => { }; let queriedUserId: unknown; - const response = await handleGetTotalStorageTool( - {}, - { - authInfo: { - extra: { - authKind: "apikey", - userId: user._id, - user, - appId: "test-app", - }, - }, - }, - { - getTotalSpace: async ({ userId }: any) => { - queriedUserId = userId; - return 2103931; - }, + mock.method(mediaQueries, "getTotalSpace").mock.mockImplementation( + async ({ userId }: any) => { + queriedUserId = userId; + return 2103931; }, ); + const response = await callTool("get_total_storage", { user }); assert.equal(queriedUserId, user._id); - assert.deepEqual((response as any).structuredContent, { + assert.deepEqual(response, { storage: 2103931, maxStorage: maxStorageAllowedSubscribed, }); diff --git a/apps/api/__tests__/mcp/upload-tool.test.ts b/apps/api/__tests__/mcp/upload-tool.test.ts index 325b0fa4..a225f2e4 100644 --- a/apps/api/__tests__/mcp/upload-tool.test.ts +++ b/apps/api/__tests__/mcp/upload-tool.test.ts @@ -1,6 +1,6 @@ import test, { afterEach, describe, mock } from "node:test"; import assert from "node:assert"; -import { handleUploadMediaTool } from "../../src/mcp/tools/upload"; +import { callTool } from "./call-tool"; import mediaQueries from "../../src/media/queries"; import mediaService from "../../src/media/service"; import { @@ -12,19 +12,6 @@ import { NOT_ENOUGH_STORAGE, } from "../../src/config/strings"; -function authInfo(user: any) { - return { - authInfo: { - extra: { - authKind: "apikey", - userId: user.id, - user, - appId: "test-app", - }, - }, - }; -} - function uploadArgs(size: number) { return { fileBase64: Buffer.alloc(size).toString("base64"), @@ -47,16 +34,17 @@ describe("MCP upload_media", () => { throw new Error("upload should not be called"); }); - const response = await handleUploadMediaTool( + const response = await callTool( + "upload_media", + { user, userId: user.id }, uploadArgs(maxFileUploadSizeNotSubscribed + 1), - authInfo(user), ); - assert.equal((response as any).isError, true); - assert.equal( - response.content[0].text, - `${FILE_SIZE_EXCEEDED}. Allowed: ${maxFileUploadSizeNotSubscribed} bytes`, - ); + assert.equal(response.error.code, "validation_failed"); + assert.deepEqual(response.error.safeDetails, { + reason: "file_size_exceeded", + message: `${FILE_SIZE_EXCEEDED}. Allowed: ${maxFileUploadSizeNotSubscribed} bytes`, + }); }); test("rejects uploads that exceed remaining account storage", async () => { @@ -71,12 +59,16 @@ describe("MCP upload_media", () => { throw new Error("upload should not be called"); }); - const response = await handleUploadMediaTool( + const response = await callTool( + "upload_media", + { user, userId: user.id }, uploadArgs(1024), - authInfo(user), ); - assert.equal((response as any).isError, true); - assert.equal(response.content[0].text, NOT_ENOUGH_STORAGE); + assert.equal(response.error.code, "forbidden"); + assert.deepEqual(response.error.safeDetails, { + reason: "not_enough_storage", + message: NOT_ENOUGH_STORAGE, + }); }); }); diff --git a/apps/api/__tests__/mcp/whoami-tool.test.ts b/apps/api/__tests__/mcp/whoami-tool.test.ts index 266629ed..c2254393 100644 --- a/apps/api/__tests__/mcp/whoami-tool.test.ts +++ b/apps/api/__tests__/mcp/whoami-tool.test.ts @@ -1,8 +1,16 @@ -import test, { afterEach, describe, mock } from "node:test"; +import test, { afterEach, before, describe, mock } from "node:test"; import assert from "node:assert"; -import { handleWhoamiTool } from "../../src/mcp/tools/whoami"; +import path from "node:path"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import { setDb, type AppDb } from "../../src/db/client"; +import * as schema from "../../src/db/schema/index"; +import { createAccount, createApp, listApps } from "../../src/db"; +import mediaQueries from "../../src/media/queries"; import { maxStorageAllowedSubscribed } from "../../src/config/constants"; import { billingStateSource } from "../../src/billing/entitlements"; +import { callTool } from "./call-tool"; function mockPaidPlan() { mock.method(billingStateSource, "read").mock.mockImplementation( @@ -22,19 +30,18 @@ function mockPaidPlan() { ); } -const user = { - id: "user-1", - _id: "user-1", - email: "hi@example.com", -}; +let user: any; +let app: { id: string; appId: string; name: string }; -function extra(authKind: "oauth" | "apikey") { - return { - authInfo: { - extra: { authKind, userId: user._id, user, appId: "internal-app" }, - }, - }; -} +before(async () => { + const db = drizzle(new PGlite(), { schema }); + await migrate(db, { + migrationsFolder: path.resolve(__dirname, "../../drizzle"), + }); + setDb(db as unknown as AppDb); + user = await createAccount({ email: "hi@example.com" }); + [app] = await listApps(user._id); +}); describe("MCP whoami", () => { afterEach(() => mock.restoreAll()); @@ -42,40 +49,45 @@ describe("MCP whoami", () => { test("reports the account, the connected app and its usage", async () => { mockPaidPlan(); const queried: unknown[] = []; - const response = await handleWhoamiTool({}, extra("oauth"), { - getApp: async (id: string) => { - queried.push(id); - return { - id, - appId: "app-1", - name: "My Store", - default: true, - deleted: false, - userId: user._id, - } as any; - }, - getMediaCount: async ({ userId, appId }: any) => { + mock.method(mediaQueries, "getMediaCount").mock.mockImplementation( + async ({ userId, appId }: any) => { queried.push([userId, appId]); return 3; }, - getTotalSpace: async () => 2048, + ); + mock.method(mediaQueries, "getTotalSpace").mock.mockImplementation( + async () => 2048, + ); + + const response = await callTool("whoami", { + authKind: "oauth", + user, + appId: app.id, }); - assert.deepEqual(queried, ["internal-app", ["user-1", "internal-app"]]); - assert.deepEqual((response as any).structuredContent, { + assert.deepEqual(queried, [[user._id, app.id]]); + assert.deepEqual(response, { auth: "oauth", email: "hi@example.com", - app: { id: "app-1", name: "My Store", default: true }, + app: { id: app.appId, name: app.name, default: true }, files: 3, storage: 2048, maxStorage: maxStorageAllowedSubscribed, }); // The app is named by its public ID, never the internal one. - assert.doesNotMatch(response.content[0].text, /internal-app/); + assert.doesNotMatch(JSON.stringify(response), new RegExp(app.id)); }); - test("rejects calls without auth", async () => { - const response = await handleWhoamiTool({}, {}); - assert.equal((response as any).isError, true); + test("refuses an app that was deleted", async () => { + const deleted = await createApp({ + userId: user._id, + name: "Gone", + deleted: true, + }); + const response = await callTool("whoami", { + user, + appId: deleted.id, + }); + assert.equal(response.error.code, "tenant_forbidden"); }); }); diff --git a/apps/api/package.json b/apps/api/package.json index 33aa301d..b81d76c2 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -40,16 +40,15 @@ "@aws-sdk/s3-request-presigner": "^3.1146.0", "@better-auth/cimd": "1.7.7", "@better-auth/oauth-provider": "1.7.7", - "@codelitdev/billing": "0.4.1", - "@codelitdev/mcp-server-kit": "0.4.1", - "@codelitdev/oauth-server-kit": "0.4.1", - "@codelitdev/observability": "0.4.1", - "@codelitdev/platform": "0.4.1", + "@codelitdev/billing": "0.8.0", + "@codelitdev/mcp-server-kit": "0.8.0", + "@codelitdev/oauth-server-kit": "0.8.0", + "@codelitdev/observability": "0.8.0", + "@codelitdev/platform": "0.8.0", "@medialit/images": "workspace:*", "@medialit/models": "workspace:*", "@medialit/thumbnail": "workspace:*", "@medialit/utils": "workspace:*", - "@modelcontextprotocol/sdk": "1.32.0", "@tus/file-store": "^2.1.1", "@tus/server": "^2.4.5", "better-auth": "1.7.7", @@ -69,7 +68,8 @@ "zod": "3.25.76" }, "devDependencies": { - "@codelitdev/platform-conformance": "0.4.1", + "@codelitdev/platform-conformance": "0.8.0", + "@modelcontextprotocol/client": "2.4.0", "@electric-sql/pglite": "0.5.8", "@types/cors": "^2.8.12", "@types/express": "^4.17.20", diff --git a/apps/api/src/__tests__/lifecycle.test.ts b/apps/api/src/__tests__/lifecycle.test.ts index 8475ea94..ae49a36c 100644 --- a/apps/api/src/__tests__/lifecycle.test.ts +++ b/apps/api/src/__tests__/lifecycle.test.ts @@ -7,7 +7,7 @@ function recordingSteps(calls: string[]) { stopBackgroundJobs: () => { calls.push("jobs"); }, - closeMcpSessions: async () => { + closeMcp: async () => { calls.push("mcp"); }, closeServer: async () => { diff --git a/apps/api/src/__tests__/login-captcha.test.ts b/apps/api/src/__tests__/login-captcha.test.ts new file mode 100644 index 00000000..d7498bc6 --- /dev/null +++ b/apps/api/src/__tests__/login-captcha.test.ts @@ -0,0 +1,120 @@ +import { after, before, test } from "node:test"; +import assert from "node:assert/strict"; +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { migrate } from "drizzle-orm/pglite/migrator"; +import type { AppDb } from "../db/client.js"; +import { loginCaptchaConfig } from "../auth/options.js"; + +/* + * With Turnstile keys set, the hosted login page renders the widget and the + * API refuses to email a sign-in code without a Turnstile token. + */ +let server: Server; +let origin: string; +let client: PGlite; +const siteKey = "1x00000000000000000000AA"; + +before(async () => { + process.env.TURNSTILE_SITE_KEY = siteKey; + process.env.TURNSTILE_SECRET_KEY = "1x0000000000000000000000000000000AA"; + + const schema = await import("../db/schema/index.js"); + const { setDb } = await import("../db/client.js"); + client = new PGlite(); + const db = drizzle(client, { schema }); + await migrate(db, { + migrationsFolder: path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../../drizzle", + ), + }); + setDb(db as unknown as AppDb); + + const { createMedialitAuth } = await import("../auth/better-auth.js"); + const { createApp } = await import("../app.js"); + const { apiReadiness } = await import("../lifecycle.js"); + + server = createServer(); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + const auth = createMedialitAuth({ + publicApiUrl: origin, + secret: "captcha-test-secret-that-is-at-least-32-chars", + webOrigin: "http://127.0.0.1:3000", + }); + server.on( + "request", + createApp({ + auth, + webOrigin: "http://127.0.0.1:3000", + readiness: () => + apiReadiness({ + started: false, + shuttingDown: false, + pingDatabase: async () => undefined, + }), + }), + ); +}); + +after(async () => { + delete process.env.TURNSTILE_SITE_KEY; + delete process.env.TURNSTILE_SECRET_KEY; + await new Promise((resolve) => server.close(() => resolve())); + await client.close(); +}); + +test("the login page renders the Turnstile widget", async () => { + const response = await fetch(`${origin}/login`); + assert.equal(response.status, 200); + const html = await response.text(); + assert.ok(html.includes(`data-sitekey="${siteKey}"`)); + assert.ok( + html.includes( + '