@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 +47 -0
- package/src/auth-core.ts +269 -0
- package/src/auth-schemas.ts +58 -0
- package/src/compile-invoke.test.ts +228 -0
- package/src/compile.ts +113 -0
- package/src/deploy-files.test.ts +288 -0
- package/src/deploy-files.ts +529 -0
- package/src/handler-response.test.ts +43 -0
- package/src/handler-response.ts +95 -0
- package/src/handler-timeout.test.ts +23 -0
- package/src/handler-timeout.ts +26 -0
- package/src/ids.ts +10 -0
- package/src/index.ts +195 -0
- package/src/invoke.ts +225 -0
- package/src/load-modules.ts +164 -0
- package/src/local-auth.test.ts +412 -0
- package/src/local-auth.ts +215 -0
- package/src/multipart.ts +60 -0
- package/src/openapi.ts +396 -0
- package/src/password.ts +20 -0
- package/src/path-match.test.ts +155 -0
- package/src/path-match.ts +213 -0
- package/src/project-jwt.ts +57 -0
- package/src/reserved-paths.ts +25 -0
- package/src/schema-sync.ts +83 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { handleLocalAuth, isLocalAuthPath, resolveLocalUser } from "./local-auth.js";
|
|
4
|
+
import type { AuthQueryable } from "./auth-core.js";
|
|
5
|
+
|
|
6
|
+
type UserRow = {
|
|
7
|
+
id: string;
|
|
8
|
+
email: string;
|
|
9
|
+
password_hash: string | null;
|
|
10
|
+
name: string | null;
|
|
11
|
+
google_subject: string | null;
|
|
12
|
+
};
|
|
13
|
+
type RefreshRow = { id: string; user_id: string; token_hash: string; expires_at: Date };
|
|
14
|
+
type NonceRow = {
|
|
15
|
+
id: string;
|
|
16
|
+
user_id: string;
|
|
17
|
+
code: string;
|
|
18
|
+
type: string;
|
|
19
|
+
expires_at: Date;
|
|
20
|
+
used_at: Date | null;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** In-memory stand-in for the `robodev_auth` schema, matching the SQL `auth-core` emits. */
|
|
24
|
+
class MemoryAuthDb implements AuthQueryable {
|
|
25
|
+
users: UserRow[] = [];
|
|
26
|
+
refresh: RefreshRow[] = [];
|
|
27
|
+
nonces: NonceRow[] = [];
|
|
28
|
+
|
|
29
|
+
async query<T extends Record<string, unknown> = Record<string, unknown>>(
|
|
30
|
+
sql: string,
|
|
31
|
+
params: unknown[] = [],
|
|
32
|
+
): Promise<{ rows: T[] }> {
|
|
33
|
+
const s = sql.replace(/\s+/g, " ").trim();
|
|
34
|
+
const rows = (value: unknown[]) => ({ rows: value as T[] });
|
|
35
|
+
|
|
36
|
+
if (/^(CREATE SCHEMA|CREATE TABLE|ALTER TABLE)/.test(s)) return rows([]);
|
|
37
|
+
|
|
38
|
+
if (s.includes("FROM robodev_auth.users WHERE email")) {
|
|
39
|
+
const row = this.users.find((user) => user.email === params[0]);
|
|
40
|
+
return rows(row ? [row] : []);
|
|
41
|
+
}
|
|
42
|
+
if (s.includes("FROM robodev_auth.users WHERE id")) {
|
|
43
|
+
const row = this.users.find((user) => user.id === params[0]);
|
|
44
|
+
return rows(row ? [row] : []);
|
|
45
|
+
}
|
|
46
|
+
if (s.startsWith("INSERT INTO robodev_auth.users")) {
|
|
47
|
+
this.users.push({
|
|
48
|
+
id: String(params[0]),
|
|
49
|
+
email: String(params[1]),
|
|
50
|
+
password_hash: (params[2] as string | null) ?? null,
|
|
51
|
+
name: (params[3] as string | null) ?? null,
|
|
52
|
+
google_subject: null,
|
|
53
|
+
});
|
|
54
|
+
return rows([]);
|
|
55
|
+
}
|
|
56
|
+
if (s.includes("UPDATE robodev_auth.users SET name = $1, email = $2")) {
|
|
57
|
+
const user = this.users.find((row) => row.id === params[2]);
|
|
58
|
+
if (user) {
|
|
59
|
+
user.name = params[0] as string | null;
|
|
60
|
+
user.email = String(params[1]);
|
|
61
|
+
}
|
|
62
|
+
return rows([]);
|
|
63
|
+
}
|
|
64
|
+
if (s.includes("UPDATE robodev_auth.users SET password_hash")) {
|
|
65
|
+
const user = this.users.find((row) => row.id === params[1]);
|
|
66
|
+
if (user) user.password_hash = String(params[0]);
|
|
67
|
+
return rows([]);
|
|
68
|
+
}
|
|
69
|
+
if (s.startsWith("INSERT INTO robodev_auth.refresh_tokens")) {
|
|
70
|
+
this.refresh.push({
|
|
71
|
+
id: String(params[0]),
|
|
72
|
+
user_id: String(params[1]),
|
|
73
|
+
token_hash: String(params[2]),
|
|
74
|
+
expires_at: new Date(String(params[3])),
|
|
75
|
+
});
|
|
76
|
+
return rows([]);
|
|
77
|
+
}
|
|
78
|
+
if (s.includes("FROM robodev_auth.refresh_tokens r")) {
|
|
79
|
+
const row = this.refresh.find((item) => item.token_hash === params[0]);
|
|
80
|
+
const user = row ? this.users.find((item) => item.id === row.user_id) : undefined;
|
|
81
|
+
if (!row || !user) return rows([]);
|
|
82
|
+
return rows([
|
|
83
|
+
{
|
|
84
|
+
id: row.id,
|
|
85
|
+
user_id: row.user_id,
|
|
86
|
+
expires_at: row.expires_at,
|
|
87
|
+
email: user.email,
|
|
88
|
+
name: user.name,
|
|
89
|
+
password_hash: user.password_hash,
|
|
90
|
+
google_subject: user.google_subject,
|
|
91
|
+
},
|
|
92
|
+
]);
|
|
93
|
+
}
|
|
94
|
+
if (s.includes("DELETE FROM robodev_auth.refresh_tokens WHERE id")) {
|
|
95
|
+
this.refresh = this.refresh.filter((row) => row.id !== params[0]);
|
|
96
|
+
return rows([]);
|
|
97
|
+
}
|
|
98
|
+
if (s.includes("DELETE FROM robodev_auth.refresh_tokens WHERE token_hash")) {
|
|
99
|
+
this.refresh = this.refresh.filter((row) => row.token_hash !== params[0]);
|
|
100
|
+
return rows([]);
|
|
101
|
+
}
|
|
102
|
+
if (s.startsWith("INSERT INTO robodev_auth.authn_nonces")) {
|
|
103
|
+
this.nonces.push({
|
|
104
|
+
id: String(params[0]),
|
|
105
|
+
user_id: String(params[1]),
|
|
106
|
+
code: String(params[2]),
|
|
107
|
+
type: String(params[3]),
|
|
108
|
+
expires_at: new Date(String(params[4])),
|
|
109
|
+
used_at: null,
|
|
110
|
+
});
|
|
111
|
+
return rows([]);
|
|
112
|
+
}
|
|
113
|
+
if (s.includes("FROM robodev_auth.authn_nonces WHERE code")) {
|
|
114
|
+
const row = this.nonces.find((item) => item.code === params[0]);
|
|
115
|
+
return rows(row ? [row] : []);
|
|
116
|
+
}
|
|
117
|
+
if (s.includes("UPDATE robodev_auth.authn_nonces SET used_at")) {
|
|
118
|
+
const row = this.nonces.find((item) => item.id === params[0]);
|
|
119
|
+
if (row) row.used_at = new Date();
|
|
120
|
+
return rows([]);
|
|
121
|
+
}
|
|
122
|
+
throw new Error(`unhandled sql: ${s}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
type Delivered = { to: string; type: string; code: string };
|
|
127
|
+
|
|
128
|
+
function setup() {
|
|
129
|
+
const db = new MemoryAuthDb();
|
|
130
|
+
const delivered: Delivered[] = [];
|
|
131
|
+
const options = {
|
|
132
|
+
db,
|
|
133
|
+
projectId: "prj-local",
|
|
134
|
+
jwtSecret: "test-secret",
|
|
135
|
+
deliverCode: (input: Delivered) => delivered.push(input),
|
|
136
|
+
};
|
|
137
|
+
return { db, delivered, options };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const PASSWORD = "a-long-enough-password";
|
|
141
|
+
|
|
142
|
+
async function register(options: ReturnType<typeof setup>["options"], email = "a@b.com") {
|
|
143
|
+
return handleLocalAuth(
|
|
144
|
+
{
|
|
145
|
+
method: "POST",
|
|
146
|
+
pathname: "/api/user/auth/register",
|
|
147
|
+
body: { email, password: PASSWORD, name: "A" },
|
|
148
|
+
},
|
|
149
|
+
options,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
test("isLocalAuthPath covers the reserved Auth surface only", () => {
|
|
154
|
+
assert.equal(isLocalAuthPath("/api/user"), true);
|
|
155
|
+
assert.equal(isLocalAuthPath("/api/user/me"), true);
|
|
156
|
+
assert.equal(isLocalAuthPath("/api/user/auth/login"), true);
|
|
157
|
+
assert.equal(isLocalAuthPath("/api/planets"), false);
|
|
158
|
+
assert.equal(isLocalAuthPath("/api/users"), false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("register issues a token pair and rejects a duplicate email", async () => {
|
|
162
|
+
const { options } = setup();
|
|
163
|
+
const created = await register(options);
|
|
164
|
+
assert.equal(created.status, 201);
|
|
165
|
+
assert.ok(created.body.accessToken);
|
|
166
|
+
assert.ok(created.body.refreshToken);
|
|
167
|
+
|
|
168
|
+
const again = await register(options);
|
|
169
|
+
assert.equal(again.status, 409);
|
|
170
|
+
assert.equal(again.body.code, "identity-already-exists");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("register rejects a short password without touching the database", async () => {
|
|
174
|
+
const { db, options } = setup();
|
|
175
|
+
const result = await handleLocalAuth(
|
|
176
|
+
{
|
|
177
|
+
method: "POST",
|
|
178
|
+
pathname: "/api/user/auth/register",
|
|
179
|
+
body: { email: "a@b.com", password: "short" },
|
|
180
|
+
},
|
|
181
|
+
options,
|
|
182
|
+
);
|
|
183
|
+
assert.equal(result.status, 400);
|
|
184
|
+
assert.equal(result.body.code, "invalid-request");
|
|
185
|
+
assert.equal(db.users.length, 0);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("login accepts the right password and rejects the wrong one", async () => {
|
|
189
|
+
const { options } = setup();
|
|
190
|
+
await register(options);
|
|
191
|
+
|
|
192
|
+
const ok = await handleLocalAuth(
|
|
193
|
+
{
|
|
194
|
+
method: "POST",
|
|
195
|
+
pathname: "/api/user/auth/login",
|
|
196
|
+
body: { email: "a@b.com", password: PASSWORD },
|
|
197
|
+
},
|
|
198
|
+
options,
|
|
199
|
+
);
|
|
200
|
+
assert.equal(ok.status, 200);
|
|
201
|
+
assert.ok(ok.body.accessToken);
|
|
202
|
+
|
|
203
|
+
const bad = await handleLocalAuth(
|
|
204
|
+
{
|
|
205
|
+
method: "POST",
|
|
206
|
+
pathname: "/api/user/auth/login",
|
|
207
|
+
body: { email: "a@b.com", password: "nope-nope-nope" },
|
|
208
|
+
},
|
|
209
|
+
options,
|
|
210
|
+
);
|
|
211
|
+
assert.equal(bad.status, 401);
|
|
212
|
+
assert.equal(bad.body.code, "invalid-credentials");
|
|
213
|
+
|
|
214
|
+
const missing = await handleLocalAuth(
|
|
215
|
+
{
|
|
216
|
+
method: "POST",
|
|
217
|
+
pathname: "/api/user/auth/login",
|
|
218
|
+
body: { email: "no@b.com", password: PASSWORD },
|
|
219
|
+
},
|
|
220
|
+
options,
|
|
221
|
+
);
|
|
222
|
+
assert.equal(missing.status, 401);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("refresh rotates the token and refuses the old one", async () => {
|
|
226
|
+
const { options } = setup();
|
|
227
|
+
const created = await register(options);
|
|
228
|
+
const first = String(created.body.refreshToken);
|
|
229
|
+
|
|
230
|
+
const rotated = await handleLocalAuth(
|
|
231
|
+
{ method: "POST", pathname: "/api/user/auth/refresh", body: { refreshToken: first } },
|
|
232
|
+
options,
|
|
233
|
+
);
|
|
234
|
+
assert.equal(rotated.status, 200);
|
|
235
|
+
assert.notEqual(rotated.body.refreshToken, first);
|
|
236
|
+
|
|
237
|
+
const reused = await handleLocalAuth(
|
|
238
|
+
{ method: "POST", pathname: "/api/user/auth/refresh", body: { refreshToken: first } },
|
|
239
|
+
options,
|
|
240
|
+
);
|
|
241
|
+
assert.equal(reused.status, 401);
|
|
242
|
+
assert.equal(reused.body.code, "identity-not-found");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("refresh with revoke signs the session out", async () => {
|
|
246
|
+
const { db, options } = setup();
|
|
247
|
+
const created = await register(options);
|
|
248
|
+
const result = await handleLocalAuth(
|
|
249
|
+
{
|
|
250
|
+
method: "POST",
|
|
251
|
+
pathname: "/api/user/auth/refresh",
|
|
252
|
+
body: { refreshToken: created.body.refreshToken, revoke: true },
|
|
253
|
+
},
|
|
254
|
+
options,
|
|
255
|
+
);
|
|
256
|
+
assert.equal(result.status, 200);
|
|
257
|
+
assert.equal(result.body.code, "ok");
|
|
258
|
+
assert.equal(db.refresh.length, 0);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("me reads and updates the signed-in user", async () => {
|
|
262
|
+
const { options } = setup();
|
|
263
|
+
const created = await register(options);
|
|
264
|
+
const authorization = `Bearer ${created.body.accessToken}`;
|
|
265
|
+
|
|
266
|
+
const me = await handleLocalAuth(
|
|
267
|
+
{ method: "GET", pathname: "/api/user/me", authorization },
|
|
268
|
+
options,
|
|
269
|
+
);
|
|
270
|
+
assert.equal(me.status, 200);
|
|
271
|
+
assert.equal(me.body.email, "a@b.com");
|
|
272
|
+
assert.equal(me.body.name, "A");
|
|
273
|
+
|
|
274
|
+
const updated = await handleLocalAuth(
|
|
275
|
+
{ method: "PUT", pathname: "/api/user/me", authorization, body: { name: "B" } },
|
|
276
|
+
options,
|
|
277
|
+
);
|
|
278
|
+
assert.equal(updated.status, 200);
|
|
279
|
+
assert.equal(updated.body.name, "B");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("me rejects a missing or foreign token", async () => {
|
|
283
|
+
const { options } = setup();
|
|
284
|
+
await register(options);
|
|
285
|
+
|
|
286
|
+
const anonymous = await handleLocalAuth({ method: "GET", pathname: "/api/user/me" }, options);
|
|
287
|
+
assert.equal(anonymous.status, 401);
|
|
288
|
+
|
|
289
|
+
const created = await register(options, "c@d.com");
|
|
290
|
+
const otherProject = await handleLocalAuth(
|
|
291
|
+
{
|
|
292
|
+
method: "GET",
|
|
293
|
+
pathname: "/api/user/me",
|
|
294
|
+
authorization: `Bearer ${created.body.accessToken}`,
|
|
295
|
+
},
|
|
296
|
+
{ ...options, jwtSecret: "different-secret" },
|
|
297
|
+
);
|
|
298
|
+
assert.equal(otherProject.status, 401);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("me rejects an email already taken by someone else", async () => {
|
|
302
|
+
const { options } = setup();
|
|
303
|
+
const first = await register(options, "a@b.com");
|
|
304
|
+
await register(options, "c@d.com");
|
|
305
|
+
const result = await handleLocalAuth(
|
|
306
|
+
{
|
|
307
|
+
method: "PUT",
|
|
308
|
+
pathname: "/api/user/me",
|
|
309
|
+
authorization: `Bearer ${first.body.accessToken}`,
|
|
310
|
+
body: { email: "c@d.com" },
|
|
311
|
+
},
|
|
312
|
+
options,
|
|
313
|
+
);
|
|
314
|
+
assert.equal(result.status, 409);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("magic link delivers a code to the terminal and exchanges it once", async () => {
|
|
318
|
+
const { delivered, options } = setup();
|
|
319
|
+
await register(options);
|
|
320
|
+
|
|
321
|
+
const requested = await handleLocalAuth(
|
|
322
|
+
{ method: "GET", pathname: "/api/user/auth/magic-link", query: { email: "a@b.com" } },
|
|
323
|
+
options,
|
|
324
|
+
);
|
|
325
|
+
assert.equal(requested.status, 200);
|
|
326
|
+
assert.equal(delivered.length, 1);
|
|
327
|
+
assert.equal(delivered[0]?.type, "magic");
|
|
328
|
+
|
|
329
|
+
const code = String(delivered[0]?.code);
|
|
330
|
+
const exchanged = await handleLocalAuth(
|
|
331
|
+
{ method: "GET", pathname: "/api/user/auth/magic-link/callback", query: { code } },
|
|
332
|
+
options,
|
|
333
|
+
);
|
|
334
|
+
assert.equal(exchanged.status, 200);
|
|
335
|
+
assert.ok(exchanged.body.accessToken);
|
|
336
|
+
|
|
337
|
+
const replay = await handleLocalAuth(
|
|
338
|
+
{ method: "GET", pathname: "/api/user/auth/magic-link/callback", query: { code } },
|
|
339
|
+
options,
|
|
340
|
+
);
|
|
341
|
+
assert.equal(replay.status, 400);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test("magic link stays generic for an unknown email", async () => {
|
|
345
|
+
const { delivered, options } = setup();
|
|
346
|
+
const result = await handleLocalAuth(
|
|
347
|
+
{ method: "GET", pathname: "/api/user/auth/magic-link", query: { email: "nobody@b.com" } },
|
|
348
|
+
options,
|
|
349
|
+
);
|
|
350
|
+
assert.equal(result.status, 200);
|
|
351
|
+
assert.equal(result.body.code, "ok");
|
|
352
|
+
assert.equal(delivered.length, 0);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("forgot password resets and the new password logs in", async () => {
|
|
356
|
+
const { delivered, options } = setup();
|
|
357
|
+
await register(options);
|
|
358
|
+
|
|
359
|
+
await handleLocalAuth(
|
|
360
|
+
{ method: "POST", pathname: "/api/user/auth/forgot-password", body: { email: "a@b.com" } },
|
|
361
|
+
options,
|
|
362
|
+
);
|
|
363
|
+
const code = String(delivered.at(-1)?.code);
|
|
364
|
+
|
|
365
|
+
const reset = await handleLocalAuth(
|
|
366
|
+
{
|
|
367
|
+
method: "POST",
|
|
368
|
+
pathname: "/api/user/auth/forgot-password/callback",
|
|
369
|
+
body: { code, password: "a-brand-new-password" },
|
|
370
|
+
},
|
|
371
|
+
options,
|
|
372
|
+
);
|
|
373
|
+
assert.equal(reset.status, 200);
|
|
374
|
+
|
|
375
|
+
const login = await handleLocalAuth(
|
|
376
|
+
{
|
|
377
|
+
method: "POST",
|
|
378
|
+
pathname: "/api/user/auth/login",
|
|
379
|
+
body: { email: "a@b.com", password: "a-brand-new-password" },
|
|
380
|
+
},
|
|
381
|
+
options,
|
|
382
|
+
);
|
|
383
|
+
assert.equal(login.status, 200);
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
test("google sign-in points at hosted Starbase", async () => {
|
|
387
|
+
const { options } = setup();
|
|
388
|
+
const result = await handleLocalAuth(
|
|
389
|
+
{ method: "GET", pathname: "/api/user/auth/google", query: { redirect_uri: "http://x" } },
|
|
390
|
+
options,
|
|
391
|
+
);
|
|
392
|
+
assert.equal(result.status, 503);
|
|
393
|
+
assert.match(String(result.body.message), /hosted Starbase/);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("unknown Auth routes 404", async () => {
|
|
397
|
+
const { options } = setup();
|
|
398
|
+
const result = await handleLocalAuth({ method: "GET", pathname: "/api/user/auth/nope" }, options);
|
|
399
|
+
assert.equal(result.status, 404);
|
|
400
|
+
assert.equal(result.body.code, "not_found");
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test("resolveLocalUser verifies project-scoped tokens", async () => {
|
|
404
|
+
const { options } = setup();
|
|
405
|
+
const created = await register(options);
|
|
406
|
+
const user = await resolveLocalUser(options, `Bearer ${created.body.accessToken}`);
|
|
407
|
+
assert.equal(user?.email, "a@b.com");
|
|
408
|
+
|
|
409
|
+
assert.equal(await resolveLocalUser(options, undefined), null);
|
|
410
|
+
assert.equal(await resolveLocalUser(options, "Bearer garbage"), null);
|
|
411
|
+
assert.equal(await resolveLocalUser(options, String(created.body.accessToken)), null);
|
|
412
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { ZodError } from "zod";
|
|
2
|
+
import {
|
|
3
|
+
consumeNonce,
|
|
4
|
+
createNonce,
|
|
5
|
+
ensureAuthSchema,
|
|
6
|
+
insertPasswordUser,
|
|
7
|
+
issueProjectTokenPair,
|
|
8
|
+
loadRefreshSession,
|
|
9
|
+
loadUserByEmail,
|
|
10
|
+
loadUserById,
|
|
11
|
+
passwordMatches,
|
|
12
|
+
revokeRefresh,
|
|
13
|
+
rotateRefresh,
|
|
14
|
+
toAuthUser,
|
|
15
|
+
updateUserPassword,
|
|
16
|
+
updateUserProfile,
|
|
17
|
+
type AuthQueryable,
|
|
18
|
+
} from "./auth-core.js";
|
|
19
|
+
import {
|
|
20
|
+
forgotCallbackBody,
|
|
21
|
+
forgotRequestBody,
|
|
22
|
+
GENERIC_STATUS_MESSAGE,
|
|
23
|
+
loginBody,
|
|
24
|
+
magicConsumeQuery,
|
|
25
|
+
magicGenerateQuery,
|
|
26
|
+
meUpdateBody,
|
|
27
|
+
refreshBody,
|
|
28
|
+
registerBody,
|
|
29
|
+
statusOk,
|
|
30
|
+
} from "./auth-schemas.js";
|
|
31
|
+
import { bearerToken, verifyProjectUserToken } from "./project-jwt.js";
|
|
32
|
+
|
|
33
|
+
export type LocalAuthResult = {
|
|
34
|
+
status: number;
|
|
35
|
+
body: Record<string, unknown>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type LocalAuthRequest = {
|
|
39
|
+
method: string;
|
|
40
|
+
pathname: string;
|
|
41
|
+
body?: unknown;
|
|
42
|
+
query?: unknown;
|
|
43
|
+
authorization?: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type LocalAuthOptions = {
|
|
47
|
+
db: AuthQueryable;
|
|
48
|
+
projectId: string;
|
|
49
|
+
jwtSecret: string;
|
|
50
|
+
/**
|
|
51
|
+
* Where a magic-link / reset code is delivered. `robodev dev` prints it to the
|
|
52
|
+
* terminal instead of sending mail.
|
|
53
|
+
*/
|
|
54
|
+
deliverCode: (input: { to: string; type: string; code: string }) => void;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
function fail(status: number, code: string, message?: string): LocalAuthResult {
|
|
58
|
+
return { status, body: { code, ...(message ? { message } : {}) } };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** True for the reserved Auth surface `robodev dev` serves itself. */
|
|
62
|
+
export function isLocalAuthPath(pathname: string): boolean {
|
|
63
|
+
return pathname === "/api/user" || pathname.startsWith("/api/user/");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function resolveLocalUser(
|
|
67
|
+
options: Pick<LocalAuthOptions, "projectId" | "jwtSecret">,
|
|
68
|
+
authorization?: string,
|
|
69
|
+
) {
|
|
70
|
+
const token = bearerToken(authorization);
|
|
71
|
+
if (!token) return null;
|
|
72
|
+
try {
|
|
73
|
+
return await verifyProjectUserToken(token, options.projectId, options.jwtSecret);
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Password auth for a single local project: register, login, refresh, magic link,
|
|
81
|
+
* forgot password, and `/api/user/me`. Every operation runs through `auth-core`, the
|
|
82
|
+
* same code hosted Starbase Auth uses, so a local login behaves like a deployed one.
|
|
83
|
+
* Google OAuth and real SMTP stay hosted-only.
|
|
84
|
+
*/
|
|
85
|
+
export async function handleLocalAuth(
|
|
86
|
+
request: LocalAuthRequest,
|
|
87
|
+
options: LocalAuthOptions,
|
|
88
|
+
): Promise<LocalAuthResult> {
|
|
89
|
+
const { db, projectId, jwtSecret } = options;
|
|
90
|
+
await ensureAuthSchema(db);
|
|
91
|
+
const method = request.method.toUpperCase();
|
|
92
|
+
const pathname = request.pathname;
|
|
93
|
+
const query = (request.query ?? {}) as Record<string, unknown>;
|
|
94
|
+
|
|
95
|
+
try {
|
|
96
|
+
if (method === "POST" && pathname === "/api/user/auth/register") {
|
|
97
|
+
const body = registerBody.parse(request.body);
|
|
98
|
+
if (await loadUserByEmail(db, body.email)) {
|
|
99
|
+
return fail(409, "identity-already-exists", "Email already registered");
|
|
100
|
+
}
|
|
101
|
+
const row = await insertPasswordUser(db, body);
|
|
102
|
+
const tokens = await issueProjectTokenPair(db, projectId, toAuthUser(row), jwtSecret);
|
|
103
|
+
return { status: 201, body: tokens };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (method === "POST" && pathname === "/api/user/auth/login") {
|
|
107
|
+
const body = loginBody.parse(request.body);
|
|
108
|
+
const row = await loadUserByEmail(db, body.email);
|
|
109
|
+
if (!row || !(await passwordMatches(row, body.password))) {
|
|
110
|
+
return fail(401, "invalid-credentials", "Invalid email or password");
|
|
111
|
+
}
|
|
112
|
+
const tokens = await issueProjectTokenPair(db, projectId, toAuthUser(row), jwtSecret);
|
|
113
|
+
return { status: 200, body: tokens };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (method === "POST" && pathname === "/api/user/auth/refresh") {
|
|
117
|
+
const body = refreshBody.parse(request.body);
|
|
118
|
+
if (body.revoke) {
|
|
119
|
+
await loadRefreshSession(db, body.refreshToken);
|
|
120
|
+
await revokeRefresh(db, body.refreshToken);
|
|
121
|
+
return { status: 200, body: statusOk("Signed out") };
|
|
122
|
+
}
|
|
123
|
+
const rotated = await rotateRefresh(db, projectId, body.refreshToken, jwtSecret);
|
|
124
|
+
if (rotated === "reuse") {
|
|
125
|
+
return fail(401, "identity-not-found", "Invalid refresh token");
|
|
126
|
+
}
|
|
127
|
+
return { status: 200, body: rotated };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (method === "GET" && pathname === "/api/user/auth/magic-link") {
|
|
131
|
+
const q = magicGenerateQuery.parse(query);
|
|
132
|
+
const row = await loadUserByEmail(db, q.email);
|
|
133
|
+
if (row) {
|
|
134
|
+
const code = await createNonce(db, row.id, "magic");
|
|
135
|
+
options.deliverCode({ to: row.email, type: "magic", code });
|
|
136
|
+
}
|
|
137
|
+
return { status: 200, body: statusOk(GENERIC_STATUS_MESSAGE) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (method === "GET" && pathname === "/api/user/auth/magic-link/callback") {
|
|
141
|
+
const q = magicConsumeQuery.parse(query);
|
|
142
|
+
const row = await consumeNonce(db, q.code, "magic");
|
|
143
|
+
if (!row) {
|
|
144
|
+
return fail(400, "nonce-invalid", "Invalid or expired code");
|
|
145
|
+
}
|
|
146
|
+
const tokens = await issueProjectTokenPair(db, projectId, toAuthUser(row), jwtSecret);
|
|
147
|
+
return { status: 200, body: tokens };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (method === "POST" && pathname === "/api/user/auth/forgot-password") {
|
|
151
|
+
const body = forgotRequestBody.parse(request.body);
|
|
152
|
+
const row = await loadUserByEmail(db, body.email);
|
|
153
|
+
if (row) {
|
|
154
|
+
const code = await createNonce(db, row.id, "forgot-password");
|
|
155
|
+
options.deliverCode({ to: row.email, type: "forgot-password", code });
|
|
156
|
+
}
|
|
157
|
+
return { status: 200, body: statusOk(GENERIC_STATUS_MESSAGE) };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (method === "POST" && pathname === "/api/user/auth/forgot-password/callback") {
|
|
161
|
+
const body = forgotCallbackBody.parse(request.body);
|
|
162
|
+
const row = await consumeNonce(db, body.code, "forgot-password");
|
|
163
|
+
if (!row) {
|
|
164
|
+
return fail(400, "nonce-invalid", "Invalid or expired code");
|
|
165
|
+
}
|
|
166
|
+
await updateUserPassword(db, row.id, body.password);
|
|
167
|
+
return { status: 200, body: statusOk("Password updated") };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (pathname === "/api/user/me" && (method === "GET" || method === "PUT")) {
|
|
171
|
+
const user = await resolveLocalUser(options, request.authorization);
|
|
172
|
+
const row = user ? await loadUserById(db, user.id) : undefined;
|
|
173
|
+
if (!row) {
|
|
174
|
+
return fail(401, "identity-not-found", "missing or invalid access token");
|
|
175
|
+
}
|
|
176
|
+
if (method === "GET") {
|
|
177
|
+
return { status: 200, body: { id: row.id, name: row.name, email: row.email } };
|
|
178
|
+
}
|
|
179
|
+
const body = meUpdateBody.parse(request.body ?? {});
|
|
180
|
+
let nextName = row.name;
|
|
181
|
+
let nextEmail = row.email;
|
|
182
|
+
if (body.name !== undefined) nextName = body.name || null;
|
|
183
|
+
if (body.email !== undefined && body.email !== row.email) {
|
|
184
|
+
const clash = await loadUserByEmail(db, body.email);
|
|
185
|
+
if (clash && clash.id !== row.id) {
|
|
186
|
+
return fail(409, "identity-already-exists", "Email already registered");
|
|
187
|
+
}
|
|
188
|
+
nextEmail = body.email;
|
|
189
|
+
}
|
|
190
|
+
await updateUserProfile(db, row.id, { name: nextName, email: nextEmail });
|
|
191
|
+
return { status: 200, body: { id: row.id, name: nextName, email: nextEmail } };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (method === "GET" && pathname === "/api/user/auth/google") {
|
|
195
|
+
return fail(
|
|
196
|
+
503,
|
|
197
|
+
"google_oauth_disabled",
|
|
198
|
+
"Google sign-in needs hosted Starbase. Deploy the project to use it.",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if (error instanceof ZodError) {
|
|
203
|
+
return fail(400, "invalid-request", "Invalid request");
|
|
204
|
+
}
|
|
205
|
+
throw error;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
status: 404,
|
|
210
|
+
body: {
|
|
211
|
+
code: "not_found",
|
|
212
|
+
message: `${method} ${pathname} is not a Robodev Auth route`,
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
package/src/multipart.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
2
|
+
import busboy from "busboy";
|
|
3
|
+
import type { UploadedFile } from "@robodev-ai/sdk";
|
|
4
|
+
|
|
5
|
+
/** Default per-file ceiling. Starbase passes its own storage limit. */
|
|
6
|
+
export const MAX_MULTIPART_FILE_BYTES = 25 * 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
export async function parseMultipart(
|
|
9
|
+
raw: Buffer,
|
|
10
|
+
contentType: string,
|
|
11
|
+
maxFileBytes = MAX_MULTIPART_FILE_BYTES,
|
|
12
|
+
): Promise<{ files: UploadedFile[]; fields: Record<string, string> }> {
|
|
13
|
+
return new Promise((resolve, reject) => {
|
|
14
|
+
const files: UploadedFile[] = [];
|
|
15
|
+
const fields: Record<string, string> = {};
|
|
16
|
+
const pending: Promise<void>[] = [];
|
|
17
|
+
const parser = busboy({
|
|
18
|
+
headers: { "content-type": contentType },
|
|
19
|
+
limits: { files: 20, fileSize: maxFileBytes },
|
|
20
|
+
});
|
|
21
|
+
parser.on("file", (fieldname, stream, info) => {
|
|
22
|
+
pending.push(
|
|
23
|
+
new Promise<void>((done, fail) => {
|
|
24
|
+
const chunks: Buffer[] = [];
|
|
25
|
+
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
26
|
+
stream.on("limit", () =>
|
|
27
|
+
fail(Object.assign(new Error("payload_too_large"), { statusCode: 413 })),
|
|
28
|
+
);
|
|
29
|
+
stream.on("error", fail);
|
|
30
|
+
stream.on("end", () => {
|
|
31
|
+
files.push({
|
|
32
|
+
fieldname,
|
|
33
|
+
filename: info.filename,
|
|
34
|
+
mimetype: info.mimeType,
|
|
35
|
+
data: Buffer.concat(chunks),
|
|
36
|
+
});
|
|
37
|
+
done();
|
|
38
|
+
});
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
parser.on("field", (name, value) => {
|
|
43
|
+
fields[name] = value;
|
|
44
|
+
});
|
|
45
|
+
parser.on("error", reject);
|
|
46
|
+
parser.on("finish", () => {
|
|
47
|
+
Promise.all(pending).then(() => resolve({ files, fields }), reject);
|
|
48
|
+
});
|
|
49
|
+
Readable.from(raw).pipe(parser);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function mediaType(contentType?: string): string {
|
|
54
|
+
return (contentType ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function parseUrlEncoded(raw: Buffer): Record<string, string> {
|
|
58
|
+
const params = new URLSearchParams(raw.toString("utf8"));
|
|
59
|
+
return Object.fromEntries(params.entries());
|
|
60
|
+
}
|