@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.
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@robodev-ai/runtime",
3
+ "version": "0.1.0",
4
+ "description": "Robodev project runtime — deploy-file classification, esbuild compile, module loading, schema push, route matching, OpenAPI, handler invocation, and Robodev Auth. Shared by hosted Starbase and `robodev dev`.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/robodev-com/robodev-starbase.git",
10
+ "directory": "robodev-runtime"
11
+ },
12
+ "exports": {
13
+ ".": {
14
+ "types": "./src/index.ts",
15
+ "import": "./src/index.ts",
16
+ "default": "./src/index.ts"
17
+ }
18
+ },
19
+ "files": [
20
+ "src"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "dependencies": {
26
+ "busboy": "^1.6.0",
27
+ "drizzle-kit": "^0.31.10",
28
+ "drizzle-orm": "^0.45.2",
29
+ "esbuild": "^0.25.9",
30
+ "jose": "^6.0.13",
31
+ "nanoid": "^5.1.5",
32
+ "pg": "^8.16.3",
33
+ "zod": "^3.25.76",
34
+ "zod-to-json-schema": "^3.24.6",
35
+ "@robodev-ai/sdk": "0.10.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/busboy": "^1.5.4",
39
+ "@types/node": "^24.3.0",
40
+ "@types/pg": "^8.15.5",
41
+ "tsx": "^4.20.5",
42
+ "typescript": "^5.9.2"
43
+ },
44
+ "scripts": {
45
+ "test": "tsx --test \"src/**/*.test.ts\""
46
+ }
47
+ }
@@ -0,0 +1,269 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import type { AuthUser } from "@robodev-ai/sdk";
3
+ import { id } from "./ids.js";
4
+ import { hashPassword, verifyPassword } from "./password.js";
5
+ import { signProjectUserToken } from "./project-jwt.js";
6
+
7
+ /** Anything with a `pg`-shaped `query`. Keeps this module off a Pool type. */
8
+ export type AuthQueryable = {
9
+ query: <R extends Record<string, unknown> = Record<string, unknown>>(
10
+ sql: string,
11
+ values?: unknown[],
12
+ ) => Promise<{ rows: R[] }>;
13
+ };
14
+
15
+ export type AuthRow = {
16
+ id: string;
17
+ email: string;
18
+ password_hash: string | null;
19
+ name: string | null;
20
+ google_subject?: string | null;
21
+ };
22
+
23
+ export type NonceType = "magic" | "forgot-password";
24
+
25
+ export const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000;
26
+ export const NONCE_TTL_MS = 15 * 60 * 1000;
27
+
28
+ export function hashToken(token: string): string {
29
+ return createHash("sha256").update(token).digest("hex");
30
+ }
31
+
32
+ export function randomSecret(): string {
33
+ return randomBytes(32).toString("base64url");
34
+ }
35
+
36
+ export function toAuthUser(row: AuthRow): AuthUser {
37
+ return { id: row.id, email: row.email, name: row.name };
38
+ }
39
+
40
+ /** Creates the `robodev_auth` schema. Idempotent, run on every runtime activation. */
41
+ export async function ensureAuthSchema(db: AuthQueryable): Promise<void> {
42
+ await db.query(`CREATE SCHEMA IF NOT EXISTS robodev_auth`);
43
+ await db.query(`
44
+ CREATE TABLE IF NOT EXISTS robodev_auth.users (
45
+ id TEXT PRIMARY KEY,
46
+ email TEXT UNIQUE NOT NULL,
47
+ password_hash TEXT,
48
+ name TEXT,
49
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
50
+ )
51
+ `);
52
+ await db.query(`ALTER TABLE robodev_auth.users ADD COLUMN IF NOT EXISTS google_subject TEXT`);
53
+ await db.query(`
54
+ CREATE TABLE IF NOT EXISTS robodev_auth.refresh_tokens (
55
+ id TEXT PRIMARY KEY,
56
+ user_id TEXT NOT NULL REFERENCES robodev_auth.users(id) ON DELETE CASCADE,
57
+ token_hash TEXT UNIQUE NOT NULL,
58
+ expires_at TIMESTAMPTZ NOT NULL,
59
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
60
+ )
61
+ `);
62
+ await db.query(`
63
+ CREATE TABLE IF NOT EXISTS robodev_auth.authn_nonces (
64
+ id TEXT PRIMARY KEY,
65
+ user_id TEXT NOT NULL REFERENCES robodev_auth.users(id) ON DELETE CASCADE,
66
+ code TEXT UNIQUE NOT NULL,
67
+ type TEXT NOT NULL,
68
+ expires_at TIMESTAMPTZ NOT NULL,
69
+ used_at TIMESTAMPTZ,
70
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
71
+ )
72
+ `);
73
+ }
74
+
75
+ const USER_COLUMNS = `id, email, password_hash, name, google_subject`;
76
+
77
+ export async function loadUserByEmail(
78
+ db: AuthQueryable,
79
+ email: string,
80
+ ): Promise<AuthRow | undefined> {
81
+ const found = await db.query<AuthRow>(
82
+ `SELECT ${USER_COLUMNS} FROM robodev_auth.users WHERE email = $1`,
83
+ [email],
84
+ );
85
+ return found.rows[0];
86
+ }
87
+
88
+ export async function loadUserById(
89
+ db: AuthQueryable,
90
+ userId: string,
91
+ ): Promise<AuthRow | undefined> {
92
+ const found = await db.query<AuthRow>(
93
+ `SELECT ${USER_COLUMNS} FROM robodev_auth.users WHERE id = $1`,
94
+ [userId],
95
+ );
96
+ return found.rows[0];
97
+ }
98
+
99
+ export async function insertPasswordUser(
100
+ db: AuthQueryable,
101
+ input: { email: string; password: string; name?: string },
102
+ ): Promise<AuthRow> {
103
+ const row: AuthRow = {
104
+ id: id("usr"),
105
+ email: input.email,
106
+ password_hash: await hashPassword(input.password),
107
+ name: input.name ? input.name : null,
108
+ };
109
+ await db.query(
110
+ `INSERT INTO robodev_auth.users (id, email, password_hash, name) VALUES ($1, $2, $3, $4)`,
111
+ [row.id, row.email, row.password_hash, row.name],
112
+ );
113
+ return row;
114
+ }
115
+
116
+ export async function passwordMatches(row: AuthRow, password: string): Promise<boolean> {
117
+ if (!row.password_hash) return false;
118
+ return verifyPassword(password, row.password_hash);
119
+ }
120
+
121
+ export async function updateUserPassword(
122
+ db: AuthQueryable,
123
+ userId: string,
124
+ password: string,
125
+ ): Promise<void> {
126
+ await db.query(`UPDATE robodev_auth.users SET password_hash = $1 WHERE id = $2`, [
127
+ await hashPassword(password),
128
+ userId,
129
+ ]);
130
+ }
131
+
132
+ export async function updateUserProfile(
133
+ db: AuthQueryable,
134
+ userId: string,
135
+ input: { name: string | null; email: string },
136
+ ): Promise<void> {
137
+ await db.query(`UPDATE robodev_auth.users SET name = $1, email = $2 WHERE id = $3`, [
138
+ input.name,
139
+ input.email,
140
+ userId,
141
+ ]);
142
+ }
143
+
144
+ export async function issueProjectTokenPair(
145
+ db: AuthQueryable,
146
+ projectId: string,
147
+ user: Pick<AuthUser, "id" | "email" | "name">,
148
+ baseSecret: string,
149
+ ): Promise<{ accessToken: string; refreshToken: string }> {
150
+ const refreshToken = randomSecret();
151
+ const expires = new Date(Date.now() + REFRESH_TTL_MS);
152
+ await db.query(
153
+ `INSERT INTO robodev_auth.refresh_tokens (id, user_id, token_hash, expires_at)
154
+ VALUES ($1, $2, $3, $4)`,
155
+ [id("tok"), user.id, hashToken(refreshToken), expires.toISOString()],
156
+ );
157
+ return {
158
+ accessToken: await signProjectUserToken(projectId, user, baseSecret),
159
+ refreshToken,
160
+ };
161
+ }
162
+
163
+ export async function createNonce(
164
+ db: AuthQueryable,
165
+ userId: string,
166
+ type: NonceType,
167
+ ): Promise<string> {
168
+ const code = randomSecret();
169
+ await db.query(
170
+ `INSERT INTO robodev_auth.authn_nonces (id, user_id, code, type, expires_at)
171
+ VALUES ($1, $2, $3, $4, $5)`,
172
+ [id("nnc"), userId, code, type, new Date(Date.now() + NONCE_TTL_MS).toISOString()],
173
+ );
174
+ return code;
175
+ }
176
+
177
+ export async function consumeNonce(
178
+ db: AuthQueryable,
179
+ code: string,
180
+ type: NonceType,
181
+ ): Promise<AuthRow | null> {
182
+ const found = await db.query<{
183
+ id: string;
184
+ user_id: string;
185
+ type: string;
186
+ expires_at: Date | string;
187
+ used_at: Date | string | null;
188
+ }>(
189
+ `SELECT id, user_id, type, expires_at, used_at FROM robodev_auth.authn_nonces WHERE code = $1`,
190
+ [code],
191
+ );
192
+ const row = found.rows[0];
193
+ if (!row || row.type !== type || row.used_at || new Date(row.expires_at) < new Date()) {
194
+ return null;
195
+ }
196
+ await db.query(`UPDATE robodev_auth.authn_nonces SET used_at = now() WHERE id = $1`, [row.id]);
197
+ return (await loadUserById(db, row.user_id)) ?? null;
198
+ }
199
+
200
+ export async function rotateRefresh(
201
+ db: AuthQueryable,
202
+ projectId: string,
203
+ refreshToken: string,
204
+ baseSecret: string,
205
+ ): Promise<{ accessToken: string; refreshToken: string } | "reuse"> {
206
+ const found = await db.query<{
207
+ id: string;
208
+ user_id: string;
209
+ expires_at: Date | string;
210
+ email: string;
211
+ name: string | null;
212
+ }>(
213
+ `SELECT r.id, r.user_id, r.expires_at, u.email, u.name
214
+ FROM robodev_auth.refresh_tokens r
215
+ JOIN robodev_auth.users u ON u.id = r.user_id
216
+ WHERE r.token_hash = $1`,
217
+ [hashToken(refreshToken)],
218
+ );
219
+ const row = found.rows[0];
220
+ if (!row || new Date(row.expires_at) < new Date()) {
221
+ return "reuse";
222
+ }
223
+ await db.query(`DELETE FROM robodev_auth.refresh_tokens WHERE id = $1`, [row.id]);
224
+ return issueProjectTokenPair(
225
+ db,
226
+ projectId,
227
+ { id: row.user_id, email: row.email, name: row.name },
228
+ baseSecret,
229
+ );
230
+ }
231
+
232
+ export async function loadRefreshSession(
233
+ db: AuthQueryable,
234
+ refreshToken: string,
235
+ ): Promise<{ user: AuthRow; expired: boolean } | undefined> {
236
+ const found = await db.query<{
237
+ id: string;
238
+ user_id: string;
239
+ expires_at: Date | string;
240
+ email: string;
241
+ name: string | null;
242
+ password_hash: string | null;
243
+ google_subject: string | null;
244
+ }>(
245
+ `SELECT r.id, r.user_id, r.expires_at, u.email, u.name, u.password_hash, u.google_subject
246
+ FROM robodev_auth.refresh_tokens r
247
+ JOIN robodev_auth.users u ON u.id = r.user_id
248
+ WHERE r.token_hash = $1`,
249
+ [hashToken(refreshToken)],
250
+ );
251
+ const row = found.rows[0];
252
+ if (!row) return undefined;
253
+ return {
254
+ expired: new Date(row.expires_at) < new Date(),
255
+ user: {
256
+ id: row.user_id,
257
+ email: row.email,
258
+ password_hash: row.password_hash,
259
+ name: row.name,
260
+ google_subject: row.google_subject,
261
+ },
262
+ };
263
+ }
264
+
265
+ export async function revokeRefresh(db: AuthQueryable, refreshToken: string): Promise<void> {
266
+ await db.query(`DELETE FROM robodev_auth.refresh_tokens WHERE token_hash = $1`, [
267
+ hashToken(refreshToken),
268
+ ]);
269
+ }
@@ -0,0 +1,58 @@
1
+ import { z } from "zod";
2
+
3
+ /** Wire contract for the reserved `/api/user/**` routes. Shared by hosted and local Auth. */
4
+
5
+ export const emailField = z
6
+ .string()
7
+ .email()
8
+ .transform((value) => value.toLowerCase().trim());
9
+
10
+ export const loginBody = z.object({
11
+ email: emailField,
12
+ password: z.string().min(1).max(200),
13
+ });
14
+
15
+ export const registerBody = z.object({
16
+ email: emailField,
17
+ password: z.string().min(12).max(200),
18
+ name: z.string().trim().max(80).optional(),
19
+ });
20
+
21
+ export const refreshBody = z.object({
22
+ refreshToken: z.string().min(1),
23
+ revoke: z.boolean().optional(),
24
+ });
25
+
26
+ export const forgotRequestBody = z.object({
27
+ email: emailField,
28
+ });
29
+
30
+ export const forgotCallbackBody = z.object({
31
+ code: z.string().min(1),
32
+ password: z.string().min(12).max(200),
33
+ });
34
+
35
+ export const meUpdateBody = z.object({
36
+ name: z.string().trim().max(80).optional(),
37
+ email: emailField.optional(),
38
+ });
39
+
40
+ export const magicGenerateQuery = z.object({
41
+ email: emailField,
42
+ redirect_uri: z.string().optional(),
43
+ });
44
+
45
+ export const magicConsumeQuery = z.object({
46
+ code: z.string().min(1),
47
+ });
48
+
49
+ export const googleStartQuery = z.object({
50
+ redirect_uri: z.string().min(1),
51
+ });
52
+
53
+ export const GENERIC_STATUS_MESSAGE =
54
+ "If you entered a valid email, you'll receive an email shortly";
55
+
56
+ export function statusOk(message: string) {
57
+ return { status: "ok", message, code: "ok" };
58
+ }
@@ -0,0 +1,228 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import type { RobodevDb } from "@robodev-ai/sdk";
7
+ import { apiBundleExternals, compileApiTree, writeDeploySources } from "./compile.js";
8
+ import { loadModules } from "./load-modules.js";
9
+ import { buildOpenApi } from "./openapi.js";
10
+ import { runRoute, type RouteClients } from "./invoke.js";
11
+
12
+ /**
13
+ * Exercises the whole shared path — esbuild compile, module load, OpenAPI, and handler
14
+ * invocation — on a starter-shaped fixture. No Postgres: `ctx.db` is never touched.
15
+ */
16
+
17
+ const DATABASE_TS = `import { defineDatabase, pgTable, text } from "@robodev-ai/sdk";
18
+
19
+ export const planets = pgTable("planets", {
20
+ id: text("id").primaryKey(),
21
+ name: text("name").notNull(),
22
+ });
23
+
24
+ export default defineDatabase({ name: "space", tables: { planets } });
25
+ `;
26
+
27
+ const PLANETS_TS = `import { defineApi, z } from "@robodev-ai/sdk";
28
+ import { greeting } from "./_lib.js";
29
+
30
+ export const get = defineApi({
31
+ query: z.object({ q: z.string().optional() }),
32
+ handler: ({ query }) => ({ planets: [greeting(query.q ?? "all")] }),
33
+ });
34
+
35
+ export const post = defineApi({
36
+ body: z.object({ name: z.string().min(2) }),
37
+ handler: ({ body }) => ({ status: 201, body: { created: body.name } }),
38
+ });
39
+ `;
40
+
41
+ const LIB_TS = `export function greeting(name: string): string {
42
+ return \`hello \${name}\`;
43
+ }
44
+ `;
45
+
46
+ const BY_ID_TS = `import { defineApi } from "@robodev-ai/sdk";
47
+
48
+ export const get = defineApi({
49
+ handler: ({ params }) => ({ id: params.id }),
50
+ });
51
+ `;
52
+
53
+ const PRIVATE_TS = `import { defineApi } from "@robodev-ai/sdk";
54
+
55
+ export const get = defineApi({
56
+ auth: "required",
57
+ handler: ({ user }) => ({ email: user?.email ?? null }),
58
+ });
59
+ `;
60
+
61
+ const FIXTURE = [
62
+ { path: "database.ts", content: DATABASE_TS },
63
+ { path: "api/planets.ts", content: PLANETS_TS },
64
+ { path: "api/_lib.ts", content: LIB_TS },
65
+ { path: "api/planets/[id].ts", content: BY_ID_TS },
66
+ { path: "api/private.ts", content: PRIVATE_TS },
67
+ ];
68
+
69
+ function stubClients(): RouteClients {
70
+ const missing = () => {
71
+ throw new Error("not used in this test");
72
+ };
73
+ return {
74
+ email: { send: missing },
75
+ llm: { complete: missing, stream: missing },
76
+ agent: {
77
+ createSession: missing,
78
+ start: missing,
79
+ events: missing,
80
+ subscribe: missing,
81
+ destroy: missing,
82
+ },
83
+ storage: { upload: missing, get: missing, getUrl: missing, delete: missing, list: missing },
84
+ push: { send: missing },
85
+ jobs: { enqueue: missing },
86
+ sockets: { send: missing, broadcast: missing },
87
+ env: { GREETING: "hi" },
88
+ } as unknown as RouteClients;
89
+ }
90
+
91
+ async function compileFixture() {
92
+ const dir = await mkdtemp(join(tmpdir(), "robodev-runtime-"));
93
+ await writeDeploySources(join(dir, "src"), FIXTURE);
94
+ const distDir = await compileApiTree({
95
+ dir,
96
+ files: FIXTURE,
97
+ external: apiBundleExternals(),
98
+ nodePaths: [join(import.meta.dirname, "..", "node_modules")],
99
+ });
100
+ const loaded = await loadModules(distDir, FIXTURE);
101
+ return { dir, loaded };
102
+ }
103
+
104
+ const invoke = (
105
+ loaded: Awaited<ReturnType<typeof compileFixture>>["loaded"],
106
+ input: Partial<Parameters<typeof runRoute>[0]> & { method: string; pathname: string },
107
+ ) =>
108
+ runRoute({
109
+ db: {} as RobodevDb,
110
+ routes: loaded.routes,
111
+ query: {},
112
+ body: undefined,
113
+ headers: {},
114
+ resolveUser: async () => null,
115
+ clients: stubClients,
116
+ ...input,
117
+ });
118
+
119
+ test("a starter-shaped fixture compiles, loads, and serves its file routes", async () => {
120
+ const { dir, loaded } = await compileFixture();
121
+ try {
122
+ assert.equal(loaded.database.name, "space");
123
+ assert.deepEqual(loaded.routes.map((route) => `${route.method} ${route.path}`).sort(), [
124
+ "get /planets",
125
+ "get /planets/:id",
126
+ "get /private",
127
+ "post /planets",
128
+ ]);
129
+ // `_lib.ts` is bundled into its importers, never exposed as a route.
130
+ assert.ok(!loaded.routes.some((route) => route.path.includes("_lib")));
131
+
132
+ const openapi = buildOpenApi({
133
+ title: "space APIs",
134
+ projectId: "local",
135
+ routes: loaded.routes,
136
+ });
137
+ const paths = Object.keys(openapi.paths as Record<string, unknown>);
138
+ assert.ok(paths.includes("/api/planets"));
139
+ assert.ok(paths.includes("/api/planets/{id}"));
140
+ assert.ok(paths.includes("/api/user/me"));
141
+ } finally {
142
+ await rm(dir, { recursive: true, force: true });
143
+ }
144
+ });
145
+
146
+ test("runRoute validates, invokes, and encodes a bundled handler", async () => {
147
+ const { dir, loaded } = await compileFixture();
148
+ try {
149
+ const ok = await invoke(loaded, {
150
+ method: "get",
151
+ pathname: "/api/planets",
152
+ query: { q: "mars" },
153
+ });
154
+ assert.equal(ok.kind, "ok");
155
+ assert.deepEqual(ok.kind === "ok" ? ok.encoded.payload : null, { planets: ["hello mars"] });
156
+
157
+ const param = await invoke(loaded, { method: "get", pathname: "/api/planets/abc" });
158
+ assert.deepEqual(param.kind === "ok" ? param.encoded.payload : null, { id: "abc" });
159
+
160
+ const envelope = await invoke(loaded, {
161
+ method: "post",
162
+ pathname: "/api/planets",
163
+ body: { name: "Vega" },
164
+ });
165
+ assert.equal(envelope.kind === "ok" ? envelope.encoded.status : 0, 201);
166
+ assert.deepEqual(envelope.kind === "ok" ? envelope.encoded.payload : null, { created: "Vega" });
167
+ } finally {
168
+ await rm(dir, { recursive: true, force: true });
169
+ }
170
+ });
171
+
172
+ test("runRoute rejects invalid bodies and unknown routes", async () => {
173
+ const { dir, loaded } = await compileFixture();
174
+ try {
175
+ const invalid = await invoke(loaded, {
176
+ method: "post",
177
+ pathname: "/api/planets",
178
+ body: { name: "x" },
179
+ });
180
+ assert.equal(invalid.kind, "error");
181
+ assert.equal(invalid.kind === "error" ? invalid.status : 0, 400);
182
+ assert.equal(invalid.kind === "error" ? invalid.body.error : "", "invalid_body");
183
+
184
+ const missing = await invoke(loaded, { method: "get", pathname: "/api/nope" });
185
+ assert.equal(missing.kind === "error" ? missing.status : 0, 404);
186
+ } finally {
187
+ await rm(dir, { recursive: true, force: true });
188
+ }
189
+ });
190
+
191
+ test("runRoute enforces auth: required through the injected resolver", async () => {
192
+ const { dir, loaded } = await compileFixture();
193
+ try {
194
+ const anonymous = await invoke(loaded, { method: "get", pathname: "/api/private" });
195
+ assert.equal(anonymous.kind === "error" ? anonymous.status : 0, 401);
196
+
197
+ const signedIn = await invoke(loaded, {
198
+ method: "get",
199
+ pathname: "/api/private",
200
+ authorization: "Bearer token",
201
+ resolveUser: async () => ({ id: "usr_1", email: "a@b.com", name: null }),
202
+ });
203
+ assert.deepEqual(signedIn.kind === "ok" ? signedIn.encoded.payload : null, {
204
+ email: "a@b.com",
205
+ });
206
+ } finally {
207
+ await rm(dir, { recursive: true, force: true });
208
+ }
209
+ });
210
+
211
+ test("a broken source fails the compile instead of activating", async () => {
212
+ const dir = await mkdtemp(join(tmpdir(), "robodev-runtime-bad-"));
213
+ const broken = [
214
+ { path: "database.ts", content: DATABASE_TS },
215
+ {
216
+ path: "api/broken.ts",
217
+ content: `import { nope } from "./does-not-exist.js";\nexport const x = nope;\n`,
218
+ },
219
+ ];
220
+ try {
221
+ await writeDeploySources(join(dir, "src"), broken);
222
+ await assert.rejects(() =>
223
+ compileApiTree({ dir, files: broken, external: apiBundleExternals() }),
224
+ );
225
+ } finally {
226
+ await rm(dir, { recursive: true, force: true });
227
+ }
228
+ });