@omg-dev/server 0.4.24
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/dist/index.mjs +3278 -0
- package/dist/trigger-scan.mjs +95 -0
- package/package.json +40 -0
- package/src/auto-crud.ts +244 -0
- package/src/billing.ts +297 -0
- package/src/broker.ts +85 -0
- package/src/codec.ts +41 -0
- package/src/ctx.ts +33 -0
- package/src/db.ts +258 -0
- package/src/dispatcher.ts +257 -0
- package/src/http-error.ts +20 -0
- package/src/index.ts +702 -0
- package/src/migrator.ts +167 -0
- package/src/notifications.ts +628 -0
- package/src/predicate.ts +440 -0
- package/src/storage.ts +384 -0
- package/src/subscriptions.ts +654 -0
- package/src/test/auto-crud.test.ts +385 -0
- package/src/test/dispatcher.test.ts +271 -0
- package/src/test/migrator.test.ts +166 -0
- package/src/test/notifications.test.ts +96 -0
- package/src/test/predicate.test.ts +267 -0
- package/src/test/schema-swap.test.ts +252 -0
- package/src/test/security.test.ts +323 -0
- package/src/test/subscriptions.test.ts +878 -0
- package/src/test/trigger-scan.test.ts +78 -0
- package/src/trigger-scan.ts +173 -0
- package/src/triggers.ts +837 -0
- package/src/web-push.d.ts +18 -0
- package/src/workflows.test.ts +127 -0
- package/src/workflows.ts +438 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
|
2
|
+
import { defineSchema, collection, fields, schemaToSQL, type Schema } from "@omg-dev/schema"
|
|
3
|
+
import { openDb, setDbInstance, markScoped, type VibesDb } from "../db.ts"
|
|
4
|
+
import { addClient, removeClient } from "../broker.ts"
|
|
5
|
+
import { buildAutoCrudRoutes } from "../auto-crud.ts"
|
|
6
|
+
import { handleRequest, type Route } from "../dispatcher.ts"
|
|
7
|
+
import { ctxStore } from "../ctx.ts"
|
|
8
|
+
|
|
9
|
+
// ── Fixture ──────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
function makeSchema(): Schema {
|
|
12
|
+
return defineSchema({
|
|
13
|
+
collections: {
|
|
14
|
+
tasks: collection({
|
|
15
|
+
fields: {
|
|
16
|
+
title: fields.string(),
|
|
17
|
+
done: fields.boolean(),
|
|
18
|
+
},
|
|
19
|
+
}).scoped("global"),
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let db: VibesDb
|
|
25
|
+
let schema: Schema
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
schema = makeSchema()
|
|
29
|
+
db = openDb(":memory:")
|
|
30
|
+
setDbInstance(db)
|
|
31
|
+
for (const sql of schemaToSQL(schema)) db.raw().exec(sql)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
db.close()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
// ── Route shape ──────────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
describe("buildAutoCrudRoutes — route shape", () => {
|
|
41
|
+
it("emits 5 routes per collection with correct verb/path", () => {
|
|
42
|
+
const routes = buildAutoCrudRoutes(schema)
|
|
43
|
+
expect(routes).toHaveLength(5)
|
|
44
|
+
|
|
45
|
+
const byKey = new Map(routes.map(r => [`${r.method} ${r.path}`, r]))
|
|
46
|
+
expect(byKey.has("GET /api/tasks")).toBe(true)
|
|
47
|
+
expect(byKey.has("GET /api/tasks/:id")).toBe(true)
|
|
48
|
+
expect(byKey.has("POST /api/tasks")).toBe(true)
|
|
49
|
+
expect(byKey.has("PATCH /api/tasks/:id")).toBe(true)
|
|
50
|
+
expect(byKey.has("DELETE /api/tasks/:id")).toBe(true)
|
|
51
|
+
|
|
52
|
+
expect(byKey.get("GET /api/tasks")!.handler).toBe("list")
|
|
53
|
+
expect(byKey.get("GET /api/tasks/:id")!.handler).toBe("get")
|
|
54
|
+
expect(byKey.get("POST /api/tasks")!.handler).toBe("create")
|
|
55
|
+
expect(byKey.get("PATCH /api/tasks/:id")!.handler).toBe("update")
|
|
56
|
+
expect(byKey.get("DELETE /api/tasks/:id")!.handler).toBe("remove")
|
|
57
|
+
|
|
58
|
+
for (const r of routes) {
|
|
59
|
+
expect(typeof r.inlineHandler).toBe("function")
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
// ── End-to-end ───────────────────────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
describe("auto-crud — end-to-end via apiHandler", () => {
|
|
67
|
+
it("supports POST → GET → PATCH → DELETE → GET", async () => {
|
|
68
|
+
const routes = buildAutoCrudRoutes(schema)
|
|
69
|
+
|
|
70
|
+
// POST
|
|
71
|
+
const createRes = await handleRequest(
|
|
72
|
+
new Request("http://x/api/tasks", {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: { "content-type": "application/json" },
|
|
75
|
+
body: JSON.stringify({ title: "x", done: false }),
|
|
76
|
+
}),
|
|
77
|
+
routes,
|
|
78
|
+
)
|
|
79
|
+
expect(createRes.status).toBe(200)
|
|
80
|
+
const created = await createRes.json() as { id: string; title: string; done: boolean }
|
|
81
|
+
expect(typeof created.id).toBe("string")
|
|
82
|
+
expect(created.title).toBe("x")
|
|
83
|
+
expect(created.done).toBe(false)
|
|
84
|
+
|
|
85
|
+
// GET list
|
|
86
|
+
const listRes1 = await handleRequest(new Request("http://x/api/tasks"), routes)
|
|
87
|
+
expect(listRes1.status).toBe(200)
|
|
88
|
+
const list1 = await listRes1.json() as Array<{ id: string; done: boolean }>
|
|
89
|
+
expect(list1).toHaveLength(1)
|
|
90
|
+
expect(list1[0].done).toBe(false)
|
|
91
|
+
|
|
92
|
+
// PATCH
|
|
93
|
+
const patchRes = await handleRequest(
|
|
94
|
+
new Request(`http://x/api/tasks/${created.id}`, {
|
|
95
|
+
method: "PATCH",
|
|
96
|
+
headers: { "content-type": "application/json" },
|
|
97
|
+
body: JSON.stringify({ done: true }),
|
|
98
|
+
}),
|
|
99
|
+
routes,
|
|
100
|
+
)
|
|
101
|
+
expect(patchRes.status).toBe(200)
|
|
102
|
+
const patched = await patchRes.json() as { done: boolean }
|
|
103
|
+
expect(patched.done).toBe(true)
|
|
104
|
+
|
|
105
|
+
// GET list again — done flips
|
|
106
|
+
const listRes2 = await handleRequest(new Request("http://x/api/tasks"), routes)
|
|
107
|
+
const list2 = await listRes2.json() as Array<{ done: boolean }>
|
|
108
|
+
expect(list2[0].done).toBe(true)
|
|
109
|
+
|
|
110
|
+
// DELETE
|
|
111
|
+
const delRes = await handleRequest(
|
|
112
|
+
new Request(`http://x/api/tasks/${created.id}`, { method: "DELETE" }),
|
|
113
|
+
routes,
|
|
114
|
+
)
|
|
115
|
+
expect(delRes.status).toBe(204)
|
|
116
|
+
|
|
117
|
+
// GET list — empty
|
|
118
|
+
const listRes3 = await handleRequest(new Request("http://x/api/tasks"), routes)
|
|
119
|
+
const list3 = await listRes3.json() as unknown[]
|
|
120
|
+
expect(list3).toHaveLength(0)
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it("PATCH returns 404 for unknown id", async () => {
|
|
124
|
+
const routes = buildAutoCrudRoutes(schema)
|
|
125
|
+
const res = await handleRequest(
|
|
126
|
+
new Request("http://x/api/tasks/nope", {
|
|
127
|
+
method: "PATCH",
|
|
128
|
+
headers: { "content-type": "application/json" },
|
|
129
|
+
body: JSON.stringify({ done: true }),
|
|
130
|
+
}),
|
|
131
|
+
routes,
|
|
132
|
+
)
|
|
133
|
+
expect(res.status).toBe(404)
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("PATCH rejects unknown columns", async () => {
|
|
137
|
+
const routes = buildAutoCrudRoutes(schema)
|
|
138
|
+
const createRes = await handleRequest(
|
|
139
|
+
new Request("http://x/api/tasks", {
|
|
140
|
+
method: "POST",
|
|
141
|
+
headers: { "content-type": "application/json" },
|
|
142
|
+
body: JSON.stringify({ title: "x", done: false }),
|
|
143
|
+
}),
|
|
144
|
+
routes,
|
|
145
|
+
)
|
|
146
|
+
const created = await createRes.json() as { id: string }
|
|
147
|
+
const res = await handleRequest(
|
|
148
|
+
new Request(`http://x/api/tasks/${created.id}`, {
|
|
149
|
+
method: "PATCH",
|
|
150
|
+
headers: { "content-type": "application/json" },
|
|
151
|
+
body: JSON.stringify({ malicious: 1 }),
|
|
152
|
+
}),
|
|
153
|
+
routes,
|
|
154
|
+
)
|
|
155
|
+
expect(res.status).toBe(400)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
// ── Override precedence ──────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
describe("auto-crud — function-file precedence", () => {
|
|
162
|
+
it("explicit route at same (method, path) shadows the auto-route", async () => {
|
|
163
|
+
// Simulate the merge done in createVibesServer: explicit first.
|
|
164
|
+
const explicit: Route = {
|
|
165
|
+
method: "POST",
|
|
166
|
+
path: "/api/tasks",
|
|
167
|
+
module: "<test>",
|
|
168
|
+
handler: "create",
|
|
169
|
+
inlineHandler: async () => Response.json({ from: "explicit" }, { status: 201 }),
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const auto = buildAutoCrudRoutes(schema)
|
|
173
|
+
const explicitKeys = new Set([`${explicit.method} ${explicit.path}`])
|
|
174
|
+
const merged = [...auto.filter(r => !explicitKeys.has(`${r.method} ${r.path}`)), explicit]
|
|
175
|
+
|
|
176
|
+
const res = await handleRequest(
|
|
177
|
+
new Request("http://x/api/tasks", {
|
|
178
|
+
method: "POST",
|
|
179
|
+
headers: { "content-type": "application/json" },
|
|
180
|
+
body: JSON.stringify({ title: "x", done: false }),
|
|
181
|
+
}),
|
|
182
|
+
merged,
|
|
183
|
+
)
|
|
184
|
+
expect(res.status).toBe(201)
|
|
185
|
+
const body = await res.json() as { from: string }
|
|
186
|
+
expect(body.from).toBe("explicit")
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
// ── User-scoped collections ──────────────────────────────────────────────────
|
|
191
|
+
|
|
192
|
+
function makeScopedSchema(): Schema {
|
|
193
|
+
return defineSchema({
|
|
194
|
+
collections: {
|
|
195
|
+
notes: collection({
|
|
196
|
+
fields: {
|
|
197
|
+
title: fields.string(),
|
|
198
|
+
},
|
|
199
|
+
}).scoped("user"),
|
|
200
|
+
},
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
describe("auto-crud — scope:user", () => {
|
|
205
|
+
let scopedDb: VibesDb
|
|
206
|
+
let scopedSchema: Schema
|
|
207
|
+
|
|
208
|
+
beforeEach(() => {
|
|
209
|
+
scopedSchema = makeScopedSchema()
|
|
210
|
+
scopedDb = openDb(":memory:")
|
|
211
|
+
setDbInstance(scopedDb)
|
|
212
|
+
for (const sql of schemaToSQL(scopedSchema)) scopedDb.raw().exec(sql)
|
|
213
|
+
markScoped("notes")
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
afterEach(() => {
|
|
217
|
+
scopedDb.close()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
function runAs(userId: string | null, fn: () => Promise<Response>): Promise<Response> {
|
|
221
|
+
return ctxStore.run({ userId }, fn)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
it("create stamps _owner from ctx and lists filter by owner", async () => {
|
|
225
|
+
const routes = buildAutoCrudRoutes(scopedSchema)
|
|
226
|
+
|
|
227
|
+
const aRes = await runAs("user-a", () => handleRequest(
|
|
228
|
+
new Request("http://x/api/notes", {
|
|
229
|
+
method: "POST",
|
|
230
|
+
headers: { "content-type": "application/json" },
|
|
231
|
+
body: JSON.stringify({ title: "A's note" }),
|
|
232
|
+
}),
|
|
233
|
+
routes,
|
|
234
|
+
))
|
|
235
|
+
expect(aRes.status).toBe(200)
|
|
236
|
+
const aRow = await aRes.json() as { id: string; _owner: string }
|
|
237
|
+
expect(aRow._owner).toBe("user-a")
|
|
238
|
+
|
|
239
|
+
await runAs("user-b", () => handleRequest(
|
|
240
|
+
new Request("http://x/api/notes", {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: { "content-type": "application/json" },
|
|
243
|
+
body: JSON.stringify({ title: "B's note" }),
|
|
244
|
+
}),
|
|
245
|
+
routes,
|
|
246
|
+
))
|
|
247
|
+
|
|
248
|
+
const aListRes = await runAs("user-a", () => handleRequest(
|
|
249
|
+
new Request("http://x/api/notes"),
|
|
250
|
+
routes,
|
|
251
|
+
))
|
|
252
|
+
const aList = await aListRes.json() as Array<{ title: string }>
|
|
253
|
+
expect(aList).toHaveLength(1)
|
|
254
|
+
expect(aList[0].title).toBe("A's note")
|
|
255
|
+
|
|
256
|
+
const bListRes = await runAs("user-b", () => handleRequest(
|
|
257
|
+
new Request("http://x/api/notes"),
|
|
258
|
+
routes,
|
|
259
|
+
))
|
|
260
|
+
const bList = await bListRes.json() as Array<{ title: string }>
|
|
261
|
+
expect(bList).toHaveLength(1)
|
|
262
|
+
expect(bList[0].title).toBe("B's note")
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
it("returns 401 when listing scoped collection anonymously", async () => {
|
|
266
|
+
const routes = buildAutoCrudRoutes(scopedSchema)
|
|
267
|
+
const res = await runAs(null, () => handleRequest(
|
|
268
|
+
new Request("http://x/api/notes"),
|
|
269
|
+
routes,
|
|
270
|
+
))
|
|
271
|
+
expect(res.status).toBe(401)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it("update by another user returns 404 (no leak)", async () => {
|
|
275
|
+
const routes = buildAutoCrudRoutes(scopedSchema)
|
|
276
|
+
const aRes = await runAs("user-a", () => handleRequest(
|
|
277
|
+
new Request("http://x/api/notes", {
|
|
278
|
+
method: "POST",
|
|
279
|
+
headers: { "content-type": "application/json" },
|
|
280
|
+
body: JSON.stringify({ title: "A's secret" }),
|
|
281
|
+
}),
|
|
282
|
+
routes,
|
|
283
|
+
))
|
|
284
|
+
const a = await aRes.json() as { id: string }
|
|
285
|
+
|
|
286
|
+
const patchRes = await runAs("user-b", () => handleRequest(
|
|
287
|
+
new Request(`http://x/api/notes/${a.id}`, {
|
|
288
|
+
method: "PATCH",
|
|
289
|
+
headers: { "content-type": "application/json" },
|
|
290
|
+
body: JSON.stringify({ title: "hacked" }),
|
|
291
|
+
}),
|
|
292
|
+
routes,
|
|
293
|
+
))
|
|
294
|
+
expect(patchRes.status).toBe(404)
|
|
295
|
+
|
|
296
|
+
// Original is untouched
|
|
297
|
+
const getRes = await runAs("user-a", () => handleRequest(
|
|
298
|
+
new Request(`http://x/api/notes/${a.id}`),
|
|
299
|
+
routes,
|
|
300
|
+
))
|
|
301
|
+
const got = await getRes.json() as { title: string }
|
|
302
|
+
expect(got.title).toBe("A's secret")
|
|
303
|
+
})
|
|
304
|
+
|
|
305
|
+
it("get returns 404 for another user's record", async () => {
|
|
306
|
+
const routes = buildAutoCrudRoutes(scopedSchema)
|
|
307
|
+
const aRes = await runAs("user-a", () => handleRequest(
|
|
308
|
+
new Request("http://x/api/notes", {
|
|
309
|
+
method: "POST",
|
|
310
|
+
headers: { "content-type": "application/json" },
|
|
311
|
+
body: JSON.stringify({ title: "private" }),
|
|
312
|
+
}),
|
|
313
|
+
routes,
|
|
314
|
+
))
|
|
315
|
+
const a = await aRes.json() as { id: string }
|
|
316
|
+
|
|
317
|
+
const bRes = await runAs("user-b", () => handleRequest(
|
|
318
|
+
new Request(`http://x/api/notes/${a.id}`),
|
|
319
|
+
routes,
|
|
320
|
+
))
|
|
321
|
+
expect(bRes.status).toBe(404)
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
it("create anonymously on scoped collection returns 401", async () => {
|
|
325
|
+
const routes = buildAutoCrudRoutes(scopedSchema)
|
|
326
|
+
const res = await runAs(null, () => handleRequest(
|
|
327
|
+
new Request("http://x/api/notes", {
|
|
328
|
+
method: "POST",
|
|
329
|
+
headers: { "content-type": "application/json" },
|
|
330
|
+
body: JSON.stringify({ title: "anon" }),
|
|
331
|
+
}),
|
|
332
|
+
routes,
|
|
333
|
+
))
|
|
334
|
+
expect(res.status).toBe(401)
|
|
335
|
+
})
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
// ── Invalidation ─────────────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
describe("auto-crud — invalidation", () => {
|
|
341
|
+
it("create/update/delete each emit invalidate(<collection>)", async () => {
|
|
342
|
+
const routes = buildAutoCrudRoutes(schema)
|
|
343
|
+
const messages: string[] = []
|
|
344
|
+
const client = { readyState: 1, send(d: string) { messages.push(d) } }
|
|
345
|
+
addClient(client)
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
const createRes = await handleRequest(
|
|
349
|
+
new Request("http://x/api/tasks", {
|
|
350
|
+
method: "POST",
|
|
351
|
+
headers: { "content-type": "application/json" },
|
|
352
|
+
body: JSON.stringify({ title: "x", done: false }),
|
|
353
|
+
}),
|
|
354
|
+
routes,
|
|
355
|
+
)
|
|
356
|
+
const created = await createRes.json() as { id: string }
|
|
357
|
+
expect(messages).toHaveLength(1)
|
|
358
|
+
expect(JSON.parse(messages[0]).collection).toBe("tasks")
|
|
359
|
+
|
|
360
|
+
await handleRequest(
|
|
361
|
+
new Request(`http://x/api/tasks/${created.id}`, {
|
|
362
|
+
method: "PATCH",
|
|
363
|
+
headers: { "content-type": "application/json" },
|
|
364
|
+
body: JSON.stringify({ done: true }),
|
|
365
|
+
}),
|
|
366
|
+
routes,
|
|
367
|
+
)
|
|
368
|
+
expect(messages).toHaveLength(2)
|
|
369
|
+
|
|
370
|
+
await handleRequest(
|
|
371
|
+
new Request(`http://x/api/tasks/${created.id}`, { method: "DELETE" }),
|
|
372
|
+
routes,
|
|
373
|
+
)
|
|
374
|
+
expect(messages).toHaveLength(3)
|
|
375
|
+
|
|
376
|
+
for (const m of messages) {
|
|
377
|
+
const ev = JSON.parse(m)
|
|
378
|
+
expect(ev.type).toBe("invalidate")
|
|
379
|
+
expect(ev.collection).toBe("tasks")
|
|
380
|
+
}
|
|
381
|
+
} finally {
|
|
382
|
+
removeClient(client)
|
|
383
|
+
}
|
|
384
|
+
})
|
|
385
|
+
})
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test"
|
|
2
|
+
import { handleRequest, handlerToRoute, type Route } from "../dispatcher.ts"
|
|
3
|
+
import { VibesAuthRequiredError } from "../db.ts"
|
|
4
|
+
import { VibesHttpError } from "../http-error.ts"
|
|
5
|
+
|
|
6
|
+
// ── Response passthrough ─────────────────────────────────────────────────────
|
|
7
|
+
//
|
|
8
|
+
// Streaming handlers (e.g. streamText(...).toTextStreamResponse()) rely on
|
|
9
|
+
// the dispatcher returning the Response untouched. Wrapping it via
|
|
10
|
+
// Response.json would coerce the streaming body to "{}" / a JSON snapshot
|
|
11
|
+
// and break SSE/text-stream consumers in the browser.
|
|
12
|
+
|
|
13
|
+
describe("handleRequest — Response passthrough", () => {
|
|
14
|
+
it("returns the handler's Response object verbatim when one is returned", async () => {
|
|
15
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
16
|
+
start(controller) {
|
|
17
|
+
controller.enqueue(new TextEncoder().encode("hello "))
|
|
18
|
+
controller.enqueue(new TextEncoder().encode("world"))
|
|
19
|
+
controller.close()
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
const route: Route = {
|
|
24
|
+
method: "POST",
|
|
25
|
+
path: "/api/echo",
|
|
26
|
+
module: "inline",
|
|
27
|
+
handler: "create",
|
|
28
|
+
mod: {
|
|
29
|
+
create: async () =>
|
|
30
|
+
new Response(stream, {
|
|
31
|
+
status: 201,
|
|
32
|
+
headers: { "Content-Type": "text/plain", "X-Custom": "ok" },
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const res = await handleRequest(
|
|
38
|
+
new Request("http://x/api/echo", {
|
|
39
|
+
method: "POST",
|
|
40
|
+
headers: { "content-type": "application/json" },
|
|
41
|
+
body: JSON.stringify({}),
|
|
42
|
+
}),
|
|
43
|
+
[route],
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
expect(res.status).toBe(201)
|
|
47
|
+
expect(res.headers.get("X-Custom")).toBe("ok")
|
|
48
|
+
expect(res.headers.get("Content-Type")).toContain("text/plain")
|
|
49
|
+
expect(await res.text()).toBe("hello world")
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it("still wraps non-Response values via Response.json (CRUD shape unchanged)", async () => {
|
|
53
|
+
const route: Route = {
|
|
54
|
+
method: "GET",
|
|
55
|
+
path: "/api/things",
|
|
56
|
+
module: "inline",
|
|
57
|
+
handler: "list",
|
|
58
|
+
style: "crud",
|
|
59
|
+
mod: {
|
|
60
|
+
list: async () => [{ id: "1" }, { id: "2" }],
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const res = await handleRequest(new Request("http://x/api/things"), [route])
|
|
65
|
+
expect(res.status).toBe(200)
|
|
66
|
+
expect(res.headers.get("Content-Type")).toContain("application/json")
|
|
67
|
+
expect(await res.json()).toEqual([{ id: "1" }, { id: "2" }])
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// ── Method-style handlers ────────────────────────────────────────────────────
|
|
72
|
+
//
|
|
73
|
+
// Handlers exported as HTTP-method names (POST, GET, ...) receive the raw
|
|
74
|
+
// Request and any path params. The dispatcher must NOT pre-parse the body
|
|
75
|
+
// — the handler may read a streaming body, inspect headers, etc.
|
|
76
|
+
|
|
77
|
+
describe("handleRequest — method-style routes", () => {
|
|
78
|
+
it("invokes a POST handler with the raw Request and returns its Response", async () => {
|
|
79
|
+
let receivedReq: Request | null = null
|
|
80
|
+
let receivedParams: Record<string, string> | null = null
|
|
81
|
+
|
|
82
|
+
const route: Route = {
|
|
83
|
+
method: "POST",
|
|
84
|
+
path: "/api/chat",
|
|
85
|
+
module: "inline",
|
|
86
|
+
handler: "POST",
|
|
87
|
+
style: "method",
|
|
88
|
+
mod: {
|
|
89
|
+
POST: async (req: Request, params: Record<string, string>) => {
|
|
90
|
+
receivedReq = req
|
|
91
|
+
receivedParams = params
|
|
92
|
+
const body = await req.json() as { messages: string[] }
|
|
93
|
+
return new Response(`got ${body.messages.length} msgs`, {
|
|
94
|
+
headers: { "Content-Type": "text/plain" },
|
|
95
|
+
})
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const res = await handleRequest(
|
|
101
|
+
new Request("http://x/api/chat", {
|
|
102
|
+
method: "POST",
|
|
103
|
+
headers: { "content-type": "application/json" },
|
|
104
|
+
body: JSON.stringify({ messages: ["a", "b", "c"] }),
|
|
105
|
+
}),
|
|
106
|
+
[route],
|
|
107
|
+
)
|
|
108
|
+
expect(res.status).toBe(200)
|
|
109
|
+
expect(await res.text()).toBe("got 3 msgs")
|
|
110
|
+
expect(receivedReq).toBeInstanceOf(Request)
|
|
111
|
+
expect(receivedParams).toEqual({})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("handlerToRoute classifies HTTP-verb names as method-style", () => {
|
|
115
|
+
const r = handlerToRoute("chat", "POST", "/x/chat.ts")
|
|
116
|
+
expect(r.method).toBe("POST")
|
|
117
|
+
expect(r.path).toBe("/api/chat")
|
|
118
|
+
expect(r.style).toBe("method")
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it("handlerToRoute classifies CRUD names with style=crud", () => {
|
|
122
|
+
const r = handlerToRoute("entries", "list", "/x/entries.ts")
|
|
123
|
+
expect(r.method).toBe("GET")
|
|
124
|
+
expect(r.path).toBe("/api/entries")
|
|
125
|
+
expect(r.style).toBe("crud")
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// ── VibesAuthRequiredError → 401 mapping ─────────────────────────────────────
|
|
130
|
+
//
|
|
131
|
+
// User-authored handlers (and our own auto-crud paths) can throw
|
|
132
|
+
// VibesAuthRequiredError when an unauthenticated request reaches a scoped
|
|
133
|
+
// table operation. The dispatcher must map that to a 401, NOT a 500 — the
|
|
134
|
+
// 500 path would still deny but mislabel the cause in dashboards and
|
|
135
|
+
// surface a confusing "Internal server error" payload to the client.
|
|
136
|
+
|
|
137
|
+
describe("VibesAuthRequiredError → 401", () => {
|
|
138
|
+
function makeAuthThrowingRoutes(): Route[] {
|
|
139
|
+
return [
|
|
140
|
+
{
|
|
141
|
+
method: "POST",
|
|
142
|
+
path: "/api/inline",
|
|
143
|
+
module: "inline",
|
|
144
|
+
handler: "_",
|
|
145
|
+
inlineHandler: async () => {
|
|
146
|
+
throw new VibesAuthRequiredError("notes")
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
method: "POST",
|
|
151
|
+
path: "/api/method",
|
|
152
|
+
module: "inline",
|
|
153
|
+
handler: "POST",
|
|
154
|
+
style: "method",
|
|
155
|
+
mod: {
|
|
156
|
+
POST: async () => {
|
|
157
|
+
throw new VibesAuthRequiredError("notes")
|
|
158
|
+
},
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
method: "GET",
|
|
163
|
+
path: "/api/crud",
|
|
164
|
+
module: "inline",
|
|
165
|
+
handler: "list",
|
|
166
|
+
style: "crud",
|
|
167
|
+
mod: {
|
|
168
|
+
list: async () => {
|
|
169
|
+
throw new VibesAuthRequiredError("notes")
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
]
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
it("inline handler throwing VibesAuthRequiredError yields a 401", async () => {
|
|
177
|
+
const res = await handleRequest(
|
|
178
|
+
new Request("http://x/api/inline", { method: "POST" }),
|
|
179
|
+
makeAuthThrowingRoutes(),
|
|
180
|
+
)
|
|
181
|
+
expect(res.status).toBe(401)
|
|
182
|
+
const body = await res.json() as { error: string }
|
|
183
|
+
expect(body.error).toContain("requires an authenticated user")
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it("method-style handler throwing VibesAuthRequiredError yields a 401", async () => {
|
|
187
|
+
const res = await handleRequest(
|
|
188
|
+
new Request("http://x/api/method", { method: "POST" }),
|
|
189
|
+
makeAuthThrowingRoutes(),
|
|
190
|
+
)
|
|
191
|
+
expect(res.status).toBe(401)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it("crud handler throwing VibesAuthRequiredError yields a 401", async () => {
|
|
195
|
+
const res = await handleRequest(
|
|
196
|
+
new Request("http://x/api/crud"),
|
|
197
|
+
makeAuthThrowingRoutes(),
|
|
198
|
+
)
|
|
199
|
+
expect(res.status).toBe(401)
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
it("other errors still yield a 500 (regression: don't over-eagerly map)", async () => {
|
|
203
|
+
const routes: Route[] = [{
|
|
204
|
+
method: "GET",
|
|
205
|
+
path: "/api/oops",
|
|
206
|
+
module: "inline",
|
|
207
|
+
handler: "list",
|
|
208
|
+
style: "crud",
|
|
209
|
+
mod: { list: async () => { throw new Error("boom") } },
|
|
210
|
+
}]
|
|
211
|
+
const res = await handleRequest(new Request("http://x/api/oops"), routes)
|
|
212
|
+
expect(res.status).toBe(500)
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
// ── VibesHttpError → its own status ──────────────────────────────────────────
|
|
217
|
+
//
|
|
218
|
+
// Expected refusals (ownership checks, proxied upstream 4xx like the
|
|
219
|
+
// orchestrator's 403 "not your deploy") carry an explicit status. The
|
|
220
|
+
// dispatcher must return that status with the message — NOT a 500 — so the
|
|
221
|
+
// client sees the refusal instead of "Internal server error" and the logs
|
|
222
|
+
// don't record a handler crash for a request that was correctly denied.
|
|
223
|
+
|
|
224
|
+
describe("VibesHttpError → explicit status", () => {
|
|
225
|
+
function routesThrowing(err: Error): Route[] {
|
|
226
|
+
return [{
|
|
227
|
+
method: "GET",
|
|
228
|
+
path: "/api/guarded",
|
|
229
|
+
module: "inline",
|
|
230
|
+
handler: "list",
|
|
231
|
+
style: "crud",
|
|
232
|
+
mod: { list: async () => { throw err } },
|
|
233
|
+
}]
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
it("a 403 VibesHttpError keeps its status and message", async () => {
|
|
237
|
+
const res = await handleRequest(
|
|
238
|
+
new Request("http://x/api/guarded"),
|
|
239
|
+
routesThrowing(new VibesHttpError(403, "not your deploy")),
|
|
240
|
+
)
|
|
241
|
+
expect(res.status).toBe(403)
|
|
242
|
+
const body = await res.json() as { error: string }
|
|
243
|
+
expect(body.error).toBe("not your deploy")
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it("a 404 VibesHttpError keeps its status", async () => {
|
|
247
|
+
const res = await handleRequest(
|
|
248
|
+
new Request("http://x/api/guarded"),
|
|
249
|
+
routesThrowing(new VibesHttpError(404, "no such thing")),
|
|
250
|
+
)
|
|
251
|
+
expect(res.status).toBe(404)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it("a 502 VibesHttpError (upstream outage) keeps its status", async () => {
|
|
255
|
+
const res = await handleRequest(
|
|
256
|
+
new Request("http://x/api/guarded"),
|
|
257
|
+
routesThrowing(new VibesHttpError(502, "Infra API 524")),
|
|
258
|
+
)
|
|
259
|
+
expect(res.status).toBe(502)
|
|
260
|
+
const body = await res.json() as { error: string }
|
|
261
|
+
expect(body.error).toBe("Infra API 524")
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it("a plain Error is still a 500", async () => {
|
|
265
|
+
const res = await handleRequest(
|
|
266
|
+
new Request("http://x/api/guarded"),
|
|
267
|
+
routesThrowing(new Error("vibes-infra GET /v1/sandboxes/x: HTTP 524")),
|
|
268
|
+
)
|
|
269
|
+
expect(res.status).toBe(500)
|
|
270
|
+
})
|
|
271
|
+
})
|