@robodev-ai/runtime 0.1.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.
@@ -0,0 +1,213 @@
1
+ import { isBackendTestPath } from "./deploy-files.js";
2
+
3
+ export type FileRoute = {
4
+ path: string;
5
+ paramNames: string[];
6
+ };
7
+
8
+ export type RouteCandidate = {
9
+ method: string;
10
+ path: string;
11
+ };
12
+
13
+ const PARAM_SEGMENT = /^\[([A-Za-z_][A-Za-z0-9_]*)\]$/;
14
+
15
+ function isReservedFileRoute(path: string): boolean {
16
+ return (
17
+ path === "/user" ||
18
+ path.startsWith("/user/") ||
19
+ path === "/api/user" ||
20
+ path.startsWith("/api/user/") ||
21
+ path === "/storage/objects" ||
22
+ path.startsWith("/storage/objects/") ||
23
+ path === "/_robodev" ||
24
+ path.startsWith("/_robodev/")
25
+ );
26
+ }
27
+
28
+ function canStripApiPrefix(path: string): boolean {
29
+ if (path === "/api/user" || path.startsWith("/api/user/")) return false;
30
+ return path === "/api" || path.startsWith("/api/");
31
+ }
32
+
33
+ function segmentsOf(path: string): string[] {
34
+ return path.split("/").filter((segment) => segment.length > 0);
35
+ }
36
+
37
+ function staticScore(pattern: string): { staticCount: number; staticPrefix: number } {
38
+ const parts = segmentsOf(pattern);
39
+ const staticCount = parts.filter((part) => !part.startsWith(":")).length;
40
+ let staticPrefix = 0;
41
+ for (const part of parts) {
42
+ if (part.startsWith(":")) break;
43
+ staticPrefix += part.length + 1;
44
+ }
45
+ return { staticCount, staticPrefix };
46
+ }
47
+
48
+ export function matchPathParams(pattern: string, pathname: string): Record<string, string> | null {
49
+ const patternParts = segmentsOf(pattern);
50
+ const pathParts = segmentsOf(pathname);
51
+ if (patternParts.length !== pathParts.length) return null;
52
+ const params: Record<string, string> = {};
53
+ for (let i = 0; i < patternParts.length; i++) {
54
+ const expected = patternParts[i] ?? "";
55
+ const actual = pathParts[i] ?? "";
56
+ if (expected.startsWith(":")) {
57
+ params[expected.slice(1)] = decodeURIComponent(actual);
58
+ continue;
59
+ }
60
+ if (expected !== actual) return null;
61
+ }
62
+ return params;
63
+ }
64
+
65
+ function matchOnce<T extends RouteCandidate>(
66
+ path: string,
67
+ verb: string,
68
+ routes: T[],
69
+ ): { route: T; params: Record<string, string> } | null {
70
+ const exact = routes.find((route) => route.method === verb && route.path === path);
71
+ if (exact) return { route: exact, params: {} };
72
+
73
+ const parameterized = routes
74
+ .filter((route) => route.method === verb && route.path.includes(":"))
75
+ .map((route) => {
76
+ const params = matchPathParams(route.path, path);
77
+ return params ? { route, params, ...staticScore(route.path) } : null;
78
+ })
79
+ .filter((entry): entry is NonNullable<typeof entry> => entry !== null)
80
+ .sort((a, b) => b.staticCount - a.staticCount || b.staticPrefix - a.staticPrefix);
81
+
82
+ const best = parameterized[0];
83
+ return best ? { route: best.route, params: best.params } : null;
84
+ }
85
+
86
+ export function matchProjectRoute<T extends RouteCandidate>(
87
+ pathname: string,
88
+ method: string,
89
+ routes: T[],
90
+ ): { route: T; params: Record<string, string> } | null {
91
+ const verb = method.toLowerCase();
92
+ const path = pathname.replace(/\/$/, "") || "/";
93
+ if (!canStripApiPrefix(path)) return null;
94
+ const direct = matchOnce(path, verb, routes);
95
+ if (direct) return direct;
96
+ const stripped = path === "/api" ? "/" : path.slice("/api".length);
97
+ return matchOnce(stripped, verb, routes);
98
+ }
99
+
100
+ function stripRobodevPrefix(filePath: string): string {
101
+ const normalized = filePath.replaceAll("\\", "/");
102
+ return normalized.startsWith("robodev/") ? normalized.slice("robodev/".length) : normalized;
103
+ }
104
+
105
+ export function fileToRoute(filePath: string): FileRoute | null {
106
+ const normalized = stripRobodevPrefix(filePath);
107
+ if (!normalized.startsWith("api/") || !normalized.endsWith(".ts")) return null;
108
+ if (normalized.includes("..")) return null;
109
+ if (isBackendTestPath(normalized)) return null;
110
+ const without = normalized.slice("api/".length, -".ts".length);
111
+ if (!without) return { path: "/", paramNames: [] };
112
+
113
+ const mapped = mapFileSegments(without);
114
+ if (!mapped) return null;
115
+ const path = `/${mapped.out.join("/")}`;
116
+ if (isReservedFileRoute(path)) return null;
117
+ if (!path.startsWith("/api/") && path !== "/api" && isReservedFileRoute(`/api${path}`)) {
118
+ return null;
119
+ }
120
+ return { path, paramNames: mapped.paramNames };
121
+ }
122
+
123
+ function mapFileSegments(without: string): { out: string[]; paramNames: string[] } | null {
124
+ const paramNames: string[] = [];
125
+ const out: string[] = [];
126
+ for (const segment of without.split("/")) {
127
+ if (!segment) return null;
128
+ if (segment.includes("[") || segment.includes("]")) {
129
+ const match = PARAM_SEGMENT.exec(segment);
130
+ if (!match?.[1]) return null;
131
+ if (paramNames.includes(match[1])) return null;
132
+ paramNames.push(match[1]);
133
+ out.push(`:${match[1]}`);
134
+ continue;
135
+ }
136
+ out.push(segment);
137
+ }
138
+ return { out, paramNames };
139
+ }
140
+
141
+ export function fileToSocketRoute(filePath: string): FileRoute | null {
142
+ const normalized = stripRobodevPrefix(filePath);
143
+ if (!normalized.startsWith("sockets/") || !normalized.endsWith(".ts")) return null;
144
+ if (normalized.includes("..")) return null;
145
+ if (isBackendTestPath(normalized)) return null;
146
+ const without = normalized.slice("sockets/".length, -".ts".length);
147
+ if (!without) return null;
148
+
149
+ const mapped = mapFileSegments(without);
150
+ if (!mapped) return null;
151
+ const path = `/sockets/${mapped.out.join("/")}`;
152
+ if (isReservedFileRoute(path) || isReservedFileRoute(`/${mapped.out.join("/")}`)) return null;
153
+ return { path, paramNames: mapped.paramNames };
154
+ }
155
+
156
+ export function isAuthHookFile(filePath: string): boolean {
157
+ return stripRobodevPrefix(filePath) === "hooks/auth.ts";
158
+ }
159
+
160
+ export function jobNameFromFile(filePath: string): string | null {
161
+ const normalized = stripRobodevPrefix(filePath);
162
+ if (!normalized.startsWith("jobs/") || !normalized.endsWith(".ts")) return null;
163
+ if (normalized.includes("..")) return null;
164
+ if (isBackendTestPath(normalized)) return null;
165
+ const name = normalized.slice("jobs/".length, -".ts".length);
166
+ if (!name || name.split("/").some((segment) => !segment)) return null;
167
+ return name;
168
+ }
169
+
170
+ export function socketNameFromFile(filePath: string): string | null {
171
+ const normalized = stripRobodevPrefix(filePath);
172
+ if (!normalized.startsWith("sockets/") || !normalized.endsWith(".ts")) return null;
173
+ if (normalized.includes("..")) return null;
174
+ if (isBackendTestPath(normalized)) return null;
175
+ const name = normalized.slice("sockets/".length, -".ts".length);
176
+ if (!name || name.split("/").some((segment) => !segment)) return null;
177
+ return name;
178
+ }
179
+
180
+ export function matchSocketRoute<T extends { path: string }>(
181
+ pathname: string,
182
+ sockets: T[],
183
+ ): { socket: T; params: Record<string, string> } | null {
184
+ const path = pathname.replace(/\/$/, "") || "/";
185
+ const exact = sockets.find((socket) => socket.path === path);
186
+ if (exact) return { socket: exact, params: {} };
187
+
188
+ const parameterized = sockets
189
+ .filter((socket) => socket.path.includes(":"))
190
+ .map((socket) => {
191
+ const params = matchPathParams(socket.path, path);
192
+ return params ? { socket, params, ...staticScore(socket.path) } : null;
193
+ })
194
+ .filter((entry): entry is NonNullable<typeof entry> => entry !== null)
195
+ .sort((a, b) => b.staticCount - a.staticCount || b.staticPrefix - a.staticPrefix);
196
+
197
+ const best = parameterized[0];
198
+ return best ? { socket: best.socket, params: best.params } : null;
199
+ }
200
+
201
+ export function openApiPath(routePath: string): string {
202
+ return routePath.replaceAll(/:([A-Za-z_][A-Za-z0-9_]*)/g, "{$1}");
203
+ }
204
+
205
+ export function openApiDocumentedPath(routePath: string): string {
206
+ const path = routePath.replace(/\/$/, "") || "/";
207
+ if (path === "/api" || path.startsWith("/api/")) return openApiPath(path);
208
+ return openApiPath(path === "/" ? "/api" : `/api${path}`);
209
+ }
210
+
211
+ export function pathParamNames(routePath: string): string[] {
212
+ return [...routePath.matchAll(/:([A-Za-z_][A-Za-z0-9_]*)/g)].map((match) => match[1] ?? "");
213
+ }
@@ -0,0 +1,57 @@
1
+ import { createHash } from "node:crypto";
2
+ import { jwtVerify, SignJWT } from "jose";
3
+ import type { AuthUser } from "@robodev-ai/sdk";
4
+
5
+ export const PROJECT_ACCESS_TYP = "project_access";
6
+ export const PROJECT_ACCESS_TTL = "15m";
7
+
8
+ /** Access tokens are scoped per project, so the signing key is derived per project. */
9
+ export function projectSecret(baseSecret: string, projectId: string): Uint8Array {
10
+ return createHash("sha256").update(`${baseSecret}:${projectId}`).digest();
11
+ }
12
+
13
+ export async function signProjectUserToken(
14
+ projectId: string,
15
+ user: Pick<AuthUser, "id" | "email" | "name">,
16
+ baseSecret: string,
17
+ ): Promise<string> {
18
+ return new SignJWT({
19
+ email: user.email,
20
+ name: user.name ?? null,
21
+ project_id: projectId,
22
+ typ: PROJECT_ACCESS_TYP,
23
+ })
24
+ .setProtectedHeader({ alg: "HS256" })
25
+ .setSubject(user.id)
26
+ .setIssuedAt()
27
+ .setExpirationTime(PROJECT_ACCESS_TTL)
28
+ .sign(projectSecret(baseSecret, projectId));
29
+ }
30
+
31
+ export async function verifyProjectUserToken(
32
+ token: string,
33
+ projectId: string,
34
+ baseSecret: string,
35
+ ): Promise<AuthUser> {
36
+ const { payload } = await jwtVerify(token, projectSecret(baseSecret, projectId));
37
+ if (
38
+ !payload.sub ||
39
+ typeof payload.email !== "string" ||
40
+ payload.project_id !== projectId ||
41
+ payload.typ !== PROJECT_ACCESS_TYP
42
+ ) {
43
+ throw new Error("invalid project token");
44
+ }
45
+ return {
46
+ id: payload.sub,
47
+ email: payload.email,
48
+ name: typeof payload.name === "string" ? payload.name : null,
49
+ };
50
+ }
51
+
52
+ export function bearerToken(header?: string): string | null {
53
+ if (!header) return null;
54
+ const [scheme, token] = header.split(" ");
55
+ if (scheme?.toLowerCase() !== "bearer" || !token) return null;
56
+ return token;
57
+ }
@@ -0,0 +1,25 @@
1
+ /** Paths the runtime owns. File routes may never claim them, hosted or local. */
2
+
3
+ export function isReservedAuthPath(pathname: string): boolean {
4
+ return pathname === "/api/user" || pathname.startsWith("/api/user/");
5
+ }
6
+
7
+ export function isReservedStoragePath(pathname: string): boolean {
8
+ return pathname.startsWith("/storage/objects/") && pathname.length > "/storage/objects/".length;
9
+ }
10
+
11
+ export function isReservedRobodevPath(pathname: string): boolean {
12
+ return (
13
+ pathname === "/_robodev" || pathname.startsWith("/_robodev/") || pathname === "/openapi.json"
14
+ );
15
+ }
16
+
17
+ export function isReservedProjectPath(pathname: string): boolean {
18
+ return (
19
+ isReservedAuthPath(pathname) ||
20
+ pathname === "/user" ||
21
+ pathname.startsWith("/user/") ||
22
+ isReservedStoragePath(pathname) ||
23
+ isReservedRobodevPath(pathname)
24
+ );
25
+ }
@@ -0,0 +1,83 @@
1
+ import { pushSchema } from "drizzle-kit/api";
2
+ import pg from "pg";
3
+ import { createDb, type DatabaseDef } from "@robodev-ai/sdk";
4
+
5
+ export type SchemaOp = {
6
+ type: string;
7
+ table: string;
8
+ column?: string;
9
+ sql: string;
10
+ destructive: boolean;
11
+ };
12
+
13
+ export type SchemaPlan = {
14
+ destructive: boolean;
15
+ operations: SchemaOp[];
16
+ };
17
+
18
+ function classifySql(sql: string): SchemaOp {
19
+ const normalized = sql.trim();
20
+ const destructive =
21
+ /\bdrop\s+(table|column|constraint|index)\b/i.test(normalized) ||
22
+ /\balter\s+column\b[\s\S]*\btype\b/i.test(normalized) ||
23
+ /\bset\s+not\s+null\b/i.test(normalized);
24
+
25
+ let type = "statement";
26
+ if (/^create\s+table\b/i.test(normalized)) type = "create_table";
27
+ else if (/\badd\s+column\b/i.test(normalized)) type = "add_column";
28
+ else if (/\bdrop\s+table\b/i.test(normalized)) type = "drop_table";
29
+ else if (/\bdrop\s+column\b/i.test(normalized)) type = "drop_column";
30
+ else if (/\balter\s+column\b/i.test(normalized) && /\btype\b/i.test(normalized))
31
+ type = "alter_type";
32
+ else if (/\b(set|drop)\s+not\s+null\b/i.test(normalized)) type = "alter_null";
33
+ else if (/\b(set|drop)\s+default\b/i.test(normalized)) type = "alter_default";
34
+ else if (/\badd\s+constraint\b|\bforeign\s+key\b/i.test(normalized)) type = "add_constraint";
35
+ else if (/\bdrop\s+constraint\b/i.test(normalized)) type = "drop_constraint";
36
+ else if (/\bcreate\s+index\b/i.test(normalized)) type = "create_index";
37
+ else if (/\bdrop\s+index\b/i.test(normalized)) type = "drop_index";
38
+
39
+ const tableMatch = normalized.match(/(?:table|from|on)\s+"?([A-Za-z_][A-Za-z0-9_]*)"?/i);
40
+ const columnMatch = normalized.match(/column\s+"?([A-Za-z_][A-Za-z0-9_]*)"?/i);
41
+
42
+ return {
43
+ type,
44
+ table: tableMatch?.[1] ?? "",
45
+ column: columnMatch?.[1],
46
+ sql: normalized,
47
+ destructive,
48
+ };
49
+ }
50
+
51
+ export async function buildPlan(
52
+ connectionString: string,
53
+ desired: DatabaseDef,
54
+ ): Promise<SchemaPlan> {
55
+ const { db, pool } = createDb(connectionString, desired.tables);
56
+ try {
57
+ const { hasDataLoss, statementsToExecute } = await pushSchema(desired.tables, db as never);
58
+ return {
59
+ destructive: hasDataLoss,
60
+ operations: statementsToExecute.map(classifySql),
61
+ };
62
+ } finally {
63
+ await pool.end();
64
+ }
65
+ }
66
+
67
+ export async function applyPlan(connectionString: string, plan: SchemaPlan): Promise<void> {
68
+ if (plan.operations.length === 0) return;
69
+ const client = new pg.Client({ connectionString });
70
+ await client.connect();
71
+ try {
72
+ await client.query("BEGIN");
73
+ for (const op of plan.operations) {
74
+ await client.query(op.sql);
75
+ }
76
+ await client.query("COMMIT");
77
+ } catch (error) {
78
+ await client.query("ROLLBACK").catch(() => undefined);
79
+ throw error;
80
+ } finally {
81
+ await client.end();
82
+ }
83
+ }