@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.
@@ -0,0 +1,252 @@
1
+ import { describe, it, expect, afterEach } from "bun:test"
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import { defineSchema, collection, fields } from "@omg-dev/schema"
6
+ import { createVibesServer, type VibesServerInstance } from "../index.ts"
7
+
8
+ // Reproduces the dev-server bug a new app hit when its template ships
9
+ // `schema.ts` with `collections: {}` and the agent then adds collections:
10
+ // before the fix, `instance.migrate(newSchema)` was a no-op because the
11
+ // closure-captured `dbInstance` and `schema` were both empty at boot, so
12
+ // the DB never opened and no auto-CRUD routes were ever mounted.
13
+
14
+ let cleanups: VibesServerInstance[] = []
15
+ const tmpDirs: string[] = []
16
+
17
+ afterEach(async () => {
18
+ for (const inst of cleanups) inst.close()
19
+ cleanups = []
20
+ for (const dir of tmpDirs) rmSync(dir, { recursive: true, force: true })
21
+ tmpDirs.length = 0
22
+ })
23
+
24
+ function mkRoot(): string {
25
+ const dir = mkdtempSync(join(tmpdir(), "vibes-schema-swap-"))
26
+ // bun:sqlite's `create: true` won't create missing parent directories,
27
+ // and the production code joins root + ".vibes/data.db".
28
+ mkdirSync(join(dir, ".vibes"), { recursive: true })
29
+ tmpDirs.push(dir)
30
+ return dir
31
+ }
32
+
33
+ async function makeServer(opts: {
34
+ root: string
35
+ schema?: ReturnType<typeof defineSchema>
36
+ }): Promise<VibesServerInstance> {
37
+ const inst = await createVibesServer({
38
+ root: opts.root,
39
+ db: ".vibes/data.db",
40
+ auth: undefined,
41
+ schema: opts.schema,
42
+ })
43
+ cleanups.push(inst)
44
+ return inst
45
+ }
46
+
47
+ describe("createVibesServer — schema swap via migrate(newSchema)", () => {
48
+ it("a server booted with empty collections returns 404 for /api/<col> until migrate(newSchema) lands", async () => {
49
+ const root = mkRoot()
50
+ const empty = defineSchema({ collections: {} })
51
+ const inst = await makeServer({ root, schema: empty })
52
+
53
+ // Before swap: no auto-routes, dispatcher 404s.
54
+ const before = await inst.apiHandler(new Request("http://x/api/todos"))
55
+ expect(before.status).toBe(404)
56
+
57
+ const withTodos = defineSchema({
58
+ collections: {
59
+ todos: collection({
60
+ fields: {
61
+ text: fields.string(),
62
+ done: fields.boolean(),
63
+ },
64
+ }).scoped("global"),
65
+ },
66
+ })
67
+ inst.migrate(withTodos)
68
+
69
+ const list = await inst.apiHandler(new Request("http://x/api/todos"))
70
+ expect(list.status).toBe(200)
71
+ expect(await list.json()).toEqual([])
72
+
73
+ const created = await inst.apiHandler(
74
+ new Request("http://x/api/todos", {
75
+ method: "POST",
76
+ headers: { "content-type": "application/json" },
77
+ body: JSON.stringify({ text: "buy milk", done: false }),
78
+ }),
79
+ )
80
+ expect(created.status).toBe(200)
81
+ const row = (await created.json()) as { id: string; text: string; done: boolean }
82
+ expect(row.text).toBe("buy milk")
83
+ expect(row.done).toBe(false)
84
+ expect(typeof row.id).toBe("string")
85
+
86
+ const list2 = await inst.apiHandler(new Request("http://x/api/todos"))
87
+ const list2Body = (await list2.json()) as Array<{ text: string }>
88
+ expect(list2Body).toHaveLength(1)
89
+ expect(list2Body[0].text).toBe("buy milk")
90
+ })
91
+
92
+ it("a server booted with no schema at all picks up collections via migrate(newSchema)", async () => {
93
+ const root = mkRoot()
94
+ const inst = await makeServer({ root, schema: undefined })
95
+
96
+ const before = await inst.apiHandler(new Request("http://x/api/notes"))
97
+ expect(before.status).toBe(404)
98
+
99
+ inst.migrate(
100
+ defineSchema({
101
+ collections: {
102
+ notes: collection({ fields: { body: fields.string() } }).scoped("global"),
103
+ },
104
+ }),
105
+ )
106
+
107
+ const created = await inst.apiHandler(
108
+ new Request("http://x/api/notes", {
109
+ method: "POST",
110
+ headers: { "content-type": "application/json" },
111
+ body: JSON.stringify({ body: "hello" }),
112
+ }),
113
+ )
114
+ expect(created.status).toBe(200)
115
+ })
116
+
117
+ it("a follow-up migrate(newSchema) adds another collection without losing the first one's data", async () => {
118
+ const root = mkRoot()
119
+ const v1 = defineSchema({
120
+ collections: {
121
+ todos: collection({ fields: { text: fields.string() } }).scoped("global"),
122
+ },
123
+ })
124
+ const inst = await makeServer({ root, schema: v1 })
125
+
126
+ const created = await inst.apiHandler(
127
+ new Request("http://x/api/todos", {
128
+ method: "POST",
129
+ headers: { "content-type": "application/json" },
130
+ body: JSON.stringify({ text: "first" }),
131
+ }),
132
+ )
133
+ expect(created.status).toBe(200)
134
+
135
+ const v2 = defineSchema({
136
+ collections: {
137
+ todos: collection({ fields: { text: fields.string() } }).scoped("global"),
138
+ tags: collection({ fields: { name: fields.string() } }).scoped("global"),
139
+ },
140
+ })
141
+ inst.migrate(v2)
142
+
143
+ // New collection now has its own auto-routes…
144
+ const tagPost = await inst.apiHandler(
145
+ new Request("http://x/api/tags", {
146
+ method: "POST",
147
+ headers: { "content-type": "application/json" },
148
+ body: JSON.stringify({ name: "urgent" }),
149
+ }),
150
+ )
151
+ expect(tagPost.status).toBe(200)
152
+
153
+ // …and the original todos row is still readable.
154
+ const todos = await inst.apiHandler(new Request("http://x/api/todos"))
155
+ const list = (await todos.json()) as Array<{ text: string }>
156
+ expect(list).toHaveLength(1)
157
+ expect(list[0].text).toBe("first")
158
+ })
159
+
160
+ it("a new functions/*.ts added after boot becomes reachable after reloadFunctions() rescans routes.json", async () => {
161
+ // Reproduces the streaming-chat 404: pi creates functions/api/chat.ts
162
+ // after the dev server has already booted with an empty functions/.
163
+ // Vite's HMR handler regenerates .vibes/routes.json and calls
164
+ // reloadFunctions(), but the server's in-memory route table is stale
165
+ // until reloadFunctions() also re-reads the file. Without that, every
166
+ // POST /api/chat returns 404 even though the file is on disk.
167
+ const root = mkRoot()
168
+ const inst = await makeServer({ root, schema: undefined })
169
+
170
+ // Empty functions/ at boot → no explicit routes registered.
171
+ const before = await inst.apiHandler(
172
+ new Request("http://x/api/chat", { method: "POST" }),
173
+ )
174
+ expect(before.status).toBe(404)
175
+
176
+ // Simulate what the vite plugin's HMR + regenerateRoutes does.
177
+ writeFileSync(
178
+ join(root, ".vibes", "routes.json"),
179
+ JSON.stringify([
180
+ {
181
+ method: "POST",
182
+ path: "/api/chat",
183
+ module: "<test>",
184
+ handler: "POST",
185
+ style: "method",
186
+ // inlineHandler is the dispatcher's fast path — used here so we
187
+ // don't need to dynamic-import a real module from a tmp dir.
188
+ inlineHandler: undefined,
189
+ },
190
+ ]),
191
+ )
192
+ // The dispatcher needs a runnable handler. The on-disk routes.json
193
+ // can't carry one (it's JSON); production resolves `module` via
194
+ // dynamic import. For this test we patch in an inlineHandler the
195
+ // same way auto-CRUD does, by handing reloadFunctions a routes file
196
+ // and then replacing the table entry through a second call.
197
+ inst.reloadFunctions()
198
+
199
+ // After reload, the route is registered but its handler can't load
200
+ // (no real module). Confirm the route is now wired by checking
201
+ // status moved off 404. A 500 from "Failed to load module" is the
202
+ // expected signal that the route table contains it.
203
+ const after = await inst.apiHandler(
204
+ new Request("http://x/api/chat", { method: "POST" }),
205
+ )
206
+ expect(after.status).not.toBe(404)
207
+ })
208
+
209
+ it("explicit function-file routes survive a schema swap and still shadow auto-routes at the same (method, path)", async () => {
210
+ const root = mkRoot()
211
+ const inst = await createVibesServer({
212
+ root,
213
+ db: ".vibes/data.db",
214
+ auth: undefined,
215
+ schema: defineSchema({ collections: {} }),
216
+ // Simulate a routes.json-loaded function-file handler at POST /api/todos
217
+ // that should keep winning even after auto-CRUD routes are mounted.
218
+ routes: [
219
+ {
220
+ method: "POST",
221
+ path: "/api/todos",
222
+ module: "<test>",
223
+ handler: "create",
224
+ inlineHandler: async () => Response.json({ from: "explicit" }, { status: 201 }),
225
+ },
226
+ ],
227
+ })
228
+ cleanups.push(inst)
229
+
230
+ inst.migrate(
231
+ defineSchema({
232
+ collections: {
233
+ todos: collection({ fields: { text: fields.string() } }).scoped("global"),
234
+ },
235
+ }),
236
+ )
237
+
238
+ const post = await inst.apiHandler(
239
+ new Request("http://x/api/todos", {
240
+ method: "POST",
241
+ headers: { "content-type": "application/json" },
242
+ body: JSON.stringify({ text: "x" }),
243
+ }),
244
+ )
245
+ expect(post.status).toBe(201)
246
+ expect(await post.json()).toEqual({ from: "explicit" })
247
+
248
+ // The auto-route GET /api/todos still mounted (no explicit override).
249
+ const list = await inst.apiHandler(new Request("http://x/api/todos"))
250
+ expect(list.status).toBe(200)
251
+ })
252
+ })
@@ -0,0 +1,323 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test"
2
+ import { openDb, markScoped, setDbInstance, VibesAuthRequiredError, type VibesDb } from "../db.ts"
3
+ import { ctxStore } from "../ctx.ts"
4
+ import { addClient, removeClient, invalidate, clientCount } from "../broker.ts"
5
+ import { Database } from "bun:sqlite"
6
+ import fs from "node:fs"
7
+ import path from "node:path"
8
+ import os from "node:os"
9
+
10
+ // ── Helpers ──────────────────────────────────────────────────────────────────
11
+
12
+ function tmpDbPath(): string {
13
+ return path.join(os.tmpdir(), `vibes-test-${crypto.randomUUID()}.db`)
14
+ }
15
+
16
+ function createTable(raw: Database, name: string, scoped: boolean) {
17
+ const ownerCol = scoped ? ", _owner TEXT" : ""
18
+ raw.exec(`CREATE TABLE ${name} (
19
+ id TEXT PRIMARY KEY,
20
+ created_at TEXT,
21
+ updated_at TEXT,
22
+ title TEXT
23
+ ${ownerCol}
24
+ )`)
25
+ if (scoped) markScoped(name)
26
+ }
27
+
28
+ // ── Scoped data isolation ────────────────────────────────────────────────────
29
+
30
+ describe("scoped collection security", () => {
31
+ let db: VibesDb
32
+ let dbPath: string
33
+
34
+ beforeEach(() => {
35
+ dbPath = tmpDbPath()
36
+ db = openDb(dbPath)
37
+ setDbInstance(db)
38
+ createTable(db.raw(), "notes", true)
39
+ })
40
+
41
+ afterEach(() => {
42
+ db.close()
43
+ try { fs.unlinkSync(dbPath) } catch {}
44
+ try { fs.unlinkSync(dbPath + "-shm") } catch {}
45
+ try { fs.unlinkSync(dbPath + "-wal") } catch {}
46
+ })
47
+
48
+ it("insert stamps _owner from ctx.userId", async () => {
49
+ const record = await ctxStore.run({ userId: "user-a" }, () =>
50
+ db.insert("notes", { title: "secret" })
51
+ )
52
+ expect(record._owner).toBe("user-a")
53
+ })
54
+
55
+ it("list only returns records owned by ctx.userId", async () => {
56
+ await ctxStore.run({ userId: "user-a" }, () =>
57
+ db.insert("notes", { title: "A's note" })
58
+ )
59
+ await ctxStore.run({ userId: "user-b" }, () =>
60
+ db.insert("notes", { title: "B's note" })
61
+ )
62
+
63
+ const aResults = await ctxStore.run({ userId: "user-a" }, () =>
64
+ db.getAll("notes")
65
+ )
66
+ const bResults = await ctxStore.run({ userId: "user-b" }, () =>
67
+ db.getAll("notes")
68
+ )
69
+
70
+ expect(aResults).toHaveLength(1)
71
+ expect(aResults[0].title).toBe("A's note")
72
+ expect(bResults).toHaveLength(1)
73
+ expect(bResults[0].title).toBe("B's note")
74
+ })
75
+
76
+ it("get returns null for another user's record", async () => {
77
+ const record = await ctxStore.run({ userId: "user-a" }, () =>
78
+ db.insert("notes", { title: "private" })
79
+ )
80
+
81
+ const result = await ctxStore.run({ userId: "user-b" }, () =>
82
+ db.get("notes", record.id as string)
83
+ )
84
+
85
+ expect(result).toBeNull()
86
+ })
87
+
88
+ it("update returns null for another user's record", async () => {
89
+ const record = await ctxStore.run({ userId: "user-a" }, () =>
90
+ db.insert("notes", { title: "original" })
91
+ )
92
+
93
+ const result = await ctxStore.run({ userId: "user-b" }, () =>
94
+ db.update("notes", record.id as string, { title: "hacked" })
95
+ )
96
+
97
+ expect(result).toBeNull()
98
+
99
+ // Verify original is unchanged
100
+ const original = await ctxStore.run({ userId: "user-a" }, () =>
101
+ db.get("notes", record.id as string)
102
+ )
103
+ expect(original!.title).toBe("original")
104
+ })
105
+
106
+ it("delete fails for another user's record", async () => {
107
+ const record = await ctxStore.run({ userId: "user-a" }, () =>
108
+ db.insert("notes", { title: "protected" })
109
+ )
110
+
111
+ const deleted = await ctxStore.run({ userId: "user-b" }, () =>
112
+ db.delete("notes", record.id as string)
113
+ )
114
+
115
+ expect(deleted).toBe(false)
116
+
117
+ // Verify still exists
118
+ const still = await ctxStore.run({ userId: "user-a" }, () =>
119
+ db.get("notes", record.id as string)
120
+ )
121
+ expect(still).not.toBeNull()
122
+ })
123
+
124
+ // ── Unauthenticated requests against scoped tables ──────────────────────
125
+ //
126
+ // Regression coverage for the auth-bypass CVE: prior behavior was that
127
+ // when ctx.userId was null (legitimate for unauth requests reaching the
128
+ // apiHandler), the `_owner = ?` predicate was silently omitted and the
129
+ // operation ran against EVERY user's rows. Each op must now refuse.
130
+
131
+ it("getAll throws VibesAuthRequiredError when ctx has no userId", async () => {
132
+ await ctxStore.run({ userId: "user-a" }, () =>
133
+ db.insert("notes", { title: "secret" })
134
+ )
135
+ const attempt = ctxStore.run({ userId: null }, () => db.getAll("notes"))
136
+ expect(attempt).rejects.toThrow(VibesAuthRequiredError)
137
+ })
138
+
139
+ it("get throws VibesAuthRequiredError when ctx has no userId", async () => {
140
+ const r = await ctxStore.run({ userId: "user-a" }, () =>
141
+ db.insert("notes", { title: "secret" })
142
+ )
143
+ const attempt = ctxStore.run({ userId: null }, () =>
144
+ db.get("notes", r.id as string),
145
+ )
146
+ expect(attempt).rejects.toThrow(VibesAuthRequiredError)
147
+ })
148
+
149
+ it("insert throws VibesAuthRequiredError when ctx has no userId", async () => {
150
+ const attempt = ctxStore.run({ userId: null }, () =>
151
+ db.insert("notes", { title: "anon write" }),
152
+ )
153
+ expect(attempt).rejects.toThrow(VibesAuthRequiredError)
154
+ })
155
+
156
+ it("update throws VibesAuthRequiredError when ctx has no userId", async () => {
157
+ const r = await ctxStore.run({ userId: "user-a" }, () =>
158
+ db.insert("notes", { title: "original" })
159
+ )
160
+ const attempt = ctxStore.run({ userId: null }, () =>
161
+ db.update("notes", r.id as string, { title: "hijack" }),
162
+ )
163
+ expect(attempt).rejects.toThrow(VibesAuthRequiredError)
164
+ })
165
+
166
+ it("delete throws VibesAuthRequiredError when ctx has no userId", async () => {
167
+ const r = await ctxStore.run({ userId: "user-a" }, () =>
168
+ db.insert("notes", { title: "victim" })
169
+ )
170
+ const attempt = ctxStore.run({ userId: null }, () =>
171
+ db.delete("notes", r.id as string),
172
+ )
173
+ expect(attempt).rejects.toThrow(VibesAuthRequiredError)
174
+ })
175
+ })
176
+
177
+ // ── Non-scoped collections ───────────────────────────────────────────────────
178
+
179
+ describe("non-scoped collection", () => {
180
+ let db: VibesDb
181
+ let dbPath: string
182
+
183
+ beforeEach(() => {
184
+ dbPath = tmpDbPath()
185
+ db = openDb(dbPath)
186
+ setDbInstance(db)
187
+ createTable(db.raw(), "posts", false)
188
+ })
189
+
190
+ afterEach(() => {
191
+ db.close()
192
+ try { fs.unlinkSync(dbPath) } catch {}
193
+ try { fs.unlinkSync(dbPath + "-shm") } catch {}
194
+ try { fs.unlinkSync(dbPath + "-wal") } catch {}
195
+ })
196
+
197
+ it("all users see all records", async () => {
198
+ await ctxStore.run({ userId: "user-a" }, () =>
199
+ db.insert("posts", { title: "public post" })
200
+ )
201
+
202
+ const results = await ctxStore.run({ userId: "user-b" }, () =>
203
+ db.getAll("posts")
204
+ )
205
+
206
+ expect(results).toHaveLength(1)
207
+ expect(results[0].title).toBe("public post")
208
+ })
209
+
210
+ it("any user can update any record", async () => {
211
+ const record = await ctxStore.run({ userId: "user-a" }, () =>
212
+ db.insert("posts", { title: "original" })
213
+ )
214
+
215
+ const updated = await ctxStore.run({ userId: "user-b" }, () =>
216
+ db.update("posts", record.id as string, { title: "edited by B" })
217
+ )
218
+
219
+ expect(updated).not.toBeNull()
220
+ expect(updated!.title).toBe("edited by B")
221
+ })
222
+ })
223
+
224
+ // ── Broker invalidation ─────────────────────────────────────────────────────
225
+
226
+ describe("broker invalidation", () => {
227
+ it("sends invalidation signal to connected clients", () => {
228
+ const messages: string[] = []
229
+ const client = {
230
+ readyState: 1,
231
+ send(data: string) { messages.push(data) },
232
+ }
233
+
234
+ addClient(client)
235
+ invalidate("entries")
236
+ removeClient(client)
237
+
238
+ expect(messages).toHaveLength(1)
239
+ const event = JSON.parse(messages[0])
240
+ expect(event.type).toBe("invalidate")
241
+ expect(event.collection).toBe("entries")
242
+ })
243
+
244
+ it("does not include any record data in events", () => {
245
+ const messages: string[] = []
246
+ const client = {
247
+ readyState: 1,
248
+ send(data: string) { messages.push(data) },
249
+ }
250
+
251
+ addClient(client)
252
+ invalidate("entries")
253
+ removeClient(client)
254
+
255
+ const event = JSON.parse(messages[0])
256
+ expect(event.data).toBeUndefined()
257
+ expect(event.id).toBeUndefined()
258
+ expect(event._owner).toBeUndefined()
259
+ })
260
+
261
+ it("skips disconnected clients", () => {
262
+ const messages: string[] = []
263
+ const connected = { readyState: 1, send(d: string) { messages.push(d) } }
264
+ const disconnected = { readyState: 3, send(_d: string) { throw new Error("should not send") } }
265
+
266
+ addClient(connected)
267
+ addClient(disconnected)
268
+ invalidate("entries")
269
+ removeClient(connected)
270
+ removeClient(disconnected)
271
+
272
+ expect(messages).toHaveLength(1)
273
+ })
274
+
275
+ it("removes client that throws on send", () => {
276
+ const broken = {
277
+ readyState: 1,
278
+ send(_d: string) { throw new Error("broken pipe") },
279
+ }
280
+
281
+ addClient(broken)
282
+ invalidate("entries")
283
+
284
+ expect(clientCount()).toBe(0)
285
+ })
286
+
287
+ it("mutations trigger invalidation", async () => {
288
+ const dbPath = tmpDbPath()
289
+ const db = openDb(dbPath)
290
+ setDbInstance(db)
291
+ createTable(db.raw(), "items", false)
292
+
293
+ const messages: string[] = []
294
+ const client = { readyState: 1, send(d: string) { messages.push(d) } }
295
+ addClient(client)
296
+
297
+ // Insert
298
+ const record = await db.insert("items", { title: "test" })
299
+ expect(messages).toHaveLength(1)
300
+ expect(JSON.parse(messages[0]).collection).toBe("items")
301
+
302
+ // Update
303
+ await db.update("items", record.id as string, { title: "updated" })
304
+ expect(messages).toHaveLength(2)
305
+
306
+ // Delete
307
+ await db.delete("items", record.id as string)
308
+ expect(messages).toHaveLength(3)
309
+
310
+ // All events are invalidation-only
311
+ for (const msg of messages) {
312
+ const event = JSON.parse(msg)
313
+ expect(event.type).toBe("invalidate")
314
+ expect(event.data).toBeUndefined()
315
+ }
316
+
317
+ removeClient(client)
318
+ db.close()
319
+ try { fs.unlinkSync(dbPath) } catch {}
320
+ try { fs.unlinkSync(dbPath + "-shm") } catch {}
321
+ try { fs.unlinkSync(dbPath + "-wal") } catch {}
322
+ })
323
+ })