@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,103 @@
1
+ import { createMiddleware } from 'hono/factory';
2
+ import type { Bindings, Variables, ZooidJWT } from '../types';
3
+ import { verifyToken } from '../lib/jwt';
4
+
5
+ type Env = { Bindings: Bindings; Variables: Variables };
6
+
7
+ export function requireAuth() {
8
+ return createMiddleware<Env>(async (c, next) => {
9
+ const authHeader = c.req.header('Authorization');
10
+ if (!authHeader?.startsWith('Bearer ')) {
11
+ return c.json({ error: 'Missing or invalid Authorization header' }, 401);
12
+ }
13
+
14
+ const token = authHeader.slice(7);
15
+ try {
16
+ const payload = await verifyToken(token, c.env.ZOOID_JWT_SECRET);
17
+ c.set('jwtPayload', payload);
18
+ } catch {
19
+ return c.json({ error: 'Invalid or expired token' }, 401);
20
+ }
21
+
22
+ await next();
23
+ });
24
+ }
25
+
26
+ export function requireScope(
27
+ scope: string,
28
+ options?: { channelParam?: string },
29
+ ) {
30
+ return createMiddleware<Env>(async (c, next) => {
31
+ const payload = c.get('jwtPayload') as ZooidJWT;
32
+
33
+ // Admin can do anything
34
+ if (payload.scope === 'admin') {
35
+ await next();
36
+ return;
37
+ }
38
+
39
+ // Check scope matches
40
+ if (payload.scope !== scope) {
41
+ return c.json({ error: 'Insufficient permissions' }, 403);
42
+ }
43
+
44
+ // Check channel matches if channel-scoped
45
+ if (options?.channelParam) {
46
+ const channelId = c.req.param(options.channelParam);
47
+ if (payload.channel !== channelId) {
48
+ return c.json({ error: 'Token not valid for this channel' }, 403);
49
+ }
50
+ }
51
+
52
+ await next();
53
+ });
54
+ }
55
+
56
+ export function requireSubscribeIfPrivate(channelParam: string) {
57
+ return createMiddleware<Env>(async (c, next) => {
58
+ const channelId = c.req.param(channelParam);
59
+ const db = c.env.DB;
60
+
61
+ const channel = await db
62
+ .prepare('SELECT is_public FROM channels WHERE id = ?')
63
+ .bind(channelId)
64
+ .first<{ is_public: number }>();
65
+
66
+ if (!channel) {
67
+ return c.json({ error: 'Channel not found' }, 404);
68
+ }
69
+
70
+ if (channel.is_public === 1) {
71
+ c.set('channelIsPublic', true);
72
+ await next();
73
+ return;
74
+ }
75
+
76
+ const authHeader = c.req.header('Authorization');
77
+ const queryToken = c.req.query('token');
78
+ const rawToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : queryToken ?? null;
79
+
80
+ if (!rawToken) {
81
+ return c.json(
82
+ { error: 'Subscribe token required for private channel' },
83
+ 401,
84
+ );
85
+ }
86
+
87
+ const token = rawToken;
88
+ try {
89
+ const payload = await verifyToken(token, c.env.ZOOID_JWT_SECRET);
90
+ if (payload.scope !== 'admin' && payload.scope !== 'subscribe') {
91
+ return c.json({ error: 'Insufficient permissions' }, 403);
92
+ }
93
+ if (payload.scope === 'subscribe' && payload.channel !== channelId) {
94
+ return c.json({ error: 'Token not valid for this channel' }, 403);
95
+ }
96
+ c.set('jwtPayload', payload);
97
+ } catch {
98
+ return c.json({ error: 'Invalid or expired token' }, 401);
99
+ }
100
+
101
+ await next();
102
+ });
103
+ }
@@ -0,0 +1,335 @@
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 authRequest(
10
+ path: string,
11
+ options: RequestInit = {},
12
+ scope: 'admin' | 'publish' | 'subscribe' = 'admin',
13
+ channel?: string,
14
+ ) {
15
+ const token = await createToken({ scope, channel }, JWT_SECRET);
16
+ const headers = new Headers(options.headers);
17
+ headers.set('Authorization', `Bearer ${token}`);
18
+ headers.set('Content-Type', 'application/json');
19
+ return app.request(
20
+ path,
21
+ { ...options, headers },
22
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
23
+ );
24
+ }
25
+
26
+ describe('Channel routes', () => {
27
+ beforeAll(async () => {
28
+ await setupTestDb();
29
+ });
30
+
31
+ beforeEach(async () => {
32
+ await cleanTestDb();
33
+ });
34
+
35
+ describe('POST /channels', () => {
36
+ it('creates a channel with admin token', async () => {
37
+ const res = await authRequest('/api/v1/channels', {
38
+ method: 'POST',
39
+ body: JSON.stringify({
40
+ id: 'test-channel',
41
+ name: 'Test Channel',
42
+ description: 'A test channel',
43
+ is_public: true,
44
+ }),
45
+ });
46
+
47
+ expect(res.status).toBe(201);
48
+ const body = (await res.json()) as {
49
+ id: string;
50
+ publish_token: string;
51
+ subscribe_token: string;
52
+ };
53
+ expect(body.id).toBe('test-channel');
54
+ expect(body.publish_token).toBeTruthy();
55
+ expect(body.subscribe_token).toBeTruthy();
56
+ });
57
+
58
+ it('rejects without auth', async () => {
59
+ const res = await app.request(
60
+ '/api/v1/channels',
61
+ {
62
+ method: 'POST',
63
+ headers: { 'Content-Type': 'application/json' },
64
+ body: JSON.stringify({ id: 'test-channel', name: 'Test Channel' }),
65
+ },
66
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
67
+ );
68
+
69
+ expect(res.status).toBe(401);
70
+ });
71
+
72
+ it('rejects with non-admin token', async () => {
73
+ const res = await authRequest(
74
+ '/api/v1/channels',
75
+ {
76
+ method: 'POST',
77
+ body: JSON.stringify({ id: 'test-channel', name: 'Test Channel' }),
78
+ },
79
+ 'publish',
80
+ 'some-channel',
81
+ );
82
+
83
+ expect(res.status).toBe(403);
84
+ });
85
+
86
+ it('rejects invalid channel ID (too short)', async () => {
87
+ const res = await authRequest('/api/v1/channels', {
88
+ method: 'POST',
89
+ body: JSON.stringify({ id: 'ab', name: 'Bad Channel' }),
90
+ });
91
+
92
+ expect(res.status).toBe(400);
93
+ });
94
+
95
+ it('rejects invalid channel ID (uppercase)', async () => {
96
+ const res = await authRequest('/api/v1/channels', {
97
+ method: 'POST',
98
+ body: JSON.stringify({ id: 'MyChannel', name: 'Bad Channel' }),
99
+ });
100
+
101
+ expect(res.status).toBe(400);
102
+ });
103
+
104
+ it('rejects duplicate channel ID', async () => {
105
+ await authRequest('/api/v1/channels', {
106
+ method: 'POST',
107
+ body: JSON.stringify({ id: 'test-channel', name: 'Test Channel' }),
108
+ });
109
+
110
+ const res = await authRequest('/api/v1/channels', {
111
+ method: 'POST',
112
+ body: JSON.stringify({ id: 'test-channel', name: 'Duplicate' }),
113
+ });
114
+
115
+ expect(res.status).toBe(409);
116
+ });
117
+
118
+ it('accepts optional schema field', async () => {
119
+ const schema = {
120
+ type: 'object',
121
+ properties: { market: { type: 'string' } },
122
+ required: ['market'],
123
+ };
124
+
125
+ const res = await authRequest('/api/v1/channels', {
126
+ method: 'POST',
127
+ body: JSON.stringify({
128
+ id: 'schema-channel',
129
+ name: 'Schema Channel',
130
+ schema,
131
+ }),
132
+ });
133
+
134
+ expect(res.status).toBe(201);
135
+ });
136
+
137
+ it('creates a strict channel with schema', async () => {
138
+ const schema = {
139
+ alert: {
140
+ required: ['level'],
141
+ properties: { level: { type: 'string' } },
142
+ },
143
+ };
144
+
145
+ const res = await authRequest('/api/v1/channels', {
146
+ method: 'POST',
147
+ body: JSON.stringify({
148
+ id: 'strict-channel',
149
+ name: 'Strict Channel',
150
+ schema,
151
+ strict: true,
152
+ }),
153
+ });
154
+
155
+ expect(res.status).toBe(201);
156
+ });
157
+
158
+ it('rejects strict channel without schema', async () => {
159
+ const res = await authRequest('/api/v1/channels', {
160
+ method: 'POST',
161
+ body: JSON.stringify({
162
+ id: 'strict-no-schema',
163
+ name: 'Bad Strict',
164
+ strict: true,
165
+ }),
166
+ });
167
+
168
+ expect(res.status).toBe(400);
169
+ const body = (await res.json()) as { error: string };
170
+ expect(body.error).toContain('schema');
171
+ });
172
+
173
+ it('creates a channel with tags', async () => {
174
+ const res = await authRequest('/api/v1/channels', {
175
+ method: 'POST',
176
+ body: JSON.stringify({
177
+ id: 'tagged-channel',
178
+ name: 'Tagged Channel',
179
+ tags: ['ai', 'crypto'],
180
+ }),
181
+ });
182
+
183
+ expect(res.status).toBe(201);
184
+ });
185
+ });
186
+
187
+ describe('GET /channels', () => {
188
+ it('returns empty list when no channels exist', async () => {
189
+ const res = await app.request(
190
+ '/api/v1/channels',
191
+ {},
192
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
193
+ );
194
+ expect(res.status).toBe(200);
195
+ const body = (await res.json()) as { channels: unknown[] };
196
+ expect(body.channels).toEqual([]);
197
+ });
198
+
199
+ it('lists all channels (no auth required)', async () => {
200
+ await authRequest('/api/v1/channels', {
201
+ method: 'POST',
202
+ body: JSON.stringify({ id: 'channel-a', name: 'Channel A' }),
203
+ });
204
+ await authRequest('/api/v1/channels', {
205
+ method: 'POST',
206
+ body: JSON.stringify({ id: 'channel-b', name: 'Channel B' }),
207
+ });
208
+
209
+ const res = await app.request(
210
+ '/api/v1/channels',
211
+ {},
212
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
213
+ );
214
+ expect(res.status).toBe(200);
215
+
216
+ const body = (await res.json()) as {
217
+ channels: Array<{ id: string; name: string }>;
218
+ };
219
+ expect(body.channels).toHaveLength(2);
220
+ expect(body.channels[0].id).toBeTruthy();
221
+ expect(body.channels[0].name).toBeTruthy();
222
+ });
223
+
224
+ it('lists channels with tags', async () => {
225
+ await authRequest('/api/v1/channels', {
226
+ method: 'POST',
227
+ body: JSON.stringify({
228
+ id: 'tagged-list',
229
+ name: 'Tagged List',
230
+ tags: ['ai', 'agents'],
231
+ }),
232
+ });
233
+
234
+ const res = await app.request(
235
+ '/api/v1/channels',
236
+ {},
237
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
238
+ );
239
+ const body = (await res.json()) as {
240
+ channels: Array<{ id: string; tags: string[] }>;
241
+ };
242
+
243
+ const ch = body.channels.find((c) => c.id === 'tagged-list')!;
244
+ expect(ch.tags).toEqual(['ai', 'agents']);
245
+ });
246
+
247
+ it('lists channels without tags as empty array', async () => {
248
+ await authRequest('/api/v1/channels', {
249
+ method: 'POST',
250
+ body: JSON.stringify({
251
+ id: 'no-tags-list',
252
+ name: 'No Tags',
253
+ }),
254
+ });
255
+
256
+ const res = await app.request(
257
+ '/api/v1/channels',
258
+ {},
259
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
260
+ );
261
+ const body = (await res.json()) as {
262
+ channels: Array<{ id: string; tags: string[] }>;
263
+ };
264
+
265
+ const ch = body.channels.find((c) => c.id === 'no-tags-list')!;
266
+ expect(ch.tags).toEqual([]);
267
+ });
268
+
269
+ it('includes event_count and last_event_at fields', async () => {
270
+ await authRequest('/api/v1/channels', {
271
+ method: 'POST',
272
+ body: JSON.stringify({ id: 'count-channel', name: 'Count Channel' }),
273
+ });
274
+
275
+ const res = await app.request(
276
+ '/api/v1/channels',
277
+ {},
278
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
279
+ );
280
+ const body = (await res.json()) as {
281
+ channels: Array<{ event_count: number; last_event_at: string | null }>;
282
+ };
283
+
284
+ expect(body.channels[0]).toHaveProperty('event_count');
285
+ expect(body.channels[0]).toHaveProperty('last_event_at');
286
+ });
287
+ });
288
+
289
+ describe('POST /channels/:channelId/publishers', () => {
290
+ it('adds a publisher to a channel', async () => {
291
+ await authRequest('/api/v1/channels', {
292
+ method: 'POST',
293
+ body: JSON.stringify({ id: 'pub-channel', name: 'Pub Channel' }),
294
+ });
295
+
296
+ const res = await authRequest('/api/v1/channels/pub-channel/publishers', {
297
+ method: 'POST',
298
+ body: JSON.stringify({ name: 'whale-bot' }),
299
+ });
300
+
301
+ expect(res.status).toBe(201);
302
+ const body = (await res.json()) as {
303
+ id: string;
304
+ name: string;
305
+ publish_token: string;
306
+ };
307
+ expect(body.id).toBeTruthy();
308
+ expect(body.name).toBe('whale-bot');
309
+ expect(body.publish_token).toBeTruthy();
310
+ });
311
+
312
+ it('rejects without admin token', async () => {
313
+ const res = await authRequest(
314
+ '/api/v1/channels/pub-channel/publishers',
315
+ {
316
+ method: 'POST',
317
+ body: JSON.stringify({ name: 'whale-bot' }),
318
+ },
319
+ 'subscribe',
320
+ 'pub-channel',
321
+ );
322
+
323
+ expect(res.status).toBe(403);
324
+ });
325
+
326
+ it('returns 404 for non-existent channel', async () => {
327
+ const res = await authRequest('/api/v1/channels/nonexistent/publishers', {
328
+ method: 'POST',
329
+ body: JSON.stringify({ name: 'whale-bot' }),
330
+ });
331
+
332
+ expect(res.status).toBe(404);
333
+ });
334
+ });
335
+ });
@@ -0,0 +1,220 @@
1
+ import { OpenAPIRoute } from 'chanfana';
2
+ import { z } from 'zod';
3
+ import type { Context } from 'hono';
4
+ import type { Bindings, Variables } from '../types';
5
+ import { isValidChannelId } from '../lib/validation';
6
+ import { generateUlid } from '../lib/ulid';
7
+ import { createToken } from '../lib/jwt';
8
+ import {
9
+ createChannel,
10
+ getChannel,
11
+ listChannels,
12
+ createPublisher,
13
+ } from '../db/queries';
14
+
15
+ type Env = { Bindings: Bindings; Variables: Variables };
16
+
17
+ export class ListChannels extends OpenAPIRoute {
18
+ schema = {
19
+ summary: 'List channels',
20
+ tags: ['Channels'],
21
+ responses: {
22
+ 200: {
23
+ description: 'List of channels',
24
+ content: {
25
+ 'application/json': {
26
+ schema: z.object({
27
+ channels: z.array(
28
+ z.object({
29
+ id: z.string(),
30
+ name: z.string(),
31
+ description: z.string().nullable(),
32
+ tags: z.array(z.string()),
33
+ is_public: z.boolean(),
34
+ event_count: z.number(),
35
+ last_event_at: z.string().nullable(),
36
+ created_at: z.string(),
37
+ }),
38
+ ),
39
+ }),
40
+ },
41
+ },
42
+ },
43
+ },
44
+ };
45
+
46
+ async handle(c: Context<Env>) {
47
+ const list = await listChannels(c.env.DB);
48
+ return c.json({ channels: list });
49
+ }
50
+ }
51
+
52
+ export class CreateChannel extends OpenAPIRoute {
53
+ schema = {
54
+ summary: 'Create a channel',
55
+ tags: ['Channels'],
56
+ security: [{ bearerAuth: [] }],
57
+ request: {
58
+ body: {
59
+ content: {
60
+ 'application/json': {
61
+ schema: z.object({
62
+ id: z.string().min(3).max(64),
63
+ name: z.string().min(1),
64
+ description: z.string().optional(),
65
+ tags: z.array(z.string()).optional(),
66
+ is_public: z.boolean().optional(),
67
+ schema: z.record(z.string(), z.unknown()).optional(),
68
+ strict: z.boolean().optional(),
69
+ }),
70
+ },
71
+ },
72
+ },
73
+ },
74
+ responses: {
75
+ 201: {
76
+ description: 'Channel created',
77
+ content: {
78
+ 'application/json': {
79
+ schema: z.object({
80
+ id: z.string(),
81
+ publish_token: z.string(),
82
+ subscribe_token: z.string(),
83
+ }),
84
+ },
85
+ },
86
+ },
87
+ 400: {
88
+ description: 'Validation error',
89
+ content: {
90
+ 'application/json': {
91
+ schema: z.object({ error: z.string() }),
92
+ },
93
+ },
94
+ },
95
+ 409: {
96
+ description: 'Channel already exists',
97
+ content: {
98
+ 'application/json': {
99
+ schema: z.object({ error: z.string() }),
100
+ },
101
+ },
102
+ },
103
+ },
104
+ };
105
+
106
+ async handle(c: Context<Env>) {
107
+ const data = await this.getValidatedData<typeof this.schema>();
108
+ const body = data.body;
109
+
110
+ if (!isValidChannelId(body.id)) {
111
+ return c.json(
112
+ {
113
+ error:
114
+ 'Invalid channel ID. Must be 3-64 chars, lowercase alphanumeric + hyphens, no leading/trailing hyphens.',
115
+ },
116
+ 400,
117
+ );
118
+ }
119
+
120
+ if (body.strict && !body.schema) {
121
+ return c.json({ error: 'strict channels require a schema' }, 400);
122
+ }
123
+
124
+ const existing = await getChannel(c.env.DB, body.id);
125
+ if (existing) {
126
+ return c.json({ error: 'Channel already exists' }, 409);
127
+ }
128
+
129
+ const channel = await createChannel(c.env.DB, body);
130
+
131
+ const publishToken = await createToken(
132
+ { scope: 'publish', channel: channel.id, sub: generateUlid() },
133
+ c.env.ZOOID_JWT_SECRET,
134
+ );
135
+ const subscribeToken = await createToken(
136
+ { scope: 'subscribe', channel: channel.id, sub: generateUlid() },
137
+ c.env.ZOOID_JWT_SECRET,
138
+ );
139
+
140
+ return c.json(
141
+ {
142
+ id: channel.id,
143
+ publish_token: publishToken,
144
+ subscribe_token: subscribeToken,
145
+ },
146
+ 201,
147
+ );
148
+ }
149
+ }
150
+
151
+ export class AddPublisher extends OpenAPIRoute {
152
+ schema = {
153
+ summary: 'Add a publisher to a channel',
154
+ tags: ['Channels'],
155
+ security: [{ bearerAuth: [] }],
156
+ request: {
157
+ params: z.object({
158
+ channelId: z.string(),
159
+ }),
160
+ body: {
161
+ content: {
162
+ 'application/json': {
163
+ schema: z.object({
164
+ name: z.string().min(1),
165
+ }),
166
+ },
167
+ },
168
+ },
169
+ },
170
+ responses: {
171
+ 201: {
172
+ description: 'Publisher added',
173
+ content: {
174
+ 'application/json': {
175
+ schema: z.object({
176
+ id: z.string(),
177
+ name: z.string(),
178
+ publish_token: z.string(),
179
+ }),
180
+ },
181
+ },
182
+ },
183
+ 404: {
184
+ description: 'Channel not found',
185
+ content: {
186
+ 'application/json': {
187
+ schema: z.object({ error: z.string() }),
188
+ },
189
+ },
190
+ },
191
+ },
192
+ };
193
+
194
+ async handle(c: Context<Env>) {
195
+ const data = await this.getValidatedData<typeof this.schema>();
196
+ const { channelId } = data.params;
197
+ const body = data.body;
198
+
199
+ const channel = await getChannel(c.env.DB, channelId);
200
+ if (!channel) {
201
+ return c.json({ error: 'Channel not found' }, 404);
202
+ }
203
+
204
+ const publisher = await createPublisher(c.env.DB, channelId, body.name);
205
+
206
+ const publishToken = await createToken(
207
+ { scope: 'publish', channel: channelId, sub: publisher.id },
208
+ c.env.ZOOID_JWT_SECRET,
209
+ );
210
+
211
+ return c.json(
212
+ {
213
+ id: publisher.id,
214
+ name: publisher.name,
215
+ publish_token: publishToken,
216
+ },
217
+ 201,
218
+ );
219
+ }
220
+ }