@robodev-ai/runtime 0.5.1 → 0.7.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 +104 -0
- package/src/deploy-files.ts +136 -0
- package/src/index.ts +36 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@robodev-ai/runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.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
|
@@ -1,13 +1,21 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { test } from "node:test";
|
|
3
3
|
import {
|
|
4
|
+
applySourceLayoutWin,
|
|
4
5
|
classifyDeployPath,
|
|
5
6
|
detectFrontendKind,
|
|
7
|
+
detectSourceLayout,
|
|
8
|
+
hasCliDatabaseFile,
|
|
9
|
+
isTinyLayoutPath,
|
|
6
10
|
MAX_FILE_COUNT,
|
|
7
11
|
validateDeployFiles,
|
|
12
|
+
validateReplaceAllSourceFiles,
|
|
13
|
+
validateWorkspaceFiles,
|
|
8
14
|
backendRootFromFiles,
|
|
9
15
|
isAllowedWorkspacePath,
|
|
10
16
|
isDeniedPath,
|
|
17
|
+
isWorkspaceFrontendSourcePath,
|
|
18
|
+
validateFrontendSourceFiles,
|
|
11
19
|
mapWorkspaceToDeployFiles,
|
|
12
20
|
apiCompilePaths,
|
|
13
21
|
isAllowedDeployPath,
|
|
@@ -73,6 +81,47 @@ test("workspace allowlist persists Tiny paths and still denies .env", () => {
|
|
|
73
81
|
assert.equal(isDeniedPath("apps/fe/src/main.tsx"), true);
|
|
74
82
|
});
|
|
75
83
|
|
|
84
|
+
test("isWorkspaceFrontendSourcePath allows Tiny and root FE only", () => {
|
|
85
|
+
assert.equal(isWorkspaceFrontendSourcePath("apps/fe/package.json"), true);
|
|
86
|
+
assert.equal(isWorkspaceFrontendSourcePath("apps/fe/src/main.tsx"), true);
|
|
87
|
+
assert.equal(isWorkspaceFrontendSourcePath("apps/fe/index.html"), true);
|
|
88
|
+
assert.equal(isWorkspaceFrontendSourcePath("index.html"), true);
|
|
89
|
+
assert.equal(isWorkspaceFrontendSourcePath("src/main.tsx"), true);
|
|
90
|
+
assert.equal(isWorkspaceFrontendSourcePath("package.json"), true);
|
|
91
|
+
assert.equal(isWorkspaceFrontendSourcePath("vite.config.ts"), true);
|
|
92
|
+
assert.equal(isWorkspaceFrontendSourcePath("tsconfig.app.json"), true);
|
|
93
|
+
assert.equal(isWorkspaceFrontendSourcePath("packages/be/database.ts"), false);
|
|
94
|
+
assert.equal(isWorkspaceFrontendSourcePath(".rulesync/rules/overview.md"), false);
|
|
95
|
+
assert.equal(isWorkspaceFrontendSourcePath("database.ts"), false);
|
|
96
|
+
assert.equal(isWorkspaceFrontendSourcePath("api/health.ts"), false);
|
|
97
|
+
assert.equal(isWorkspaceFrontendSourcePath(".env"), false);
|
|
98
|
+
assert.equal(isWorkspaceFrontendSourcePath("apps/fe/.env"), false);
|
|
99
|
+
assert.equal(isWorkspaceFrontendSourcePath("apps/fe/node_modules/x.js"), false);
|
|
100
|
+
assert.equal(isWorkspaceFrontendSourcePath("apps/fe/dist/index.html"), false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("validateFrontendSourceFiles has its own 400-file budget and rejects backend", () => {
|
|
104
|
+
const files = validateFrontendSourceFiles([
|
|
105
|
+
{ path: "apps/fe/package.json", content: "{}" },
|
|
106
|
+
{ path: "apps/fe/src/main.tsx", content: "export {}" },
|
|
107
|
+
]);
|
|
108
|
+
assert.equal(files.length, 2);
|
|
109
|
+
assert.throws(
|
|
110
|
+
() => validateFrontendSourceFiles([{ path: "packages/be/database.ts", content: "export {}" }]),
|
|
111
|
+
(err: { code?: string }) => err.code === "invalid_file",
|
|
112
|
+
);
|
|
113
|
+
assert.throws(
|
|
114
|
+
() =>
|
|
115
|
+
validateFrontendSourceFiles(
|
|
116
|
+
Array.from({ length: MAX_FILE_COUNT + 1 }, (_, i) => ({
|
|
117
|
+
path: `apps/fe/src/f${i}.ts`,
|
|
118
|
+
content: "export {}",
|
|
119
|
+
})),
|
|
120
|
+
),
|
|
121
|
+
(err: { code?: string }) => err.code === "too_many_files",
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
76
125
|
test("maps packages/be to hosted database.ts and api/**", () => {
|
|
77
126
|
const mapped = mapWorkspaceToDeployFiles([
|
|
78
127
|
{ path: "packages/be/database.ts", content: "db" },
|
|
@@ -182,6 +231,61 @@ test("enforces file count and size limits", () => {
|
|
|
182
231
|
);
|
|
183
232
|
});
|
|
184
233
|
|
|
234
|
+
test("CLI database file wins and drops leftover Tiny paths", () => {
|
|
235
|
+
const mixed = [
|
|
236
|
+
{ path: "robodev/database.ts", content: "db" },
|
|
237
|
+
{ path: "apps/fe/src/main.tsx", content: "fe" },
|
|
238
|
+
{ path: "packages/be/database.ts", content: "tiny-db" },
|
|
239
|
+
{ path: "src/main.tsx", content: "src" },
|
|
240
|
+
];
|
|
241
|
+
assert.equal(hasCliDatabaseFile(mixed), true);
|
|
242
|
+
assert.equal(detectSourceLayout(mixed), "cli");
|
|
243
|
+
assert.equal(isTinyLayoutPath("apps/fe/src/main.tsx"), true);
|
|
244
|
+
assert.deepEqual(
|
|
245
|
+
applySourceLayoutWin(mixed).map((file) => file.path),
|
|
246
|
+
["robodev/database.ts", "src/main.tsx"],
|
|
247
|
+
);
|
|
248
|
+
const tinyOnly = [
|
|
249
|
+
{ path: "packages/be/database.ts", content: "db" },
|
|
250
|
+
{ path: "apps/fe/index.html", content: "<html></html>" },
|
|
251
|
+
];
|
|
252
|
+
assert.equal(hasCliDatabaseFile(tinyOnly), false);
|
|
253
|
+
assert.equal(detectSourceLayout(tinyOnly), "tiny");
|
|
254
|
+
assert.deepEqual(
|
|
255
|
+
applySourceLayoutWin(tinyOnly).map((file) => file.path),
|
|
256
|
+
["packages/be/database.ts", "apps/fe/index.html"],
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("validateReplaceAllSourceFiles allows apps/fe and still rejects it in deployed payloads", () => {
|
|
261
|
+
const source = validateReplaceAllSourceFiles([
|
|
262
|
+
{ path: "packages/be/database.ts", content: "export default {}" },
|
|
263
|
+
{ path: "apps/fe/src/main.tsx", content: "export {}" },
|
|
264
|
+
]);
|
|
265
|
+
assert.equal(source.length, 2);
|
|
266
|
+
assert.throws(
|
|
267
|
+
() =>
|
|
268
|
+
validateDeployFiles([
|
|
269
|
+
db,
|
|
270
|
+
{ path: "apps/fe/src/main.tsx", content: "export {}" },
|
|
271
|
+
]),
|
|
272
|
+
(err: { code?: string }) => err.code === "invalid_file",
|
|
273
|
+
);
|
|
274
|
+
const won = validateReplaceAllSourceFiles([
|
|
275
|
+
{ path: "robodev/database.ts", content: "export default {}" },
|
|
276
|
+
{ path: "apps/fe/src/main.tsx", content: "export {}" },
|
|
277
|
+
{ path: "src/App.tsx", content: "export {}" },
|
|
278
|
+
]);
|
|
279
|
+
assert.deepEqual(
|
|
280
|
+
won.map((file) => file.path),
|
|
281
|
+
["robodev/database.ts", "src/App.tsx"],
|
|
282
|
+
);
|
|
283
|
+
assert.throws(
|
|
284
|
+
() => validateWorkspaceFiles([{ path: ".env", content: "x" }]),
|
|
285
|
+
(err: { code?: string }) => err.code === "invalid_file",
|
|
286
|
+
);
|
|
287
|
+
});
|
|
288
|
+
|
|
185
289
|
test("classifies api vs frontend vs config", () => {
|
|
186
290
|
assert.equal(classifyDeployPath("database.ts"), "api");
|
|
187
291
|
assert.equal(classifyDeployPath("robodev/database.ts"), "api");
|
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;
|
|
@@ -300,6 +363,79 @@ export function mapWorkspaceToDeployFiles(
|
|
|
300
363
|
return out;
|
|
301
364
|
}
|
|
302
365
|
|
|
366
|
+
export type SourceLayout = "tiny" | "cli";
|
|
367
|
+
|
|
368
|
+
export function hasCliDatabaseFile(files: readonly { path: string }[]): boolean {
|
|
369
|
+
const paths = new Set(files.map((file) => normalizeDeployPath(file.path)));
|
|
370
|
+
return paths.has(ROBODEV_DATABASE_FILE) || paths.has(LEGACY_DATABASE_FILE);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function isTinyLayoutPath(filePath: string): boolean {
|
|
374
|
+
const path = normalizeDeployPath(filePath);
|
|
375
|
+
return path.startsWith("apps/fe/") || path.startsWith("packages/be/");
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function applySourceLayoutWin<T extends { path: string }>(files: readonly T[]): T[] {
|
|
379
|
+
if (!hasCliDatabaseFile(files)) return files.map((file) => file);
|
|
380
|
+
return files.filter((file) => !isTinyLayoutPath(file.path));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function detectSourceLayout(files: readonly { path: string }[]): SourceLayout | null {
|
|
384
|
+
if (hasCliDatabaseFile(files)) return "cli";
|
|
385
|
+
if (files.some((file) => isTinyLayoutPath(file.path))) return "tiny";
|
|
386
|
+
return null;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export function validateWorkspaceFiles(input: { path: string; content: string }[]): DeployFile[] {
|
|
390
|
+
const files: DeployFile[] = [];
|
|
391
|
+
const seen = new Set<string>();
|
|
392
|
+
let totalBytes = 0;
|
|
393
|
+
|
|
394
|
+
if (input.length > MAX_WORKSPACE_FILE_COUNT) {
|
|
395
|
+
fail("too_many_files", `Source may include at most ${MAX_WORKSPACE_FILE_COUNT} files`);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
for (const raw of input) {
|
|
399
|
+
const path = normalizeDeployPath(raw.path);
|
|
400
|
+
if (
|
|
401
|
+
isUnsafeDeployPath(path) ||
|
|
402
|
+
path.length > MAX_PATH_LENGTH ||
|
|
403
|
+
path.split("/").length > MAX_PATH_DEPTH
|
|
404
|
+
) {
|
|
405
|
+
fail("invalid_file_path", `Invalid file path: ${path}`, path);
|
|
406
|
+
}
|
|
407
|
+
if (seen.has(path)) {
|
|
408
|
+
fail("invalid_file", `Duplicate file path: ${path}`, path);
|
|
409
|
+
}
|
|
410
|
+
seen.add(path);
|
|
411
|
+
if (!isAllowedWorkspacePath(path)) {
|
|
412
|
+
fail("invalid_file", `File is not allowed: ${path}`, path);
|
|
413
|
+
}
|
|
414
|
+
const storedBytes = Buffer.byteLength(raw.content, "utf8");
|
|
415
|
+
if (storedBytes > MAX_FILE_BYTES) {
|
|
416
|
+
fail("file_too_large", `File must be under ${formatLimitMb(MAX_FILE_BYTES)}: ${path}`, path);
|
|
417
|
+
}
|
|
418
|
+
totalBytes += storedBytes;
|
|
419
|
+
if (totalBytes > MAX_TREE_BYTES) {
|
|
420
|
+
fail("tree_too_large", `Source tree must be under ${formatLimitMb(MAX_TREE_BYTES)}`);
|
|
421
|
+
}
|
|
422
|
+
files.push({ path, content: raw.content });
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const mixed = mixedBackendLayoutError(files.map((file) => file.path));
|
|
426
|
+
if (mixed) {
|
|
427
|
+
fail("mixed_backend_layout", mixed);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
return files;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function validateReplaceAllSourceFiles(
|
|
434
|
+
input: { path: string; content: string }[],
|
|
435
|
+
): DeployFile[] {
|
|
436
|
+
return applySourceLayoutWin(validateWorkspaceFiles(input));
|
|
437
|
+
}
|
|
438
|
+
|
|
303
439
|
export function workspaceHasTinyFrontend(files: readonly { path: string }[]): boolean {
|
|
304
440
|
return files.some((file) => {
|
|
305
441
|
const path = normalizeDeployPath(file.path);
|
package/src/index.ts
CHANGED
|
@@ -23,14 +23,19 @@ export {
|
|
|
23
23
|
ROBODEV_PACKAGE_JSON,
|
|
24
24
|
ROBODEV_PREFIX,
|
|
25
25
|
apiCompilePaths,
|
|
26
|
+
applySourceLayoutWin,
|
|
26
27
|
backendRootFromFiles,
|
|
27
28
|
classifyDeployPath,
|
|
28
29
|
databaseFilePath,
|
|
29
30
|
deployFileStoredBytes,
|
|
30
31
|
detectFrontendKind,
|
|
32
|
+
detectSourceLayout,
|
|
33
|
+
hasCliDatabaseFile,
|
|
31
34
|
formatLimitMb,
|
|
32
35
|
isAllowedDeployPath,
|
|
33
36
|
isAllowedWorkspacePath,
|
|
37
|
+
isWorkspaceFrontendSourcePath,
|
|
38
|
+
validateFrontendSourceFiles,
|
|
34
39
|
isApiPath,
|
|
35
40
|
isBackendTestPath,
|
|
36
41
|
isConfigPath,
|
|
@@ -44,6 +49,7 @@ export {
|
|
|
44
49
|
isServedStaticPath,
|
|
45
50
|
isSocketPath,
|
|
46
51
|
isSrcPath,
|
|
52
|
+
isTinyLayoutPath,
|
|
47
53
|
isUnsafeDeployPath,
|
|
48
54
|
mapPackagesBePath,
|
|
49
55
|
mapWorkspaceToDeployFiles,
|
|
@@ -52,6 +58,8 @@ export {
|
|
|
52
58
|
reactEntryPath,
|
|
53
59
|
stripRobodevPrefix,
|
|
54
60
|
validateDeployFiles,
|
|
61
|
+
validateReplaceAllSourceFiles,
|
|
62
|
+
validateWorkspaceFiles,
|
|
55
63
|
webServePath,
|
|
56
64
|
workspaceHasFrontend,
|
|
57
65
|
workspaceHasPublishableFrontend,
|
|
@@ -60,6 +68,7 @@ export {
|
|
|
60
68
|
type DeployFileError,
|
|
61
69
|
type FileClass,
|
|
62
70
|
type FrontendKind,
|
|
71
|
+
type SourceLayout,
|
|
63
72
|
} from "./deploy-files.js";
|
|
64
73
|
|
|
65
74
|
export {
|
|
@@ -342,3 +351,30 @@ export {
|
|
|
342
351
|
resolvePdfOptions,
|
|
343
352
|
type ResolvedPdfOptions,
|
|
344
353
|
} from "./local-pdf.js";
|
|
354
|
+
|
|
355
|
+
export {
|
|
356
|
+
BrowseError,
|
|
357
|
+
IDENT,
|
|
358
|
+
MAX_LIMIT,
|
|
359
|
+
MAX_SEARCH,
|
|
360
|
+
SECRET_COLUMNS,
|
|
361
|
+
asStringArray,
|
|
362
|
+
attachForeignKeys,
|
|
363
|
+
browseRows,
|
|
364
|
+
browseSchema,
|
|
365
|
+
buildRowWhere,
|
|
366
|
+
escapeIlikePattern,
|
|
367
|
+
isSecretColumn,
|
|
368
|
+
isTextLikeType,
|
|
369
|
+
quoteIdent,
|
|
370
|
+
quoteTable,
|
|
371
|
+
type BrowseColumn,
|
|
372
|
+
type BrowseForeignKey,
|
|
373
|
+
type BrowseQueryable,
|
|
374
|
+
type BrowseRowFilter,
|
|
375
|
+
type BrowseRowFilterOp,
|
|
376
|
+
type BrowseRowsOptions,
|
|
377
|
+
type BrowseRowsResult,
|
|
378
|
+
type BrowseTable,
|
|
379
|
+
type ResolvedColumn,
|
|
380
|
+
} from "./db-browse.js";
|