@zooid/server 0.0.1

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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +37 -0
  3. package/src/cloudflare-test.d.ts +4 -0
  4. package/src/db/queries.test.ts +501 -0
  5. package/src/db/queries.ts +450 -0
  6. package/src/db/schema.sql +56 -0
  7. package/src/do/channel.ts +69 -0
  8. package/src/index.ts +88 -0
  9. package/src/lib/jwt.test.ts +89 -0
  10. package/src/lib/jwt.ts +28 -0
  11. package/src/lib/schema-validator.test.ts +101 -0
  12. package/src/lib/schema-validator.ts +64 -0
  13. package/src/lib/signing.test.ts +73 -0
  14. package/src/lib/signing.ts +60 -0
  15. package/src/lib/ulid.test.ts +25 -0
  16. package/src/lib/ulid.ts +8 -0
  17. package/src/lib/validation.test.ts +35 -0
  18. package/src/lib/validation.ts +8 -0
  19. package/src/lib/xml.ts +13 -0
  20. package/src/middleware/auth.test.ts +125 -0
  21. package/src/middleware/auth.ts +103 -0
  22. package/src/routes/channels.test.ts +335 -0
  23. package/src/routes/channels.ts +220 -0
  24. package/src/routes/directory.test.ts +223 -0
  25. package/src/routes/directory.ts +109 -0
  26. package/src/routes/events.test.ts +477 -0
  27. package/src/routes/events.ts +315 -0
  28. package/src/routes/feed.test.ts +238 -0
  29. package/src/routes/feed.ts +101 -0
  30. package/src/routes/opml.test.ts +131 -0
  31. package/src/routes/opml.ts +41 -0
  32. package/src/routes/rss.test.ts +224 -0
  33. package/src/routes/rss.ts +91 -0
  34. package/src/routes/server-meta.test.ts +157 -0
  35. package/src/routes/server-meta.ts +100 -0
  36. package/src/routes/webhooks.test.ts +238 -0
  37. package/src/routes/webhooks.ts +111 -0
  38. package/src/routes/well-known.test.ts +34 -0
  39. package/src/routes/well-known.ts +58 -0
  40. package/src/routes/ws.test.ts +503 -0
  41. package/src/routes/ws.ts +25 -0
  42. package/src/test-utils.ts +79 -0
  43. package/src/types.ts +63 -0
  44. package/wrangler.toml +26 -0
@@ -0,0 +1,477 @@
1
+ import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
2
+ import { env } from 'cloudflare:test';
3
+ import app from '../index';
4
+ import { setupTestDb, cleanTestDb } from '../test-utils';
5
+ import { createToken } from '../lib/jwt';
6
+
7
+ const JWT_SECRET = 'test-jwt-secret';
8
+
9
+ async function adminRequest(path: string, options: RequestInit = {}) {
10
+ const token = await createToken({ scope: 'admin' }, JWT_SECRET);
11
+ const headers = new Headers(options.headers);
12
+ headers.set('Authorization', `Bearer ${token}`);
13
+ headers.set('Content-Type', 'application/json');
14
+ return app.request(
15
+ path,
16
+ { ...options, headers },
17
+ {
18
+ ...env,
19
+ ZOOID_JWT_SECRET: JWT_SECRET,
20
+ },
21
+ );
22
+ }
23
+
24
+ async function publishRequest(
25
+ path: string,
26
+ options: RequestInit = {},
27
+ channel: string,
28
+ ) {
29
+ const token = await createToken(
30
+ { scope: 'publish', channel, sub: 'test-publisher' },
31
+ JWT_SECRET,
32
+ );
33
+ const headers = new Headers(options.headers);
34
+ headers.set('Authorization', `Bearer ${token}`);
35
+ headers.set('Content-Type', 'application/json');
36
+ return app.request(
37
+ path,
38
+ { ...options, headers },
39
+ {
40
+ ...env,
41
+ ZOOID_JWT_SECRET: JWT_SECRET,
42
+ },
43
+ );
44
+ }
45
+
46
+ async function subscribeRequest(path: string, channel: string) {
47
+ const token = await createToken(
48
+ { scope: 'subscribe', channel, sub: 'test-subscriber' },
49
+ JWT_SECRET,
50
+ );
51
+ return app.request(
52
+ path,
53
+ {
54
+ headers: { Authorization: `Bearer ${token}` },
55
+ },
56
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
57
+ );
58
+ }
59
+
60
+ describe('Event routes', () => {
61
+ beforeAll(async () => {
62
+ await setupTestDb();
63
+ });
64
+
65
+ beforeEach(async () => {
66
+ await cleanTestDb();
67
+ await adminRequest('/api/v1/channels', {
68
+ method: 'POST',
69
+ body: JSON.stringify({
70
+ id: 'pub-channel',
71
+ name: 'Public Channel',
72
+ is_public: true,
73
+ }),
74
+ });
75
+ await adminRequest('/api/v1/channels', {
76
+ method: 'POST',
77
+ body: JSON.stringify({
78
+ id: 'priv-channel',
79
+ name: 'Private Channel',
80
+ is_public: false,
81
+ }),
82
+ });
83
+ });
84
+
85
+ describe('POST /channels/:channelId/events (publish)', () => {
86
+ it('publishes a single event', async () => {
87
+ const res = await publishRequest(
88
+ '/api/v1/channels/pub-channel/events',
89
+ {
90
+ method: 'POST',
91
+ body: JSON.stringify({
92
+ type: 'signal',
93
+ data: { message: 'hello' },
94
+ }),
95
+ },
96
+ 'pub-channel',
97
+ );
98
+
99
+ expect(res.status).toBe(201);
100
+ const body = (await res.json()) as {
101
+ id: string;
102
+ channel_id: string;
103
+ type: string;
104
+ publisher_id: string;
105
+ created_at: string;
106
+ };
107
+ expect(body.id).toBeTruthy();
108
+ expect(body.channel_id).toBe('pub-channel');
109
+ expect(body.type).toBe('signal');
110
+ expect(body.publisher_id).toBe('test-publisher');
111
+ expect(body.created_at).toBeTruthy();
112
+ });
113
+
114
+ it('publishes a batch of events', async () => {
115
+ const res = await publishRequest(
116
+ '/api/v1/channels/pub-channel/events',
117
+ {
118
+ method: 'POST',
119
+ body: JSON.stringify({
120
+ events: [
121
+ { type: 'a', data: { v: 1 } },
122
+ { type: 'b', data: { v: 2 } },
123
+ ],
124
+ }),
125
+ },
126
+ 'pub-channel',
127
+ );
128
+
129
+ expect(res.status).toBe(201);
130
+ const body = (await res.json()) as {
131
+ events: Array<{ id: string }>;
132
+ };
133
+ expect(body.events).toHaveLength(2);
134
+ expect(body.events[0].id).toBeTruthy();
135
+ expect(body.events[1].id).toBeTruthy();
136
+ });
137
+
138
+ it('rejects without publish token', async () => {
139
+ const res = await app.request(
140
+ '/api/v1/channels/pub-channel/events',
141
+ {
142
+ method: 'POST',
143
+ headers: { 'Content-Type': 'application/json' },
144
+ body: JSON.stringify({ type: 'x', data: {} }),
145
+ },
146
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
147
+ );
148
+ expect(res.status).toBe(401);
149
+ });
150
+
151
+ it('rejects publish token for wrong channel', async () => {
152
+ const res = await publishRequest(
153
+ '/api/v1/channels/pub-channel/events',
154
+ {
155
+ method: 'POST',
156
+ body: JSON.stringify({ type: 'x', data: {} }),
157
+ },
158
+ 'other-channel',
159
+ );
160
+ expect(res.status).toBe(403);
161
+ });
162
+
163
+ it('rejects missing data field', async () => {
164
+ const res = await publishRequest(
165
+ '/api/v1/channels/pub-channel/events',
166
+ {
167
+ method: 'POST',
168
+ body: JSON.stringify({ type: 'x' }),
169
+ },
170
+ 'pub-channel',
171
+ );
172
+ expect(res.status).toBe(400);
173
+ });
174
+
175
+ it('returns 404 for non-existent channel', async () => {
176
+ const res = await publishRequest(
177
+ '/api/v1/channels/nonexistent/events',
178
+ {
179
+ method: 'POST',
180
+ body: JSON.stringify({ type: 'x', data: {} }),
181
+ },
182
+ 'nonexistent',
183
+ );
184
+ expect(res.status).toBe(404);
185
+ });
186
+ });
187
+
188
+ describe('Strict schema validation', () => {
189
+ const strictSchema = {
190
+ alert: {
191
+ required: ['level', 'message'],
192
+ properties: {
193
+ level: { type: 'string', enum: ['info', 'warn', 'error'] },
194
+ message: { type: 'string' },
195
+ },
196
+ },
197
+ metric: {
198
+ required: ['name', 'value'],
199
+ properties: {
200
+ name: { type: 'string' },
201
+ value: { type: 'number' },
202
+ },
203
+ },
204
+ };
205
+
206
+ beforeEach(async () => {
207
+ await adminRequest('/api/v1/channels', {
208
+ method: 'POST',
209
+ body: JSON.stringify({
210
+ id: 'strict-channel',
211
+ name: 'Strict Channel',
212
+ schema: strictSchema,
213
+ strict: true,
214
+ }),
215
+ });
216
+ await adminRequest('/api/v1/channels', {
217
+ method: 'POST',
218
+ body: JSON.stringify({
219
+ id: 'doconly-channel',
220
+ name: 'Doc-Only Channel',
221
+ schema: strictSchema,
222
+ strict: false,
223
+ }),
224
+ });
225
+ });
226
+
227
+ it('strict channel accepts valid event', async () => {
228
+ const res = await publishRequest(
229
+ '/api/v1/channels/strict-channel/events',
230
+ {
231
+ method: 'POST',
232
+ body: JSON.stringify({
233
+ type: 'alert',
234
+ data: { level: 'info', message: 'hello' },
235
+ }),
236
+ },
237
+ 'strict-channel',
238
+ );
239
+ expect(res.status).toBe(201);
240
+ });
241
+
242
+ it('strict channel rejects event with no type', async () => {
243
+ const res = await publishRequest(
244
+ '/api/v1/channels/strict-channel/events',
245
+ {
246
+ method: 'POST',
247
+ body: JSON.stringify({ data: { level: 'info', message: 'hello' } }),
248
+ },
249
+ 'strict-channel',
250
+ );
251
+ expect(res.status).toBe(400);
252
+ const body = (await res.json()) as { error: string };
253
+ expect(body.error).toContain('must have a type');
254
+ });
255
+
256
+ it('strict channel rejects event with unknown type', async () => {
257
+ const res = await publishRequest(
258
+ '/api/v1/channels/strict-channel/events',
259
+ {
260
+ method: 'POST',
261
+ body: JSON.stringify({ type: 'unknown', data: { foo: 'bar' } }),
262
+ },
263
+ 'strict-channel',
264
+ );
265
+ expect(res.status).toBe(400);
266
+ const body = (await res.json()) as { error: string };
267
+ expect(body.error).toContain('Unknown event type');
268
+ });
269
+
270
+ it('strict channel rejects event with wrong data shape', async () => {
271
+ const res = await publishRequest(
272
+ '/api/v1/channels/strict-channel/events',
273
+ {
274
+ method: 'POST',
275
+ body: JSON.stringify({
276
+ type: 'metric',
277
+ data: { name: 'cpu', value: 'not-a-number' },
278
+ }),
279
+ },
280
+ 'strict-channel',
281
+ );
282
+ expect(res.status).toBe(400);
283
+ const body = (await res.json()) as { error: string };
284
+ expect(body.error).toContain('Validation failed');
285
+ });
286
+
287
+ it('strict channel validates batch events', async () => {
288
+ const res = await publishRequest(
289
+ '/api/v1/channels/strict-channel/events',
290
+ {
291
+ method: 'POST',
292
+ body: JSON.stringify({
293
+ events: [
294
+ { type: 'alert', data: { level: 'info', message: 'ok' } },
295
+ { type: 'metric', data: { name: 'cpu', value: 'bad' } },
296
+ ],
297
+ }),
298
+ },
299
+ 'strict-channel',
300
+ );
301
+ expect(res.status).toBe(400);
302
+ });
303
+
304
+ it('non-strict channel with schema does NOT validate', async () => {
305
+ const res = await publishRequest(
306
+ '/api/v1/channels/doconly-channel/events',
307
+ {
308
+ method: 'POST',
309
+ body: JSON.stringify({
310
+ type: 'unknown-type',
311
+ data: { anything: true },
312
+ }),
313
+ },
314
+ 'doconly-channel',
315
+ );
316
+ expect(res.status).toBe(201);
317
+ });
318
+
319
+ it('channel without schema is unaffected', async () => {
320
+ const res = await publishRequest(
321
+ '/api/v1/channels/pub-channel/events',
322
+ {
323
+ method: 'POST',
324
+ body: JSON.stringify({ data: { anything: true } }),
325
+ },
326
+ 'pub-channel',
327
+ );
328
+ expect(res.status).toBe(201);
329
+ });
330
+ });
331
+
332
+ describe('GET /channels/:channelId/events (poll)', () => {
333
+ it('polls events from a public channel without auth', async () => {
334
+ await publishRequest(
335
+ '/api/v1/channels/pub-channel/events',
336
+ {
337
+ method: 'POST',
338
+ body: JSON.stringify({ type: 'signal', data: { v: 1 } }),
339
+ },
340
+ 'pub-channel',
341
+ );
342
+
343
+ const res = await app.request(
344
+ '/api/v1/channels/pub-channel/events',
345
+ {},
346
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
347
+ );
348
+
349
+ expect(res.status).toBe(200);
350
+ const body = (await res.json()) as {
351
+ events: Array<{ type: string }>;
352
+ has_more: boolean;
353
+ };
354
+ expect(body.events).toHaveLength(1);
355
+ expect(body.events[0].type).toBe('signal');
356
+ expect(body.has_more).toBe(false);
357
+ });
358
+
359
+ it('requires subscribe token for private channel', async () => {
360
+ const res = await app.request(
361
+ '/api/v1/channels/priv-channel/events',
362
+ {},
363
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
364
+ );
365
+ expect(res.status).toBe(401);
366
+ });
367
+
368
+ it('allows subscribe token for private channel', async () => {
369
+ const res = await subscribeRequest(
370
+ '/api/v1/channels/priv-channel/events',
371
+ 'priv-channel',
372
+ );
373
+ expect(res.status).toBe(200);
374
+ });
375
+
376
+ it('supports limit query param', async () => {
377
+ for (let i = 0; i < 5; i++) {
378
+ await publishRequest(
379
+ '/api/v1/channels/pub-channel/events',
380
+ {
381
+ method: 'POST',
382
+ body: JSON.stringify({ type: 'evt', data: { i } }),
383
+ },
384
+ 'pub-channel',
385
+ );
386
+ }
387
+
388
+ const res = await app.request(
389
+ '/api/v1/channels/pub-channel/events?limit=2',
390
+ {},
391
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
392
+ );
393
+ const body = (await res.json()) as {
394
+ events: unknown[];
395
+ has_more: boolean;
396
+ cursor: string;
397
+ };
398
+ expect(body.events).toHaveLength(2);
399
+ expect(body.has_more).toBe(true);
400
+ expect(body.cursor).toBeTruthy();
401
+ });
402
+
403
+ it('supports cursor pagination', async () => {
404
+ for (let i = 0; i < 3; i++) {
405
+ await publishRequest(
406
+ '/api/v1/channels/pub-channel/events',
407
+ {
408
+ method: 'POST',
409
+ body: JSON.stringify({ type: 'evt', data: { i } }),
410
+ },
411
+ 'pub-channel',
412
+ );
413
+ }
414
+
415
+ const page1 = await app.request(
416
+ '/api/v1/channels/pub-channel/events?limit=2',
417
+ {},
418
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
419
+ );
420
+ const body1 = (await page1.json()) as {
421
+ cursor: string;
422
+ events: unknown[];
423
+ };
424
+
425
+ const page2 = await app.request(
426
+ `/api/v1/channels/pub-channel/events?limit=2&cursor=${body1.cursor}`,
427
+ {},
428
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
429
+ );
430
+ const body2 = (await page2.json()) as {
431
+ events: unknown[];
432
+ has_more: boolean;
433
+ };
434
+ expect(body2.events).toHaveLength(1);
435
+ expect(body2.has_more).toBe(false);
436
+ });
437
+
438
+ it('supports type filter', async () => {
439
+ await publishRequest(
440
+ '/api/v1/channels/pub-channel/events',
441
+ {
442
+ method: 'POST',
443
+ body: JSON.stringify({ type: 'signal', data: {} }),
444
+ },
445
+ 'pub-channel',
446
+ );
447
+ await publishRequest(
448
+ '/api/v1/channels/pub-channel/events',
449
+ {
450
+ method: 'POST',
451
+ body: JSON.stringify({ type: 'alert', data: {} }),
452
+ },
453
+ 'pub-channel',
454
+ );
455
+
456
+ const res = await app.request(
457
+ '/api/v1/channels/pub-channel/events?type=signal',
458
+ {},
459
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
460
+ );
461
+ const body = (await res.json()) as {
462
+ events: Array<{ type: string }>;
463
+ };
464
+ expect(body.events).toHaveLength(1);
465
+ expect(body.events[0].type).toBe('signal');
466
+ });
467
+
468
+ it('returns 404 for non-existent channel', async () => {
469
+ const res = await app.request(
470
+ '/api/v1/channels/nonexistent/events',
471
+ {},
472
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
473
+ );
474
+ expect(res.status).toBe(404);
475
+ });
476
+ });
477
+ });