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

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/7579-data-browser-core-read-any-sqlite-file-a → main 14 commits fork 6 threads · 6 unresolved

  • Merged. scout/7579-data-browser-core-read-any-sqlite-file-a was merged into main as de5e0b1.

Review

Sign in to review this change.
25 files changed +1474 −3
.gitignore +1
@@ -1,5 +1,6 @@
11 node_modules
22 data/
3 !src/data/
34 dist/
45 *.local
56 .DS_Store
docs/design/audit.md +1
@@ -58,6 +58,7 @@ Machine routes are not pages and have no entry: `/audit.csv`,
5858 - `/feed` — `studio/home.ts` · the composer and one timeline, what home was before it became the living pipeline (#8015) · `/` · none seen.
5959 - `/boards/data` — `pages/boards.ts` · the boards' query/data surface · `/board` · none seen.
6060 - `/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.
6162 - `/asked` — `studio/asked_page.ts` · what I answered, as a record · `/asks` · none seen.
6263 - `/review` — `studio/review_page.ts` · every ask waiting on me, one row each, newest first · `/asks` (same asks, with buttons) · none seen.
6364 - `/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.
outdated

'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.

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[] = [
8989 "mcp",
9090 "metrics",
9191 "contract",
92 "data",
9293 "migrate",
9394 "mode",
9495 "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. CREATE VIEW allusers AS SELECT * FROM users never names api_token in its text. A view with renamed columns, such as CREATE VIEW v(a,b) AS SELECT * FROM users, matches nothing either. For a FORCE column like forge_setting.value, a view column still named value is not in FORCE under the view name. Result: rows(src,"allusers") and SELECT * FROM allusers show the secret in clear. Fix: treat a view containing * as fully masked, or resolve each view column to its origin table.column and apply masker to that. Add a test.

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";
4646 export { tool as contribute } from "./contribute.ts";
4747 export { tool as converge } from "./converge.ts";
4848 export { tool as crew } from "./crew.ts";
49 export { tool as data } from "./data.ts";
4950 export { tool as decisions_by } from "./decisions_by.ts";
5051 export { tool as detach } from "./detach.ts";
5152 export { tool as drills } from "./drills.ts";
src/http/server.ts +2
@@ -27,6 +27,7 @@ import { prerenderComments } from "../web/prerender.ts";
2727 import { systemPage, systemStream } from "../web/pages/system.ts";
2828 import { contributePage } from "../web/pages/contribute.ts";
2929 import { pipelinePage } from "../web/pages/pipeline.ts";
30 import { dataPage } from "../web/pages/data.ts";
3031 import { fleetAct, fleetReceipt, fleetUndo } from "../web/pages/fleet-ops.ts";
3132 import { maintenancePage, maintenancePrepare, maintenanceResume } from "../web/pages/maintenance.ts";
3233 import { nightlyOutputPage, resiliencePage } from "../web/pages/resilience.ts";
@@ -921,6 +922,7 @@ export function buildRouter(): Router<Handler> {
921922 pinnedRoutes(router);
922923 // Each THREATS.md protection against the last gate; literal, above /:owner.
923924 router.add("GET", "/resilience", resiliencePage);
925 router.add("GET", "/data", dataPage);
924926 router.add("GET", "/maintenance", maintenancePage);
925927 router.add("POST", "/maintenance/prepare", maintenancePrepare);
926928 router.add("POST", "/maintenance/resume", maintenanceResume);
src/tui/forge.ts +2
@@ -15,6 +15,7 @@ import { guestCodeSearch } from "../http/strangers.ts";
1515 import { canRead, getRepo, type Repo } from "../core/repos.ts";
1616 import { sourceFor } from "../view/source.ts";
1717 import { fleetCommand } from "./screens/fleet.ts";
18 import { dataCommand } from "./screens/data.ts";
1819 import { packsCommand } from "./screens/packs.ts";
1920 import { App, type Ctx, type Screen } from "./app/app.ts";
2021 import type { Command, PaletteItem, PaletteSearch } from "./app/palette.ts";
@@ -208,6 +209,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
208209 run: () => openOnWeb(session, ctx, "/explore", "The explorer"),
209210 },
210211 packsCommand(session, ctx),
212 ...dataCommand(session, ctx),
211213 fleetCommand(session, ctx),
212214 {
213215 // 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> {
236236 costLine(ctx.app.db, writableAppsOf(ctx, ctx.auth.user).repos),
237237 );
238238 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);
239244 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 <p class="muted small mb"><a> outside the design components (principles 3 and 7). Put the Data door in the page head actions or the existing tab/nav pattern, and make /data useful for a writer with no database yet (see body).

247 sub: APPS_PURPOSE,
248 })}
241249 ${cost}
242250 ${filters}
243251 ${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 page-head with a bare 'Data' and no sentence saying what the page is for. Use pageHead("Data", { sub: ... }) as /apps does, with one plain sentence.

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 card and the table is raw markup. Use the named table/row component from docs/DESIGN.md without a box, and a dl/rows for the source/table lists. Wide tables must scroll inside their own container so the phone layout does not shift.

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 <p class=muted> with inline links: use the pager the design system names, if there is one.

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: {
10991099 prev?: string;
11001100 next?: string;
11011101 note?: Html | string;
1102 /** Words for the two buttons when the list is not in time order. */
1103 words?: { prev: string; next: string };
11021104 }): Html {
11031105 if (!o.prev && !o.next && !o.note) return html``;
11041106 return html`<nav class="pager" aria-label="Pagination">
11051107 ${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" })
11071109 : null}
11081110 ${o.note ? html`<span>${o.note}</span>` : null}
11091111 ${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" })
11111113 : null}
11121114 </nav>`;
11131115 }
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("&lt;script&gt;alert(1)&lt;/script&gt;");
89 expect(html).toContain("&lt;img src=x onerror=alert(1)&gt;");
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();
2222 const NOT_A_PAGE: Record<string, string> = {
2323 "/audit": "no picture: its slot went to /pinned; /audit.csv is its download, covered by test/audit.test.ts",
2424 "/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",
2526 "/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",
2627 "/forges": "no picture: the cap of 24 looks went to the preview's /waves and /milestones; the page is covered by its own tests",
2728 "/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", () => {
211211 const names = listed.body.result.tools.map((t: { name: string }) => t.name);
212212 // Listed by name, one file each (#5924), and `report` last.
213213 expect(names).toEqual(sortedThenReport([
214 "data",
214215 "drills",
215216 "sizes",
216217 "releases",
test/palette-roster.test.ts +2
@@ -48,6 +48,7 @@ test("a site-admin person is offered the whole roster", () => {
4848 ["home", "Home", "your repositories"],
4949 ["explore", "Explore", "on the web"],
5050 ["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"],
5152 ["fleet", "Fleet rows", "the rows a fleet query line selects"],
5253 ["profile", "Open a profile", "username or org"],
5354 ["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",
163164 ["home", "Home", "your repositories"],
164165 ["explore", "Explore", "on the web"],
165166 ["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"],
166168 ["fleet", "Fleet rows", "the rows a fleet query line selects"],
167169 ["profile", "Open a profile", "username or org"],
168170 ["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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs