Public: anyone can read this; the crew can change it.
Data browser core: read any SQLite file a viewer may see — the forge's own (admins) and each app's per environment (its writers) — tables, rows, read-only SQL, secrets masked #10273
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7579-data-browser-core-read-any-sqlite-file-a → main 14 commits fork 6 threads · 6 unresolved
.gitignore +1
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | node_modules |
| 2 | 2 | data/ |
| 3 | !src/data/ | |
| 3 | 4 | dist/ |
| 4 | 5 | *.local |
| 5 | 6 | .DS_Store |
docs/design/audit.md +1
| @@ -58,6 +58,7 @@ Machine routes are not pages and have no entry: `/audit.csv`, | ||
| 58 | 58 | - `/feed` — `studio/home.ts` · the composer and one timeline, what home was before it became the living pipeline (#8015) · `/` · none seen. |
| 59 | 59 | - `/boards/data` — `pages/boards.ts` · the boards' query/data surface · `/board` · none seen. |
| 60 | 60 | - `/asks` — `studio/asks_page.ts` · what the crew is waiting on me for · `/` "needs me" list · colours 2. |
| 61 | - `/data` — `pages/data.ts` · the SQLite files I may see, their tables and rows, secrets masked · `/system` · none seen. | |
| 61 | 62 | - `/asked` — `studio/asked_page.ts` · what I answered, as a record · `/asks` · none seen. |
| 62 | 63 | - `/review` — `studio/review_page.ts` · every ask waiting on me, one row each, newest first · `/asks` (same asks, with buttons) · none seen. |
| 63 | 64 | - `/flow` — `pages/flow.ts` · the pipeline from ready issue to live deploy, and the bottleneck · `/system`, `/board`, `/` · none seen. A topbar button points here. |
docs/features/data-browser.md added +17
| @@ -0,0 +1,17 @@ | ||
| 1 | # Data browser (core) | |
| 2 | ||
| 3 | `src/data/` reads SQLite files a viewer may see. Reach it at `/data` (the Data button on Apps, for an admin or an app's writer), in the terminal as the palette command Data, or through the MCP tool `data`. An admin or a writer of an app gets the page (an empty state until a database exists); a guest or a reader gets a 404, no palette entry, and nothing from the tool. | |
| 4 | ||
| 5 | - `listSources(db, dataDir, viewer)` — `forge.db` for admins; for each app the viewer can write, every `.db` under each environment's data dir. `findSource` resolves an id only through that list, so a reader gets `undefined` (a 404), and no input becomes a path. | |
| 6 | - `tables(source)`, `rows(source, table, {filter, order, page})` — 50 rows a page; table and column names are checked against the schema. | |
| 7 | - `query(source, sql, timeoutMs?)` — one `SELECT`/`WITH`, read-only connection (`query_only`), 1,000 rows, 5 s. It runs in a worker that is terminated at the cap, so even a query that never yields stops. | |
| 8 | - Masking (`mask.ts`): columns named like token, secret, password, hash, sealed, key, cookie, … show `••••••`. Queries read through temporary views that shadow each table, so aliases and subqueries are masked too; `main.` qualifiers are refused. Masked columns cannot be searched or ordered. `ALLOW` lists `table.column` exceptions per source id. | |
| 9 | - Connections open per call and close after; they never take a writer lock. | |
| 10 | ||
| 11 | Views are masked whole when their text mentions a masked column (an alias cannot launder a secret). `FORCE` masks generic columns by source (`forge_setting.value`, …); `test/data-browser.test.ts` audits every `forge.db` column against the name rule and lists the ones reviewed as clear. | |
| 12 | ||
| 13 | Limits on work: `query` runs at most 2 workers at once (a third is told it is busy), each with a 128 MB heap cap (`hard_heap_limit`), a 5 s timer, 1,000 rows and about 250 KB of result; every cell is cut to 1,000 characters inside SQLite and a blob shows as its size. `rows()` cuts cells the same way. | |
| 14 | ||
| 15 | Terminal: `src/tui/screens/data.ts` (files → tables → rows, `n`/`p` for pages), offered by `maySeeData`. | |
| 16 | ||
| 17 | Not done: a SQL editor on the page or in the terminal (SQL is MCP only). |
src/core/reserved.ts +1
| @@ -89,6 +89,7 @@ export const SYSTEM_NAMES: readonly string[] = [ | ||
| 89 | 89 | "mcp", |
| 90 | 90 | "metrics", |
| 91 | 91 | "contract", |
| 92 | "data", | |
| 92 | 93 | "migrate", |
| 93 | 94 | "mode", |
| 94 | 95 | "new", |
src/data/browse.ts added +142
| @@ -0,0 +1,142 @@ | ||
| 1 | /** | |
| 2 | * Tables and rows of one source, read on a connection opened for the call | |
| 3 | * and closed after it. Read-only: it takes no writer lock and a busy WAL | |
| 4 | * answers in milliseconds or not at all. | |
| 5 | */ | |
| 6 | ||
| 7 | import { Database } from "bun:sqlite"; | |
| 8 | import { cell, maskPlan, MASK } from "./mask.ts"; | |
| 9 | import type { Source } from "./sources.ts"; | |
| 10 | ||
| 11 | export const PAGE_SIZE = 50; | |
| 12 | /** Deepest page served: a huge OFFSET is a slow scan. */ | |
| 13 | export const MAX_PAGE = 1000; | |
| 14 | ||
| 15 | export interface TableInfo { | |
| 16 | readonly name: string; | |
| 17 | readonly rows: number; | |
| 18 | } | |
| 19 | ||
| 20 | export interface RowFilter { | |
| 21 | /** Rows where any column (or just `column`) contains `text`. */ | |
| 22 | readonly text: string; | |
| 23 | readonly column?: string; | |
| 24 | } | |
| 25 | ||
| 26 | export interface RowsOptions { | |
| 27 | readonly filter?: RowFilter; | |
| 28 | readonly order?: { readonly column: string; readonly dir?: "asc" | "desc" }; | |
| 29 | /** Zero-based. */ | |
| 30 | readonly page?: number; | |
| 31 | } | |
| 32 | ||
| 33 | export interface RowsResult { | |
| 34 | readonly columns: readonly string[]; | |
| 35 | readonly rows: readonly (readonly (string | number | null)[])[]; | |
| 36 | readonly page: number; | |
| 37 | readonly total: number; | |
| 38 | } | |
| 39 | ||
| 40 | export function openReadOnly(path: string): Database { | |
| 41 | const db = new Database(path, { readonly: true }); | |
| 42 | db.exec("PRAGMA busy_timeout = 250"); | |
| 43 | db.exec("PRAGMA query_only = 1"); | |
| 44 | return db; | |
| 45 | } | |
| 46 | ||
| 47 | const quote = (name: string): string => `"${name.replaceAll('"', '""')}"`; | |
| 48 | ||
| 49 | function withDb<T>(source: Source, fn: (db: Database) => T): T { | |
| 50 | const db = openReadOnly(source.path); | |
| 51 | try { | |
| 52 | return fn(db); | |
| 53 | } finally { | |
| 54 | db.close(); | |
| 55 | } | |
| 56 | } | |
| 57 | ||
| 58 | const tableNames = (db: Database): string[] => | |
| 59 | db | |
| 60 | .query<{ name: string }, []>( | |
| 61 | "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name", | |
| 62 | ) | |
| 63 | .all() | |
| 64 | .map((r) => r.name); | |
| 65 | ||
| 66 | export const tables = (source: Source): TableInfo[] => | |
| 67 | withDb(source, (db) => | |
| 68 | tableNames(db).map((name) => ({ | |
| 69 | name, | |
| 70 | rows: | |
| 71 | db | |
| 72 | .query<{ n: number }, []>(`SELECT count(*) AS n FROM ${quote(name)}`) | |
| 73 | .get()?.n ?? 0, | |
| 74 | })), | |
| 75 | ); | |
| 76 | ||
| 77 | /** Rows of `table`, or `undefined` when the table does not exist. */ | |
| 78 | export function rows( | |
| 79 | source: Source, | |
| 80 | table: string, | |
| 81 | opts: RowsOptions = {}, | |
| 82 | ): RowsResult | undefined { | |
| 83 | return withDb(source, (db) => { | |
| 84 | if (!tableNames(db).includes(table)) return undefined; | |
| 85 | const columns = db | |
| 86 | .query<{ name: string }, []>(`PRAGMA table_info(${quote(table)})`) | |
| 87 | .all() | |
| 88 | .map((c) => c.name); | |
| 89 | const masked = maskPlan(db, source.id).get(table) ?? new Set<string>(); | |
| 90 | const where: string[] = []; | |
| 91 | const args: string[] = []; | |
| 92 | if (opts.filter && opts.filter.text !== "") { | |
| 93 | const col = opts.filter.column; | |
| 94 | if (col !== undefined && !columns.includes(col)) return undefined; | |
| 95 | // Searching a masked column would leak it a character at a time. | |
| 96 | const searchable = (col === undefined ? columns : [col]).filter( | |
| 97 | (c) => !masked.has(c), | |
| 98 | ); | |
| 99 | const like = opts.filter.text.replace(/[\\%_]/g, (m) => `\\${m}`); | |
| 100 | where.push( | |
| 101 | searchable.length === 0 | |
| 102 | ? "0" | |
| 103 | : searchable | |
| 104 | .map((c) => `CAST(${quote(c)} AS TEXT) LIKE ? ESCAPE '\\'`) | |
| 105 | .join(" OR "), | |
| 106 | ); | |
| 107 | for (let i = 0; i < searchable.length; i++) args.push(`%${like}%`); | |
| 108 | } | |
| 109 | let order = ""; | |
| 110 | if (opts.order) { | |
| 111 | if (!columns.includes(opts.order.column)) return undefined; | |
| 112 | if (masked.has(opts.order.column)) return undefined; | |
| 113 | order = ` ORDER BY ${quote(opts.order.column)} ${opts.order.dir === "desc" ? "DESC" : "ASC"}`; | |
| 114 | } | |
| 115 | const clause = where.length ? ` WHERE ${where.join(" AND ")}` : ""; | |
| 116 | const wanted = Number(opts.page ?? 0); | |
| 117 | const page = Number.isFinite(wanted) | |
| 118 | ? Math.min(MAX_PAGE, Math.max(0, Math.floor(wanted))) | |
| 119 | : 0; | |
| 120 | const total = | |
| 121 | db | |
| 122 | .query<{ n: number }, string[]>( | |
| 123 | `SELECT count(*) AS n FROM ${quote(table)}${clause}`, | |
| 124 | ) | |
| 125 | .get(...args)?.n ?? 0; | |
| 126 | const raw = db | |
| 127 | .query<unknown[], string[]>( | |
| 128 | `SELECT ${columns.map(quote).join(", ")} FROM ${quote(table)}${clause}${order} LIMIT ${PAGE_SIZE} OFFSET ${page * PAGE_SIZE}`, | |
| 129 | ) | |
| 130 | .values(...args); | |
| 131 | return { | |
| 132 | columns, | |
| 133 | page, | |
| 134 | total, | |
| 135 | rows: raw.map((r) => | |
| 136 | r.map((v, i) => | |
| 137 | masked.has(columns[i]!) && v !== null ? MASK : cell(v), | |
| 138 | ), | |
| 139 | ), | |
| 140 | }; | |
| 141 | }); | |
| 142 | } |
src/data/mask.ts added +134
| @@ -0,0 +1,134 @@ | ||
| 1 | import type { Database } from "bun:sqlite"; | |
| 2 | ||
| 3 | /** | |
| 4 | * Which columns hold secrets, by rule. A masked cell shows that a value is | |
| 5 | * there and nothing of it. A source may allow a column by name | |
| 6 | * (`table.column`) when its content is known to be harmless. | |
| 7 | */ | |
| 8 | ||
| 9 | export const MASK = "••••••"; | |
| 10 | ||
| 11 | const SECRET = | |
| 12 | /_enc$|verifier|nonce|challenge|(^|_)code(_hash)?$|token|secret|passw(or)?d|passhash|(^|_)hash$|^hash$|sealed|api_?key|(^|_)key$|^key$|private|credential|cookie|(^|_)salt$|(^|_)sig(nature)?$|^pw/i; | |
| 13 | ||
| 14 | /** Per-source allowlist: source id → `table.column` entries shown in clear. */ | |
| 15 | export const ALLOW: Record<string, readonly string[]> = {}; | |
| 16 | ||
| 17 | /** | |
| 18 | * Per-source columns masked whatever their name: generic ones that hold | |
| 19 | * secrets in some rows (a setting's `value`, a stored response `body`). | |
| 20 | */ | |
| 21 | export const FORCE: Readonly<Record<string, readonly string[]>> = { | |
| 22 | forge: [ | |
| 23 | "forge_setting.value", | |
| 24 | "forge_setting_change.from_value", | |
| 25 | "forge_setting_change.to_value", | |
| 26 | "front_setting.value", | |
| 27 | "user_setting.value", | |
| 28 | "origin_cursor.value", | |
| 29 | "sentinel_kept.value", | |
| 30 | "idempotency.body", | |
| 31 | "event.payload", | |
| 32 | "sentinel_finding.evidence", | |
| 33 | ], | |
| 34 | }; | |
| 35 | ||
| 36 | export const isSecretColumn = (column: string): boolean => SECRET.test(column); | |
| 37 | ||
| 38 | /** Whether `table.column` of a source shows only the mask. */ | |
| 39 | export function masker(sourceId: string): (table: string, column: string) => boolean { | |
| 40 | const allow = new Set(ALLOW[sourceId] ?? []); | |
| 41 | const force = new Set(FORCE[sourceId] ?? []); | |
| 42 | return (table, column) => { | |
| 43 | const key = `${table}.${column}`; | |
| 44 | if (allow.has(key)) return false; | |
| 45 | return force.has(key) || SECRET.test(column); | |
| 46 | }; | |
| 47 | } | |
| 48 | ||
| 49 | const quote = (name: string): string => `"${name.replaceAll('"', '""')}"`; | |
| 50 | ||
| 51 | /** | |
| 52 | * The masked columns of every table and view in `db`. A view is judged by | |
| 53 | * its text, and masked whole when that text uses `*` (which can pull a | |
| 54 | * secret column without naming it, under any name), names any masked | |
| 55 | * column of the database, or names another view masked whole. So neither | |
| 56 | * an alias, a column list nor a view over a view can launder a secret. | |
| 57 | */ | |
| 58 | export function maskPlan(db: Database, sourceId: string): Map<string, Set<string>> { | |
| 59 | const masked = masker(sourceId); | |
| 60 | const plan = new Map<string, Set<string>>(); | |
| 61 | const objects = db | |
| 62 | .query<{ name: string; type: string; sql: string | null }, []>( | |
| 63 | "SELECT name, type, sql FROM sqlite_master WHERE type IN ('table','view') AND name NOT LIKE 'sqlite_%'", | |
| 64 | ) | |
| 65 | .all(); | |
| 66 | const columnsOf = (name: string): string[] => | |
| 67 | db | |
| 68 | .query<{ name: string }, []>(`PRAGMA table_info(${quote(name)})`) | |
| 69 | .all() | |
| 70 | .map((c) => c.name); | |
| 71 | const secretNames = new Set<string>(); | |
| 72 | const cols = new Map<string, string[]>(); | |
| 73 | for (const o of objects) { | |
| 74 | const c = columnsOf(o.name); | |
| 75 | cols.set(o.name, c); | |
| 76 | if (o.type === "table") | |
| 77 | plan.set(o.name, new Set(c.filter((x) => masked(o.name, x)))); | |
| 78 | for (const x of plan.get(o.name) ?? []) secretNames.add(x.toLowerCase()); | |
outdated
High: a view is masked only if its SQL text names a masked column, or if its own column name matches the rule. |
||
| 79 | } | |
| 80 | const names = (text: string, name: string): boolean => { | |
| 81 | const n = name.toLowerCase(); | |
| 82 | let at = text.indexOf(n); | |
| 83 | while (at >= 0) { | |
| 84 | const before = text[at - 1] ?? " "; | |
| 85 | const after = text[at + n.length] ?? " "; | |
| 86 | if (!/[a-z0-9_$]/.test(before) && !/[a-z0-9_$]/.test(after)) return true; | |
| 87 | at = text.indexOf(n, at + 1); | |
| 88 | } | |
| 89 | return false; | |
| 90 | }; | |
| 91 | const views = objects.filter((o) => o.type === "view"); | |
| 92 | const text = new Map(views.map((o) => [o.name, (o.sql ?? "").toLowerCase()])); | |
| 93 | // The part after the view's own name and column list: its SELECT. | |
| 94 | const body = (name: string): string => { | |
| 95 | const t = text.get(name)!; | |
| 96 | const as = t.search(/\bas\b/); | |
| 97 | return as >= 0 ? t.slice(as) : t; | |
| 98 | }; | |
| 99 | const whole = new Set<string>(); | |
| 100 | for (const o of views) { | |
| 101 | const b = body(o.name); | |
| 102 | if (b.includes("*") || [...secretNames].some((n) => names(b, n))) whole.add(o.name); | |
| 103 | } | |
| 104 | // A view over a view masked whole is masked whole, to any depth. | |
| 105 | for (let grew = true; grew; ) { | |
| 106 | grew = false; | |
| 107 | for (const o of views) { | |
| 108 | if (whole.has(o.name)) continue; | |
| 109 | if ([...whole].some((w) => names(body(o.name), w))) { | |
| 110 | whole.add(o.name); | |
| 111 | grew = true; | |
| 112 | } | |
| 113 | } | |
| 114 | } | |
| 115 | for (const o of views) { | |
| 116 | const c = cols.get(o.name)!; | |
| 117 | plan.set(o.name, new Set(whole.has(o.name) ? c : c.filter((x) => masked(o.name, x)))); | |
| 118 | } | |
| 119 | return plan; | |
| 120 | } | |
| 121 | ||
| 122 | /** Longest text a cell shows; the rest is cut and marked. */ | |
| 123 | export const CELL_CAP = 1000; | |
| 124 | ||
| 125 | /** A cell as plain JSON-safe data. */ | |
| 126 | export function cell(value: unknown): string | number | null { | |
| 127 | if (value === null || value === undefined) return null; | |
| 128 | if (typeof value === "bigint") return value.toString(); | |
| 129 | if (typeof value === "number") return value; | |
| 130 | if (typeof value === "string") | |
| 131 | return value.length > CELL_CAP ? `${value.slice(0, CELL_CAP)}…` : value; | |
| 132 | if (value instanceof Uint8Array) return `<blob ${value.byteLength} bytes>`; | |
| 133 | return String(value); | |
| 134 | } |
src/data/query.ts added +97
| @@ -0,0 +1,97 @@ | ||
| 1 | /** | |
| 2 | * Read-only SQL against one source: `SELECT` or `WITH`, a read-only | |
| 3 | * connection, a time cap (the worker is ended, which stops even a query | |
| 4 | * that never yields) and a row cap. | |
| 5 | */ | |
| 6 | ||
| 7 | import { checkQuery } from "./sql.ts"; | |
| 8 | import type { Source } from "./sources.ts"; | |
| 9 | ||
| 10 | export const QUERY_TIMEOUT_MS = 5000; | |
| 11 | export const QUERY_ROW_CAP = 1000; | |
| 12 | /** Characters of result kept (a quarter megabyte). */ | |
| 13 | export const QUERY_BYTE_CAP = 250_000; | |
| 14 | /** Memory a query may use. */ | |
| 15 | export const QUERY_HEAP = 128 * 1024 * 1024; | |
| 16 | /** Queries running at once; one more is told the forge is busy. */ | |
| 17 | export const QUERY_WORKERS = 2; | |
| 18 | let running = 0; | |
| 19 | ||
| 20 | export type QueryResult = | |
| 21 | | { | |
| 22 | readonly ok: true; | |
| 23 | readonly columns: readonly string[]; | |
| 24 | readonly rows: readonly (readonly (string | number | null)[])[]; | |
| 25 | readonly truncated: boolean; | |
| 26 | } | |
| 27 | | { | |
| 28 | readonly ok: false; | |
| 29 | readonly error: "empty" | "not_select" | "multiple" | "schema" | "timeout" | "sql" | "busy"; | |
| 30 | readonly message: string; | |
| 31 | }; | |
| 32 | ||
| 33 | const REFUSALS = { | |
| 34 | empty: "Write a query.", | |
| 35 | not_select: "Only SELECT and WITH statements are allowed.", | |
| 36 | multiple: "One statement at a time.", | |
| 37 | schema: "Name tables without a schema prefix.", | |
| 38 | } as const; | |
| 39 | ||
| 40 | /** Queries running now (for tests). */ | |
| 41 | export const queriesRunning = (): number => running; | |
| 42 | ||
| 43 | export function query( | |
| 44 | source: Source, | |
| 45 | sql: string, | |
| 46 | timeoutMs = QUERY_TIMEOUT_MS, | |
| 47 | ): Promise<QueryResult> { | |
| 48 | const verdict = checkQuery(sql); | |
| 49 | if (verdict !== "ok") | |
| 50 | return Promise.resolve({ ok: false, error: verdict, message: REFUSALS[verdict] }); | |
| 51 | if (running >= QUERY_WORKERS) | |
| 52 | return Promise.resolve({ | |
| 53 | ok: false, | |
| 54 | error: "busy", | |
| 55 | message: "Busy with other queries: try again in a moment.", | |
| 56 | }); | |
| 57 | running++; | |
| 58 | return new Promise((resolve) => { | |
| 59 | const worker = new Worker(new URL("./query_worker.ts", import.meta.url).href); | |
| 60 | let finished = false; | |
| 61 | const done = (r: QueryResult) => { | |
| 62 | if (finished) return; | |
| 63 | finished = true; | |
| 64 | clearTimeout(timer); | |
| 65 | worker.terminate(); | |
| 66 | running--; | |
| 67 | resolve(r); | |
| 68 | }; | |
| 69 | const timer = setTimeout( | |
| 70 | () => | |
| 71 | done({ | |
| 72 | ok: false, | |
| 73 | error: "timeout", | |
| 74 | message: `Stopped after ${timeoutMs / 1000} s.`, | |
| 75 | }), | |
| 76 | timeoutMs, | |
| 77 | ); | |
| 78 | worker.onmessage = (e: MessageEvent) => { | |
| 79 | const m = e.data; | |
| 80 | done( | |
| 81 | m.ok | |
| 82 | ? { ok: true, columns: m.columns, rows: m.rows, truncated: m.truncated } | |
| 83 | : { ok: false, error: "sql", message: String(m.message) }, | |
| 84 | ); | |
| 85 | }; | |
| 86 | worker.onerror = (e) => | |
| 87 | done({ ok: false, error: "sql", message: e.message || "query failed" }); | |
| 88 | worker.postMessage({ | |
| 89 | path: source.path, | |
| 90 | sourceId: source.id, | |
| 91 | sql, | |
| 92 | limit: QUERY_ROW_CAP, | |
| 93 | bytes: QUERY_BYTE_CAP, | |
| 94 | heap: QUERY_HEAP, | |
| 95 | }); | |
| 96 | }); | |
| 97 | } |
src/data/query_worker.ts added +83
| @@ -0,0 +1,83 @@ | ||
| 1 | /** | |
| 2 | * Runs one query off the main thread so that a slow one can be stopped by | |
| 3 | * ending the thread. Secret columns are shadowed by temporary views that | |
| 4 | * show the mask, so an alias or a subquery cannot read them. | |
| 5 | */ | |
| 6 | ||
| 7 | import { Database } from "bun:sqlite"; | |
| 8 | import { CELL_CAP, cell, maskPlan, MASK } from "./mask.ts"; | |
| 9 | ||
| 10 | declare const self: Worker; | |
| 11 | ||
| 12 | interface Job { | |
| 13 | readonly path: string; | |
| 14 | readonly sourceId: string; | |
| 15 | readonly sql: string; | |
| 16 | readonly limit: number; | |
| 17 | /** Bytes of result text kept before the rest is dropped. */ | |
| 18 | readonly bytes: number; | |
| 19 | /** Memory SQLite may hold for this query. */ | |
| 20 | readonly heap: number; | |
| 21 | } | |
| 22 | ||
| 23 | const quote = (name: string): string => `"${name.replaceAll('"', '""')}"`; | |
| 24 | ||
| 25 | self.onmessage = (event: MessageEvent<Job>) => { | |
| 26 | const job = event.data; | |
| 27 | let db: Database | undefined; | |
| 28 | try { | |
| 29 | db = new Database(job.path, { readonly: true }); | |
| 30 | db.exec("PRAGMA busy_timeout = 250"); | |
| 31 | for (const [name, hide] of maskPlan(db, job.sourceId)) { | |
| 32 | if (hide.size === 0) continue; | |
| 33 | const cols = db | |
| 34 | .query<{ name: string }, []>(`PRAGMA table_info(${quote(name)})`) | |
| 35 | .all(); | |
| 36 | const select = cols | |
| 37 | .map((c) => | |
| 38 | hide.has(c.name) | |
| 39 | ? `CASE WHEN main.${quote(name)}.${quote(c.name)} IS NULL THEN NULL ELSE '${MASK}' END AS ${quote(c.name)}` | |
| 40 | : `main.${quote(name)}.${quote(c.name)} AS ${quote(c.name)}`, | |
| 41 | ) | |
| 42 | .join(", "); | |
| 43 | db.exec(`CREATE TEMP VIEW ${quote(name)} AS SELECT ${select} FROM main.${quote(name)}`); | |
| 44 | } | |
| 45 | db.exec("PRAGMA query_only = 1"); | |
| 46 | db.exec(`PRAGMA hard_heap_limit = ${Math.floor(job.heap)}`); | |
| 47 | const columns = db.query(job.sql).columnNames; | |
| 48 | // Cut every cell inside SQLite, so a giant blob or string never reaches | |
| 49 | // this thread whole. | |
| 50 | const names = columns.map((_, i) => `c${i}`); | |
| 51 | const shown = names | |
| 52 | .map( | |
| 53 | (n) => | |
| 54 | `CASE typeof(${n}) WHEN 'blob' THEN '<blob ' || length(${n}) || ' bytes>' WHEN 'text' THEN substr(${n}, 1, ${CELL_CAP + 1}) ELSE ${n} END`, | |
| 55 | ) | |
| 56 | .join(", "); | |
| 57 | const body = job.sql.trim().replace(/;+\s*$/, ""); | |
| 58 | const stmt = | |
| 59 | columns.length === 0 | |
| 60 | ? db.query(job.sql) | |
| 61 | : db.query(`WITH q(${names.join(", ")}) AS (\n${body}\n) SELECT ${shown} FROM q`); | |
| 62 | const out: (string | number | null)[][] = []; | |
| 63 | let truncated = false; | |
| 64 | let size = 0; | |
| 65 | for (const row of stmt.values() as unknown[][]) { | |
| 66 | const cells = row.map(cell); | |
| 67 | size += cells.reduce<number>((n, v) => n + (typeof v === "string" ? v.length : 8), 0); | |
| 68 | if (out.length >= job.limit || size > job.bytes) { | |
| 69 | truncated = true; | |
| 70 | break; | |
| 71 | } | |
| 72 | out.push(cells); | |
| 73 | } | |
| 74 | self.postMessage({ ok: true, columns, rows: out, truncated }); | |
| 75 | } catch (error) { | |
| 76 | self.postMessage({ | |
| 77 | ok: false, | |
| 78 | message: error instanceof Error ? error.message : String(error), | |
| 79 | }); | |
| 80 | } finally { | |
| 81 | db?.close(); | |
| 82 | } | |
| 83 | }; |
src/data/sources.ts added +93
| @@ -0,0 +1,93 @@ | ||
| 1 | /** | |
| 2 | * The SQLite files a viewer may see: the forge's own (admins), and each | |
| 3 | * app's per environment (people who can write the app's repository). An id | |
| 4 | * is only ever resolved by looking it up in this list, so no input becomes | |
| 5 | * a path. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { Database } from "bun:sqlite"; | |
| 9 | import { existsSync, readdirSync } from "node:fs"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { listApps } from "../core/apps.ts"; | |
| 12 | import { getRepoById, writableRepoIds } from "../core/repos.ts"; | |
| 13 | import type { User } from "../core/users.ts"; | |
| 14 | import { envDataDir, isEnvName } from "../apps/data.ts"; | |
| 15 | ||
| 16 | export interface Source { | |
| 17 | /** `forge`, or `app:<appId>:<env>:<file>`. */ | |
| 18 | readonly id: string; | |
| 19 | readonly label: string; | |
| 20 | readonly kind: "forge" | "app"; | |
| 21 | readonly path: string; | |
| 22 | } | |
| 23 | ||
| 24 | const DB_FILE = /^[\w.-]+\.db$/; | |
| 25 | ||
| 26 | export function listSources( | |
| 27 | db: Database, | |
| 28 | dataDir: string, | |
| 29 | viewer: User | undefined, | |
| 30 | ): Source[] { | |
| 31 | const out: Source[] = []; | |
| 32 | if (viewer === undefined) return out; | |
| 33 | if (viewer.isAdmin) { | |
| 34 | const path = join(dataDir, "forge.db"); | |
| 35 | if (existsSync(path)) | |
| 36 | out.push({ id: "forge", label: "forge", kind: "forge", path }); | |
| 37 | } | |
| 38 | const apps = listApps(db); | |
| 39 | const writable = writableRepoIds( | |
| 40 | db, | |
| 41 | viewer, | |
| 42 | apps.map((a) => a.repoId), | |
| 43 | ); | |
| 44 | for (const app of apps) { | |
| 45 | if (!writable.has(app.repoId) || getRepoById(db, app.repoId) === undefined) | |
| 46 | continue; | |
| 47 | let envs: string[]; | |
| 48 | try { | |
| 49 | envs = readdirSync(join(dataDir, "apps", app.id)).filter(isEnvName); | |
| 50 | } catch { | |
| 51 | continue; | |
| 52 | } | |
| 53 | for (const env of envs.sort()) { | |
| 54 | if (!isEnvName(env)) continue; | |
| 55 | const dir = envDataDir(dataDir, app.id, env); | |
| 56 | let files: string[]; | |
| 57 | try { | |
| 58 | files = readdirSync(dir).filter((f) => DB_FILE.test(f)); | |
| 59 | } catch { | |
| 60 | continue; | |
| 61 | } | |
| 62 | for (const file of files.sort()) | |
| 63 | out.push({ | |
| 64 | id: `app:${app.id}:${env}:${file}`, | |
| 65 | label: `${app.slug} · ${env} · ${file}`, | |
| 66 | kind: "app", | |
| 67 | path: join(dir, file), | |
| 68 | }); | |
| 69 | } | |
| 70 | } | |
| 71 | return out; | |
| 72 | } | |
| 73 | ||
| 74 | /** | |
| 75 | * Whether the viewer has a door to the data browser at all: an admin, or a | |
| 76 | * writer of an app, whether or not any database file exists yet. A guest and | |
| 77 | * a reader have none (a 404 on the web, no entry in the terminal). | |
| 78 | */ | |
| 79 | export function maySeeData(db: Database, viewer: User | undefined): boolean { | |
| 80 | if (viewer === undefined) return false; | |
| 81 | if (viewer.isAdmin) return true; | |
| 82 | const ids = listApps(db).map((a) => a.repoId); | |
| 83 | return ids.length > 0 && writableRepoIds(db, viewer, ids).size > 0; | |
| 84 | } | |
| 85 | ||
| 86 | /** The source `id` names, if this viewer may see it; else `undefined` (a 404). */ | |
| 87 | export const findSource = ( | |
| 88 | db: Database, | |
| 89 | dataDir: string, | |
| 90 | viewer: User | undefined, | |
| 91 | id: string, | |
| 92 | ): Source | undefined => | |
| 93 | listSources(db, dataDir, viewer).find((s) => s.id === id); |
src/data/sql.ts added +32
| @@ -0,0 +1,32 @@ | ||
| 1 | /** What a read-only query may be: one SELECT or WITH statement, nothing else. */ | |
| 2 | ||
| 3 | /** The statement with comments and string literals blanked; identifiers stay. */ | |
| 4 | function skeleton(sql: string): string { | |
| 5 | let out = ""; | |
| 6 | for (let i = 0; i < sql.length; i++) { | |
| 7 | const c = sql[i]!; | |
| 8 | if (c === "-" && sql[i + 1] === "-") { | |
| 9 | while (i < sql.length && sql[i] !== "\n") i++; | |
| 10 | out += " "; | |
| 11 | } else if (c === "/" && sql[i + 1] === "*") { | |
| 12 | const end = sql.indexOf("*/", i + 2); | |
| 13 | i = end < 0 ? sql.length : end + 1; | |
| 14 | out += " "; | |
| 15 | } else if (c === "'") { | |
| 16 | i++; | |
| 17 | while (i < sql.length && sql[i] !== "'") i++; | |
| 18 | out += "''"; | |
| 19 | } else out += c; | |
| 20 | } | |
| 21 | return out; | |
| 22 | } | |
| 23 | ||
| 24 | export function checkQuery(sql: string): "ok" | "empty" | "not_select" | "multiple" | "schema" { | |
| 25 | const s = skeleton(sql).trim().replace(/;+\s*$/, "").trim(); | |
| 26 | if (s === "") return "empty"; | |
| 27 | if (s.includes(";")) return "multiple"; | |
| 28 | if (!/^(select|with)\b/i.test(s)) return "not_select"; | |
| 29 | // `main.t` would step round the masking views that shadow `t`. | |
| 30 | if (/(^|[^\w])("main"|\[main\]|`main`|main)\s*\./i.test(s)) return "schema"; | |
| 31 | return "ok"; | |
| 32 | } |
src/http/mcp/tools/data.ts added +39
| @@ -0,0 +1,39 @@ | ||
| 1 | import { rows, tables } from "../../../data/browse.ts"; | |
| 2 | import { query } from "../../../data/query.ts"; | |
| 3 | import { findSource, listSources } from "../../../data/sources.ts"; | |
| 4 | import { bad, good, type Tool } from "../shared.ts"; | |
| 5 | ||
| 6 | export const tool: Tool = { | |
| 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", | |
| 9 | description: "Read SQLite files you may see: tables, rows, read-only SQL; secrets masked.", | |
| 10 | inputSchema: { | |
| 11 | type: "object", | |
| 12 | properties: { | |
| 13 | source: { type: "string" }, | |
| 14 | table: { type: "string" }, | |
| 15 | page: { type: "integer" }, | |
| 16 | sql: { type: "string" }, | |
| 17 | }, | |
| 18 | }, | |
| 19 | writes: false, | |
| 20 | async run(ctx, user, args) { | |
| 21 | 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"); | |
| 26 | if (id === undefined) { | |
| 27 | return good(listSources(db, config.dataDir, user).map((s) => ({ source: s.id, label: s.label }))); | |
| 28 | } | |
| 29 | const source = findSource(db, config.dataDir, user, id as string); | |
| 30 | 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); | |
| 34 | } | |
| 35 | if (table === undefined) return good(tables(source)); | |
| 36 | const found = rows(source, table as string, page === undefined ? {} : { page: page as number }); | |
| 37 | return found ? good(found) : bad("not_found", "no such table"); | |
| 38 | }, | |
| 39 | }; |
src/http/mcp/tools/index.ts +1
| @@ -46,6 +46,7 @@ export { tool as confirm_wait } from "./confirm_wait.ts"; | ||
| 46 | 46 | export { tool as contribute } from "./contribute.ts"; |
| 47 | 47 | export { tool as converge } from "./converge.ts"; |
| 48 | 48 | export { tool as crew } from "./crew.ts"; |
| 49 | export { tool as data } from "./data.ts"; | |
| 49 | 50 | export { tool as decisions_by } from "./decisions_by.ts"; |
| 50 | 51 | export { tool as detach } from "./detach.ts"; |
| 51 | 52 | export { tool as drills } from "./drills.ts"; |
src/http/server.ts +2
| @@ -27,6 +27,7 @@ import { prerenderComments } from "../web/prerender.ts"; | ||
| 27 | 27 | import { systemPage, systemStream } from "../web/pages/system.ts"; |
| 28 | 28 | import { contributePage } from "../web/pages/contribute.ts"; |
| 29 | 29 | import { pipelinePage } from "../web/pages/pipeline.ts"; |
| 30 | import { dataPage } from "../web/pages/data.ts"; | |
| 30 | 31 | import { fleetAct, fleetReceipt, fleetUndo } from "../web/pages/fleet-ops.ts"; |
| 31 | 32 | import { maintenancePage, maintenancePrepare, maintenanceResume } from "../web/pages/maintenance.ts"; |
| 32 | 33 | import { nightlyOutputPage, resiliencePage } from "../web/pages/resilience.ts"; |
| @@ -921,6 +922,7 @@ export function buildRouter(): Router<Handler> { | ||
| 921 | 922 | pinnedRoutes(router); |
| 922 | 923 | // Each THREATS.md protection against the last gate; literal, above /:owner. |
| 923 | 924 | router.add("GET", "/resilience", resiliencePage); |
| 925 | router.add("GET", "/data", dataPage); | |
| 924 | 926 | router.add("GET", "/maintenance", maintenancePage); |
| 925 | 927 | router.add("POST", "/maintenance/prepare", maintenancePrepare); |
| 926 | 928 | router.add("POST", "/maintenance/resume", maintenanceResume); |
src/tui/forge.ts +2
| @@ -15,6 +15,7 @@ import { guestCodeSearch } from "../http/strangers.ts"; | ||
| 15 | 15 | import { canRead, getRepo, type Repo } from "../core/repos.ts"; |
| 16 | 16 | import { sourceFor } from "../view/source.ts"; |
| 17 | 17 | import { fleetCommand } from "./screens/fleet.ts"; |
| 18 | import { dataCommand } from "./screens/data.ts"; | |
| 18 | 19 | import { packsCommand } from "./screens/packs.ts"; |
| 19 | 20 | import { App, type Ctx, type Screen } from "./app/app.ts"; |
| 20 | 21 | import type { Command, PaletteItem, PaletteSearch } from "./app/palette.ts"; |
| @@ -208,6 +209,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] { | ||
| 208 | 209 | run: () => openOnWeb(session, ctx, "/explore", "The explorer"), |
| 209 | 210 | }, |
| 210 | 211 | packsCommand(session, ctx), |
| 212 | ...dataCommand(session, ctx), | |
| 211 | 213 | fleetCommand(session, ctx), |
| 212 | 214 | { |
| 213 | 215 | // A person's or an org's page is the web's (#636): the terminal |
src/tui/screens/data.ts added +124
| @@ -0,0 +1,124 @@ | ||
| 1 | /** | |
| 2 | * `/data` in the terminal: the SQLite files the viewer may see, a file's | |
| 3 | * tables, a table's rows 50 a page, secrets masked. The same reads, and the | |
| 4 | * same gate (`maySeeData`, `listSources`), as the web page and MCP `data`. | |
| 5 | */ | |
| 6 | ||
| 7 | import { PAGE_SIZE, rows, tables } from "../../data/browse.ts"; | |
| 8 | import { listSources, maySeeData, type Source } from "../../data/sources.ts"; | |
| 9 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 10 | import { binding } from "../app/keys.ts"; | |
| 11 | import type { Session } from "../session.ts"; | |
| 12 | import { style } from "../term/style.ts"; | |
| 13 | import { truncate } from "../term/width.ts"; | |
| 14 | ||
| 15 | /** A list of rows to move through; `open` is what Enter does on one. */ | |
| 16 | function listScreen( | |
| 17 | ctx: Ctx, | |
| 18 | title: string, | |
| 19 | intro: string, | |
| 20 | items: () => readonly string[], | |
| 21 | open: (index: number) => void, | |
| 22 | extra?: (key: string) => boolean, | |
| 23 | keys: ReturnType<typeof binding>[] = [], | |
| 24 | ): Screen { | |
| 25 | let at = 0; | |
| 26 | return { | |
| 27 | title, | |
| 28 | keys: [binding(["j", "k"], "move"), binding(["enter"], "open"), ...keys, binding(["esc"], "back")], | |
| 29 | render(area, s) { | |
| 30 | const c = ctx.theme.c; | |
| 31 | s.text(area.x + 1, area.y, truncate(intro, area.w - 2), style(c.mutedFg, c.bg), area.x + area.w); | |
| 32 | const list = items(); | |
| 33 | if (list.length === 0) s.text(area.x + 1, area.y + 2, "Nothing here yet.", style(c.mutedFg, c.bg), area.x + area.w); | |
| 34 | const h = Math.max(1, area.h - 2); | |
| 35 | const from = Math.max(0, Math.min(at - h + 1, list.length - h)); | |
| 36 | list.slice(from, from + h).forEach((line, i) => { | |
| 37 | const on = from + i === at; | |
| 38 | s.text(area.x + 1, area.y + 2 + i, truncate(`${on ? "> " : " "}${line}`, area.w - 2), on ? style(c.accent, c.bg) : style(c.fg, c.bg), area.x + area.w); | |
| 39 | }); | |
| 40 | }, | |
| 41 | key(e) { | |
| 42 | if (e.ctrl || e.alt) return false; | |
| 43 | const n = items().length; | |
| 44 | if (e.key === "esc" || e.key === "q") ctx.pop(); | |
| 45 | else if (e.key === "j" || e.key === "down") at = Math.min(Math.max(0, n - 1), at + 1); | |
| 46 | else if (e.key === "k" || e.key === "up") at = Math.max(0, at - 1); | |
| 47 | else if (e.key === "enter") { | |
| 48 | if (n > 0) open(at); | |
| 49 | } else return extra?.(e.key) ?? false; | |
| 50 | return true; | |
| 51 | }, | |
| 52 | }; | |
| 53 | } | |
| 54 | ||
| 55 | const cellText = (v: string | number | null): string => (v === null ? "" : String(v).replace(/[\r\n\t]+/g, " ").replace(/[\u0000-\u001f\u007f]/g, " ")); | |
| 56 | ||
| 57 | function rowsScreen(ctx: Ctx, source: Source, table: string): Screen { | |
| 58 | let page = 0; | |
| 59 | const read = () => rows(source, table, { page }); | |
| 60 | return listScreen( | |
| 61 | ctx, | |
| 62 | `${table} · ${source.label}`, | |
| 63 | "", | |
| 64 | () => { | |
| 65 | const r = read(); | |
| 66 | if (!r) return []; | |
| 67 | return [ | |
| 68 | `${r.total} rows, page ${r.page + 1} · ${r.columns.join(" | ")}`, | |
| 69 | ...r.rows.map((row) => row.map(cellText).join(" | ")), | |
| 70 | ]; | |
| 71 | }, | |
| 72 | () => undefined, | |
| 73 | (key) => { | |
| 74 | const r = read(); | |
| 75 | if (!r) return false; | |
| 76 | if (key === "n" && (r.page + 1) * PAGE_SIZE < r.total) page = r.page + 1; | |
| 77 | else if (key === "p" && r.page > 0) page = r.page - 1; | |
| 78 | else return false; | |
| 79 | return true; | |
| 80 | }, | |
| 81 | [binding(["n", "p"], "next / previous page")], | |
| 82 | ); | |
| 83 | } | |
| 84 | ||
| 85 | function tablesScreen(ctx: Ctx, source: Source): Screen { | |
| 86 | const list = tables(source); | |
| 87 | return listScreen( | |
| 88 | ctx, | |
| 89 | source.label, | |
| 90 | "Tables; secrets are masked.", | |
| 91 | () => list.map((t) => `${t.name} ${t.rows} rows`), | |
| 92 | (i) => { | |
| 93 | const t = list[i]; | |
| 94 | if (t) ctx.push(rowsScreen(ctx, source, t.name)); | |
| 95 | }, | |
| 96 | ); | |
| 97 | } | |
| 98 | ||
| 99 | export function dataScreen(session: Session, ctx: Ctx): Screen { | |
| 100 | const sources = () => listSources(session.app.db, session.app.config.dataDir, session.user); | |
| 101 | return listScreen( | |
| 102 | ctx, | |
| 103 | "Data", | |
| 104 | "The SQLite files you may read.", | |
| 105 | () => sources().map((s) => s.label), | |
| 106 | (i) => { | |
| 107 | const s = sources()[i]; | |
| 108 | if (s) ctx.push(tablesScreen(ctx, s)); | |
| 109 | }, | |
| 110 | ); | |
| 111 | } | |
| 112 | ||
| 113 | /** Offered to an admin and to an app's writers; a reader and a guest see nothing. */ | |
| 114 | export const dataCommand = (session: Session, ctx: Ctx) => | |
| 115 | maySeeData(session.app.db, session.user) | |
| 116 | ? [ | |
| 117 | { | |
| 118 | id: "data", | |
| 119 | title: "Data", | |
| 120 | hint: "the SQLite files you may read, secrets masked", | |
| 121 | run: () => ctx.push(dataScreen(session, ctx)), | |
| 122 | }, | |
| 123 | ] | |
| 124 | : []; |
src/web/pages/apps.ts +9 −1
| @@ -236,8 +236,16 @@ export async function appsList(ctx: Ctx): Promise<Response> { | ||
| 236 | 236 | costLine(ctx.app.db, writableAppsOf(ctx, ctx.auth.user).repos), |
| 237 | 237 | ); |
| 238 | 238 | const rendering = performance.now(); |
| 239 | // The door to /data: an admin, or someone who writes an app (already counted | |
| 240 | // above); no per-app work here, /data itself lists what exists. | |
| 241 | const hasData = | |
| 242 | ctx.auth.user !== undefined && | |
| 243 | (ctx.auth.user.isAdmin || writableAppsOf(ctx, ctx.auth.user).repos.length > 0); | |
| 239 | 244 | const body = html`<div data-apps-page>${when(ctx.auth.user, (user) => homeTabs("apps", unshippedAsks(ctx.app.db, user)))} |
| 240 | ${pageHead("Apps", { actions: create, sub: APPS_PURPOSE })} | |
| 245 | ${pageHead("Apps", { | |
| 246 | actions: hasData ? html`${btn("Data", { href: "/data", size: "sm" })}${create}` : create, | |
outdated
Hand-rolled |
||
| 247 | sub: APPS_PURPOSE, | |
| 248 | })} | |
| 241 | 249 | ${cost} |
| 242 | 250 | ${filters} |
| 243 | 251 | ${archivedView |
src/web/pages/data.ts added +87
| @@ -0,0 +1,87 @@ | ||
| 1 | /** | |
| 2 | * /data: the SQLite files the viewer may see (`data/sources.ts`), the | |
| 3 | * tables in one, and a page of a table's rows, secrets masked. Anyone with | |
| 4 | * no door to the data (a guest, a reader) gets the not-found page; an admin | |
| 5 | * or an app's writer gets the page, with an empty state until a file exists. | |
| 6 | */ | |
| 7 | ||
| 8 | import { PAGE_SIZE, rows, tables } from "../../data/browse.ts"; | |
| 9 | import { findSource, listSources, maySeeData } from "../../data/sources.ts"; | |
| 10 | import type { Ctx } from "../../http/context.ts"; | |
| 11 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | |
| 12 | import { notFoundPage, pageHead, signInFirst } from "../chrome.ts"; | |
| 13 | import { html } from "../html.ts"; | |
| 14 | import { page } from "../layout.ts"; | |
| 15 | import { empty, pager, row } from "../ui.ts"; | |
| 16 | ||
| 17 | const PURPOSE = "The SQLite files you may read, their tables and rows; secrets are masked."; | |
| 18 | ||
| 19 | const href = (params: Record<string, string | number>): string => | |
| 20 | `/data?${new URLSearchParams(Object.entries(params).map(([k, v]): [string, string] => [k, String(v)]))}`; | |
| 21 | ||
| 22 | // GET /data[?source=<id>[&table=<name>[&page=<n>]]] | |
| 23 | export function dataPage(ctx: Ctx): Response { | |
| 24 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); | |
| 25 | const viewer = ctx.auth.user; | |
| 26 | if (!viewer) return signInFirst(ctx); | |
| 27 | const { db, config } = ctx.app; | |
| 28 | if (!maySeeData(db, viewer)) return notFoundPage(ctx); | |
| 29 | const q = ctx.url.searchParams; | |
| 30 | const all = listSources(db, config.dataDir, viewer); | |
| 31 | const chosen = q.get("source"); | |
| 32 | const source = chosen === null ? undefined : findSource(db, config.dataDir, viewer, chosen); | |
outdated
Principle 1 and 8: hand-rolled |
||
| 33 | if (chosen !== null && !source) return notFoundPage(ctx); | |
| 34 | ||
| 35 | let body = html`${pageHead("Data", { sub: PURPOSE })} | |
| 36 | ${all.length === 0 | |
| 37 | ? empty({ | |
| 38 | title: "No database yet", | |
| 39 | note: "An app's SQLite files appear here once it has written one under its data directory.", | |
| 40 | }) | |
| 41 | : html`<ul class="list"> | |
| 42 | ${all.map((s) => row({ href: href({ source: s.id }), title: s.label, current: s.id === source?.id }))} | |
| 43 | </ul>`}`; | |
| 44 | ||
| 45 | if (source) { | |
| 46 | const list = tables(source); | |
| 47 | const name = q.get("table"); | |
| 48 | const shown = name === null ? undefined : rows(source, name, { page: Number(q.get("page") ?? 0) }); | |
| 49 | if (name !== null && !shown) return notFoundPage(ctx); | |
| 50 | body = html`${body} | |
| 51 | <h2>${source.label}</h2> | |
| 52 | <ul class="list"> | |
| 53 | ${list.map((t) => | |
| 54 | row({ | |
| 55 | href: href({ source: source.id, table: t.name }), | |
| 56 | title: t.name, | |
| 57 | trail: html`${String(t.rows)} rows`, | |
| 58 | current: t.name === name, | |
| 59 | }), | |
| 60 | )} | |
| 61 | </ul>`; | |
| 62 | if (shown && name !== null) { | |
| 63 | const more = (shown.page + 1) * PAGE_SIZE < shown.total; | |
| 64 | body = html`${body} | |
| 65 | <h2>${name}</h2> | |
| 66 | <div class="table-scroll"> | |
| 67 | <table class="table" data-dense> | |
| 68 | <caption class="sr-only">${name} in ${source.label}</caption> | |
| 69 | <thead><tr>${shown.columns.map((c) => html`<th scope="col">${c}</th>`)}</tr></thead> | |
outdated
Principles 2, 3 and 6/7: rows are wrapped in a |
||
| 70 | <tbody> | |
| 71 | ${shown.rows.map((r) => html`<tr>${r.map((v) => html`<td>${v === null ? "" : String(v)}</td>`)}</tr>`)} | |
| 72 | </tbody> | |
| 73 | </table> | |
| 74 | </div> | |
| 75 | ${pager({ | |
| 76 | note: `${shown.total} rows`, | |
| 77 | words: { prev: "Previous page", next: "Next page" }, | |
| 78 | ...(shown.page > 0 ? { prev: href({ source: source.id, table: name, page: shown.page - 1 }) } : {}), | |
| 79 | ...(more ? { next: href({ source: source.id, table: name, page: shown.page + 1 }) } : {}), | |
| 80 | })}`; | |
outdated
Wrong word: page-1 is the previous page, not 'Newer page' (the data is not ordered by time). Say 'Previous page'. Also a bare |
||
| 81 | } | |
| 82 | } | |
| 83 | const rendering = performance.now(); | |
| 84 | const response = page({ ctx, title: "Data", body }); | |
| 85 | notePhase(ctx.request, "render", performance.now() - rendering); | |
| 86 | return response; | |
| 87 | } |
src/web/ui.ts +4 −2
| @@ -1099,15 +1099,17 @@ export function pager(o: { | ||
| 1099 | 1099 | prev?: string; |
| 1100 | 1100 | next?: string; |
| 1101 | 1101 | note?: Html | string; |
| 1102 | /** Words for the two buttons when the list is not in time order. */ | |
| 1103 | words?: { prev: string; next: string }; | |
| 1102 | 1104 | }): Html { |
| 1103 | 1105 | if (!o.prev && !o.next && !o.note) return html``; |
| 1104 | 1106 | return html`<nav class="pager" aria-label="Pagination"> |
| 1105 | 1107 | ${o.prev |
| 1106 | ? btn("Newer", { href: o.prev, size: "sm", variant: "outline" }) | |
| 1108 | ? btn(o.words?.prev ?? "Newer", { href: o.prev, size: "sm", variant: "outline" }) | |
| 1107 | 1109 | : null} |
| 1108 | 1110 | ${o.note ? html`<span>${o.note}</span>` : null} |
| 1109 | 1111 | ${o.next |
| 1110 | ? btn("Older", { href: o.next, size: "sm", variant: "outline" }) | |
| 1112 | ? btn(o.words?.next ?? "Older", { href: o.next, size: "sm", variant: "outline" }) | |
| 1111 | 1113 | : null} |
| 1112 | 1114 | </nav>`; |
| 1113 | 1115 | } |
test/data-browser.test.ts added +314
| @@ -0,0 +1,314 @@ | ||
| 1 | /** | |
| 2 | * The data browser's core: who sees which SQLite file, what a query may be, | |
| 3 | * and what is masked. | |
| 4 | */ | |
| 5 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 6 | import { Database } from "bun:sqlite"; | |
| 7 | import { mkdirSync } from "node:fs"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp as createAppRecord } from "../src/core/apps.ts"; | |
| 12 | import { createRepo } from "../src/core/repos.ts"; | |
| 13 | import { createApp, type App } from "../src/app.ts"; | |
| 14 | import { envDataDir } from "../src/apps/data.ts"; | |
| 15 | import { createUser, type User } from "../src/core/users.ts"; | |
| 16 | import { rows, tables } from "../src/data/browse.ts"; | |
| 17 | import { ALLOW, maskPlan } from "../src/data/mask.ts"; | |
| 18 | import { query } from "../src/data/query.ts"; | |
| 19 | import { findSource, listSources } from "../src/data/sources.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | const AUDITED_CLEAR: string[] = [ | |
| 25 | "app_domain.said", | |
| 26 | "app_feature_setting.value", | |
| 27 | "comment.body", | |
| 28 | "degraded_span.sentence", | |
| 29 | "feed_chip.value", | |
| 30 | "fleet_operation.value", | |
| 31 | "issue.body", | |
| 32 | "oauth_flow.detail", | |
| 33 | "oidc_key.public_jwk", | |
| 34 | "probe.said", | |
| 35 | "prompt_source.last_said", | |
| 36 | "repo_upstream_check.detail", | |
| 37 | "report.body", | |
| 38 | "review.body", | |
| 39 | "run.check_detail", | |
| 40 | "subscription.resting_said", | |
| 41 | "subscription_limit.said", | |
| 42 | "user_key.blob", | |
| 43 | "webhook_ping.payload", | |
| 44 | "week_projection.body", | |
| 45 | "week_retro.body", | |
| 46 | ]; | |
| 47 | ||
| 48 | describe("data browser core", () => { | |
| 49 | let dataDir: string; | |
| 50 | let app: App; | |
| 51 | let admin: User; | |
| 52 | let alice: User; | |
| 53 | let bob: User; | |
| 54 | let appId = ""; | |
| 55 | ||
| 56 | const user = async (username: string, adminIfFirst = false) => { | |
| 57 | const made = await createUser(app.db, { | |
| 58 | origin: "seed", | |
| 59 | trusted: true, | |
| 60 | username, | |
| 61 | email: `${username}@forge.local`, | |
| 62 | password: "a-good-passphrase", | |
| 63 | adminIfFirst, | |
| 64 | }); | |
| 65 | if (!made.ok) throw new Error(made.error); | |
| 66 | return made.value; | |
| 67 | }; | |
| 68 | const ids = (viewer: User | undefined) => | |
| 69 | listSources(app.db, dataDir, viewer).map((s) => s.id); | |
| 70 | ||
| 71 | beforeAll(async () => { | |
| 72 | dataDir = await mkdtemp(join(tmpdir(), "forge-data-browser-")); | |
| 73 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 74 | admin = await user("owner1", true); | |
| 75 | alice = await user("alice"); | |
| 76 | bob = await user("bob"); | |
| 77 | const repo = await createRepo(app.db, app.reposRoot, { | |
| 78 | ownerId: alice.id, | |
| 79 | name: "notes", | |
| 80 | visibility: "public", | |
| 81 | }); | |
| 82 | if (!repo.ok) throw new Error(repo.error); | |
| 83 | const made = createAppRecord(app.db, { | |
| 84 | repoId: repo.value.id, | |
| 85 | slug: "notes", | |
| 86 | access: "public", | |
| 87 | }); | |
| 88 | if (!made.ok) throw new Error(made.error); | |
| 89 | appId = made.value.id; | |
| 90 | for (const env of ["production", "pull-3"] as const) { | |
| 91 | mkdirSync(envDataDir(dataDir, appId, env), { recursive: true }); | |
| 92 | const d = new Database(join(envDataDir(dataDir, appId, env), "app.db")); | |
| 93 | d.exec("PRAGMA journal_mode = WAL"); | |
| 94 | d.exec( | |
| 95 | "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, api_token TEXT, password_hash TEXT)", | |
| 96 | ); | |
| 97 | d.exec("CREATE VIEW people AS SELECT name, api_token AS harmless FROM users"); | |
| 98 | d.exec("CREATE VIEW allusers AS SELECT * FROM users"); | |
| 99 | d.exec("CREATE VIEW renamed(a, b, c, d) AS SELECT * FROM users"); | |
| 100 | d.exec("CREATE VIEW deeper AS SELECT c FROM renamed"); | |
| 101 | d.exec("CREATE VIEW names AS SELECT id, name FROM users"); | |
| 102 | d.exec( | |
| 103 | `INSERT INTO users VALUES (1, 'ann', 'tok-ann-1', 'hash-1'), (2, 'ben', 'tok-ben-2', NULL)`, | |
| 104 | ); | |
| 105 | d.close(); | |
| 106 | } | |
| 107 | }); | |
| 108 | ||
| 109 | afterAll(async () => { | |
| 110 | app.close(); | |
| 111 | await rm(dataDir, { recursive: true, force: true }); | |
| 112 | }); | |
| 113 | ||
| 114 | test("a writer sees their app's production and preview databases", () => { | |
| 115 | expect(ids(alice)).toEqual([ | |
| 116 | `app:${appId}:production:app.db`, | |
| 117 | `app:${appId}:pull-3:app.db`, | |
| 118 | ]); | |
| 119 | }); | |
| 120 | ||
| 121 | test("a reader of the app can't find its data", () => { | |
| 122 | expect(ids(bob)).toEqual([]); | |
| 123 | expect(ids(undefined)).toEqual([]); | |
| 124 | expect( | |
| 125 | findSource(app.db, dataDir, bob, `app:${appId}:production:app.db`), | |
| 126 | ).toBeUndefined(); | |
| 127 | }); | |
| 128 | ||
| 129 | test("only an admin sees forge.db", () => { | |
| 130 | expect(ids(admin)).toContain("forge"); | |
| 131 | expect(ids(alice)).not.toContain("forge"); | |
| 132 | expect(findSource(app.db, dataDir, alice, "forge")).toBeUndefined(); | |
| 133 | }); | |
| 134 | ||
| 135 | test("tables with counts, rows with filter, order and page", () => { | |
| 136 | const src = findSource(app.db, dataDir, alice, `app:${appId}:production:app.db`)!; | |
| 137 | expect(tables(src)).toEqual([{ name: "users", rows: 2 }]); | |
| 138 | const r = rows(src, "users", { filter: { text: "b" }, order: { column: "id", dir: "desc" } })!; | |
| 139 | expect(r.total).toBe(1); | |
| 140 | expect(r.rows[0]![1]).toBe("ben"); | |
| 141 | expect(rows(src, "nope")).toBeUndefined(); | |
| 142 | expect(rows(src, "users", { order: { column: "id; DROP TABLE users" } })).toBeUndefined(); | |
| 143 | }); | |
| 144 | ||
| 145 | test("a token column is masked, and cannot be searched or ordered", () => { | |
| 146 | const src = findSource(app.db, dataDir, alice, `app:${appId}:production:app.db`)!; | |
| 147 | const r = rows(src, "users")!; | |
| 148 | expect(JSON.stringify(r)).not.toContain("tok-ann"); | |
| 149 | expect(JSON.stringify(r)).not.toContain("hash-1"); | |
| 150 | expect(r.rows[1]![3]).toBeNull(); | |
| 151 | expect(rows(src, "users", { filter: { text: "tok-ann", column: "api_token" } })!.total).toBe(0); | |
| 152 | expect(rows(src, "users", { order: { column: "api_token" } })).toBeUndefined(); | |
| 153 | }); | |
| 154 | ||
| 155 | test("a query cannot read a secret by alias or subquery either", async () => { | |
| 156 | const src = findSource(app.db, dataDir, alice, `app:${appId}:production:app.db`)!; | |
| 157 | for (const sql of [ | |
| 158 | "SELECT api_token AS t FROM users", | |
| 159 | "SELECT (SELECT api_token FROM users LIMIT 1) AS t", | |
| 160 | "SELECT * FROM users", | |
| 161 | ]) { | |
| 162 | const r = await query(src, sql); | |
| 163 | expect(r.ok).toBe(true); | |
| 164 | expect(JSON.stringify(r)).not.toContain("tok-ann"); | |
| 165 | } | |
| 166 | const bypass = await query(src, "SELECT api_token FROM main.users"); | |
| 167 | expect(bypass.ok).toBe(false); | |
| 168 | const plain = await query(src, "SELECT name FROM users ORDER BY id"); | |
| 169 | expect(plain.ok && plain.rows).toEqual([["ann"], ["ben"]]); | |
| 170 | }); | |
| 171 | ||
| 172 | test("INSERT and PRAGMA writable_schema are refused", async () => { | |
| 173 | const src = findSource(app.db, dataDir, alice, `app:${appId}:production:app.db`)!; | |
| 174 | for (const sql of [ | |
| 175 | "INSERT INTO users (name) VALUES ('x')", | |
| 176 | "PRAGMA writable_schema = 1", | |
| 177 | "SELECT 1; DELETE FROM users", | |
| 178 | "WITH x AS (SELECT 1) DELETE FROM users", | |
| 179 | "-- hi\nUPDATE users SET name = 'x'", | |
| 180 | ]) { | |
| 181 | const r = await query(src, sql); | |
| 182 | expect(r.ok).toBe(false); | |
| 183 | } | |
| 184 | expect(rows(src, "users")!.total).toBe(2); | |
| 185 | }); | |
| 186 | ||
| 187 | test("a result stops at 1,000 rows", async () => { | |
| 188 | const src = findSource(app.db, dataDir, alice, `app:${appId}:production:app.db`)!; | |
| 189 | const r = await query( | |
| 190 | src, | |
| 191 | "WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM n WHERE i < 5000) SELECT i FROM n", | |
| 192 | ); | |
| 193 | expect(r.ok && r.rows.length).toBe(1000); | |
| 194 | expect(r.ok && r.truncated).toBe(true); | |
| 195 | }); | |
| 196 | ||
| 197 | test("a slow query stops at the cap", async () => { | |
| 198 | const src = findSource(app.db, dataDir, alice, `app:${appId}:production:app.db`)!; | |
| 199 | const started = performance.now(); | |
| 200 | const r = await query( | |
| 201 | src, | |
| 202 | "WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM n) SELECT count(*) FROM n", | |
| 203 | 400, | |
| 204 | ); | |
| 205 | expect(r.ok).toBe(false); | |
| 206 | expect(!r.ok && r.error).toBe("timeout"); | |
| 207 | expect(performance.now() - started).toBeLessThan(3000); | |
| 208 | }); | |
| 209 | ||
| 210 | test("reading never blocks the app's own writes", () => { | |
| 211 | const src = findSource(app.db, dataDir, alice, `app:${appId}:production:app.db`)!; | |
| 212 | const w = new Database(src.path); | |
| 213 | tables(src); | |
| 214 | w.exec("INSERT INTO users (name) VALUES ('cy')"); | |
| 215 | w.close(); | |
| 216 | expect(rows(src, "users")!.total).toBe(3); | |
| 217 | }); | |
| 218 | ||
| 219 | const prod = () => findSource(app.db, dataDir, alice, `app:${appId}:production:app.db`)!; | |
| 220 | ||
| 221 | test("a schema qualifier in any quoting is refused", async () => { | |
| 222 | for (const from of [ | |
| 223 | '"main"."users"', | |
| 224 | "[main].[users]", | |
| 225 | "`main`.`users`", | |
| 226 | 'main /**/ .users', | |
| 227 | '"MAIN" . users', | |
| 228 | "main\n.users", | |
| 229 | ]) { | |
| 230 | const r = await query(prod(), `SELECT api_token FROM ${from}`); | |
| 231 | expect(r.ok).toBe(false); | |
| 232 | expect(JSON.stringify(r)).not.toContain("tok-ann"); | |
| 233 | } | |
| 234 | }); | |
| 235 | ||
| 236 | test("a view that aliases a secret is masked, in rows and in queries", async () => { | |
| 237 | const src = prod(); | |
| 238 | expect(JSON.stringify(rows(src, "people") ?? "")).not.toContain("tok-ann"); | |
| 239 | const r = await query(src, "SELECT harmless FROM people"); | |
| 240 | expect(r.ok).toBe(true); | |
| 241 | expect(JSON.stringify(r)).not.toContain("tok-ann"); | |
| 242 | expect(JSON.stringify(r)).toContain("••••"); | |
| 243 | }); | |
| 244 | ||
| 245 | test("a view by star, by a column list, or over such a view is masked whole", async () => { | |
| 246 | const src = prod(); | |
| 247 | for (const [view, col] of [["allusers", "api_token"], ["renamed", "c"], ["deeper", "c"]] as const) { | |
| 248 | expect(JSON.stringify(rows(src, view) ?? "")).not.toContain("tok-ann"); | |
| 249 | for (const sql of [`SELECT * FROM ${view}`, `SELECT ${col} AS x FROM ${view}`]) { | |
| 250 | const r = await query(src, sql); | |
| 251 | expect(r.ok).toBe(true); | |
| 252 | expect(JSON.stringify(r)).not.toContain("tok-ann"); | |
| 253 | expect(JSON.stringify(r)).toContain("••••"); | |
| 254 | } | |
| 255 | } | |
| 256 | // A view that names no secret and uses no star stays in clear. | |
| 257 | const clear = await query(src, "SELECT name FROM names"); | |
| 258 | expect(JSON.stringify(clear)).toContain("ann"); | |
| 259 | }); | |
| 260 | ||
| 261 | test("an allowlisted column is shown in clear, and only for its source", () => { | |
| 262 | const src = prod(); | |
| 263 | ALLOW[src.id] = ["users.api_token"]; | |
| 264 | try { | |
| 265 | expect(JSON.stringify(rows(src, "users"))).toContain("tok-ann-1"); | |
| 266 | expect(JSON.stringify(rows(src, "users"))).not.toContain("hash-1"); | |
| 267 | const other = findSource(app.db, dataDir, alice, `app:${appId}:pull-3:app.db`)!; | |
| 268 | expect(JSON.stringify(rows(other, "users"))).not.toContain("tok-ann"); | |
| 269 | } finally { | |
| 270 | delete ALLOW[src.id]; | |
| 271 | } | |
| 272 | }); | |
| 273 | ||
| 274 | test("a filter on a column that does not exist finds nothing", () => { | |
| 275 | expect(rows(prod(), "users", { filter: { text: "a", column: "nope" } })).toBeUndefined(); | |
| 276 | }); | |
| 277 | ||
| 278 | test("a guest or a non-writer cannot find forge", () => { | |
| 279 | for (const who of [undefined, alice, bob]) expect(findSource(app.db, dataDir, who, "forge")).toBeUndefined(); | |
| 280 | }); | |
| 281 | ||
| 282 | test("a page that is not a finite number is the first page; a huge one is capped", () => { | |
| 283 | const src = prod(); | |
| 284 | for (const page of [Number.NaN, Number.POSITIVE_INFINITY, -4]) | |
| 285 | expect(rows(src, "users", { page })!.page).toBe(0); | |
| 286 | expect(rows(src, "users", { page: 1e12 })!.page).toBe(1000); | |
| 287 | }); | |
| 288 | ||
| 289 | test("forge.db is masked for an admin, setting values included", async () => { | |
| 290 | app.db.exec("INSERT OR REPLACE INTO forge_setting (key, value, updated_by, updated_at) VALUES ('smtp', 'SETTING-SECRET', 'x', 'now')"); | |
| 291 | const src = findSource(app.db, dataDir, admin, "forge")!; | |
| 292 | expect(JSON.stringify(rows(src, "forge_setting"))).not.toContain("SETTING-SECRET"); | |
| 293 | const r = await query(src, "SELECT value FROM forge_setting"); | |
| 294 | expect(JSON.stringify(r)).not.toContain("SETTING-SECRET"); | |
| 295 | const users = JSON.stringify(rows(src, "user")); | |
| 296 | expect(users).not.toContain("$argon2"); | |
| 297 | }); | |
| 298 | ||
| 299 | test("every forge.db column that could hold a secret is masked or audited as clear", () => { | |
| 300 | const src = findSource(app.db, dataDir, admin, "forge")!; | |
| 301 | const plan = maskPlan(new Database(src.path, { readonly: true }), "forge"); | |
| 302 | const loose = /value|body|payload|code|key|blob|nonce|verifier|secret|token|hash|salt|enc|jwk|evidence|said|detail/i; | |
| 303 | const open: string[] = []; | |
| 304 | for (const [table, masked] of plan) { | |
| 305 | const db = new Database(src.path, { readonly: true }); | |
| 306 | for (const c of db.query<{ name: string }, []>(`PRAGMA table_info("${table}")`).all()) | |
| 307 | if (loose.test(c.name) && !masked.has(c.name)) open.push(`${table}.${c.name}`); | |
| 308 | db.close(); | |
| 309 | } | |
| 310 | // Reviewed: these hold public or operational text, never a credential. | |
| 311 | // A new column that matches and is not here fails until someone looks. | |
| 312 | expect(open.sort()).toEqual(AUDITED_CLEAR); | |
| 313 | }); | |
| 314 | }); |
test/data-door.test.ts added +126
| @@ -0,0 +1,126 @@ | ||
| 1 | /** /data and the `data` MCP tool: gated by listSources, so a guest or a reader sees nothing. */ | |
| 2 | import { afterAll, beforeAll, describe, 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 { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | import { mcpTool } from "./helpers/mcp.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | describe("the data door", () => { | |
| 22 | let dataDir: string; | |
| 23 | let app: App; | |
| 24 | let servers: Servers; | |
| 25 | const cookie: Record<string, string> = {}; | |
| 26 | const token: Record<string, string> = {}; | |
| 27 | let appId = ""; | |
| 28 | ||
| 29 | beforeAll(async () => { | |
| 30 | dataDir = await mkdtemp(join(tmpdir(), "forge-data-door-")); | |
| 31 | app = createApp({ dataDir, port: 0, host: "forge.test" }); | |
| 32 | servers = await serve(app); | |
| 33 | const users: Record<string, string> = {}; | |
| 34 | for (const name of ["owner1", "alice", "bob", "dana"]) { | |
| 35 | const made = await createUser(app.db, { | |
| 36 | origin: "seed", trusted: true, username: name, | |
| 37 | email: `${name}@forge.local`, password: "a-good-passphrase", adminIfFirst: name === "owner1", | |
| 38 | }); | |
| 39 | if (!made.ok) throw new Error(made.error); | |
| 40 | users[name] = made.value.id; | |
| 41 | cookie[name] = `forge_session=${createSession(app.db, made.value.id).token}`; | |
| 42 | token[name] = createToken(app.db, made.value.id, "t", "admin").token; | |
| 43 | } | |
| 44 | const repo = await createRepo(app.db, app.reposRoot, { ownerId: users["alice"]!, name: "notes", visibility: "public" }); | |
| 45 | if (!repo.ok) throw new Error(repo.error); | |
| 46 | const rec = createAppRecord(app.db, { repoId: repo.value.id, slug: "notes", access: "public" }); | |
| 47 | if (!rec.ok) throw new Error(rec.error); | |
| 48 | appId = rec.value.id; | |
| 49 | const bare = await createRepo(app.db, app.reposRoot, { ownerId: users["dana"]!, name: "bare", visibility: "public" }); | |
| 50 | if (!bare.ok) throw new Error(bare.error); | |
| 51 | const bareApp = createAppRecord(app.db, { repoId: bare.value.id, slug: "bare", access: "public" }); | |
| 52 | if (!bareApp.ok) throw new Error(bareApp.error); | |
| 53 | mkdirSync(envDataDir(dataDir, appId, "production"), { recursive: true }); | |
| 54 | const d = new Database(join(envDataDir(dataDir, appId, "production"), "app.db")); | |
| 55 | d.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, api_token TEXT)"); | |
| 56 | d.exec("INSERT INTO users VALUES (1, 'ann', 'tok-ann-1')"); | |
| 57 | d.exec(`CREATE TABLE hostile ("<img src=x onerror=alert(1)>" TEXT)`); | |
| 58 | d.exec(`INSERT INTO hostile VALUES ('<script>alert(1)</script>')`); | |
| 59 | d.close(); | |
| 60 | }, 60_000); | |
| 61 | ||
| 62 | afterAll(async () => { | |
| 63 | servers.stop(); | |
| 64 | app.close(); | |
| 65 | await rm(dataDir, { recursive: true, force: true }); | |
| 66 | }); | |
| 67 | ||
| 68 | const get = (path: string, who?: string) => | |
| 69 | fetch(`${servers.url}${path}`, { headers: who ? { cookie: cookie[who]! } : {}, redirect: "manual" }); | |
| 70 | const src = () => encodeURIComponent(`app:${appId}:production:app.db`); | |
| 71 | ||
| 72 | test("a writer reaches sources, tables and masked rows on the page", async () => { | |
| 73 | const list = await (await get("/data", "alice")).text(); | |
| 74 | expect(list).toContain("notes · production · app.db"); | |
| 75 | const tables = await (await get(`/data?source=${src()}`, "alice")).text(); | |
| 76 | expect(tables).toContain("users"); | |
| 77 | const res = await get(`/data?source=${src()}&table=users`, "alice"); | |
| 78 | const rows = await res.text(); | |
| 79 | expect(rows).toContain("ann"); | |
| 80 | expect(rows).not.toContain("tok-ann-1"); | |
| 81 | expect(await (await get("/apps", "alice")).text()).toContain('href="/data"'); | |
| 82 | }); | |
| 83 | ||
| 84 | test("hostile column and cell text is escaped on the page", async () => { | |
| 85 | const html = await (await get(`/data?source=${src()}&table=hostile`, "alice")).text(); | |
| 86 | expect(html).not.toContain("<script>alert(1)</script>"); | |
| 87 | expect(html).not.toContain("<img src=x"); | |
| 88 | expect(html).toContain("<script>alert(1)</script>"); | |
| 89 | expect(html).toContain("<img src=x onerror=alert(1)>"); | |
| 90 | }); | |
| 91 | ||
| 92 | test("a writer whose app has no database yet gets the page with an empty state, not a 404", async () => { | |
| 93 | const res = await get("/data", "dana"); | |
| 94 | expect(res.status).toBe(200); | |
| 95 | expect(await res.text()).toContain("No database yet"); | |
| 96 | }); | |
| 97 | ||
| 98 | test("a guest is sent to sign in, never shown the page", async () => { | |
| 99 | const res = await get("/data"); | |
| 100 | expect([302, 303, 401]).toContain(res.status); | |
| 101 | expect(await res.text()).not.toContain("No database yet"); | |
| 102 | }); | |
| 103 | ||
| 104 | test("a reader, a guest and a wrong source see nothing", async () => { | |
| 105 | expect((await get("/data", "bob")).status).toBe(404); | |
| 106 | expect((await get(`/data?source=${src()}`, "bob")).status).toBe(404); | |
| 107 | expect((await get("/data")).status).not.toBe(200); | |
| 108 | expect((await get("/data?source=forge", "alice")).status).toBe(404); | |
| 109 | expect((await get("/data?source=forge", "owner1")).status).toBe(200); | |
| 110 | expect(await (await get("/apps", "bob")).text()).not.toContain('href="/data"'); | |
| 111 | }); | |
| 112 | ||
| 113 | test("the MCP tool lists, reads, queries and refuses the same way", async () => { | |
| 114 | const id = `app:${appId}:production:app.db`; | |
| 115 | expect((await mcpTool(servers.url, token["alice"]!, "data")).value).toEqual([ | |
| 116 | { source: id, label: "notes · production · app.db" }, | |
| 117 | ]); | |
| 118 | expect((await mcpTool(servers.url, token["alice"]!, "data", { source: id })).value).toEqual([{ name: "hostile", rows: 1 }, { name: "users", rows: 1 }]); | |
| 119 | const q = await mcpTool(servers.url, token["alice"]!, "data", { source: id, sql: "SELECT api_token FROM users" }); | |
| 120 | expect(JSON.stringify(q)).not.toContain("tok-ann-1"); | |
| 121 | const w = await mcpTool(servers.url, token["alice"]!, "data", { source: id, sql: "DELETE FROM users" }); | |
| 122 | expect(w.error).toBeDefined(); | |
| 123 | expect((await mcpTool(servers.url, token["bob"]!, "data", { source: id })).error).toBeDefined(); | |
| 124 | expect((await mcpTool(servers.url, token["bob"]!, "data")).value).toEqual([]); | |
| 125 | }); | |
| 126 | }); |
test/data-query-limits.test.ts added +67
| @@ -0,0 +1,67 @@ | ||
| 1 | /** Work a query may cause is bounded: cells are cut, the result has a byte cap, workers are few. */ | |
| 2 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 3 | import { Database } from "bun:sqlite"; | |
| 4 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 5 | import { tmpdir } from "node:os"; | |
| 6 | import { join } from "node:path"; | |
| 7 | import { rows } from "../src/data/browse.ts"; | |
| 8 | import { CELL_CAP } from "../src/data/mask.ts"; | |
| 9 | import { query, QUERY_WORKERS, queriesRunning } from "../src/data/query.ts"; | |
| 10 | import type { Source } from "../src/data/sources.ts"; | |
| 11 | ||
| 12 | let dir: string; | |
| 13 | let src: Source; | |
| 14 | ||
| 15 | beforeAll(async () => { | |
| 16 | dir = await mkdtemp(join(tmpdir(), "forge-data-limits-")); | |
| 17 | const path = join(dir, "x.db"); | |
| 18 | const d = new Database(path); | |
| 19 | d.exec("CREATE TABLE t (id INTEGER PRIMARY KEY, body TEXT)"); | |
| 20 | d.query("INSERT INTO t (body) VALUES (?)").run("y".repeat(50_000)); | |
| 21 | d.close(); | |
| 22 | src = { id: "app:x:production:x.db", label: "x", kind: "app", path }; | |
| 23 | }); | |
| 24 | afterAll(() => rm(dir, { recursive: true, force: true })); | |
| 25 | ||
| 26 | test("browsing a table cuts a long cell", () => { | |
| 27 | const cell = rows(src, "t")!.rows[0]![1] as string; | |
| 28 | expect(cell.length).toBe(CELL_CAP + 1); | |
| 29 | expect(cell.endsWith("…")).toBe(true); | |
| 30 | }); | |
| 31 | ||
| 32 | test("a query cuts a long cell and shows a blob by its size", async () => { | |
| 33 | const r = await query(src, "SELECT body, zeroblob(100000) AS z FROM t"); | |
| 34 | if (!r.ok) throw new Error(r.message); | |
| 35 | expect((r.rows[0]![0] as string).length).toBeLessThanOrEqual(CELL_CAP + 1); | |
| 36 | expect(r.rows[0]![1]).toBe("<blob 100000 bytes>"); | |
| 37 | }); | |
| 38 | ||
| 39 | test("a result stops at its byte cap", async () => { | |
| 40 | const r = await query( | |
| 41 | src, | |
| 42 | "WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 900) SELECT i, (SELECT body FROM t) FROM n", | |
| 43 | ); | |
| 44 | if (!r.ok) throw new Error(r.message); | |
| 45 | expect(r.truncated).toBe(true); | |
| 46 | expect(r.rows.length).toBeLessThan(900); | |
| 47 | }); | |
| 48 | ||
| 49 | test("more queries than workers are told the forge is busy, and the slot frees", async () => { | |
| 50 | const slow = "WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 3000000) SELECT count(*) FROM n"; | |
| 51 | const held = Array.from({ length: QUERY_WORKERS }, () => query(src, slow)); | |
| 52 | expect(queriesRunning()).toBe(QUERY_WORKERS); | |
| 53 | const extra = await query(src, "SELECT 1"); | |
| 54 | expect(extra.ok).toBe(false); | |
| 55 | if (!extra.ok) expect(extra.error).toBe("busy"); | |
| 56 | await Promise.all(held); | |
| 57 | expect(queriesRunning()).toBe(0); | |
| 58 | expect((await query(src, "SELECT 1")).ok).toBe(true); | |
| 59 | }); | |
| 60 | ||
| 61 | test("a query that would use huge memory is stopped, not served", async () => { | |
| 62 | const r = await query( | |
| 63 | src, | |
| 64 | "WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 100) SELECT length(group_concat(hex(zeroblob(5000000)))) FROM n", | |
| 65 | ); | |
| 66 | expect(r.ok).toBe(false); | |
| 67 | }, 15_000); |
test/forge-looks.test.ts +1
| @@ -22,6 +22,7 @@ quietLogs(); | ||
| 22 | 22 | const NOT_A_PAGE: Record<string, string> = { |
| 23 | 23 | "/audit": "no picture: its slot went to /pinned; /audit.csv is its download, covered by test/audit.test.ts", |
| 24 | 24 | "/deploy-to": "a form for a forge admin that fills from its link; covered by test/pinned-page.test.ts", |
| 25 | "/data": "no picture: the cap of 24 looks is spent; a guest sees the not-found page and the page is covered by its own tests", | |
| 25 | 26 | "/asked": "no picture: the cap of 24 looks went to /notes, the page that now leads with a person's thoughts; /asked is covered by test/asked.test.ts", |
| 26 | 27 | "/forges": "no picture: the cap of 24 looks went to the preview's /waves and /milestones; the page is covered by its own tests", |
| 27 | 28 | "/family": "no picture: the cap of 24 looks went to the preview's /waves and /milestones; the page is covered by its own tests", |
test/mcp.test.ts +1
| @@ -211,6 +211,7 @@ describe("the forge over MCP", () => { | ||
| 211 | 211 | const names = listed.body.result.tools.map((t: { name: string }) => t.name); |
| 212 | 212 | // Listed by name, one file each (#5924), and `report` last. |
| 213 | 213 | expect(names).toEqual(sortedThenReport([ |
| 214 | "data", | |
| 214 | 215 | "drills", |
| 215 | 216 | "sizes", |
| 216 | 217 | "releases", |
test/palette-roster.test.ts +2
| @@ -48,6 +48,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 48 | 48 | ["home", "Home", "your repositories"], |
| 49 | 49 | ["explore", "Explore", "on the web"], |
| 50 | 50 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 51 | ["data", "Data", "the SQLite files you may read, secrets masked"], | |
| 51 | 52 | ["fleet", "Fleet rows", "the rows a fleet query line selects"], |
| 52 | 53 | ["profile", "Open a profile", "username or org"], |
| 53 | 54 | ["repos", "Repositories", "the repositories you may write"], |
| @@ -163,6 +164,7 @@ test("a site-admin agent is offered the roster without the person-only entries", | ||
| 163 | 164 | ["home", "Home", "your repositories"], |
| 164 | 165 | ["explore", "Explore", "on the web"], |
| 165 | 166 | ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"], |
| 167 | ["data", "Data", "the SQLite files you may read, secrets masked"], | |
| 166 | 168 | ["fleet", "Fleet rows", "the rows a fleet query line selects"], |
| 167 | 169 | ["profile", "Open a profile", "username or org"], |
| 168 | 170 | ["repos", "Repositories", "the repositories you may write"], |
test/tui-data.test.ts added +94
| @@ -0,0 +1,94 @@ | ||
| 1 | /** The terminal's Data screen: gated as the page is, reads the same masked rows. */ | |
| 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 { createUser, getUserByName } from "../src/core/users.ts"; | |
| 13 | import { forgeCommands } from "../src/tui/forge.ts"; | |
| 14 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 15 | import { dataScreen } from "../src/tui/screens/data.ts"; | |
| 16 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 17 | import { quietLogs } from "./helpers/globals.ts"; | |
| 18 | import { settleApp } from "./helpers/tui.ts"; | |
| 19 | ||
| 20 | quietLogs(); | |
| 21 | ||
| 22 | let dir: string; | |
| 23 | let app: App; | |
| 24 | ||
| 25 | beforeAll(async () => { | |
| 26 | dir = await mkdtemp(join(tmpdir(), "forge-tui-data-")); | |
| 27 | app = createApp({ dataDir: dir, port: 0 }); | |
| 28 | const ids: Record<string, string> = {}; | |
| 29 | for (const name of ["owner1", "alice", "bob"]) { | |
| 30 | const made = await createUser(app.db, { origin: "seed", trusted: true, username: name, email: `${name}@forge.local`, password: "a-good-passphrase", adminIfFirst: name === "owner1" }); | |
| 31 | if (!made.ok) throw new Error(made.error); | |
| 32 | ids[name] = made.value.id; | |
| 33 | } | |
| 34 | const repo = await createRepo(app.db, app.reposRoot, { ownerId: ids["alice"]!, name: "notes", visibility: "public" }); | |
| 35 | if (!repo.ok) throw new Error(repo.error); | |
| 36 | const rec = createAppRecord(app.db, { repoId: repo.value.id, slug: "notes", access: "public" }); | |
| 37 | if (!rec.ok) throw new Error(rec.error); | |
| 38 | const where = envDataDir(dir, rec.value.id, "production"); | |
| 39 | mkdirSync(where, { recursive: true }); | |
| 40 | const d = new Database(join(where, "app.db")); | |
| 41 | d.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, api_token TEXT)"); | |
| 42 | d.exec("INSERT INTO users VALUES (1, 'ann-visible', 'tok-secret-1')"); | |
| 43 | d.close(); | |
| 44 | }); | |
| 45 | afterAll(async () => { | |
| 46 | app.close(); | |
| 47 | await rm(dir, { recursive: true, force: true }); | |
| 48 | }); | |
| 49 | ||
| 50 | const open = (username?: string) => | |
| 51 | createForgeApp( | |
| 52 | app, | |
| 53 | { sshUsername: "t", remoteAddress: "127.0.0.1", ...(username ? { user: getUserByName(app.db, username)! } : { fingerprint: "SHA256:t" }) }, | |
| 54 | { terminal: headlessTerminal(120, 30), exitHooks: false }, | |
| 55 | ); | |
| 56 | const offered = (username?: string) => { | |
| 57 | const { app: tui, session } = open(username); | |
| 58 | const has = forgeCommands(session)(tui.ctx).some((c) => c.id === "data"); | |
| 59 | tui.stop?.(); | |
| 60 | return has; | |
| 61 | }; | |
| 62 | ||
| 63 | test("an admin and an app's writer are offered it; a reader and a guest are not", () => { | |
| 64 | expect(offered("owner1")).toBe(true); | |
| 65 | expect(offered("alice")).toBe(true); | |
| 66 | expect(offered("bob")).toBe(false); | |
| 67 | expect(offered()).toBe(false); | |
| 68 | }); | |
| 69 | ||
| 70 | test("a writer walks from the files to the tables to masked rows", async () => { | |
| 71 | const { app: tui, session } = open("alice"); | |
| 72 | tui.start(); | |
| 73 | tui.ctx.push(dataScreen(session, tui.ctx)); | |
| 74 | await settleApp(tui); | |
| 75 | expect(tui.screen.toText()).toContain("notes · production · app.db"); | |
| 76 | tui.dispatch({ type: "key", key: "enter", ctrl: false, alt: false, shift: false }); | |
| 77 | await settleApp(tui); | |
| 78 | expect(tui.screen.toText()).toContain("users 1 rows"); | |
| 79 | tui.dispatch({ type: "key", key: "enter", ctrl: false, alt: false, shift: false }); | |
| 80 | await settleApp(tui); | |
| 81 | const text = tui.screen.toText(); | |
| 82 | expect(text).toContain("ann-visible"); | |
| 83 | expect(text).not.toContain("tok-secret-1"); | |
| 84 | tui.stop?.(); | |
| 85 | }); | |
| 86 | ||
| 87 | test("an admin sees forge.db among the files", async () => { | |
| 88 | const { app: tui, session } = open("owner1"); | |
| 89 | tui.start(); | |
| 90 | tui.ctx.push(dataScreen(session, tui.ctx)); | |
| 91 | await settleApp(tui); | |
| 92 | expect(tui.screen.toText()).toContain("forge"); | |
| 93 | tui.stop?.(); | |
| 94 | }); |
'Not done: a terminal screen' is a miss against 'both doors'. Add the
src/tui/screen, its entry, and a test, then drop this line.