@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 +1 -1
- package/src/db-browse.test.ts +210 -0
- package/src/db-browse.ts +411 -0
- package/src/deploy-files.test.ts +43 -0
- package/src/deploy-files.ts +63 -0
- package/src/index.ts +29 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robodev-ai/runtime",
|
|
3
|
-
"version": "0.
|
|
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
|
+
});
|
package/src/db-browse.ts
ADDED
|
@@ -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
|
+
}
|
package/src/deploy-files.test.ts
CHANGED
|
@@ -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" },
|
package/src/deploy-files.ts
CHANGED
|
@@ -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";
|