@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.
- package/LICENSE +21 -0
- package/package.json +37 -0
- package/src/cloudflare-test.d.ts +4 -0
- package/src/db/queries.test.ts +501 -0
- package/src/db/queries.ts +450 -0
- package/src/db/schema.sql +56 -0
- package/src/do/channel.ts +69 -0
- package/src/index.ts +88 -0
- package/src/lib/jwt.test.ts +89 -0
- package/src/lib/jwt.ts +28 -0
- package/src/lib/schema-validator.test.ts +101 -0
- package/src/lib/schema-validator.ts +64 -0
- package/src/lib/signing.test.ts +73 -0
- package/src/lib/signing.ts +60 -0
- package/src/lib/ulid.test.ts +25 -0
- package/src/lib/ulid.ts +8 -0
- package/src/lib/validation.test.ts +35 -0
- package/src/lib/validation.ts +8 -0
- package/src/lib/xml.ts +13 -0
- package/src/middleware/auth.test.ts +125 -0
- package/src/middleware/auth.ts +103 -0
- package/src/routes/channels.test.ts +335 -0
- package/src/routes/channels.ts +220 -0
- package/src/routes/directory.test.ts +223 -0
- package/src/routes/directory.ts +109 -0
- package/src/routes/events.test.ts +477 -0
- package/src/routes/events.ts +315 -0
- package/src/routes/feed.test.ts +238 -0
- package/src/routes/feed.ts +101 -0
- package/src/routes/opml.test.ts +131 -0
- package/src/routes/opml.ts +41 -0
- package/src/routes/rss.test.ts +224 -0
- package/src/routes/rss.ts +91 -0
- package/src/routes/server-meta.test.ts +157 -0
- package/src/routes/server-meta.ts +100 -0
- package/src/routes/webhooks.test.ts +238 -0
- package/src/routes/webhooks.ts +111 -0
- package/src/routes/well-known.test.ts +34 -0
- package/src/routes/well-known.ts +58 -0
- package/src/routes/ws.test.ts +503 -0
- package/src/routes/ws.ts +25 -0
- package/src/test-utils.ts +79 -0
- package/src/types.ts +63 -0
- package/wrangler.toml +26 -0
|
@@ -0,0 +1,315 @@
|
|
|
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 {
|
|
6
|
+
getChannel,
|
|
7
|
+
createEvent,
|
|
8
|
+
createEvents,
|
|
9
|
+
pollEvents,
|
|
10
|
+
cleanupExpiredEvents,
|
|
11
|
+
getWebhooksForChannel,
|
|
12
|
+
} from '../db/queries';
|
|
13
|
+
import { importPrivateKey, signPayload } from '../lib/signing';
|
|
14
|
+
import { validateEvent } from '../lib/schema-validator';
|
|
15
|
+
|
|
16
|
+
type Env = { Bindings: Bindings; Variables: Variables };
|
|
17
|
+
|
|
18
|
+
export class PublishEvents extends OpenAPIRoute {
|
|
19
|
+
schema = {
|
|
20
|
+
summary: 'Publish event(s) to a channel',
|
|
21
|
+
tags: ['Events'],
|
|
22
|
+
security: [{ bearerAuth: [] }],
|
|
23
|
+
request: {
|
|
24
|
+
params: z.object({
|
|
25
|
+
channelId: z.string(),
|
|
26
|
+
}),
|
|
27
|
+
body: {
|
|
28
|
+
content: {
|
|
29
|
+
'application/json': {
|
|
30
|
+
schema: z.object({
|
|
31
|
+
type: z.string().optional(),
|
|
32
|
+
data: z.unknown().optional(),
|
|
33
|
+
events: z
|
|
34
|
+
.array(
|
|
35
|
+
z.object({
|
|
36
|
+
type: z.string().optional(),
|
|
37
|
+
data: z.unknown(),
|
|
38
|
+
}),
|
|
39
|
+
)
|
|
40
|
+
.optional(),
|
|
41
|
+
}),
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
responses: {
|
|
47
|
+
201: {
|
|
48
|
+
description: 'Event(s) published',
|
|
49
|
+
content: {
|
|
50
|
+
'application/json': {
|
|
51
|
+
schema: z.union([
|
|
52
|
+
z.object({
|
|
53
|
+
id: z.string(),
|
|
54
|
+
channel_id: z.string(),
|
|
55
|
+
type: z.string().nullable(),
|
|
56
|
+
data: z.string(),
|
|
57
|
+
publisher_id: z.string().nullable(),
|
|
58
|
+
created_at: z.string(),
|
|
59
|
+
}),
|
|
60
|
+
z.object({
|
|
61
|
+
events: z.array(
|
|
62
|
+
z.object({
|
|
63
|
+
id: z.string(),
|
|
64
|
+
channel_id: z.string(),
|
|
65
|
+
type: z.string().nullable(),
|
|
66
|
+
data: z.string(),
|
|
67
|
+
publisher_id: z.string().nullable(),
|
|
68
|
+
created_at: z.string(),
|
|
69
|
+
}),
|
|
70
|
+
),
|
|
71
|
+
}),
|
|
72
|
+
]),
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
400: {
|
|
77
|
+
description: 'Validation error',
|
|
78
|
+
content: {
|
|
79
|
+
'application/json': {
|
|
80
|
+
schema: z.object({ error: z.string() }),
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
404: {
|
|
85
|
+
description: 'Channel not found',
|
|
86
|
+
content: {
|
|
87
|
+
'application/json': {
|
|
88
|
+
schema: z.object({ error: z.string() }),
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
async handle(c: Context<Env>) {
|
|
96
|
+
const data = await this.getValidatedData<typeof this.schema>();
|
|
97
|
+
const { channelId } = data.params;
|
|
98
|
+
const db = c.env.DB;
|
|
99
|
+
|
|
100
|
+
const channel = await getChannel(db, channelId);
|
|
101
|
+
if (!channel) {
|
|
102
|
+
return c.json({ error: 'Channel not found' }, 404);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const body = data.body;
|
|
106
|
+
const jwt = c.get('jwtPayload');
|
|
107
|
+
const publisherId = jwt.sub ?? null;
|
|
108
|
+
|
|
109
|
+
const strictSchema =
|
|
110
|
+
channel.strict && channel.schema
|
|
111
|
+
? (JSON.parse(channel.schema) as Record<
|
|
112
|
+
string,
|
|
113
|
+
{
|
|
114
|
+
required?: string[];
|
|
115
|
+
properties?: Record<string, unknown>;
|
|
116
|
+
}
|
|
117
|
+
>)
|
|
118
|
+
: null;
|
|
119
|
+
|
|
120
|
+
// Batch publish
|
|
121
|
+
if ('events' in body && Array.isArray(body.events)) {
|
|
122
|
+
for (const evt of body.events) {
|
|
123
|
+
if (evt.data === undefined) {
|
|
124
|
+
return c.json(
|
|
125
|
+
{ error: 'Each event must include a data field' },
|
|
126
|
+
400,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (strictSchema) {
|
|
132
|
+
for (const evt of body.events) {
|
|
133
|
+
const result = validateEvent(strictSchema, evt.type, evt.data);
|
|
134
|
+
if (!result.valid) {
|
|
135
|
+
return c.json({ error: result.error }, 400);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const created = await createEvents(
|
|
141
|
+
db,
|
|
142
|
+
channelId,
|
|
143
|
+
publisherId,
|
|
144
|
+
body.events,
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const fanOut = async () => {
|
|
148
|
+
try {
|
|
149
|
+
const doId = c.env.CHANNEL_DO.idFromName(channelId);
|
|
150
|
+
const stub = c.env.CHANNEL_DO.get(doId);
|
|
151
|
+
for (const event of created) {
|
|
152
|
+
await stub.broadcast(event as unknown as Record<string, unknown>);
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
// CHANNEL_DO binding may not exist in tests
|
|
156
|
+
}
|
|
157
|
+
for (const event of created) {
|
|
158
|
+
await deliverToWebhooks(c.env, channelId, event);
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
try {
|
|
162
|
+
c.executionCtx.waitUntil(fanOut());
|
|
163
|
+
} catch {
|
|
164
|
+
await fanOut();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return c.json({ events: created }, 201);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Single publish
|
|
171
|
+
if (!('data' in body) || body.data === undefined) {
|
|
172
|
+
return c.json({ error: 'Event must include a data field' }, 400);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (strictSchema) {
|
|
176
|
+
const result = validateEvent(strictSchema, body.type, body.data);
|
|
177
|
+
if (!result.valid) {
|
|
178
|
+
return c.json({ error: result.error }, 400);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const event = await createEvent(db, {
|
|
183
|
+
channelId,
|
|
184
|
+
publisherId,
|
|
185
|
+
type: body.type ?? null,
|
|
186
|
+
data: body.data,
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const afterPublish = async () => {
|
|
190
|
+
try {
|
|
191
|
+
const doId = c.env.CHANNEL_DO.idFromName(channelId);
|
|
192
|
+
const stub = c.env.CHANNEL_DO.get(doId);
|
|
193
|
+
await stub.broadcast(event as unknown as Record<string, unknown>);
|
|
194
|
+
} catch {
|
|
195
|
+
// CHANNEL_DO binding may not exist in tests
|
|
196
|
+
}
|
|
197
|
+
await deliverToWebhooks(c.env, channelId, event);
|
|
198
|
+
};
|
|
199
|
+
try {
|
|
200
|
+
c.executionCtx.waitUntil(afterPublish());
|
|
201
|
+
} catch {
|
|
202
|
+
// No execution context in tests
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return c.json(event, 201);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export class PollEvents extends OpenAPIRoute {
|
|
210
|
+
schema = {
|
|
211
|
+
summary: 'Poll events from a channel',
|
|
212
|
+
tags: ['Events'],
|
|
213
|
+
request: {
|
|
214
|
+
params: z.object({
|
|
215
|
+
channelId: z.string(),
|
|
216
|
+
}),
|
|
217
|
+
query: z.object({
|
|
218
|
+
since: z.string().optional(),
|
|
219
|
+
cursor: z.string().optional(),
|
|
220
|
+
type: z.string().optional(),
|
|
221
|
+
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
222
|
+
}),
|
|
223
|
+
},
|
|
224
|
+
responses: {
|
|
225
|
+
200: {
|
|
226
|
+
description: 'Polled events',
|
|
227
|
+
content: {
|
|
228
|
+
'application/json': {
|
|
229
|
+
schema: z.object({
|
|
230
|
+
events: z.array(
|
|
231
|
+
z.object({
|
|
232
|
+
id: z.string(),
|
|
233
|
+
channel_id: z.string(),
|
|
234
|
+
type: z.string().nullable(),
|
|
235
|
+
data: z.string(),
|
|
236
|
+
publisher_id: z.string().nullable(),
|
|
237
|
+
created_at: z.string(),
|
|
238
|
+
}),
|
|
239
|
+
),
|
|
240
|
+
cursor: z.string().nullable(),
|
|
241
|
+
has_more: z.boolean(),
|
|
242
|
+
}),
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
async handle(c: Context<Env>) {
|
|
250
|
+
const data = await this.getValidatedData<typeof this.schema>();
|
|
251
|
+
const { channelId } = data.params;
|
|
252
|
+
const db = c.env.DB;
|
|
253
|
+
|
|
254
|
+
await cleanupExpiredEvents(db, channelId);
|
|
255
|
+
|
|
256
|
+
const { since, cursor, type, limit } = data.query;
|
|
257
|
+
|
|
258
|
+
const result = await pollEvents(db, channelId, {
|
|
259
|
+
since,
|
|
260
|
+
cursor,
|
|
261
|
+
type,
|
|
262
|
+
limit,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
if (c.get('channelIsPublic')) {
|
|
266
|
+
const maxAge = parseInt(c.env.ZOOID_POLL_INTERVAL ?? '2', 10);
|
|
267
|
+
c.header('Cache-Control', `public, s-maxage=${maxAge}`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return c.json(result);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function deliverToWebhooks(
|
|
275
|
+
env: Bindings,
|
|
276
|
+
channelId: string,
|
|
277
|
+
event: { id: string; type: string | null },
|
|
278
|
+
) {
|
|
279
|
+
const webhooks = await getWebhooksForChannel(
|
|
280
|
+
env.DB,
|
|
281
|
+
channelId,
|
|
282
|
+
event.type ?? undefined,
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
if (webhooks.length === 0) return;
|
|
286
|
+
|
|
287
|
+
const signingKey = env.ZOOID_SIGNING_KEY
|
|
288
|
+
? await importPrivateKey(env.ZOOID_SIGNING_KEY)
|
|
289
|
+
: null;
|
|
290
|
+
|
|
291
|
+
const body = JSON.stringify(event as unknown as Record<string, unknown>);
|
|
292
|
+
const timestamp = new Date().toISOString();
|
|
293
|
+
|
|
294
|
+
const signature = signingKey
|
|
295
|
+
? await signPayload(signingKey, timestamp, body)
|
|
296
|
+
: null;
|
|
297
|
+
|
|
298
|
+
await Promise.allSettled(
|
|
299
|
+
webhooks.map((webhook) => {
|
|
300
|
+
const headers: Record<string, string> = {
|
|
301
|
+
'Content-Type': 'application/json',
|
|
302
|
+
'X-Zooid-Timestamp': timestamp,
|
|
303
|
+
'X-Zooid-Channel': channelId,
|
|
304
|
+
'X-Zooid-Event-Id': event.id,
|
|
305
|
+
'X-Zooid-Key-Id': env.ZOOID_SERVER_ID || 'zooid-local',
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
if (signature) {
|
|
309
|
+
headers['X-Zooid-Signature'] = signature;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return fetch(webhook.url, { method: 'POST', headers, body });
|
|
313
|
+
}),
|
|
314
|
+
);
|
|
315
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
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
|
+
interface FeedBody {
|
|
8
|
+
version: string;
|
|
9
|
+
title: string;
|
|
10
|
+
description: string;
|
|
11
|
+
items: Array<{
|
|
12
|
+
id: string;
|
|
13
|
+
title: string;
|
|
14
|
+
content_text: string;
|
|
15
|
+
tags: string[];
|
|
16
|
+
_zooid: { data: unknown };
|
|
17
|
+
}>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const JWT_SECRET = 'test-jwt-secret';
|
|
21
|
+
|
|
22
|
+
async function adminRequest(path: string, options: RequestInit = {}) {
|
|
23
|
+
const token = await createToken({ scope: 'admin' }, JWT_SECRET);
|
|
24
|
+
const headers = new Headers(options.headers);
|
|
25
|
+
headers.set('Authorization', `Bearer ${token}`);
|
|
26
|
+
headers.set('Content-Type', 'application/json');
|
|
27
|
+
return app.request(
|
|
28
|
+
path,
|
|
29
|
+
{ ...options, headers },
|
|
30
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function publishRequest(
|
|
35
|
+
path: string,
|
|
36
|
+
options: RequestInit = {},
|
|
37
|
+
channel: string,
|
|
38
|
+
) {
|
|
39
|
+
const token = await createToken(
|
|
40
|
+
{ scope: 'publish', channel, sub: 'test-publisher' },
|
|
41
|
+
JWT_SECRET,
|
|
42
|
+
);
|
|
43
|
+
const headers = new Headers(options.headers);
|
|
44
|
+
headers.set('Authorization', `Bearer ${token}`);
|
|
45
|
+
headers.set('Content-Type', 'application/json');
|
|
46
|
+
return app.request(
|
|
47
|
+
path,
|
|
48
|
+
{ ...options, headers },
|
|
49
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('JSON Feed routes', () => {
|
|
54
|
+
beforeAll(async () => {
|
|
55
|
+
await setupTestDb();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
beforeEach(async () => {
|
|
59
|
+
await cleanTestDb();
|
|
60
|
+
await adminRequest('/api/v1/channels', {
|
|
61
|
+
method: 'POST',
|
|
62
|
+
body: JSON.stringify({
|
|
63
|
+
id: 'feed-channel',
|
|
64
|
+
name: 'Feed Channel',
|
|
65
|
+
description: 'Test JSON feed',
|
|
66
|
+
is_public: true,
|
|
67
|
+
}),
|
|
68
|
+
});
|
|
69
|
+
await adminRequest('/api/v1/channels', {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
body: JSON.stringify({
|
|
72
|
+
id: 'priv-feed',
|
|
73
|
+
name: 'Private Feed',
|
|
74
|
+
is_public: false,
|
|
75
|
+
}),
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('GET /channels/:channelId/feed.json', () => {
|
|
80
|
+
it('returns valid JSON Feed with correct content type', async () => {
|
|
81
|
+
await publishRequest(
|
|
82
|
+
'/api/v1/channels/feed-channel/events',
|
|
83
|
+
{
|
|
84
|
+
method: 'POST',
|
|
85
|
+
body: JSON.stringify({
|
|
86
|
+
type: 'signal',
|
|
87
|
+
data: { market: 'test', shift: 0.05 },
|
|
88
|
+
}),
|
|
89
|
+
},
|
|
90
|
+
'feed-channel',
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const res = await app.request(
|
|
94
|
+
'/api/v1/channels/feed-channel/feed.json',
|
|
95
|
+
{},
|
|
96
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
expect(res.status).toBe(200);
|
|
100
|
+
expect(res.headers.get('Content-Type')).toBe('application/feed+json');
|
|
101
|
+
|
|
102
|
+
const body = await res.json() as FeedBody;
|
|
103
|
+
expect(body.version).toBe('https://jsonfeed.org/version/1.1');
|
|
104
|
+
expect(body.title).toBe('Feed Channel');
|
|
105
|
+
expect(body.description).toBe('Test JSON feed');
|
|
106
|
+
expect(body.items).toHaveLength(1);
|
|
107
|
+
expect(body.items[0].id).toBeDefined();
|
|
108
|
+
expect(body.items[0]._zooid).toBeDefined();
|
|
109
|
+
expect(body.items[0]._zooid.data).toEqual({ market: 'test', shift: 0.05 });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('formats data as YAML by default', async () => {
|
|
113
|
+
await publishRequest(
|
|
114
|
+
'/api/v1/channels/feed-channel/events',
|
|
115
|
+
{
|
|
116
|
+
method: 'POST',
|
|
117
|
+
body: JSON.stringify({
|
|
118
|
+
type: 'signal',
|
|
119
|
+
data: { market: 'election', shift: 0.07 },
|
|
120
|
+
}),
|
|
121
|
+
},
|
|
122
|
+
'feed-channel',
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const res = await app.request(
|
|
126
|
+
'/api/v1/channels/feed-channel/feed.json',
|
|
127
|
+
{},
|
|
128
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
const body = await res.json() as FeedBody;
|
|
132
|
+
// YAML-style: key: value
|
|
133
|
+
expect(body.items[0].content_text).toContain('market: election');
|
|
134
|
+
expect(body.items[0].content_text).toContain('shift: 0.07');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('formats data as JSON when format=json', async () => {
|
|
138
|
+
await publishRequest(
|
|
139
|
+
'/api/v1/channels/feed-channel/events',
|
|
140
|
+
{
|
|
141
|
+
method: 'POST',
|
|
142
|
+
body: JSON.stringify({
|
|
143
|
+
type: 'signal',
|
|
144
|
+
data: { market: 'election' },
|
|
145
|
+
}),
|
|
146
|
+
},
|
|
147
|
+
'feed-channel',
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const res = await app.request(
|
|
151
|
+
'/api/v1/channels/feed-channel/feed.json?format=json',
|
|
152
|
+
{},
|
|
153
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const body = await res.json() as FeedBody;
|
|
157
|
+
expect(body.items[0].content_text).toContain('"market"');
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('includes event type and publisher in item title', async () => {
|
|
161
|
+
await publishRequest(
|
|
162
|
+
'/api/v1/channels/feed-channel/events',
|
|
163
|
+
{
|
|
164
|
+
method: 'POST',
|
|
165
|
+
body: JSON.stringify({
|
|
166
|
+
type: 'odds_shift',
|
|
167
|
+
data: { v: 1 },
|
|
168
|
+
}),
|
|
169
|
+
},
|
|
170
|
+
'feed-channel',
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const res = await app.request(
|
|
174
|
+
'/api/v1/channels/feed-channel/feed.json',
|
|
175
|
+
{},
|
|
176
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
const body = await res.json() as FeedBody;
|
|
180
|
+
expect(body.items[0].title).toContain('[odds_shift]');
|
|
181
|
+
expect(body.items[0].title).toContain('test-publisher');
|
|
182
|
+
expect(body.items[0].tags).toEqual(['odds_shift']);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('returns empty feed when no events', async () => {
|
|
186
|
+
const res = await app.request(
|
|
187
|
+
'/api/v1/channels/feed-channel/feed.json',
|
|
188
|
+
{},
|
|
189
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
expect(res.status).toBe(200);
|
|
193
|
+
const body = await res.json() as FeedBody;
|
|
194
|
+
expect(body.version).toBe('https://jsonfeed.org/version/1.1');
|
|
195
|
+
expect(body.items).toEqual([]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('allows public channel without auth', async () => {
|
|
199
|
+
const res = await app.request(
|
|
200
|
+
'/api/v1/channels/feed-channel/feed.json',
|
|
201
|
+
{},
|
|
202
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
203
|
+
);
|
|
204
|
+
expect(res.status).toBe(200);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('requires token query param for private channel', async () => {
|
|
208
|
+
const res = await app.request(
|
|
209
|
+
'/api/v1/channels/priv-feed/feed.json',
|
|
210
|
+
{},
|
|
211
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
212
|
+
);
|
|
213
|
+
expect(res.status).toBe(401);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('accepts token via query param for private channel', async () => {
|
|
217
|
+
const token = await createToken(
|
|
218
|
+
{ scope: 'subscribe', channel: 'priv-feed', sub: 'sub-1' },
|
|
219
|
+
JWT_SECRET,
|
|
220
|
+
);
|
|
221
|
+
const res = await app.request(
|
|
222
|
+
`/api/v1/channels/priv-feed/feed.json?token=${token}`,
|
|
223
|
+
{},
|
|
224
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
225
|
+
);
|
|
226
|
+
expect(res.status).toBe(200);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('returns 404 for non-existent channel', async () => {
|
|
230
|
+
const res = await app.request(
|
|
231
|
+
'/api/v1/channels/nonexistent/feed.json',
|
|
232
|
+
{},
|
|
233
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
234
|
+
);
|
|
235
|
+
expect(res.status).toBe(404);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { stringify } from 'yaml';
|
|
3
|
+
import type { Bindings, Variables, ZooidEvent } from '../types';
|
|
4
|
+
import { getChannel } from '../db/queries';
|
|
5
|
+
import { pollEvents, cleanupExpiredEvents } from '../db/queries';
|
|
6
|
+
import { verifyToken } from '../lib/jwt';
|
|
7
|
+
|
|
8
|
+
type Env = { Bindings: Bindings; Variables: Variables };
|
|
9
|
+
|
|
10
|
+
export const feed = new Hono<Env>();
|
|
11
|
+
|
|
12
|
+
feed.get('/channels/:channelId/feed.json', async (c) => {
|
|
13
|
+
const channelId = c.req.param('channelId');
|
|
14
|
+
const db = c.env.DB;
|
|
15
|
+
|
|
16
|
+
const channel = await getChannel(db, channelId);
|
|
17
|
+
if (!channel) {
|
|
18
|
+
return c.json({ error: 'Channel not found' }, 404);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Auth: public channels need no auth, private channels use ?token= query param
|
|
22
|
+
if (channel.is_public !== 1) {
|
|
23
|
+
const tokenStr = c.req.query('token');
|
|
24
|
+
if (!tokenStr) {
|
|
25
|
+
return c.json(
|
|
26
|
+
{ error: 'Subscribe token required for private channel' },
|
|
27
|
+
401,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const payload = await verifyToken(tokenStr, c.env.ZOOID_JWT_SECRET);
|
|
33
|
+
if (payload.scope !== 'admin' && payload.scope !== 'subscribe') {
|
|
34
|
+
return c.json({ error: 'Insufficient permissions' }, 403);
|
|
35
|
+
}
|
|
36
|
+
if (payload.scope === 'subscribe' && payload.channel !== channelId) {
|
|
37
|
+
return c.json({ error: 'Token not valid for this channel' }, 403);
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
return c.json({ error: 'Invalid or expired token' }, 401);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await cleanupExpiredEvents(db, channelId);
|
|
45
|
+
|
|
46
|
+
const result = await pollEvents(db, channelId, { limit: 50 });
|
|
47
|
+
const format = c.req.query('format') || 'yaml';
|
|
48
|
+
|
|
49
|
+
const items = result.events.map((event) => formatItem(event, channelId, format));
|
|
50
|
+
|
|
51
|
+
const jsonFeed = {
|
|
52
|
+
version: 'https://jsonfeed.org/version/1.1',
|
|
53
|
+
title: channel.name,
|
|
54
|
+
description: channel.description || '',
|
|
55
|
+
items,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return c.json(jsonFeed, 200, {
|
|
59
|
+
'Content-Type': 'application/feed+json',
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
function formatItem(
|
|
64
|
+
event: ZooidEvent,
|
|
65
|
+
channelId: string,
|
|
66
|
+
format: string,
|
|
67
|
+
): Record<string, unknown> {
|
|
68
|
+
const type = event.type || null;
|
|
69
|
+
const publisher = event.publisher_id || 'unknown';
|
|
70
|
+
|
|
71
|
+
let data: Record<string, unknown>;
|
|
72
|
+
try {
|
|
73
|
+
data = JSON.parse(event.data);
|
|
74
|
+
} catch {
|
|
75
|
+
data = {};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const contentText =
|
|
79
|
+
format === 'json' ? JSON.stringify(data, null, 2) : stringify(data).trim();
|
|
80
|
+
|
|
81
|
+
const titleLabel = type || 'event';
|
|
82
|
+
|
|
83
|
+
const item: Record<string, unknown> = {
|
|
84
|
+
id: event.id,
|
|
85
|
+
title: `[${titleLabel}] ${publisher}`,
|
|
86
|
+
content_text: contentText,
|
|
87
|
+
date_published: new Date(event.created_at).toISOString(),
|
|
88
|
+
_zooid: {
|
|
89
|
+
channel_id: channelId,
|
|
90
|
+
publisher_id: event.publisher_id || null,
|
|
91
|
+
type: type,
|
|
92
|
+
data,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
if (type) {
|
|
97
|
+
item.tags = [type];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return item;
|
|
101
|
+
}
|