@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/src/openapi.ts ADDED
@@ -0,0 +1,396 @@
1
+ import { zodToJsonSchema } from "zod-to-json-schema";
2
+ import type { ApiDefinition, HttpMethod } from "@robodev-ai/sdk";
3
+ import { openApiDocumentedPath, pathParamNames } from "./path-match.js";
4
+
5
+ export type RuntimeRoute = {
6
+ method: HttpMethod;
7
+ path: string;
8
+ file: string;
9
+ def: ApiDefinition;
10
+ paramNames?: string[];
11
+ };
12
+
13
+ function ref(name: string) {
14
+ return { $ref: `#/components/schemas/${name}` };
15
+ }
16
+
17
+ function authComponents() {
18
+ return {
19
+ AuthnTokenResponse: {
20
+ type: "object",
21
+ properties: {
22
+ accessToken: { type: "string" },
23
+ refreshToken: { type: "string" },
24
+ },
25
+ required: ["accessToken", "refreshToken"],
26
+ },
27
+ AuthnTokenRequest: {
28
+ type: "object",
29
+ properties: { refreshToken: { type: "string" } },
30
+ required: ["refreshToken"],
31
+ },
32
+ StatusResponse: {
33
+ type: "object",
34
+ properties: {
35
+ status: { type: "string" },
36
+ message: { type: "string" },
37
+ code: { type: "string" },
38
+ },
39
+ required: ["status", "message", "code"],
40
+ },
41
+ UserMeResponse: {
42
+ type: "object",
43
+ properties: {
44
+ id: { type: "string" },
45
+ name: { type: "string", nullable: true },
46
+ email: { type: "string" },
47
+ },
48
+ required: ["id", "email"],
49
+ },
50
+ UserMeUpdateRequest: {
51
+ type: "object",
52
+ properties: {
53
+ name: { type: "string" },
54
+ email: { type: "string" },
55
+ },
56
+ },
57
+ UserAuthPasswordLoginRequest: {
58
+ type: "object",
59
+ properties: {
60
+ email: { type: "string" },
61
+ password: { type: "string" },
62
+ },
63
+ required: ["email", "password"],
64
+ },
65
+ UserAuthPasswordRegisterRequest: {
66
+ type: "object",
67
+ properties: {
68
+ email: { type: "string" },
69
+ password: { type: "string", minLength: 12 },
70
+ name: { type: "string" },
71
+ },
72
+ required: ["email", "password"],
73
+ },
74
+ UserAuthEmailGenerateRequest: {
75
+ type: "object",
76
+ properties: { email: { type: "string" } },
77
+ required: ["email"],
78
+ },
79
+ UserAuthMagicConsumeRequest: {
80
+ type: "object",
81
+ properties: { code: { type: "string" } },
82
+ required: ["code"],
83
+ },
84
+ };
85
+ }
86
+
87
+ function reservedStoragePaths(): Record<string, Record<string, unknown>> {
88
+ return {
89
+ "/storage/objects/{key}": {
90
+ get: {
91
+ operationId: "storage_get_object",
92
+ tags: ["robodev_storage"],
93
+ parameters: [
94
+ { name: "key", in: "path", required: true, schema: { type: "string" } },
95
+ { name: "exp", in: "query", schema: { type: "integer" } },
96
+ { name: "sig", in: "query", schema: { type: "string" } },
97
+ ],
98
+ responses: {
99
+ 302: { description: "Redirect to object bytes" },
100
+ 404: { description: "Not found" },
101
+ },
102
+ },
103
+ },
104
+ };
105
+ }
106
+
107
+ function jsonBody(schemaName: string) {
108
+ return {
109
+ required: true,
110
+ content: { "application/json": { schema: ref(schemaName) } },
111
+ };
112
+ }
113
+
114
+ function jsonOk(schemaName: string, status = "200") {
115
+ return {
116
+ [status]: {
117
+ description: "OK",
118
+ content: { "application/json": { schema: ref(schemaName) } },
119
+ },
120
+ };
121
+ }
122
+
123
+ function reservedAuthPaths(): Record<string, Record<string, unknown>> {
124
+ const userAuth = ["User Auth"];
125
+ const user = ["User"];
126
+ const bearer = { security: [{ bearerAuth: [] }] };
127
+ return {
128
+ "/api/user/auth/login": {
129
+ post: {
130
+ operationId: "UserAuthLocalPasswordController_login",
131
+ tags: userAuth,
132
+ requestBody: jsonBody("UserAuthPasswordLoginRequest"),
133
+ responses: {
134
+ ...jsonOk("AuthnTokenResponse"),
135
+ 401: { description: "invalid-credentials" },
136
+ },
137
+ },
138
+ },
139
+ "/api/user/auth/register": {
140
+ post: {
141
+ operationId: "UserAuthLocalPasswordController_register",
142
+ tags: userAuth,
143
+ requestBody: jsonBody("UserAuthPasswordRegisterRequest"),
144
+ responses: {
145
+ ...jsonOk("AuthnTokenResponse", "201"),
146
+ 409: { description: "identity-already-exists" },
147
+ },
148
+ },
149
+ },
150
+ "/api/user/auth/refresh": {
151
+ post: {
152
+ operationId: "UserAuthJwtController_accessToken",
153
+ tags: userAuth,
154
+ requestBody: jsonBody("AuthnTokenRequest"),
155
+ responses: {
156
+ ...jsonOk("AuthnTokenResponse"),
157
+ 401: { description: "identity-not-found" },
158
+ },
159
+ },
160
+ },
161
+ "/api/user/auth/magic-link": {
162
+ get: {
163
+ operationId: "UserAuthMagicLinkController_generate",
164
+ tags: userAuth,
165
+ parameters: [
166
+ { name: "email", in: "query", required: true, schema: { type: "string" } },
167
+ { name: "redirect_uri", in: "query", schema: { type: "string" } },
168
+ ],
169
+ responses: jsonOk("StatusResponse"),
170
+ },
171
+ },
172
+ "/api/user/auth/magic-link/callback": {
173
+ get: {
174
+ operationId: "UserAuthMagicLinkController_consume",
175
+ tags: userAuth,
176
+ parameters: [{ name: "code", in: "query", required: true, schema: { type: "string" } }],
177
+ responses: {
178
+ ...jsonOk("AuthnTokenResponse"),
179
+ 400: { description: "nonce-invalid" },
180
+ },
181
+ },
182
+ },
183
+ "/api/user/auth/forgot-password": {
184
+ post: {
185
+ operationId: "UserAuthForgotPasswordController_requestReset",
186
+ tags: userAuth,
187
+ requestBody: jsonBody("UserAuthEmailGenerateRequest"),
188
+ responses: jsonOk("StatusResponse"),
189
+ },
190
+ },
191
+ "/api/user/auth/forgot-password/callback": {
192
+ post: {
193
+ operationId: "UserAuthForgotPasswordController_consumeReset",
194
+ tags: userAuth,
195
+ requestBody: {
196
+ required: true,
197
+ content: {
198
+ "application/json": {
199
+ schema: {
200
+ type: "object",
201
+ properties: {
202
+ code: { type: "string" },
203
+ password: { type: "string", minLength: 12 },
204
+ },
205
+ required: ["code", "password"],
206
+ },
207
+ },
208
+ },
209
+ },
210
+ responses: {
211
+ ...jsonOk("StatusResponse"),
212
+ 400: { description: "nonce-invalid" },
213
+ },
214
+ },
215
+ },
216
+ "/api/user/auth/google": {
217
+ get: {
218
+ operationId: "UserAuthGoogleController_login",
219
+ tags: userAuth,
220
+ parameters: [
221
+ { name: "redirect_uri", in: "query", required: true, schema: { type: "string" } },
222
+ ],
223
+ responses: {
224
+ 302: { description: "Redirect to Google" },
225
+ 400: { description: "invalid_redirect_uri" },
226
+ 503: { description: "Google OAuth is not configured" },
227
+ },
228
+ },
229
+ },
230
+ "/api/user/auth/apple/callback": {
231
+ get: {
232
+ operationId: "UserAuthAppleController_login",
233
+ tags: userAuth,
234
+ responses: {
235
+ 503: { description: "Apple sign-in is not available" },
236
+ },
237
+ },
238
+ },
239
+ "/api/user/me": {
240
+ get: {
241
+ operationId: "UserMeController_get",
242
+ tags: user,
243
+ ...bearer,
244
+ responses: {
245
+ ...jsonOk("UserMeResponse"),
246
+ 401: { description: "identity-not-found" },
247
+ },
248
+ },
249
+ put: {
250
+ operationId: "UserMeController_update",
251
+ tags: user,
252
+ ...bearer,
253
+ requestBody: jsonBody("UserMeUpdateRequest"),
254
+ responses: {
255
+ ...jsonOk("UserMeResponse"),
256
+ 401: { description: "identity-not-found" },
257
+ 409: { description: "identity-already-exists" },
258
+ },
259
+ },
260
+ },
261
+ };
262
+ }
263
+
264
+ export function buildOpenApi(input: {
265
+ title: string;
266
+ projectId: string;
267
+ routes: RuntimeRoute[];
268
+ }): Record<string, unknown> {
269
+ const paths: Record<string, Record<string, unknown>> = {
270
+ ...reservedAuthPaths(),
271
+ ...reservedStoragePaths(),
272
+ };
273
+
274
+ for (const route of input.routes) {
275
+ const documentedPath = openApiDocumentedPath(route.path);
276
+ const item = (paths[documentedPath] ??= {});
277
+ const op: Record<string, unknown> = {
278
+ operationId: `${route.method}_${route.path.replaceAll("/", "_") || "root"}`,
279
+ tags: [route.file],
280
+ responses: {
281
+ 200: {
282
+ description: "OK",
283
+ content: route.def.response
284
+ ? {
285
+ "application/json": {
286
+ schema: zodToJsonSchema(route.def.response, { $refStrategy: "none" }),
287
+ },
288
+ }
289
+ : { "application/json": { schema: {} } },
290
+ },
291
+ },
292
+ };
293
+
294
+ const auth = route.def.auth ?? false;
295
+ if (auth === "required" || typeof auth === "function") {
296
+ op.security = [{ bearerAuth: [] }];
297
+ (op.responses as Record<string, unknown>)[401] = { description: "Unauthorized" };
298
+ } else if (auth === "optional") {
299
+ op.security = [{}, { bearerAuth: [] }];
300
+ }
301
+
302
+ const parameters: Record<string, unknown>[] = pathParamNames(route.path).map((name) => ({
303
+ name,
304
+ in: "path",
305
+ required: true,
306
+ schema: { type: "string" },
307
+ }));
308
+ if (route.def.query) {
309
+ const schema = zodToJsonSchema(route.def.query, { $refStrategy: "none" }) as {
310
+ properties?: Record<string, unknown>;
311
+ required?: string[];
312
+ };
313
+ parameters.push(
314
+ ...Object.entries(schema.properties ?? {}).map(([name, prop]) => ({
315
+ name,
316
+ in: "query",
317
+ required: schema.required?.includes(name) ?? false,
318
+ schema: prop,
319
+ })),
320
+ );
321
+ }
322
+ if (parameters.length) op.parameters = parameters;
323
+
324
+ if (route.def.body && route.method !== "get") {
325
+ op.requestBody = {
326
+ required: true,
327
+ content: {
328
+ "application/json": {
329
+ schema: zodToJsonSchema(route.def.body, { $refStrategy: "none" }),
330
+ },
331
+ },
332
+ };
333
+ }
334
+
335
+ item[route.method] = op;
336
+ }
337
+
338
+ return {
339
+ openapi: "3.0.3",
340
+ info: {
341
+ title: input.title,
342
+ version: "1.0.0",
343
+ description: `Hosted APIs for ${input.projectId}`,
344
+ },
345
+ components: {
346
+ securitySchemes: {
347
+ bearerAuth: { type: "http", scheme: "bearer", bearerFormat: "JWT" },
348
+ },
349
+ schemas: authComponents(),
350
+ },
351
+ paths,
352
+ };
353
+ }
354
+
355
+ export function swaggerHtml(openapiUrl: string): string {
356
+ return `<!doctype html>
357
+ <html>
358
+ <head>
359
+ <meta charset="utf-8" />
360
+ <title>Robodev API</title>
361
+ <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
362
+ <style>body { margin: 0; }</style>
363
+ </head>
364
+ <body>
365
+ <div id="swagger-ui"></div>
366
+ <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
367
+ <script>
368
+ window.ui = SwaggerUIBundle({
369
+ url: ${JSON.stringify(openapiUrl)},
370
+ dom_id: '#swagger-ui',
371
+ });
372
+ </script>
373
+ </body>
374
+ </html>`;
375
+ }
376
+
377
+ export function scalarHtml(openapiUrl: string): string {
378
+ return `<!doctype html>
379
+ <html>
380
+ <head>
381
+ <meta charset="utf-8" />
382
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
383
+ <title>Robodev API</title>
384
+ <style>body { margin: 0; }</style>
385
+ </head>
386
+ <body>
387
+ <div id="app"></div>
388
+ <script src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
389
+ <script>
390
+ Scalar.createApiReference("#app", {
391
+ url: ${JSON.stringify(openapiUrl)},
392
+ });
393
+ </script>
394
+ </body>
395
+ </html>`;
396
+ }
@@ -0,0 +1,20 @@
1
+ import { randomBytes, scrypt as scryptCb, timingSafeEqual } from "node:crypto";
2
+ import { promisify } from "node:util";
3
+
4
+ const scrypt = promisify(scryptCb);
5
+
6
+ export async function hashPassword(password: string): Promise<string> {
7
+ const salt = randomBytes(16);
8
+ const key = (await scrypt(password, salt, 64)) as Buffer;
9
+ return `${salt.toString("hex")}:${key.toString("hex")}`;
10
+ }
11
+
12
+ export async function verifyPassword(password: string, stored: string): Promise<boolean> {
13
+ const [saltHex, keyHex] = stored.split(":");
14
+ if (!saltHex || !keyHex) return false;
15
+ const salt = Buffer.from(saltHex, "hex");
16
+ const expected = Buffer.from(keyHex, "hex");
17
+ const actual = (await scrypt(password, salt, 64)) as Buffer;
18
+ if (actual.length !== expected.length) return false;
19
+ return timingSafeEqual(actual, expected);
20
+ }
@@ -0,0 +1,155 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import {
4
+ fileToRoute,
5
+ fileToSocketRoute,
6
+ isAuthHookFile,
7
+ jobNameFromFile,
8
+ matchProjectRoute,
9
+ matchSocketRoute,
10
+ openApiDocumentedPath,
11
+ socketNameFromFile,
12
+ } from "./path-match.js";
13
+
14
+ test("isAuthHookFile is hooks/auth.ts or robodev/hooks/auth.ts", () => {
15
+ assert.equal(isAuthHookFile("hooks/auth.ts"), true);
16
+ assert.equal(isAuthHookFile("robodev/hooks/auth.ts"), true);
17
+ assert.equal(isAuthHookFile("hooks/lib.ts"), false);
18
+ assert.equal(isAuthHookFile("jobs/auth.ts"), false);
19
+ });
20
+
21
+ test("jobNameFromFile strips robodev/", () => {
22
+ assert.equal(jobNameFromFile("jobs/resize.ts"), "resize");
23
+ assert.equal(jobNameFromFile("robodev/jobs/resize.ts"), "resize");
24
+ });
25
+
26
+ test("maps socket files to /sockets/... routes", () => {
27
+ assert.deepEqual(fileToSocketRoute("sockets/chat.ts"), {
28
+ path: "/sockets/chat",
29
+ paramNames: [],
30
+ });
31
+ assert.deepEqual(fileToSocketRoute("robodev/sockets/chat.ts"), {
32
+ path: "/sockets/chat",
33
+ paramNames: [],
34
+ });
35
+ assert.deepEqual(fileToSocketRoute("sockets/rooms/[roomId].ts"), {
36
+ path: "/sockets/rooms/:roomId",
37
+ paramNames: ["roomId"],
38
+ });
39
+ assert.equal(fileToSocketRoute("api/chat.ts"), null);
40
+ assert.equal(fileToSocketRoute("sockets/user.ts"), null);
41
+ assert.equal(fileToSocketRoute("sockets/api/user.ts"), null);
42
+ assert.equal(fileToSocketRoute("sockets/_robodev.ts"), null);
43
+ assert.equal(fileToSocketRoute("sockets/storage/objects.ts"), null);
44
+ assert.equal(socketNameFromFile("sockets/chat.ts"), "chat");
45
+ assert.equal(socketNameFromFile("robodev/sockets/rooms/[roomId].ts"), "rooms/[roomId]");
46
+ });
47
+
48
+ test("matchSocketRoute prefers exact paths over params", () => {
49
+ const sockets = [{ path: "/sockets/rooms/:roomId" }, { path: "/sockets/rooms/lobby" }];
50
+ assert.equal(
51
+ matchSocketRoute("/sockets/rooms/lobby", sockets)?.socket.path,
52
+ "/sockets/rooms/lobby",
53
+ );
54
+ assert.equal(matchSocketRoute("/sockets/rooms/abc", sockets)?.params.roomId, "abc");
55
+ });
56
+
57
+ test("maps robodev/api files the same as api/", () => {
58
+ assert.deepEqual(fileToRoute("robodev/api/planets.ts"), {
59
+ path: "/planets",
60
+ paramNames: [],
61
+ });
62
+ assert.deepEqual(fileToRoute("robodev/api/invoices/[id].ts"), {
63
+ path: "/invoices/:id",
64
+ paramNames: ["id"],
65
+ });
66
+ });
67
+
68
+ test("maps [id] files to parameterized routes", () => {
69
+ assert.deepEqual(fileToRoute("api/invoices/[id].ts"), {
70
+ path: "/invoices/:id",
71
+ paramNames: ["id"],
72
+ });
73
+ assert.deepEqual(fileToRoute("api/invoices/[id]/items.ts"), {
74
+ path: "/invoices/:id/items",
75
+ paramNames: ["id"],
76
+ });
77
+ assert.deepEqual(fileToRoute("api/invoices/[id]/items/[itemId].ts"), {
78
+ path: "/invoices/:id/items/:itemId",
79
+ paramNames: ["id", "itemId"],
80
+ });
81
+ });
82
+
83
+ test("maps nested api/api files to /api/...", () => {
84
+ assert.deepEqual(fileToRoute("api/api/planets.ts"), {
85
+ path: "/api/planets",
86
+ paramNames: [],
87
+ });
88
+ });
89
+
90
+ test("exact routes win over parameterized", () => {
91
+ const routes = [
92
+ { method: "get", path: "/invoices/:id" },
93
+ { method: "get", path: "/invoices/export" },
94
+ ];
95
+ const exact = matchProjectRoute("/api/invoices/export", "GET", routes);
96
+ assert.equal(exact?.route.path, "/invoices/export");
97
+ const param = matchProjectRoute("/api/invoices/abc", "GET", routes);
98
+ assert.equal(param?.route.path, "/invoices/:id");
99
+ assert.equal(param?.params.id, "abc");
100
+ });
101
+
102
+ test("serves file routes only under /api; exact /api/... wins over stripped", () => {
103
+ const routes = [
104
+ { method: "get", path: "/me" },
105
+ { method: "get", path: "/planets" },
106
+ { method: "get", path: "/api/planets" },
107
+ ];
108
+ assert.equal(matchProjectRoute("/api/me", "GET", routes)?.route.path, "/me");
109
+ assert.equal(matchProjectRoute("/planets", "GET", routes), null);
110
+ assert.equal(matchProjectRoute("/api/planets", "GET", routes)?.route.path, "/api/planets");
111
+ assert.equal(matchProjectRoute("/api/user/me", "GET", routes), null);
112
+ });
113
+
114
+ test("skips unsafe, reserved, and malformed paths", () => {
115
+ assert.equal(fileToRoute("api/../secret.ts"), null);
116
+ assert.deepEqual(fileToRoute("api/auth/login.ts"), {
117
+ path: "/auth/login",
118
+ paramNames: [],
119
+ });
120
+ assert.equal(fileToRoute("api/user.ts"), null);
121
+ assert.equal(fileToRoute("api/user/me.ts"), null);
122
+ assert.equal(fileToRoute("api/api/user.ts"), null);
123
+ assert.deepEqual(fileToRoute("api/storage.ts"), {
124
+ path: "/storage",
125
+ paramNames: [],
126
+ });
127
+ assert.equal(fileToRoute("api/storage/objects.ts"), null);
128
+ assert.equal(fileToRoute("api/storage/objects/[key].ts"), null);
129
+ assert.equal(fileToRoute("api/_robodev/ready.ts"), null);
130
+ assert.equal(fileToRoute("api/invoices/[id.ts"), null);
131
+ assert.equal(fileToRoute("api/invoices/[...id].ts"), null);
132
+ assert.equal(fileToRoute("api/invoices/[id]/[id].ts"), null);
133
+ assert.equal(fileToRoute("api/invoices/[123bad].ts"), null);
134
+ });
135
+
136
+ test("api/storage.ts is a normal defineApi at /api/storage", () => {
137
+ const routes = [{ method: "post", path: "/storage" }];
138
+ assert.equal(matchProjectRoute("/api/storage", "POST", routes)?.route.path, "/storage");
139
+ });
140
+
141
+ test("OpenAPI documents /api/... for defineApi routes", () => {
142
+ assert.equal(openApiDocumentedPath("/me"), "/api/me");
143
+ assert.equal(openApiDocumentedPath("/invoices/:id"), "/api/invoices/{id}");
144
+ assert.equal(openApiDocumentedPath("/api/planets"), "/api/planets");
145
+ assert.equal(openApiDocumentedPath("/"), "/api");
146
+ });
147
+
148
+ test("backend tests are not routes, jobs, or sockets", () => {
149
+ assert.equal(fileToRoute("robodev/api/_lib.seed.test.ts"), null);
150
+ assert.equal(fileToRoute("api/health.spec.ts"), null);
151
+ assert.equal(fileToSocketRoute("robodev/sockets/chat.test.ts"), null);
152
+ assert.equal(jobNameFromFile("robodev/jobs/resize.test.ts"), null);
153
+ assert.equal(socketNameFromFile("robodev/sockets/chat.spec.ts"), null);
154
+ assert.deepEqual(fileToRoute("robodev/api/latest.ts"), { path: "/latest", paramNames: [] });
155
+ });