@robodev-ai/runtime 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robodev-ai/runtime",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Robodev project runtime — deploy-file classification, esbuild compile, module loading, schema push, route matching, OpenAPI, handler invocation, Robodev Auth, the socket engine, local file storage, and the offline PDF hop client. Shared by hosted Starbase and `robodev dev`.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,210 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ BrowseError,
5
+ asStringArray,
6
+ attachForeignKeys,
7
+ buildRowWhere,
8
+ escapeIlikePattern,
9
+ isSecretColumn,
10
+ isTextLikeType,
11
+ quoteIdent,
12
+ quoteTable,
13
+ } from "./db-browse.js";
14
+
15
+ function expectBrowse(fn: () => unknown, status: number, message: RegExp) {
16
+ assert.throws(fn, (error: unknown) => {
17
+ assert.ok(error instanceof BrowseError);
18
+ assert.equal(error.statusCode, status);
19
+ assert.match(error.message, message);
20
+ return true;
21
+ });
22
+ }
23
+
24
+ test("quoteIdent rejects invalid identifiers and quotes safe names", () => {
25
+ assert.equal(quoteIdent("users"), '"users"');
26
+ assert.equal(quoteIdent("user_id"), '"user_id"');
27
+ expectBrowse(() => quoteIdent("users;drop"), 400, /Invalid identifier/);
28
+ expectBrowse(() => quoteIdent("foo.bar"), 400, /Invalid identifier/);
29
+ expectBrowse(() => quoteIdent('foo"bar'), 400, /Invalid identifier/);
30
+ expectBrowse(() => quoteIdent("1users"), 400, /Invalid identifier/);
31
+ expectBrowse(() => quoteIdent(""), 400, /Invalid identifier/);
32
+ expectBrowse(() => quoteTable("public", "items;x"), 400, /Invalid identifier/);
33
+ });
34
+
35
+ test("secret columns are excluded from search and rejected as filters", () => {
36
+ for (const name of ["password_hash", "password", "secret", "access_token", "refresh_token"]) {
37
+ assert.equal(isSecretColumn(name), true);
38
+ assert.equal(isSecretColumn(name.toUpperCase()), true);
39
+ }
40
+ assert.equal(isSecretColumn("email"), false);
41
+
42
+ const columns = [
43
+ { name: "email", type: "text" },
44
+ { name: "password_hash", type: "text" },
45
+ { name: "secret", type: "text" },
46
+ ];
47
+ const { sql, params } = buildRowWhere(columns, { q: "abc" });
48
+ assert.match(sql, /"email"::text ILIKE \$1 ESCAPE/);
49
+ assert.doesNotMatch(sql, /password/i);
50
+ assert.doesNotMatch(sql, /secret/i);
51
+ assert.deepEqual(params, ["%abc%"]);
52
+
53
+ expectBrowse(
54
+ () => buildRowWhere(columns, { filters: [{ column: "password_hash", op: "eq", value: "x" }] }),
55
+ 400,
56
+ /secret column/,
57
+ );
58
+ expectBrowse(
59
+ () =>
60
+ buildRowWhere(columns, { filters: [{ column: "access_token", op: "contains", value: "x" }] }),
61
+ 400,
62
+ /secret column/,
63
+ );
64
+ });
65
+
66
+ test("buildRowWhere binds values and never concatenates unsanitized identifiers", () => {
67
+ const columns = [
68
+ { name: "name", type: "varchar" },
69
+ { name: "age", type: "int4" },
70
+ { name: "bio", type: "text" },
71
+ ];
72
+
73
+ const injected = "a'; DROP TABLE users; --";
74
+ const eq = buildRowWhere(columns, { filters: [{ column: "name", op: "eq", value: injected }] });
75
+ assert.equal(eq.sql, '"name" = $1');
76
+ assert.deepEqual(eq.params, [injected]);
77
+ assert.doesNotMatch(eq.sql, /DROP/i);
78
+ assert.doesNotMatch(eq.sql, /;/);
79
+
80
+ const contains = buildRowWhere(columns, {
81
+ filters: [{ column: "bio", op: "contains", value: "100%" }],
82
+ });
83
+ assert.equal(contains.sql, `"bio"::text ILIKE $1 ESCAPE '\\'`);
84
+ assert.deepEqual(contains.params, [`%${escapeIlikePattern("100%")}%`]);
85
+ assert.equal(contains.params[0], "%100\\%%");
86
+
87
+ const nulls = buildRowWhere(columns, {
88
+ filters: [
89
+ { column: "name", op: "is_null" },
90
+ { column: "age", op: "is_not_null" },
91
+ ],
92
+ });
93
+ assert.equal(nulls.sql, '"name" IS NULL AND "age" IS NOT NULL');
94
+ assert.deepEqual(nulls.params, []);
95
+
96
+ const combined = buildRowWhere(columns, {
97
+ q: "rob",
98
+ filters: [{ column: "age", op: "eq", value: "3" }],
99
+ });
100
+ assert.match(combined.sql, /"name"::text ILIKE \$1/);
101
+ assert.match(combined.sql, /"bio"::text ILIKE \$1/);
102
+ assert.match(combined.sql, / AND "age" = \$2$/);
103
+ assert.deepEqual(combined.params, ["%rob%", "3"]);
104
+
105
+ expectBrowse(
106
+ () => buildRowWhere(columns, { filters: [{ column: "name;drop", op: "eq", value: "x" }] }),
107
+ 400,
108
+ /Invalid identifier/,
109
+ );
110
+ expectBrowse(
111
+ () => buildRowWhere(columns, { filters: [{ column: "missing", op: "eq", value: "x" }] }),
112
+ 400,
113
+ /Unknown column/,
114
+ );
115
+ expectBrowse(
116
+ () => buildRowWhere(columns, { filters: [{ column: "age", op: "contains", value: "1" }] }),
117
+ 400,
118
+ /contains is only valid on text columns/,
119
+ );
120
+ });
121
+
122
+ test("search skips non-text types and escapes ILIKE wildcards", () => {
123
+ assert.equal(isTextLikeType("text"), true);
124
+ assert.equal(isTextLikeType("varchar"), true);
125
+ assert.equal(isTextLikeType("uuid"), true);
126
+ assert.equal(isTextLikeType("int4"), false);
127
+ assert.equal(isTextLikeType("jsonb"), false);
128
+ assert.equal(isTextLikeType("bytea"), false);
129
+ assert.equal(isTextLikeType("bool"), false);
130
+ assert.equal(isTextLikeType("numeric"), false);
131
+ assert.equal(isTextLikeType("timestamptz"), false);
132
+
133
+ const { sql, params } = buildRowWhere(
134
+ [
135
+ { name: "name", type: "text" },
136
+ { name: "meta", type: "jsonb" },
137
+ { name: "age", type: "int4" },
138
+ { name: "active", type: "bool" },
139
+ ],
140
+ { q: "50%_off" },
141
+ );
142
+ assert.match(sql, /"name"::text ILIKE \$1 ESCAPE/);
143
+ assert.doesNotMatch(sql, /meta|age|active/);
144
+ assert.equal(params[0], `%${escapeIlikePattern("50%_off")}%`);
145
+ assert.equal(params[0], "%50\\%\\_off%");
146
+ });
147
+
148
+ test("attachForeignKeys maps outgoing, composite, cross-schema, and self-ref FKs", () => {
149
+ const tables = [
150
+ { schema: "public", name: "users", rowCount: 0, columns: [] },
151
+ { schema: "public", name: "posts", rowCount: 0, columns: [] },
152
+ { schema: "billing", name: "invoices", rowCount: 0, columns: [] },
153
+ { schema: "public", name: "tags", rowCount: 0, columns: [] },
154
+ ];
155
+ const attached = attachForeignKeys(tables, [
156
+ {
157
+ name: "posts_author_fkey",
158
+ schema: "public",
159
+ table: "posts",
160
+ columns: ["author_id"],
161
+ referencedSchema: "public",
162
+ referencedTable: "users",
163
+ referencedColumns: ["id"],
164
+ },
165
+ {
166
+ name: "invoices_user_fkey",
167
+ schema: "billing",
168
+ table: "invoices",
169
+ columns: ["org_id", "user_id"],
170
+ referencedSchema: "public",
171
+ referencedTable: "users",
172
+ referencedColumns: ["org_id", "id"],
173
+ },
174
+ {
175
+ name: "users_parent_fkey",
176
+ schema: "public",
177
+ table: "users",
178
+ columns: ["parent_id"],
179
+ referencedSchema: "public",
180
+ referencedTable: "users",
181
+ referencedColumns: ["id"],
182
+ },
183
+ ]);
184
+
185
+ const users = attached.find((table) => table.name === "users");
186
+ const posts = attached.find((table) => table.name === "posts");
187
+ const invoices = attached.find((table) => table.schema === "billing");
188
+ const tags = attached.find((table) => table.name === "tags");
189
+ assert.ok(users && posts && invoices && tags);
190
+ assert.equal(users.foreignKeys.length, 1);
191
+ assert.equal(users.foreignKeys[0]?.name, "users_parent_fkey");
192
+ assert.deepEqual(posts.foreignKeys[0]?.columns, ["author_id"]);
193
+ assert.equal(invoices.foreignKeys[0]?.referencedSchema, "public");
194
+ assert.deepEqual(invoices.foreignKeys[0]?.columns, ["org_id", "user_id"]);
195
+ assert.deepEqual(tags.foreignKeys, []);
196
+ });
197
+
198
+ test("asStringArray keeps arrays and parses pg / quoted list leftovers", () => {
199
+ assert.deepEqual(asStringArray(["org_id", "user_id"]), ["org_id", "user_id"]);
200
+ assert.deepEqual(asStringArray("{a,b}"), ["a", "b"]);
201
+ assert.deepEqual(asStringArray("{id}"), ["id"]);
202
+ assert.deepEqual(asStringArray('"a,b"'), ["a", "b"]);
203
+ assert.deepEqual(asStringArray('{"org_id","user_id"}'), ["org_id", "user_id"]);
204
+ assert.deepEqual(asStringArray(""), []);
205
+ assert.deepEqual(asStringArray("plain"), []);
206
+ assert.deepEqual(asStringArray(null), []);
207
+ assert.deepEqual(asStringArray(undefined), []);
208
+ assert.deepEqual(asStringArray(1), []);
209
+ assert.deepEqual(asStringArray({ a: 1 }), []);
210
+ });
@@ -0,0 +1,411 @@
1
+ /** Shared read-only Postgres browse for Starbase Databases and `robodev dev` inspect. */
2
+
3
+ export const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/;
4
+ export const MAX_LIMIT = 100;
5
+ export const MAX_SEARCH = 200;
6
+ export const SECRET_COLUMNS = new Set([
7
+ "password_hash",
8
+ "password",
9
+ "secret",
10
+ "access_token",
11
+ "refresh_token",
12
+ ]);
13
+ const TEXT_LIKE_TYPES = new Set([
14
+ "text",
15
+ "varchar",
16
+ "citext",
17
+ "name",
18
+ "char",
19
+ "bpchar",
20
+ "uuid",
21
+ "character",
22
+ "character varying",
23
+ ]);
24
+
25
+ /** Anything with a `pg`-shaped `query`. Keeps this module off a Pool type. */
26
+ export type BrowseQueryable = {
27
+ query: <R extends Record<string, unknown> = Record<string, unknown>>(
28
+ sql: string,
29
+ values?: unknown[],
30
+ ) => Promise<{ rows: R[]; fields?: Array<{ name: string }> }>;
31
+ };
32
+
33
+ export class BrowseError extends Error {
34
+ readonly statusCode: number;
35
+
36
+ constructor(statusCode: number, message: string) {
37
+ super(message);
38
+ this.name = "BrowseError";
39
+ this.statusCode = statusCode;
40
+ }
41
+ }
42
+
43
+ export type BrowseColumn = {
44
+ name: string;
45
+ type: string;
46
+ notNull: boolean;
47
+ primaryKey: boolean;
48
+ };
49
+
50
+ export type BrowseForeignKey = {
51
+ name: string;
52
+ schema: string;
53
+ table: string;
54
+ columns: string[];
55
+ referencedSchema: string;
56
+ referencedTable: string;
57
+ referencedColumns: string[];
58
+ };
59
+
60
+ export type BrowseTable = {
61
+ schema: string;
62
+ name: string;
63
+ rowCount: number;
64
+ columns: BrowseColumn[];
65
+ foreignKeys: BrowseForeignKey[];
66
+ };
67
+
68
+ export type BrowseRowFilterOp = "eq" | "contains" | "is_null" | "is_not_null";
69
+
70
+ export type BrowseRowFilter = {
71
+ column: string;
72
+ op: BrowseRowFilterOp;
73
+ value?: string;
74
+ };
75
+
76
+ export type BrowseRowsOptions = {
77
+ q?: string;
78
+ filters?: BrowseRowFilter[];
79
+ };
80
+
81
+ export type ResolvedColumn = {
82
+ name: string;
83
+ type: string;
84
+ };
85
+
86
+ export type BrowseRowsResult = {
87
+ columns: string[];
88
+ rows: Record<string, unknown>[];
89
+ total: number;
90
+ };
91
+
92
+ export function quoteIdent(name: string): string {
93
+ if (!IDENT.test(name)) {
94
+ throw new BrowseError(400, `Invalid identifier: ${name}`);
95
+ }
96
+ return `"${name.replaceAll('"', '""')}"`;
97
+ }
98
+
99
+ export function quoteTable(schema: string, table: string): string {
100
+ return `${quoteIdent(schema)}.${quoteIdent(table)}`;
101
+ }
102
+
103
+ export function isSecretColumn(name: string): boolean {
104
+ return SECRET_COLUMNS.has(name.toLowerCase());
105
+ }
106
+
107
+ export function isTextLikeType(type: string): boolean {
108
+ const normalized = type.trim().toLowerCase();
109
+ if (TEXT_LIKE_TYPES.has(normalized)) return true;
110
+ const base = normalized.split("(")[0]?.trim() ?? normalized;
111
+ return TEXT_LIKE_TYPES.has(base);
112
+ }
113
+
114
+ export function escapeIlikePattern(value: string): string {
115
+ return value.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_");
116
+ }
117
+
118
+ /** Normalize pg array / text-array leftovers to `string[]`. */
119
+ export function asStringArray(value: unknown): string[] {
120
+ if (Array.isArray(value)) {
121
+ return value.map((item) => String(item));
122
+ }
123
+ if (typeof value !== "string") return [];
124
+ const trimmed = value.trim();
125
+ if (!trimmed) return [];
126
+ const wrapped =
127
+ (trimmed.startsWith("{") && trimmed.endsWith("}")) ||
128
+ (trimmed.startsWith('"') && trimmed.endsWith('"'));
129
+ if (!wrapped) return [];
130
+ const inner = trimmed.slice(1, -1);
131
+ if (!inner) return [];
132
+ return inner
133
+ .split(",")
134
+ .map((part) => part.trim().replace(/^"|"$/g, ""))
135
+ .filter((part) => part.length > 0);
136
+ }
137
+
138
+ function schemaRank(schema: string): number {
139
+ if (schema === "public") return 0;
140
+ if (schema === "robodev_auth") return 1;
141
+ return 2;
142
+ }
143
+
144
+ function serializeValue(value: unknown): unknown {
145
+ if (value === null || value === undefined) return null;
146
+ if (value instanceof Date) return value.toISOString();
147
+ if (typeof value === "bigint") return value.toString();
148
+ if (Buffer.isBuffer(value)) return value.toString("base64");
149
+ return value;
150
+ }
151
+
152
+ function tableKey(schema: string, table: string): string {
153
+ return `${schema}.${table}`;
154
+ }
155
+
156
+ export function attachForeignKeys(
157
+ tables: Array<Omit<BrowseTable, "foreignKeys">>,
158
+ foreignKeys: BrowseForeignKey[],
159
+ ): BrowseTable[] {
160
+ const byTable = new Map<string, BrowseForeignKey[]>();
161
+ for (const fk of foreignKeys) {
162
+ const key = tableKey(fk.schema, fk.table);
163
+ const list = byTable.get(key) ?? [];
164
+ list.push(fk);
165
+ byTable.set(key, list);
166
+ }
167
+ return tables.map((table) => ({
168
+ ...table,
169
+ foreignKeys: byTable.get(tableKey(table.schema, table.name)) ?? [],
170
+ }));
171
+ }
172
+
173
+ export function buildRowWhere(
174
+ columns: ResolvedColumn[],
175
+ options: BrowseRowsOptions = {},
176
+ ): { sql: string; params: unknown[] } {
177
+ const params: unknown[] = [];
178
+ const clauses: string[] = [];
179
+ const byName = new Map(columns.map((column) => [column.name, column]));
180
+
181
+ const q = options.q?.trim().slice(0, MAX_SEARCH) ?? "";
182
+ if (q) {
183
+ const searchable = columns.filter(
184
+ (column) => !isSecretColumn(column.name) && isTextLikeType(column.type),
185
+ );
186
+ if (searchable.length === 0) {
187
+ clauses.push("FALSE");
188
+ } else {
189
+ params.push(`%${escapeIlikePattern(q)}%`);
190
+ const placeholder = `$${params.length}`;
191
+ const ors = searchable.map(
192
+ (column) => `${quoteIdent(column.name)}::text ILIKE ${placeholder} ESCAPE '\\'`,
193
+ );
194
+ clauses.push(`(${ors.join(" OR ")})`);
195
+ }
196
+ }
197
+
198
+ for (const filter of options.filters ?? []) {
199
+ if (!IDENT.test(filter.column)) {
200
+ throw new BrowseError(400, `Invalid identifier: ${filter.column}`);
201
+ }
202
+ if (isSecretColumn(filter.column)) {
203
+ throw new BrowseError(400, `Cannot filter secret column: ${filter.column}`);
204
+ }
205
+ const column = byName.get(filter.column);
206
+ if (!column) {
207
+ throw new BrowseError(400, `Unknown column: ${filter.column}`);
208
+ }
209
+ const ident = quoteIdent(column.name);
210
+ if (filter.op === "is_null") {
211
+ clauses.push(`${ident} IS NULL`);
212
+ continue;
213
+ }
214
+ if (filter.op === "is_not_null") {
215
+ clauses.push(`${ident} IS NOT NULL`);
216
+ continue;
217
+ }
218
+ if (filter.op === "contains") {
219
+ if (!isTextLikeType(column.type)) {
220
+ throw new BrowseError(400, "contains is only valid on text columns");
221
+ }
222
+ params.push(`%${escapeIlikePattern(filter.value ?? "")}%`);
223
+ clauses.push(`${ident}::text ILIKE $${params.length} ESCAPE '\\'`);
224
+ continue;
225
+ }
226
+ if (filter.op === "eq") {
227
+ params.push(filter.value ?? "");
228
+ clauses.push(`${ident} = $${params.length}`);
229
+ continue;
230
+ }
231
+ throw new BrowseError(400, `Invalid filter operator: ${String(filter.op)}`);
232
+ }
233
+
234
+ return { sql: clauses.join(" AND "), params };
235
+ }
236
+
237
+ async function listForeignKeys(db: BrowseQueryable): Promise<BrowseForeignKey[]> {
238
+ const result = await db.query<{
239
+ name: string;
240
+ schema: string;
241
+ table: string;
242
+ columns: unknown;
243
+ referenced_schema: string;
244
+ referenced_table: string;
245
+ referenced_columns: unknown;
246
+ }>(
247
+ `SELECT
248
+ con.conname AS name,
249
+ n.nspname AS schema,
250
+ c.relname AS table,
251
+ ARRAY(
252
+ SELECT a.attname
253
+ FROM unnest(con.conkey) WITH ORDINALITY AS u(attnum, ord)
254
+ JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = u.attnum
255
+ ORDER BY u.ord
256
+ )::text[] AS columns,
257
+ nf.nspname AS referenced_schema,
258
+ cf.relname AS referenced_table,
259
+ ARRAY(
260
+ SELECT a.attname
261
+ FROM unnest(con.confkey) WITH ORDINALITY AS u(attnum, ord)
262
+ JOIN pg_attribute a ON a.attrelid = con.confrelid AND a.attnum = u.attnum
263
+ ORDER BY u.ord
264
+ )::text[] AS referenced_columns
265
+ FROM pg_constraint con
266
+ JOIN pg_class c ON c.oid = con.conrelid
267
+ JOIN pg_namespace n ON n.oid = c.relnamespace
268
+ JOIN pg_class cf ON cf.oid = con.confrelid
269
+ JOIN pg_namespace nf ON nf.oid = cf.relnamespace
270
+ WHERE con.contype = 'f'
271
+ AND n.nspname NOT IN ('pg_catalog', 'information_schema')
272
+ AND n.nspname NOT LIKE 'pg_toast%'
273
+ AND n.nspname NOT LIKE 'pg_temp%'`,
274
+ );
275
+ return result.rows.map((row) => ({
276
+ name: row.name,
277
+ schema: row.schema,
278
+ table: row.table,
279
+ columns: asStringArray(row.columns),
280
+ referencedSchema: row.referenced_schema,
281
+ referencedTable: row.referenced_table,
282
+ referencedColumns: asStringArray(row.referenced_columns),
283
+ }));
284
+ }
285
+
286
+ export async function browseSchema(db: BrowseQueryable): Promise<BrowseTable[]> {
287
+ const tables = await db.query<{ schemaname: string; tablename: string }>(
288
+ `SELECT schemaname, tablename
289
+ FROM pg_catalog.pg_tables
290
+ WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
291
+ AND schemaname NOT LIKE 'pg_toast%'
292
+ AND schemaname NOT LIKE 'pg_temp%'`,
293
+ );
294
+
295
+ const listed = [...tables.rows].sort((a, b) => {
296
+ const rank = schemaRank(a.schemaname) - schemaRank(b.schemaname);
297
+ if (rank !== 0) return rank;
298
+ const schemaCmp = a.schemaname.localeCompare(b.schemaname);
299
+ if (schemaCmp !== 0) return schemaCmp;
300
+ return a.tablename.localeCompare(b.tablename);
301
+ });
302
+
303
+ const result: Array<Omit<BrowseTable, "foreignKeys">> = [];
304
+ for (const { schemaname, tablename } of listed) {
305
+ const cols = await db.query<{
306
+ column_name: string;
307
+ data_type: string;
308
+ udt_name: string;
309
+ is_nullable: string;
310
+ }>(
311
+ `SELECT column_name, data_type, udt_name, is_nullable
312
+ FROM information_schema.columns
313
+ WHERE table_schema = $1 AND table_name = $2
314
+ ORDER BY ordinal_position`,
315
+ [schemaname, tablename],
316
+ );
317
+ const pks = await db.query<{ column_name: string }>(
318
+ `SELECT a.attname AS column_name
319
+ FROM pg_index i
320
+ JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
321
+ JOIN pg_class c ON c.oid = i.indrelid
322
+ JOIN pg_namespace n ON n.oid = c.relnamespace
323
+ WHERE i.indisprimary AND n.nspname = $1 AND c.relname = $2`,
324
+ [schemaname, tablename],
325
+ );
326
+ const pkSet = new Set(pks.rows.map((row) => row.column_name));
327
+ const count = await db.query<{ count: string }>(
328
+ `SELECT COUNT(*)::text AS count FROM ${quoteTable(schemaname, tablename)}`,
329
+ );
330
+ result.push({
331
+ schema: schemaname,
332
+ name: tablename,
333
+ rowCount: Number(count.rows[0]?.count ?? 0),
334
+ columns: cols.rows.map((col) => ({
335
+ name: col.column_name,
336
+ type: col.udt_name || col.data_type,
337
+ notNull: col.is_nullable === "NO",
338
+ primaryKey: pkSet.has(col.column_name),
339
+ })),
340
+ });
341
+ }
342
+
343
+ const foreignKeys = await listForeignKeys(db);
344
+ return attachForeignKeys(result, foreignKeys);
345
+ }
346
+
347
+ export async function browseRows(
348
+ db: BrowseQueryable,
349
+ schema: string,
350
+ table: string,
351
+ limit: number,
352
+ offset: number,
353
+ options: BrowseRowsOptions = {},
354
+ ): Promise<BrowseRowsResult> {
355
+ if (!IDENT.test(schema)) {
356
+ throw new BrowseError(400, "Invalid schema name");
357
+ }
358
+ if (!IDENT.test(table)) {
359
+ throw new BrowseError(400, "Invalid table name");
360
+ }
361
+ const take = Math.min(Math.max(limit, 1), MAX_LIMIT);
362
+ const skip = Math.max(offset, 0);
363
+
364
+ const exists = await db.query<{ tablename: string }>(
365
+ `SELECT tablename FROM pg_catalog.pg_tables WHERE schemaname = $1 AND tablename = $2`,
366
+ [schema, table],
367
+ );
368
+ if (!exists.rows[0]) {
369
+ throw new BrowseError(404, `table "${schema}"."${table}" not found`);
370
+ }
371
+
372
+ const cols = await db.query<{ column_name: string; data_type: string; udt_name: string }>(
373
+ `SELECT column_name, data_type, udt_name
374
+ FROM information_schema.columns
375
+ WHERE table_schema = $1 AND table_name = $2
376
+ ORDER BY ordinal_position`,
377
+ [schema, table],
378
+ );
379
+ const resolved = cols.rows.map((col) => ({
380
+ name: col.column_name,
381
+ type: col.udt_name || col.data_type,
382
+ }));
383
+ const where = buildRowWhere(resolved, options);
384
+ const whereSql = where.sql ? `WHERE ${where.sql}` : "";
385
+ const quoted = quoteTable(schema, table);
386
+ const total = await db.query<{ count: string }>(
387
+ `SELECT COUNT(*)::text AS count FROM ${quoted} ${whereSql}`,
388
+ where.params,
389
+ );
390
+ const limitIndex = where.params.length + 1;
391
+ const offsetIndex = where.params.length + 2;
392
+ const data = await db.query(
393
+ `SELECT * FROM ${quoted} ${whereSql} LIMIT $${limitIndex} OFFSET $${offsetIndex}`,
394
+ [...where.params, take, skip],
395
+ );
396
+ const columns = data.fields?.map((field) => field.name) ?? resolved.map((column) => column.name);
397
+ return {
398
+ columns,
399
+ rows: data.rows.map((row) => {
400
+ const out: Record<string, unknown> = {};
401
+ for (const [key, value] of Object.entries(row)) {
402
+ out[key] = serializeValue(value);
403
+ if (isSecretColumn(key)) {
404
+ out[key] = "***";
405
+ }
406
+ }
407
+ return out;
408
+ }),
409
+ total: Number(total.rows[0]?.count ?? 0),
410
+ };
411
+ }
@@ -8,6 +8,8 @@ import {
8
8
  backendRootFromFiles,
9
9
  isAllowedWorkspacePath,
10
10
  isDeniedPath,
11
+ isWorkspaceFrontendSourcePath,
12
+ validateFrontendSourceFiles,
11
13
  mapWorkspaceToDeployFiles,
12
14
  apiCompilePaths,
13
15
  isAllowedDeployPath,
@@ -73,6 +75,47 @@ test("workspace allowlist persists Tiny paths and still denies .env", () => {
73
75
  assert.equal(isDeniedPath("apps/fe/src/main.tsx"), true);
74
76
  });
75
77
 
78
+ test("isWorkspaceFrontendSourcePath allows Tiny and root FE only", () => {
79
+ assert.equal(isWorkspaceFrontendSourcePath("apps/fe/package.json"), true);
80
+ assert.equal(isWorkspaceFrontendSourcePath("apps/fe/src/main.tsx"), true);
81
+ assert.equal(isWorkspaceFrontendSourcePath("apps/fe/index.html"), true);
82
+ assert.equal(isWorkspaceFrontendSourcePath("index.html"), true);
83
+ assert.equal(isWorkspaceFrontendSourcePath("src/main.tsx"), true);
84
+ assert.equal(isWorkspaceFrontendSourcePath("package.json"), true);
85
+ assert.equal(isWorkspaceFrontendSourcePath("vite.config.ts"), true);
86
+ assert.equal(isWorkspaceFrontendSourcePath("tsconfig.app.json"), true);
87
+ assert.equal(isWorkspaceFrontendSourcePath("packages/be/database.ts"), false);
88
+ assert.equal(isWorkspaceFrontendSourcePath(".rulesync/rules/overview.md"), false);
89
+ assert.equal(isWorkspaceFrontendSourcePath("database.ts"), false);
90
+ assert.equal(isWorkspaceFrontendSourcePath("api/health.ts"), false);
91
+ assert.equal(isWorkspaceFrontendSourcePath(".env"), false);
92
+ assert.equal(isWorkspaceFrontendSourcePath("apps/fe/.env"), false);
93
+ assert.equal(isWorkspaceFrontendSourcePath("apps/fe/node_modules/x.js"), false);
94
+ assert.equal(isWorkspaceFrontendSourcePath("apps/fe/dist/index.html"), false);
95
+ });
96
+
97
+ test("validateFrontendSourceFiles has its own 400-file budget and rejects backend", () => {
98
+ const files = validateFrontendSourceFiles([
99
+ { path: "apps/fe/package.json", content: "{}" },
100
+ { path: "apps/fe/src/main.tsx", content: "export {}" },
101
+ ]);
102
+ assert.equal(files.length, 2);
103
+ assert.throws(
104
+ () => validateFrontendSourceFiles([{ path: "packages/be/database.ts", content: "export {}" }]),
105
+ (err: { code?: string }) => err.code === "invalid_file",
106
+ );
107
+ assert.throws(
108
+ () =>
109
+ validateFrontendSourceFiles(
110
+ Array.from({ length: MAX_FILE_COUNT + 1 }, (_, i) => ({
111
+ path: `apps/fe/src/f${i}.ts`,
112
+ content: "export {}",
113
+ })),
114
+ ),
115
+ (err: { code?: string }) => err.code === "too_many_files",
116
+ );
117
+ });
118
+
76
119
  test("maps packages/be to hosted database.ts and api/**", () => {
77
120
  const mapped = mapWorkspaceToDeployFiles([
78
121
  { path: "packages/be/database.ts", content: "db" },
@@ -268,6 +268,69 @@ export function isAllowedWorkspacePath(filePath: string): boolean {
268
268
  return isAllowedDeployPath(path);
269
269
  }
270
270
 
271
+ const ROOT_FE_CONFIG_FILES = new Set([
272
+ "package.json",
273
+ "package-lock.json",
274
+ "pnpm-lock.yaml",
275
+ "tsconfig.json",
276
+ "vite.config.ts",
277
+ "vite.config.js",
278
+ "vite.config.mts",
279
+ "vite.config.mjs",
280
+ ]);
281
+
282
+ /** Tiny `apps/fe/**` or root FE (`index.html`, `src/**`, Vite/package) — never backend or secrets. */
283
+ export function isWorkspaceFrontendSourcePath(filePath: string): boolean {
284
+ const path = normalizeDeployPath(filePath);
285
+ if (!isAllowedWorkspacePath(path)) return false;
286
+ if (path.startsWith("apps/fe/")) return true;
287
+ if (path === "index.html" || path.startsWith("src/") || path.startsWith("public/")) return true;
288
+ if (ROOT_FE_CONFIG_FILES.has(path)) return true;
289
+ if (isTsconfigName(basename(path)) && !path.includes("/")) return true;
290
+ return false;
291
+ }
292
+
293
+ export function validateFrontendSourceFiles(
294
+ input: { path: string; content: string }[],
295
+ ): DeployFile[] {
296
+ const files: DeployFile[] = [];
297
+ const seen = new Set<string>();
298
+ let totalBytes = 0;
299
+
300
+ if (input.length > MAX_FILE_COUNT) {
301
+ fail("too_many_files", `Frontend source may include at most ${MAX_FILE_COUNT} files`);
302
+ }
303
+
304
+ for (const raw of input) {
305
+ const path = normalizeDeployPath(raw.path);
306
+ if (
307
+ isUnsafeDeployPath(path) ||
308
+ path.length > MAX_PATH_LENGTH ||
309
+ path.split("/").length > MAX_PATH_DEPTH
310
+ ) {
311
+ fail("invalid_file_path", `Invalid file path: ${path}`, path);
312
+ }
313
+ if (seen.has(path)) {
314
+ fail("invalid_file", `Duplicate file path: ${path}`, path);
315
+ }
316
+ seen.add(path);
317
+ if (!isWorkspaceFrontendSourcePath(path)) {
318
+ fail("invalid_file", `File is not allowed: ${path}`, path);
319
+ }
320
+ const storedBytes = Buffer.byteLength(raw.content, "utf8");
321
+ if (storedBytes > MAX_FILE_BYTES) {
322
+ fail("file_too_large", `File must be under ${formatLimitMb(MAX_FILE_BYTES)}: ${path}`, path);
323
+ }
324
+ totalBytes += storedBytes;
325
+ if (totalBytes > MAX_TREE_BYTES) {
326
+ fail("tree_too_large", `Frontend source tree must be under ${formatLimitMb(MAX_TREE_BYTES)}`);
327
+ }
328
+ files.push({ path, content: raw.content });
329
+ }
330
+
331
+ return files;
332
+ }
333
+
271
334
  export function mapPackagesBePath(filePath: string): string | null {
272
335
  const path = normalizeDeployPath(filePath);
273
336
  if (path === "packages/be/database.ts") return LEGACY_DATABASE_FILE;
package/src/index.ts CHANGED
@@ -31,6 +31,8 @@ export {
31
31
  formatLimitMb,
32
32
  isAllowedDeployPath,
33
33
  isAllowedWorkspacePath,
34
+ isWorkspaceFrontendSourcePath,
35
+ validateFrontendSourceFiles,
34
36
  isApiPath,
35
37
  isBackendTestPath,
36
38
  isConfigPath,
@@ -342,3 +344,30 @@ export {
342
344
  resolvePdfOptions,
343
345
  type ResolvedPdfOptions,
344
346
  } from "./local-pdf.js";
347
+
348
+ export {
349
+ BrowseError,
350
+ IDENT,
351
+ MAX_LIMIT,
352
+ MAX_SEARCH,
353
+ SECRET_COLUMNS,
354
+ asStringArray,
355
+ attachForeignKeys,
356
+ browseRows,
357
+ browseSchema,
358
+ buildRowWhere,
359
+ escapeIlikePattern,
360
+ isSecretColumn,
361
+ isTextLikeType,
362
+ quoteIdent,
363
+ quoteTable,
364
+ type BrowseColumn,
365
+ type BrowseForeignKey,
366
+ type BrowseQueryable,
367
+ type BrowseRowFilter,
368
+ type BrowseRowFilterOp,
369
+ type BrowseRowsOptions,
370
+ type BrowseRowsResult,
371
+ type BrowseTable,
372
+ type ResolvedColumn,
373
+ } from "./db-browse.js";