@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,450 @@
1
+
2
+ import type {
3
+ Channel,
4
+ ChannelListItem,
5
+ Publisher,
6
+ ZooidEvent,
7
+ Webhook,
8
+ PollResult,
9
+ ServerIdentity,
10
+ } from '../types';
11
+ import { generateUlid } from '../lib/ulid';
12
+
13
+ export async function createChannel(
14
+ db: D1Database,
15
+ channel: {
16
+ id: string;
17
+ name: string;
18
+ description?: string;
19
+ tags?: string[];
20
+ is_public?: boolean;
21
+ schema?: Record<string, unknown>;
22
+ strict?: boolean;
23
+ },
24
+ ): Promise<Channel> {
25
+ const isPublic = channel.is_public === false ? 0 : 1;
26
+ const strict = channel.strict ? 1 : 0;
27
+ const schema = channel.schema ? JSON.stringify(channel.schema) : null;
28
+ const tags = channel.tags ? JSON.stringify(channel.tags) : null;
29
+
30
+ await db
31
+ .prepare(
32
+ `INSERT INTO channels (id, name, description, tags, is_public, schema, strict) VALUES (?, ?, ?, ?, ?, ?, ?)`,
33
+ )
34
+ .bind(channel.id, channel.name, channel.description ?? null, tags, isPublic, schema, strict)
35
+ .run();
36
+
37
+ const row = await db
38
+ .prepare(`SELECT * FROM channels WHERE id = ?`)
39
+ .bind(channel.id)
40
+ .first<Channel>();
41
+
42
+ return row!;
43
+ }
44
+
45
+ export async function getChannel(
46
+ db: D1Database,
47
+ id: string,
48
+ ): Promise<Channel | null> {
49
+ return db.prepare(`SELECT * FROM channels WHERE id = ?`).bind(id).first<Channel>();
50
+ }
51
+
52
+ export async function listChannels(
53
+ db: D1Database,
54
+ ): Promise<ChannelListItem[]> {
55
+ const rows = await db
56
+ .prepare(
57
+ `SELECT
58
+ c.id,
59
+ c.name,
60
+ c.description,
61
+ c.tags,
62
+ c.is_public,
63
+ c.schema,
64
+ c.strict,
65
+ COALESCE(e.event_count, 0) as event_count,
66
+ e.last_event_at
67
+ FROM channels c
68
+ LEFT JOIN (
69
+ SELECT
70
+ channel_id,
71
+ COUNT(*) as event_count,
72
+ MAX(created_at) as last_event_at
73
+ FROM events
74
+ GROUP BY channel_id
75
+ ) e ON c.id = e.channel_id
76
+ ORDER BY c.created_at DESC`,
77
+ )
78
+ .all<{
79
+ id: string;
80
+ name: string;
81
+ description: string | null;
82
+ tags: string | null;
83
+ is_public: number;
84
+ schema: string | null;
85
+ strict: number;
86
+ event_count: number;
87
+ last_event_at: string | null;
88
+ }>();
89
+
90
+ const channels: ChannelListItem[] = [];
91
+
92
+ for (const row of rows.results) {
93
+ const publishers = await db
94
+ .prepare(`SELECT name FROM publishers WHERE channel_id = ?`)
95
+ .bind(row.id)
96
+ .all<{ name: string }>();
97
+
98
+ channels.push({
99
+ id: row.id,
100
+ name: row.name,
101
+ description: row.description,
102
+ tags: row.tags ? JSON.parse(row.tags) : [],
103
+ is_public: row.is_public === 1,
104
+ schema: row.schema ? JSON.parse(row.schema) : null,
105
+ strict: row.strict === 1,
106
+ event_count: row.event_count,
107
+ last_event_at: row.last_event_at,
108
+ publishers: publishers.results.map((p) => p.name),
109
+ });
110
+ }
111
+
112
+ return channels;
113
+ }
114
+
115
+ export async function createPublisher(
116
+ db: D1Database,
117
+ channelId: string,
118
+ name: string,
119
+ ): Promise<Publisher> {
120
+ const id = generateUlid();
121
+
122
+ await db
123
+ .prepare(
124
+ `INSERT INTO publishers (id, channel_id, name) VALUES (?, ?, ?)`,
125
+ )
126
+ .bind(id, channelId, name)
127
+ .run();
128
+
129
+ const row = await db
130
+ .prepare(`SELECT * FROM publishers WHERE id = ?`)
131
+ .bind(id)
132
+ .first<Publisher>();
133
+
134
+ return row!;
135
+ }
136
+
137
+ // --- Event queries ---
138
+
139
+ const MAX_PAYLOAD_BYTES = 64 * 1024;
140
+ const MAX_BATCH_SIZE = 100;
141
+ const DEFAULT_POLL_LIMIT = 50;
142
+ const DEFAULT_WEBHOOK_TTL_SECONDS = 3 * 24 * 60 * 60; // 3 days
143
+ const MAX_WEBHOOK_TTL_SECONDS = 30 * 24 * 60 * 60; // 30 days
144
+
145
+ export async function createEvent(
146
+ db: D1Database,
147
+ event: {
148
+ channelId: string;
149
+ publisherId?: string | null;
150
+ type?: string | null;
151
+ data: unknown;
152
+ },
153
+ ): Promise<ZooidEvent> {
154
+ const dataStr = JSON.stringify(event.data);
155
+ if (new TextEncoder().encode(dataStr).length > MAX_PAYLOAD_BYTES) {
156
+ throw new Error('Event payload exceeds 64KB limit');
157
+ }
158
+
159
+ const id = generateUlid();
160
+
161
+ await db
162
+ .prepare(
163
+ `INSERT INTO events (id, channel_id, publisher_id, type, data) VALUES (?, ?, ?, ?, ?)`,
164
+ )
165
+ .bind(
166
+ id,
167
+ event.channelId,
168
+ event.publisherId ?? null,
169
+ event.type ?? null,
170
+ dataStr,
171
+ )
172
+ .run();
173
+
174
+ const row = await db
175
+ .prepare(`SELECT * FROM events WHERE id = ?`)
176
+ .bind(id)
177
+ .first<ZooidEvent>();
178
+
179
+ return row!;
180
+ }
181
+
182
+ export async function createEvents(
183
+ db: D1Database,
184
+ channelId: string,
185
+ publisherId: string | null,
186
+ events: Array<{ type?: string | null; data: unknown }>,
187
+ ): Promise<ZooidEvent[]> {
188
+ if (events.length > MAX_BATCH_SIZE) {
189
+ throw new Error(`Batch size exceeds maximum of ${MAX_BATCH_SIZE}`);
190
+ }
191
+
192
+ const ids: string[] = [];
193
+
194
+ for (const event of events) {
195
+ const dataStr = JSON.stringify(event.data);
196
+ if (new TextEncoder().encode(dataStr).length > MAX_PAYLOAD_BYTES) {
197
+ throw new Error('Event payload exceeds 64KB limit');
198
+ }
199
+
200
+ const id = generateUlid();
201
+ ids.push(id);
202
+
203
+ await db
204
+ .prepare(
205
+ `INSERT INTO events (id, channel_id, publisher_id, type, data) VALUES (?, ?, ?, ?, ?)`,
206
+ )
207
+ .bind(id, channelId, publisherId, event.type ?? null, dataStr)
208
+ .run();
209
+ }
210
+
211
+ const results: ZooidEvent[] = [];
212
+ for (const id of ids) {
213
+ const row = await db
214
+ .prepare(`SELECT * FROM events WHERE id = ?`)
215
+ .bind(id)
216
+ .first<ZooidEvent>();
217
+ results.push(row!);
218
+ }
219
+
220
+ return results;
221
+ }
222
+
223
+ export async function pollEvents(
224
+ db: D1Database,
225
+ channelId: string,
226
+ options: {
227
+ since?: string;
228
+ cursor?: string;
229
+ limit?: number;
230
+ type?: string;
231
+ },
232
+ ): Promise<PollResult> {
233
+ const limit = options.limit ?? DEFAULT_POLL_LIMIT;
234
+ const conditions: string[] = ['channel_id = ?'];
235
+ const bindings: unknown[] = [channelId];
236
+
237
+ if (options.since) {
238
+ conditions.push('created_at > ?');
239
+ bindings.push(options.since);
240
+ }
241
+
242
+ if (options.cursor) {
243
+ conditions.push('id > ?');
244
+ bindings.push(options.cursor);
245
+ }
246
+
247
+ if (options.type) {
248
+ conditions.push('type = ?');
249
+ bindings.push(options.type);
250
+ }
251
+
252
+ const sql = `SELECT * FROM events WHERE ${conditions.join(' AND ')} ORDER BY id ASC LIMIT ?`;
253
+ bindings.push(limit + 1);
254
+
255
+ const stmt = db.prepare(sql);
256
+ const result = await stmt.bind(...bindings).all<ZooidEvent>();
257
+ const rows = result.results;
258
+
259
+ const hasMore = rows.length > limit;
260
+ const events = hasMore ? rows.slice(0, limit) : rows;
261
+ const cursor = events.length > 0 ? events[events.length - 1].id : null;
262
+
263
+ return {
264
+ events,
265
+ cursor: hasMore ? cursor : null,
266
+ has_more: hasMore,
267
+ };
268
+ }
269
+
270
+ export async function cleanupExpiredEvents(
271
+ db: D1Database,
272
+ channelId: string,
273
+ ): Promise<number> {
274
+ const result = await db
275
+ .prepare(
276
+ `DELETE FROM events WHERE channel_id = ? AND created_at < datetime('now', '-7 days')`,
277
+ )
278
+ .bind(channelId)
279
+ .run();
280
+
281
+ return result.meta.changes ?? 0;
282
+ }
283
+
284
+ // --- Webhook queries ---
285
+
286
+ export async function createWebhook(
287
+ db: D1Database,
288
+ webhook: {
289
+ channelId: string;
290
+ url: string;
291
+ eventTypes?: string[];
292
+ ttlSeconds?: number;
293
+ },
294
+ ): Promise<Webhook> {
295
+ const ttl = Math.min(
296
+ webhook.ttlSeconds ?? DEFAULT_WEBHOOK_TTL_SECONDS,
297
+ MAX_WEBHOOK_TTL_SECONDS,
298
+ );
299
+ const expiresAt = new Date(Date.now() + ttl * 1000).toISOString();
300
+ const eventTypes = webhook.eventTypes
301
+ ? JSON.stringify(webhook.eventTypes)
302
+ : null;
303
+ const id = generateUlid();
304
+
305
+ await db
306
+ .prepare(
307
+ `INSERT INTO webhooks (id, channel_id, url, event_types, expires_at)
308
+ VALUES (?, ?, ?, ?, ?)
309
+ ON CONFLICT(channel_id, url) DO UPDATE SET
310
+ expires_at = excluded.expires_at,
311
+ event_types = excluded.event_types`,
312
+ )
313
+ .bind(id, webhook.channelId, webhook.url, eventTypes, expiresAt)
314
+ .run();
315
+
316
+ // Fetch back — could be the new row or the updated existing one
317
+ const row = await db
318
+ .prepare(
319
+ `SELECT * FROM webhooks WHERE channel_id = ? AND url = ?`,
320
+ )
321
+ .bind(webhook.channelId, webhook.url)
322
+ .first<Webhook>();
323
+
324
+ return row!;
325
+ }
326
+
327
+ export async function deleteWebhook(
328
+ db: D1Database,
329
+ webhookId: string,
330
+ channelId: string,
331
+ ): Promise<boolean> {
332
+ const result = await db
333
+ .prepare(`DELETE FROM webhooks WHERE id = ? AND channel_id = ?`)
334
+ .bind(webhookId, channelId)
335
+ .run();
336
+
337
+ return (result.meta.changes ?? 0) > 0;
338
+ }
339
+
340
+ export async function getWebhooksForChannel(
341
+ db: D1Database,
342
+ channelId: string,
343
+ eventType?: string,
344
+ ): Promise<Webhook[]> {
345
+ if (eventType) {
346
+ // Return webhooks that match the event type OR have no filter (null = all events)
347
+ const result = await db
348
+ .prepare(
349
+ `SELECT * FROM webhooks
350
+ WHERE channel_id = ? AND expires_at > datetime('now')
351
+ AND (event_types IS NULL OR event_types LIKE ?)`,
352
+ )
353
+ .bind(channelId, `%"${eventType}"%`)
354
+ .all<Webhook>();
355
+ return result.results;
356
+ }
357
+
358
+ const result = await db
359
+ .prepare(
360
+ `SELECT * FROM webhooks WHERE channel_id = ? AND expires_at > datetime('now')`,
361
+ )
362
+ .bind(channelId)
363
+ .all<Webhook>();
364
+ return result.results;
365
+ }
366
+
367
+ // --- Server meta queries ---
368
+
369
+ interface ServerMetaRow {
370
+ id: number;
371
+ name: string;
372
+ description: string | null;
373
+ tags: string | null;
374
+ owner: string | null;
375
+ company: string | null;
376
+ email: string | null;
377
+ updated_at: string;
378
+ }
379
+
380
+ function rowToServerMeta(row: ServerMetaRow): ServerIdentity {
381
+ return {
382
+ name: row.name,
383
+ description: row.description,
384
+ tags: row.tags ? JSON.parse(row.tags) : [],
385
+ owner: row.owner,
386
+ company: row.company,
387
+ email: row.email,
388
+ updated_at: row.updated_at,
389
+ };
390
+ }
391
+
392
+ export async function getServerMeta(
393
+ db: D1Database,
394
+ ): Promise<ServerIdentity | null> {
395
+ const row = await db
396
+ .prepare(`SELECT * FROM server_meta WHERE id = 1`)
397
+ .first<ServerMetaRow>();
398
+
399
+ return row ? rowToServerMeta(row) : null;
400
+ }
401
+
402
+ export async function upsertServerMeta(
403
+ db: D1Database,
404
+ meta: {
405
+ name?: string;
406
+ description?: string | null;
407
+ tags?: string[];
408
+ owner?: string | null;
409
+ company?: string | null;
410
+ email?: string | null;
411
+ },
412
+ ): Promise<ServerIdentity> {
413
+ const tags = meta.tags ? JSON.stringify(meta.tags) : null;
414
+
415
+ await db
416
+ .prepare(
417
+ `INSERT INTO server_meta (id, name, description, tags, owner, company, email, updated_at)
418
+ VALUES (1, ?, ?, ?, ?, ?, ?, datetime('now'))
419
+ ON CONFLICT(id) DO UPDATE SET
420
+ name = COALESCE(?, server_meta.name),
421
+ description = ?,
422
+ tags = ?,
423
+ owner = ?,
424
+ company = ?,
425
+ email = ?,
426
+ updated_at = datetime('now')`,
427
+ )
428
+ .bind(
429
+ meta.name ?? 'Zooid',
430
+ meta.description ?? null,
431
+ tags,
432
+ meta.owner ?? null,
433
+ meta.company ?? null,
434
+ meta.email ?? null,
435
+ // ON CONFLICT values
436
+ meta.name ?? null,
437
+ meta.description ?? null,
438
+ tags,
439
+ meta.owner ?? null,
440
+ meta.company ?? null,
441
+ meta.email ?? null,
442
+ )
443
+ .run();
444
+
445
+ const row = await db
446
+ .prepare(`SELECT * FROM server_meta WHERE id = 1`)
447
+ .first<ServerMetaRow>();
448
+
449
+ return rowToServerMeta(row!);
450
+ }
@@ -0,0 +1,56 @@
1
+ CREATE TABLE IF NOT EXISTS channels (
2
+ id TEXT PRIMARY KEY,
3
+ name TEXT NOT NULL,
4
+ description TEXT,
5
+ tags TEXT,
6
+ is_public INTEGER NOT NULL DEFAULT 1,
7
+ schema TEXT,
8
+ strict INTEGER NOT NULL DEFAULT 0,
9
+ max_subscribers INTEGER DEFAULT 100,
10
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
11
+ );
12
+
13
+ CREATE TABLE IF NOT EXISTS events (
14
+ id TEXT PRIMARY KEY,
15
+ channel_id TEXT NOT NULL,
16
+ publisher_id TEXT,
17
+ type TEXT,
18
+ data TEXT NOT NULL,
19
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
20
+ FOREIGN KEY (channel_id) REFERENCES channels(id)
21
+ );
22
+
23
+ CREATE TABLE IF NOT EXISTS webhooks (
24
+ id TEXT PRIMARY KEY,
25
+ channel_id TEXT NOT NULL,
26
+ url TEXT NOT NULL,
27
+ event_types TEXT,
28
+ expires_at TEXT NOT NULL,
29
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
30
+ FOREIGN KEY (channel_id) REFERENCES channels(id)
31
+ );
32
+
33
+ CREATE TABLE IF NOT EXISTS publishers (
34
+ id TEXT PRIMARY KEY,
35
+ channel_id TEXT NOT NULL,
36
+ name TEXT NOT NULL,
37
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
38
+ FOREIGN KEY (channel_id) REFERENCES channels(id)
39
+ );
40
+
41
+ CREATE TABLE IF NOT EXISTS server_meta (
42
+ id INTEGER PRIMARY KEY CHECK (id = 1),
43
+ name TEXT NOT NULL DEFAULT 'Zooid',
44
+ description TEXT,
45
+ tags TEXT,
46
+ owner TEXT,
47
+ company TEXT,
48
+ email TEXT,
49
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
50
+ );
51
+
52
+ CREATE INDEX IF NOT EXISTS idx_events_channel_created ON events(channel_id, created_at DESC);
53
+ CREATE INDEX IF NOT EXISTS idx_events_channel_type ON events(channel_id, type);
54
+ CREATE INDEX IF NOT EXISTS idx_webhooks_channel ON webhooks(channel_id);
55
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_webhooks_channel_url ON webhooks(channel_id, url);
56
+ CREATE INDEX IF NOT EXISTS idx_publishers_channel ON publishers(channel_id);
@@ -0,0 +1,69 @@
1
+ import { DurableObject } from 'cloudflare:workers';
2
+ import type { Bindings } from '../types';
3
+
4
+ const TAG_ALL = 'type:*';
5
+ const TAG_PREFIX = 'type:';
6
+
7
+ export class ChannelDO extends DurableObject<Bindings> {
8
+ async fetch(request: Request): Promise<Response> {
9
+ const upgradeHeader = request.headers.get('Upgrade');
10
+ if (upgradeHeader !== 'websocket') {
11
+ return new Response('Expected WebSocket upgrade', { status: 426 });
12
+ }
13
+
14
+ const url = new URL(request.url);
15
+ const typesParam = url.searchParams.get('types');
16
+
17
+ const tags: string[] = [];
18
+ if (typesParam) {
19
+ for (const t of typesParam.split(',')) {
20
+ const trimmed = t.trim();
21
+ if (trimmed) tags.push(TAG_PREFIX + trimmed);
22
+ }
23
+ }
24
+ // No types specified → receive all events
25
+ if (tags.length === 0) tags.push(TAG_ALL);
26
+
27
+ const pair = new WebSocketPair();
28
+ this.ctx.acceptWebSocket(pair[1], tags);
29
+
30
+ return new Response(null, { status: 101, webSocket: pair[0] });
31
+ }
32
+
33
+ async broadcast(event: Record<string, unknown>): Promise<void> {
34
+ const message = JSON.stringify(event);
35
+ const eventType = typeof event.type === 'string' ? event.type : null;
36
+
37
+ // Collect sockets that should receive this event
38
+ const targets = new Set<WebSocket>();
39
+
40
+ // Unfiltered sockets always receive
41
+ for (const ws of this.ctx.getWebSockets(TAG_ALL)) {
42
+ targets.add(ws);
43
+ }
44
+
45
+ // If the event has a type, also include sockets subscribed to that type
46
+ if (eventType) {
47
+ for (const ws of this.ctx.getWebSockets(TAG_PREFIX + eventType)) {
48
+ targets.add(ws);
49
+ }
50
+ }
51
+
52
+ for (const ws of targets) {
53
+ try {
54
+ ws.send(message);
55
+ } catch {
56
+ // Client disconnected — close silently
57
+ try {
58
+ ws.close(1011, 'Broadcast failed');
59
+ } catch {
60
+ // Already closed
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ webSocketMessage() {}
67
+ webSocketClose() {}
68
+ webSocketError() {}
69
+ }
package/src/index.ts ADDED
@@ -0,0 +1,88 @@
1
+ import { Hono } from 'hono';
2
+ import { fromHono } from 'chanfana';
3
+ import type { Bindings, Variables } from './types';
4
+ import { wellKnown } from './routes/well-known';
5
+ import { requireAuth, requireScope, requireSubscribeIfPrivate } from './middleware/auth';
6
+ import { ListChannels, CreateChannel, AddPublisher } from './routes/channels';
7
+ import { PublishEvents, PollEvents } from './routes/events';
8
+ import { RegisterWebhook, DeleteWebhook } from './routes/webhooks';
9
+ import { GetServerMeta, UpdateServerMeta } from './routes/server-meta';
10
+ import { DirectoryClaim } from './routes/directory';
11
+ import { ws } from './routes/ws';
12
+ import { rss } from './routes/rss';
13
+ import { feed } from './routes/feed';
14
+ import { opml } from './routes/opml';
15
+
16
+ type Env = { Bindings: Bindings; Variables: Variables };
17
+
18
+ const app = new Hono<Env>();
19
+
20
+ // Well-known stays at root
21
+ app.route('', wellKnown);
22
+
23
+ // All API routes under /api
24
+ const api = new Hono<Env>();
25
+ const openapi = fromHono(api, {
26
+ docs_url: '/docs',
27
+ redoc_url: null,
28
+ openapi_url: '/openapi.json',
29
+ schema: {
30
+ info: {
31
+ title: 'Zooid',
32
+ version: '0.1.0',
33
+ description: 'Pub/sub for AI agents',
34
+ },
35
+ security: [{ bearerAuth: [] }],
36
+ },
37
+ });
38
+ openapi.registry.registerComponent('securitySchemes', 'bearerAuth', {
39
+ type: 'http',
40
+ scheme: 'bearer',
41
+ bearerFormat: 'JWT',
42
+ });
43
+
44
+ // Server meta routes
45
+ openapi.get('/server', GetServerMeta);
46
+ // @ts-expect-error chanfana types don't include middleware overloads
47
+ openapi.put('/server', requireAuth(), requireScope('admin'), UpdateServerMeta);
48
+
49
+ // Channel routes
50
+ openapi.get('/channels', ListChannels);
51
+ // @ts-expect-error chanfana types don't include middleware overloads
52
+ openapi.post('/channels', requireAuth(), requireScope('admin'), CreateChannel);
53
+ // prettier-ignore
54
+ // @ts-expect-error chanfana types don't include middleware overloads
55
+ openapi.post('/channels/:channelId/publishers', requireAuth(), requireScope('admin'), AddPublisher);
56
+
57
+ // Event routes
58
+ // prettier-ignore
59
+ // @ts-expect-error chanfana types don't include middleware overloads
60
+ openapi.post('/channels/:channelId/events', requireAuth(), requireScope('publish', { channelParam: 'channelId' }), PublishEvents);
61
+ // prettier-ignore
62
+ // @ts-expect-error chanfana types don't include middleware overloads
63
+ openapi.get('/channels/:channelId/events', requireSubscribeIfPrivate('channelId'), PollEvents);
64
+
65
+ // Directory claim route
66
+ // prettier-ignore
67
+ // @ts-expect-error chanfana types don't include middleware overloads
68
+ openapi.post('/directory/claim', requireAuth(), requireScope('admin'), DirectoryClaim);
69
+
70
+ // Webhook routes
71
+ // prettier-ignore
72
+ // @ts-expect-error chanfana types don't include middleware overloads
73
+ openapi.post('/channels/:channelId/webhooks', requireSubscribeIfPrivate('channelId'), RegisterWebhook);
74
+ // prettier-ignore
75
+ // @ts-expect-error chanfana types don't include middleware overloads
76
+ openapi.delete('/channels/:channelId/webhooks/:webhookId', requireAuth(), requireScope('admin'), DeleteWebhook);
77
+
78
+ // Plain Hono routes (streaming/XML — not suited for OpenAPI)
79
+ api.route('', ws);
80
+ api.route('', rss);
81
+ api.route('', feed);
82
+ api.route('', opml);
83
+
84
+ api.get('/', (c) => c.json({ ok: true }));
85
+ app.route('/api/v1', api);
86
+
87
+ export { ChannelDO } from './do/channel';
88
+ export default app;