Public: anyone can read this; the crew can change it.
MCP and API for data: a `data` tool and /api/v1/…/data endpoints, so agents and editors browse what a person can #10347
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7582-mcp-and-api-for-data-a-data-tool-and-api → main 10 commits fork
12 files
changed +454
−13
docs/features/data-browser.md +4
| @@ -14,6 +14,10 @@ Limits on work: `query` runs at most 2 workers at once (a third is told it is bu | ||
| 14 | 14 | |
| 15 | 15 | Terminal: `src/tui/screens/data.ts` (files → tables → rows, `n`/`p` for pages, `s` sort, `f` filter, `c` clear, `x` read-only SQL), offered by `maySeeData`; the forge's own database is among an admin's files. SQL on either door is 20 runs a minute per person (`src/data/sql_window.ts`); beyond that it says when to retry. |
| 16 | 16 | |
| 17 | API and MCP by repository (`data/repo_scope.ts`, `http/api/data.ts`): `GET /api/v1/repos/:o/:n/data` (databases as `{ env, file }`), `…/data/:env/:file/tables`, `…/rows?table=&page=`, `POST …/query {sql}`; MCP `data` with `{ repo, env?, file?, table?, sql? }` answers the same. Only writers of the repository (and admins) see anything; everyone else gets a 404 (an error from the tool). Same masking, read-only SQL and 1,000-row cap as the web; SQL shares the per-person 20 a minute (`sql_window.ts`), and past it the API answers 429 with `retry-after`, the tool an error. `page` is a whole number from 0 to 1000. | |
| 18 | ||
| 19 | Not done: SQL in the terminal (SQL is on the web page and MCP). | |
| 20 | ||
| 17 | 21 | ## On the web (#7580) |
| 18 | 22 | |
| 19 | 23 | Three doors, one body (`src/web/pages/data_view.ts`): `/data` (every file you may see), `/admin/data` (the forge's own database, admins; the Forge data button on `/data`) and `/:owner/:name/data` (an app's Data tab for its writers: environments → files → tables → rows; a reader gets the 404). Rows sort by header (`sort`, `dir`), filter by text on one column or all (`q`, `col`), JSON cells are pretty-printed, and a copy button per row puts it on the clipboard as JSON. Sealed columns show `••••••` and cannot be sorted or searched. The SQL box is a GET form (`sql`, capped at 4,000 characters), so a query is an address to share; it shows the rows and the elapsed ms. The sorted column carries `aria-sort` and a muted arrow from CSS; a wide grid scrolls sideways only. Not done: saved queries, export, editing rows. |
src/contract/openapi/routes/getApiV1ReposOwnerNameData.ts added +9
| @@ -0,0 +1,9 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: `${REPO}/data`, | |
| 6 | summary: | |
| 7 | "The SQLite databases of the repository's app that you may read, as the MCP data tool with repo lists them: { items: [{ env, file, label }] }; only people who can write the repository (and admins) see them, anyone else a 404; needs a token", | |
| 8 | auth: true, | |
| 9 | }; |
src/contract/openapi/routes/getApiV1ReposOwnerNameDataEnvFileRows.ts added +14
| @@ -0,0 +1,14 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: `${REPO}/data/{env}/{file}/rows`, | |
| 6 | summary: | |
| 7 | "One page of 50 rows of a table: { columns, page, total, rows }; secret columns (token, secret, password, hash, key, cookie, …) show ••••••; no such table is a 404; needs a token", | |
| 8 | auth: true, | |
| 9 | query: { table: "string", page: { type: "integer", min: 0, max: 1000 } }, | |
| 10 | refusals: { | |
| 11 | invalid_table: "table is missing (422)", | |
| 12 | invalid_page: "page is not a whole number from 0 (422)", | |
| 13 | }, | |
| 14 | }; |
src/contract/openapi/routes/getApiV1ReposOwnerNameDataEnvFileTables.ts added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "get", | |
| 5 | path: `${REPO}/data/{env}/{file}/tables`, | |
| 6 | summary: "The tables of one database with their row counts: { items: [{ name, rows }] }; a database you may not read is a 404; needs a token", | |
| 7 | auth: true, | |
| 8 | }; |
src/contract/openapi/routes/index.ts +4
| @@ -121,6 +121,9 @@ export { route as getApiV1ReposOwnerNameCommitsOidCheck } from "./getApiV1ReposO | ||
| 121 | 121 | export { route as getApiV1ReposOwnerNameContents } from "./getApiV1ReposOwnerNameContents.ts"; |
| 122 | 122 | export { route as getApiV1ReposOwnerNameContentsPath } from "./getApiV1ReposOwnerNameContentsPath.ts"; |
| 123 | 123 | export { route as getApiV1ReposOwnerNameCrew } from "./getApiV1ReposOwnerNameCrew.ts"; |
| 124 | export { route as getApiV1ReposOwnerNameData } from "./getApiV1ReposOwnerNameData.ts"; | |
| 125 | export { route as getApiV1ReposOwnerNameDataEnvFileRows } from "./getApiV1ReposOwnerNameDataEnvFileRows.ts"; | |
| 126 | export { route as getApiV1ReposOwnerNameDataEnvFileTables } from "./getApiV1ReposOwnerNameDataEnvFileTables.ts"; | |
| 124 | 127 | export { route as getApiV1ReposOwnerNameDecisions } from "./getApiV1ReposOwnerNameDecisions.ts"; |
| 125 | 128 | export { route as getApiV1ReposOwnerNameEgressGrants } from "./getApiV1ReposOwnerNameEgressGrants.ts"; |
| 126 | 129 | export { route as getApiV1ReposOwnerNameEvents } from "./getApiV1ReposOwnerNameEvents.ts"; |
| @@ -284,6 +287,7 @@ export { route as postApiV1ReposOwnerNameBuildsPreview } from "./postApiV1ReposO | ||
| 284 | 287 | export { route as postApiV1ReposOwnerNameChecksRecheck } from "./postApiV1ReposOwnerNameChecksRecheck.ts"; |
| 285 | 288 | export { route as postApiV1ReposOwnerNameContents } from "./postApiV1ReposOwnerNameContents.ts"; |
| 286 | 289 | export { route as postApiV1ReposOwnerNameContribute } from "./postApiV1ReposOwnerNameContribute.ts"; |
| 290 | export { route as postApiV1ReposOwnerNameDataEnvFileQuery } from "./postApiV1ReposOwnerNameDataEnvFileQuery.ts"; | |
| 287 | 291 | export { route as postApiV1ReposOwnerNameEdits } from "./postApiV1ReposOwnerNameEdits.ts"; |
| 288 | 292 | export { route as postApiV1ReposOwnerNameEgressGrants } from "./postApiV1ReposOwnerNameEgressGrants.ts"; |
| 289 | 293 | export { route as postApiV1ReposOwnerNameFollowingConverge } from "./postApiV1ReposOwnerNameFollowingConverge.ts"; |
src/contract/openapi/routes/postApiV1ReposOwnerNameDataEnvFileQuery.ts added +15
| @@ -0,0 +1,15 @@ | ||
| 1 | import { REPO, type Route } from "../shared.ts"; | |
| 2 | ||
| 3 | export const route: Route = { | |
| 4 | method: "post", | |
| 5 | path: `${REPO}/data/{env}/{file}/query`, | |
| 6 | summary: | |
| 7 | "Run one read-only SELECT or WITH on a database (read-only connection, 5 s, at most 1,000 rows, secret columns masked): { columns, rows, truncated }; reads only, so a read-scope token suffices; needs a token", | |
| 8 | auth: true, | |
| 9 | body: { sql: "string" }, | |
| 10 | refusals: { | |
| 11 | invalid_sql: "sql is not one SELECT or WITH statement, or SQLite refused it (422)", | |
| 12 | timeout: "the query ran past 5 seconds (422)", | |
| 13 | busy: "other queries are running; try again (429)", | |
| 14 | }, | |
| 15 | }; |
src/data/repo_scope.ts added +78
| @@ -0,0 +1,78 @@ | ||
| 1 | /** | |
| 2 | * The data browser addressed by repository, env and file: what `/api/v1/repos/:o/:n/data…` and the | |
| 3 | * MCP `data` tool (with `repo`) answer. Visibility is `listSources` alone, so a reader, a guest and | |
| 4 | * a stranger see exactly what the web shows them: nothing, as a 404. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | import type { App } from "../app.ts"; | |
| 9 | import { getAppByRepo } from "../core/apps.ts"; | |
| 10 | import { canWrite, type Repo } from "../core/repos.ts"; | |
| 11 | import type { User } from "../core/users.ts"; | |
| 12 | import { isEnvName } from "../apps/data.ts"; | |
| 13 | import { rows, tables } from "./browse.ts"; | |
| 14 | import { query } from "./query.ts"; | |
| 15 | import { sqlWindow } from "./sql_window.ts"; | |
| 16 | import { listSources, type Source } from "./sources.ts"; | |
| 17 | ||
| 18 | /** | |
| 19 | * The one guard of every door: the sources of the repository's app this viewer may read, or | |
| 20 | * undefined (a 404 to the caller) when the viewer cannot write to the repository: a reader, a | |
| 21 | * guest or a stranger has no door to the data. An empty list means a writer, and no database yet. | |
| 22 | */ | |
| 23 | export function repoSources(db: Database, dataDir: string, viewer: User | undefined, repo: Repo): Source[] | undefined { | |
| 24 | if (viewer === undefined || !canWrite(db, repo, viewer)) return undefined; | |
| 25 | const app = getAppByRepo(db, repo.id); | |
| 26 | if (app === undefined) return []; | |
| 27 | const prefix = `app:${app.id}:`; | |
| 28 | return listSources(db, dataDir, viewer).filter((s) => s.kind === "app" && s.id.startsWith(prefix)); | |
| 29 | } | |
| 30 | ||
| 31 | /** A page number as REST and MCP both take it: a whole number from 0 to 1000, else undefined. */ | |
| 32 | export function parsePage(raw: unknown): number | undefined { | |
| 33 | if (typeof raw === "string") return /^[0-9]{1,4}$/.test(raw) && Number(raw) <= MAX_PAGE ? Number(raw) : undefined; | |
| 34 | return typeof raw === "number" && Number.isInteger(raw) && raw >= 0 && raw <= MAX_PAGE ? raw : undefined; | |
| 35 | } | |
| 36 | const MAX_PAGE = 1000; | |
| 37 | ||
| 38 | export interface Pick { | |
| 39 | readonly env?: string | undefined; | |
| 40 | readonly file?: string | undefined; | |
| 41 | } | |
| 42 | ||
| 43 | export type Failure = { readonly error: "not_found" | "invalid"; readonly message: string }; | |
| 44 | ||
| 45 | /** `{ env, file }` to one source; both are checked, then looked up in the allowed list. */ | |
| 46 | export function pickSource(sources: readonly Source[], pick: Pick): Source | Failure { | |
| 47 | const { env, file } = pick; | |
| 48 | if (env === undefined || file === undefined) return { error: "invalid", message: "`env` and `file` name a database" }; | |
| 49 | if (!isEnvName(env)) return { error: "invalid", message: "`env` is an environment name" }; | |
| 50 | const found = sources.find((s) => { | |
| 51 | const part = s.id.split(":"); | |
| 52 | return part.length === 4 && part[2] === env && part[3] === file; | |
| 53 | }); | |
| 54 | return found ?? { error: "not_found", message: "no such database" }; | |
| 55 | } | |
| 56 | ||
| 57 | export const isFailure = (v: Source | Failure): v is Failure => "error" in v; | |
| 58 | ||
| 59 | export const sourceList = (sources: readonly Source[]) => | |
| 60 | sources.map((s) => { | |
| 61 | const [, , env, file] = s.id.split(":"); | |
| 62 | return { env, file, label: s.label }; | |
| 63 | }); | |
| 64 | ||
| 65 | export const tableList = (source: Source) => tables(source); | |
| 66 | ||
| 67 | export function rowPage(source: Source, table: string, page: number | undefined) { | |
| 68 | return rows(source, table, page === undefined ? {} : { page }); | |
| 69 | } | |
| 70 | ||
| 71 | export type SqlOutcome = Awaited<ReturnType<typeof query>> | { readonly ok: false; readonly error: "rate"; readonly message: string; readonly retryAfter: number }; | |
| 72 | ||
| 73 | /** Read-only SQL: the same checks, caps and masking as the web (`query.ts`), and its per-person budget (`sql_window.ts`). */ | |
| 74 | export async function runSql(app: App, user: User, source: Source, sql: string): Promise<SqlOutcome> { | |
| 75 | const wait = sqlWindow(app, user.id); | |
| 76 | if (wait !== undefined) return { ok: false, error: "rate", message: `too many queries: try again in ${wait} s`, retryAfter: wait }; | |
| 77 | return query(source, sql); | |
| 78 | } |
src/http/api.ts +2
| @@ -352,6 +352,7 @@ import { boardRoutes } from "./api/boards.ts"; | ||
| 352 | 352 | import { displayRoutes } from "./api/displays.ts"; |
| 353 | 353 | import { followingRoutes } from "./api/following.ts"; |
| 354 | 354 | import { labelsRoutes } from "./api/labels.ts"; |
| 355 | import { dataRoutes } from "./api/data.ts"; | |
| 355 | 356 | import { visionRoutes } from "./api/vision.ts"; |
| 356 | 357 | import { businessPlanRoutes } from "./api/business_plan.ts"; |
| 357 | 358 | import { tokenRoutes } from "./api/tokens.ts"; |
| @@ -2089,6 +2090,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 2089 | 2090 | |
| 2090 | 2091 | labelsRoutes(router); |
| 2091 | 2092 | visionRoutes(router); |
| 2093 | dataRoutes(router); | |
| 2092 | 2094 | businessPlanRoutes(router); |
| 2093 | 2095 | router.add( |
| 2094 | 2096 | "PUT", |
src/http/api/data.ts added +82
| @@ -0,0 +1,82 @@ | ||
| 1 | /** | |
| 2 | * A repository's databases over the API, as the web `/data` and MCP `data` show them: `GET …/data`, | |
| 3 | * `…/data/:env/:file/tables`, `…/rows?table=&page=`, and `POST …/query` (one read-only SELECT). Who | |
| 4 | * may see what is `data/sources.ts`; to anyone else every one of these is a 404. | |
| 5 | */ | |
| 6 | ||
| 7 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 8 | import { | |
| 9 | isFailure, | |
| 10 | parsePage, | |
| 11 | pickSource, | |
| 12 | repoSources, | |
| 13 | rowPage, | |
| 14 | runSql, | |
| 15 | sourceList, | |
| 16 | tableList, | |
| 17 | } from "../../data/repo_scope.ts"; | |
| 18 | import { loadRepo } from "../api.ts"; | |
| 19 | import type { Ctx, Handler } from "../context.ts"; | |
| 20 | import { apiError, body, json, notFound, requireScope } from "../respond.ts"; | |
| 21 | import type { Router } from "../router.ts"; | |
| 22 | ||
| 23 | const PRIVATE = { "cache-control": "private, no-store", vary: "authorization, cookie" }; | |
| 24 | const reply = (value: unknown): Response => json(value, 200, PRIVATE); | |
| 25 | ||
| 26 | function find(ctx: Ctx) { | |
| 27 | const repo = loadRepo(ctx); | |
| 28 | if (!repo) return undefined; | |
| 29 | const sources = repoSources(ctx.app.db, ctx.app.config.dataDir, ctx.auth.user, repo); | |
| 30 | if (sources === undefined) return undefined; | |
| 31 | const picked = pickSource(sources, { env: ctx.params.env, file: ctx.params.file }); | |
| 32 | return isFailure(picked) ? undefined : { sources, source: picked }; | |
| 33 | } | |
| 34 | ||
| 35 | export function dataRoutes(router: Router<Handler>): void { | |
| 36 | router.add("GET", "/api/v1/repos/:owner/:name/data", (ctx) => { | |
| 37 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 38 | const repo = loadRepo(ctx); | |
| 39 | if (!repo) return notFound(); | |
| 40 | const sources = repoSources(ctx.app.db, ctx.app.config.dataDir, ctx.auth.user, repo); | |
| 41 | if (sources === undefined) return notFound(); | |
| 42 | return reply({ items: sourceList(sources) }); | |
| 43 | }); | |
| 44 | ||
| 45 | router.add("GET", "/api/v1/repos/:owner/:name/data/:env/:file/tables", (ctx) => { | |
| 46 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 47 | const found = find(ctx); | |
| 48 | if (!found?.source) return notFound(); | |
| 49 | return reply({ items: tableList(found.source) }); | |
| 50 | }); | |
| 51 | ||
| 52 | router.add("GET", "/api/v1/repos/:owner/:name/data/:env/:file/rows", (ctx) => { | |
| 53 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 54 | const found = find(ctx); | |
| 55 | if (!found?.source) return notFound(); | |
| 56 | const table = ctx.url.searchParams.get("table"); | |
| 57 | if (table === null || table === "") return apiError("invalid_table", 422, "table names a table"); | |
| 58 | const raw = ctx.url.searchParams.get("page"); | |
| 59 | const page = raw === null ? undefined : parsePage(raw); | |
| 60 | if (raw !== null && page === undefined) return apiError("invalid_page", 422, "page is a whole number from 0 to 1000"); | |
| 61 | const result = rowPage(found.source, table, page); | |
| 62 | return result ? reply(result) : notFound(); | |
| 63 | }); | |
| 64 | ||
| 65 | router.add("POST", "/api/v1/repos/:owner/:name/data/:env/:file/query", async (ctx) => { | |
| 66 | const denied = requireScope(ctx, "ro"); | |
| 67 | if (denied) return denied; | |
| 68 | const found = find(ctx); | |
| 69 | if (!found?.source) return notFound(); | |
| 70 | const input = await body(ctx.request); | |
| 71 | if (typeof input["sql"] !== "string") return apiError("invalid_sql", 422, "sql is text"); | |
| 72 | const user = ctx.auth.user; | |
| 73 | if (!user) return notFound(); | |
| 74 | const result = await runSql(ctx.app, user, found.source, input["sql"]); | |
| 75 | if (!result.ok) { | |
| 76 | if (result.error === "rate") | |
| 77 | return json({ error: "rate_limited", message: result.message, retryAfter: result.retryAfter }, 429, { ...PRIVATE, "retry-after": String(result.retryAfter) }); | |
| 78 | return apiError(result.error === "busy" ? "busy" : result.error === "timeout" ? "timeout" : "invalid_sql", result.error === "busy" ? 429 : 422, result.message); | |
| 79 | } | |
| 80 | return reply(result); | |
| 81 | }); | |
| 82 | } |
src/http/mcp/tools/data.ts +37 −12
| @@ -1,15 +1,18 @@ | ||
| 1 | 1 | import { rows, tables } from "../../../data/browse.ts"; |
| 2 | import { query } from "../../../data/query.ts"; | |
| 3 | 2 | import { findSource, listSources } from "../../../data/sources.ts"; |
| 4 | import { bad, good, type Tool } from "../shared.ts"; | |
| 3 | import { isFailure, parsePage, pickSource, repoSources, rowPage, runSql, sourceList, tableList } from "../../../data/repo_scope.ts"; | |
| 4 | import { bad, good, isAnswer, repoArg, type Tool } from "../shared.ts"; | |
| 5 | 5 | |
| 6 | 6 | export const tool: Tool = { |
| 7 | 7 | name: "data", |
| 8 | doc: "the SQLite files you may read (the forge's own for an admin; each app's per environment for its writers), with `source`: its tables and row counts, with `table` (and `page`): a page of rows, with `sql`: a read-only SELECT (5 s, 1,000 rows); secret columns are masked", | |
| 8 | doc: "`{ repo, env?, file?, table?, sql? }` as `GET …/data`: an app's databases, then `env`+`file`: its tables, `table`: rows, `sql`: one read-only SELECT; its writers only. Without `repo`: the SQLite files you may read (the forge's own for an admin; each app's per environment for its writers), with `source`: its tables and row counts, with `table` (and `page`): a page of rows, with `sql`: a read-only SELECT (5 s, 1,000 rows); secret columns are masked", | |
| 9 | 9 | description: "Read SQLite files you may see: tables, rows, read-only SQL; secrets masked.", |
| 10 | 10 | inputSchema: { |
| 11 | 11 | type: "object", |
| 12 | 12 | properties: { |
| 13 | repo: { type: "string" }, | |
| 14 | env: { type: "string" }, | |
| 15 | file: { type: "string" }, | |
| 13 | 16 | source: { type: "string" }, |
| 14 | 17 | table: { type: "string" }, |
| 15 | 18 | page: { type: "integer" }, |
| @@ -19,21 +22,43 @@ export const tool: Tool = { | ||
| 19 | 22 | writes: false, |
| 20 | 23 | async run(ctx, user, args) { |
| 21 | 24 | const { db, config } = ctx.app; |
| 22 | const { source: id, table, page, sql } = args; | |
| 23 | for (const [k, v] of Object.entries({ source: id, table, sql })) | |
| 24 | if (v !== undefined && typeof v !== "string") return bad("invalid", `\`${k}\` is text`); | |
| 25 | if (page !== undefined && typeof page !== "number") return bad("invalid", "`page` is a number"); | |
| 25 | for (const k of ["source", "table", "sql", "env", "file"]) | |
| 26 | if (args[k] !== undefined && typeof args[k] !== "string") return bad("invalid", `\`${k}\` is text`); | |
| 27 | const str = (k: string): string | undefined => { | |
| 28 | const v = args[k]; | |
| 29 | return typeof v === "string" ? v : undefined; | |
| 30 | }; | |
| 31 | const [id, table, sql, env, file] = ["source", "table", "sql", "env", "file"].map(str); | |
| 32 | const page = args["page"]; | |
| 33 | const pageNo = page === undefined || typeof page !== "number" ? undefined : parsePage(page); | |
| 34 | if (page !== undefined && pageNo === undefined) return bad("invalid", "`page` is a whole number from 0 to 1000"); | |
| 35 | if (args["repo"] !== undefined) { | |
| 36 | const repo = repoArg(ctx, user, args); | |
| 37 | if (isAnswer(repo)) return repo; | |
| 38 | const sources = repoSources(db, config.dataDir, user, repo); | |
| 39 | if (sources === undefined) return bad("not_found", "no such app or repository (owner/name)"); | |
| 40 | if (env === undefined && file === undefined) return good({ items: sourceList(sources) }); | |
| 41 | const picked = pickSource(sources, { env, file }); | |
| 42 | if (isFailure(picked)) return bad(picked.error, picked.message); | |
| 43 | if (typeof sql === "string") { | |
| 44 | const result = await runSql(ctx.app, user, picked, sql); | |
| 45 | return result.ok ? good(result) : bad(result.error === "rate" ? "rate_limited" : "invalid", result.message); | |
| 46 | } | |
| 47 | if (table === undefined) return good({ items: tableList(picked) }); | |
| 48 | const found = rowPage(picked, table, pageNo); | |
| 49 | return found ? good(found) : bad("not_found", "no such table"); | |
| 50 | } | |
| 26 | 51 | if (id === undefined) { |
| 27 | 52 | return good(listSources(db, config.dataDir, user).map((s) => ({ source: s.id, label: s.label }))); |
| 28 | 53 | } |
| 29 | const source = findSource(db, config.dataDir, user, id as string); | |
| 54 | const source = findSource(db, config.dataDir, user, id); | |
| 30 | 55 | if (!source) return bad("not_found", "no such source"); |
| 31 | if (sql !== undefined) { | |
| 32 | const result = await query(source, sql as string); | |
| 33 | return result.ok ? good(result) : bad("invalid", result.message); | |
| 56 | if (typeof sql === "string") { | |
| 57 | const result = await runSql(ctx.app, user, source, sql); | |
| 58 | return result.ok ? good(result) : bad(result.error === "rate" ? "rate_limited" : "invalid", result.message); | |
| 34 | 59 | } |
| 35 | 60 | if (table === undefined) return good(tables(source)); |
| 36 | const found = rows(source, table as string, page === undefined ? {} : { page: page as number }); | |
| 61 | const found = rows(source, table, pageNo === undefined ? {} : { page: pageNo }); | |
| 37 | 62 | return found ? good(found) : bad("not_found", "no such table"); |
| 38 | 63 | }, |
| 39 | 64 | }; |
test/api-data.test.ts added +188
| @@ -0,0 +1,188 @@ | ||
| 1 | /** `/api/v1/repos/:o/:n/data…` and MCP `data` with `repo`: the web's permissions, read-only SQL, masking, the 1,000-row cap. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { Database } from "bun:sqlite"; | |
| 4 | import { mkdirSync } from "node:fs"; | |
| 5 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 6 | import { tmpdir } from "node:os"; | |
| 7 | import { join } from "node:path"; | |
| 8 | import { createApp, type App } from "../src/app.ts"; | |
| 9 | import { envDataDir } from "../src/apps/data.ts"; | |
| 10 | import { createApp as createAppRecord } from "../src/core/apps.ts"; | |
| 11 | import { createRepo } from "../src/core/repos.ts"; | |
| 12 | import { createSession } from "../src/core/sessions.ts"; | |
| 13 | import { createToken } from "../src/core/tokens.ts"; | |
| 14 | import { createUser } from "../src/core/users.ts"; | |
| 15 | import { listSources } from "../src/data/sources.ts"; | |
| 16 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | let dataDir: string; | |
| 22 | let app: App; | |
| 23 | let aliceId = ""; | |
| 24 | let servers: Servers; | |
| 25 | const tok: Record<string, string> = {}; | |
| 26 | const base = "/api/v1/repos/alice/notes/data"; | |
| 27 | ||
| 28 | const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 29 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 30 | return r.value; | |
| 31 | }; | |
| 32 | const get = (token: string | null, path: string) => | |
| 33 | fetch(`${servers.url}${base}${path}`, { headers: token ? { authorization: `Bearer ${token}` } : {} }); | |
| 34 | const post = (token: string, path: string, sql: unknown) => | |
| 35 | fetch(`${servers.url}${base}${path}`, { | |
| 36 | method: "POST", | |
| 37 | headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, | |
| 38 | body: JSON.stringify({ sql }), | |
| 39 | }); | |
| 40 | async function mcp(token: string, args: Record<string, unknown>): Promise<any> { | |
| 41 | const res = await fetch(`${servers.url}/mcp`, { | |
| 42 | method: "POST", | |
| 43 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` }, | |
| 44 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "data", arguments: args } }), | |
| 45 | }); | |
| 46 | const result = ((await res.json()) as any).result; | |
| 47 | const text = result.content[0].text as string; | |
| 48 | return { isError: result.isError === true, body: result.isError === true ? text : JSON.parse(text) }; | |
| 49 | } | |
| 50 | ||
| 51 | beforeAll(async () => { | |
| 52 | dataDir = await mkdtemp(join(tmpdir(), "forge-api-data-")); | |
| 53 | app = createApp({ dataDir, port: 0 }); | |
| 54 | servers = await serve(app); | |
| 55 | const make = async (username: string, adminIfFirst = false) => | |
| 56 | ok(await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@forge.local`, password: "a-good-passphrase", adminIfFirst })); | |
| 57 | const admin = await make("boss", true); | |
| 58 | const alice = await make("alice"); | |
| 59 | const bob = await make("bob"); | |
| 60 | aliceId = alice.id; | |
| 61 | const repo = ok(await createRepo(app.db, app.reposRoot, { ownerId: alice.id, name: "notes", visibility: "public" })); | |
| 62 | const record = ok(createAppRecord(app.db, { repoId: repo.id, slug: "notes", access: "public" })); | |
| 63 | mkdirSync(envDataDir(dataDir, record.id, "production"), { recursive: true }); | |
| 64 | const d = new Database(join(envDataDir(dataDir, record.id, "production"), "app.db")); | |
| 65 | d.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, api_token TEXT)"); | |
| 66 | d.exec("CREATE TABLE big (n INTEGER)"); | |
| 67 | d.exec("INSERT INTO users VALUES (1, 'ann', 'tok-secret-1')"); | |
| 68 | d.exec("WITH RECURSIVE c(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM c WHERE n < 1500) INSERT INTO big SELECT n FROM c"); | |
| 69 | d.close(); | |
| 70 | tok["alice"] = createToken(app.db, alice.id, "rw", "rw").token; | |
| 71 | tok["alice-ro"] = createToken(app.db, alice.id, "ro", "ro").token; | |
| 72 | tok["bob"] = createToken(app.db, bob.id, "rw", "rw").token; | |
| 73 | tok["admin"] = createToken(app.db, admin.id, "rw", "rw").token; | |
| 74 | }, 30_000); | |
| 75 | ||
| 76 | afterAll(async () => { | |
| 77 | servers?.stop(); | |
| 78 | app?.close(); | |
| 79 | await rm(dataDir, { recursive: true, force: true }); | |
| 80 | }); | |
| 81 | ||
| 82 | test("a writer lists, browses and gets the same from MCP", async () => { | |
| 83 | const list = (await (await get(tok["alice"]!, "")).json()) as any; | |
| 84 | expect(list.items).toEqual([{ env: "production", file: "app.db", label: "notes · production · app.db" }]); | |
| 85 | expect((await mcp(tok["alice"]!, { repo: "alice/notes" })).body).toEqual(list); | |
| 86 | const tables = (await (await get(tok["alice"]!, "/production/app.db/tables")).json()) as any; | |
| 87 | expect(tables.items).toEqual([{ name: "big", rows: 1500 }, { name: "users", rows: 1 }]); | |
| 88 | expect((await mcp(tok["alice"]!, { repo: "alice/notes", env: "production", file: "app.db" })).body).toEqual(tables); | |
| 89 | const rows = (await (await get(tok["alice"]!, "/production/app.db/rows?table=big&page=1")).json()) as any; | |
| 90 | expect(rows.rows.length).toBe(50); | |
| 91 | expect((await mcp(tok["alice"]!, { repo: "alice/notes", env: "production", file: "app.db", table: "big", page: 1 })).body).toEqual(rows); | |
| 92 | }); | |
| 93 | ||
| 94 | test("permission parity with the web: a reader, a stranger and a guest get 404s", () => | |
| 95 | Promise.all( | |
| 96 | [null, tok["bob"]!].map(async (t) => { | |
| 97 | for (const path of ["", "/production/app.db/tables", "/production/app.db/rows?table=users"]) | |
| 98 | expect((await get(t, path)).status).toBe(404); | |
| 99 | }), | |
| 100 | ).then(async () => { | |
| 101 | expect(listSources(app.db, dataDir, undefined)).toEqual([]); | |
| 102 | // A reader's token and a signed-out caller meet the same refusal: 404 for the token, 401 for no identity. | |
| 103 | expect((await post(tok["bob"]!, "/production/app.db/query", "SELECT 1")).status).toBe(404); | |
| 104 | expect((await post("x", "/production/app.db/query", "SELECT 1")).status).toBe(401); | |
| 105 | const m = await mcp(tok["bob"]!, { repo: "alice/notes" }); | |
| 106 | expect(m.isError).toBe(true); | |
| 107 | expect((await get(tok["admin"]!, "")).status).toBe(200); | |
| 108 | })); | |
| 109 | ||
| 110 | test("unknown env, file or table is a 404; odd names resolve to nothing", async () => { | |
| 111 | for (const path of ["/staging/app.db/tables", "/production/other.db/tables", "/production/..%2Fforge.db/tables", "/production/app.db/rows?table=nope"]) | |
| 112 | expect((await get(tok["alice"]!, path)).status).toBe(404); | |
| 113 | expect((await get(tok["alice"]!, "/production/app.db/rows")).status).toBe(422); | |
| 114 | expect((await get(tok["alice"]!, "/production/app.db/rows?table=big&page=x")).status).toBe(422); | |
| 115 | }); | |
| 116 | ||
| 117 | test("masking: secret columns show dots in rows and queries, over REST and MCP", async () => { | |
| 118 | const rows = await (await get(tok["alice"]!, "/production/app.db/rows?table=users")).text(); | |
| 119 | expect(rows).not.toContain("tok-secret-1"); | |
| 120 | expect(rows).toContain("ann"); | |
| 121 | const q = await (await post(tok["alice"]!, "/production/app.db/query", "SELECT api_token AS t FROM users")).text(); | |
| 122 | expect(q).not.toContain("tok-secret-1"); | |
| 123 | const m = await mcp(tok["alice"]!, { repo: "alice/notes", env: "production", file: "app.db", sql: "SELECT * FROM users" }); | |
| 124 | expect(JSON.stringify(m.body)).not.toContain("tok-secret-1"); | |
| 125 | }); | |
| 126 | ||
| 127 | test("read-only: writes are refused and change nothing; a read token may query", async () => { | |
| 128 | for (const sql of ["DELETE FROM users", "DROP TABLE users", "SELECT 1; DELETE FROM users", "PRAGMA writable_schema = 1"]) { | |
| 129 | const res = await post(tok["alice"]!, "/production/app.db/query", sql); | |
| 130 | expect(res.status).toBe(422); | |
| 131 | expect(((await res.json()) as any).error).toBe("invalid_sql"); | |
| 132 | expect((await mcp(tok["alice"]!, { repo: "alice/notes", env: "production", file: "app.db", sql })).isError).toBe(true); | |
| 133 | } | |
| 134 | expect((await post(tok["alice"]!, "/production/app.db/query", 5)).status).toBe(422); | |
| 135 | const count = await post(tok["alice-ro"]!, "/production/app.db/query", "SELECT count(*) AS n FROM users"); | |
| 136 | expect(((await count.json()) as any).rows).toEqual([[1]]); | |
| 137 | }); | |
| 138 | ||
| 139 | test("a query returns at most 1,000 rows and says it was cut", async () => { | |
| 140 | const res = (await (await post(tok["alice"]!, "/production/app.db/query", "SELECT n FROM big")).json()) as any; | |
| 141 | expect(res.rows.length).toBe(1000); | |
| 142 | expect(res.truncated).toBe(true); | |
| 143 | const m = await mcp(tok["alice"]!, { repo: "alice/notes", env: "production", file: "app.db", sql: "SELECT n FROM big" }); | |
| 144 | expect(m.body.rows.length).toBe(1000); | |
| 145 | }); | |
| 146 | ||
| 147 | test("a cross-origin session post is refused with a 403; the same-origin one runs", async () => { | |
| 148 | const cookie = `forge_session=${createSession(app.db, aliceId).token}`; | |
| 149 | const send = (origin: string) => | |
| 150 | fetch(`${servers.url}${base}/production/app.db/query`, { | |
| 151 | method: "POST", | |
| 152 | headers: { cookie, origin, "content-type": "application/json" }, | |
| 153 | body: JSON.stringify({ sql: "SELECT 1 AS one" }), | |
| 154 | }); | |
| 155 | expect((await send("https://evil.example")).status).toBe(403); | |
| 156 | expect((await send(servers.url)).status).toBe(200); | |
| 157 | }); | |
| 158 | ||
| 159 | test("the spec lists all four routes", async () => { | |
| 160 | const spec = (await (await fetch(`${servers.url}/api/v1/openapi.json`, { redirect: "follow" })).json()) as any; | |
| 161 | const paths = Object.keys(spec.paths); | |
| 162 | for (const p of ["/data", "/data/{env}/{file}/tables", "/data/{env}/{file}/rows", "/data/{env}/{file}/query"]) | |
| 163 | expect(paths).toContain(`/api/v1/repos/{owner}/{name}${p}`); | |
| 164 | }); | |
| 165 | ||
| 166 | test("page is a whole number from 0 to 1000 on both doors", async () => { | |
| 167 | for (const page of ["-1", "1.5", "1001", "99999", "abc"]) | |
| 168 | expect((await get(tok["alice"]!, `/production/app.db/rows?table=big&page=${page}`)).status).toBe(422); | |
| 169 | expect((await get(tok["alice"]!, "/production/app.db/rows?table=big&page=1000")).status).toBe(200); | |
| 170 | const args = { repo: "alice/notes", env: "production", file: "app.db", table: "big" }; | |
| 171 | for (const page of [-1, 1.5, 1001, Number.MAX_SAFE_INTEGER, "2", null]) | |
| 172 | expect((await mcp(tok["alice"]!, { ...args, page })).isError).toBe(true); | |
| 173 | expect((await mcp(tok["alice"]!, { ...args, page: 0 })).isError).toBe(false); | |
| 174 | }); | |
| 175 | ||
| 176 | test("SQL is 20 runs a minute per person on both doors, then 429 with the wait", async () => { | |
| 177 | const run = () => post(tok["admin"]!, "/production/app.db/query", "SELECT 1 AS one"); | |
| 178 | for (let i = 0; i < 20; i++) expect((await run()).status).toBe(200); | |
| 179 | const refused = await run(); | |
| 180 | expect(refused.status).toBe(429); | |
| 181 | expect(Number(refused.headers.get("retry-after"))).toBeGreaterThanOrEqual(1); | |
| 182 | expect(((await refused.json()) as any).error).toBe("rate_limited"); | |
| 183 | const m = await mcp(tok["admin"]!, { repo: "alice/notes", env: "production", file: "app.db", sql: "SELECT 1" }); | |
| 184 | expect(m.isError).toBe(true); | |
| 185 | expect(m.body).toContain("too many queries"); | |
| 186 | // Another person has their own budget. | |
| 187 | expect((await post(tok["alice"]!, "/production/app.db/query", "SELECT 1")).status).toBe(200); | |
| 188 | }); |
test/security-floor-routes.test.ts +13 −1
| @@ -9,6 +9,9 @@ | ||
| 9 | 9 | * miss answers carry the floor too — and the list is checked both ways. |
| 10 | 10 | */ |
| 11 | 11 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| 12 | import { Database } from "bun:sqlite"; | |
| 13 | import { mkdirSync } from "node:fs"; | |
| 14 | import { envDataDir } from "../src/apps/data.ts"; | |
| 12 | 15 | import { mkdtemp, rm } from "node:fs/promises"; |
| 13 | 16 | import { tmpdir } from "node:os"; |
| 14 | 17 | import { join } from "node:path"; |
| @@ -155,6 +158,11 @@ beforeAll(async () => { | ||
| 155 | 158 | const run = createRun(app.db, { repoId: repo.id, kind: "check", issueId: pull.value.id, commitOid: pull.value.headOid }); |
| 156 | 159 | const inserted = insertApp(app.db, { repoId: repo.id, slug: "notes", access: "public" }); |
| 157 | 160 | if (!inserted.ok) throw new Error(inserted.error); |
| 161 | // An app database for the data routes: data routes name the production env. | |
| 162 | mkdirSync(envDataDir(dataDir, inserted.value.id, "production"), { recursive: true }); | |
| 163 | const appDb = new Database(join(envDataDir(dataDir, inserted.value.id, "production"), "app.db")); | |
| 164 | appDb.exec("CREATE TABLE t (n INTEGER); INSERT INTO t VALUES (1)"); | |
| 165 | appDb.close(); | |
| 158 | 166 | Object.assign(fill, { |
| 159 | 167 | owner: "ada", name: "notes", username: "ada", ref: "main", oid: pull.value.headOid, |
| 160 | 168 | path: "a.txt", env: "prod", slug: "notes", spec: "192", machine: "standard", |
| @@ -182,12 +190,16 @@ function pathFor(pattern: string): string { | ||
| 182 | 190 | .replace(/[:*]([a-z_]+)/g, (_, key: string) => { |
| 183 | 191 | if (key === "number") return number!; |
| 184 | 192 | if (key === "id") return runId!; |
| 193 | if (key === "env" && pattern.includes("/data/:env/")) return "production"; | |
| 194 | if (key === "file" && pattern.includes("/data/:env/:file/")) return "app.db"; | |
| 185 | 195 | return SEEDED.has(key) ? fill[key]! : "x"; |
| 186 | 196 | }); |
| 187 | 197 | } |
| 188 | 198 | |
| 189 | 199 | const unseeded = (pattern: string): boolean => |
| 190 | paramsOf(pattern).some((key) => !SEEDED.has(key) && !(key === "id" && pattern.includes("/runs/:id"))); | |
| 200 | paramsOf(pattern).some( | |
| 201 | (key) => !SEEDED.has(key) && !(key === "id" && pattern.includes("/runs/:id")) && !(key === "file" && pattern.includes("/data/:env/:file/")), | |
| 202 | ); | |
| 191 | 203 | |
| 192 | 204 | /** |
| 193 | 205 | * `accept-encoding: identity` skips gzip, which only costs time here: the floor |