@zooid/server 0.0.1 → 0.0.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zooid/server",
3
- "version": "0.0.1",
3
+ "version": "0.0.9",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "author": "Ori Ben",
@@ -21,12 +21,13 @@
21
21
  "ulidx": "^2.4.1",
22
22
  "yaml": "^2.8.2",
23
23
  "zod": "^4.3.6",
24
- "@zooid/web": "0.0.1",
25
- "@zooid/types": "0.0.1"
24
+ "@zooid/types": "0.0.9",
25
+ "@zooid/web": "0.0.9"
26
26
  },
27
27
  "devDependencies": {
28
28
  "@cloudflare/vitest-pool-workers": "^0.12.13",
29
29
  "@cloudflare/workers-types": "^4.20260217.0",
30
+ "@vitest/coverage-istanbul": "^3.2.4",
30
31
  "wrangler": "^4.66.0"
31
32
  },
32
33
  "scripts": {
@@ -120,7 +120,7 @@ describe('Event queries', () => {
120
120
  expect(result.events[0].type).toBe('signal');
121
121
  });
122
122
 
123
- it('supports cursor-based pagination', async () => {
123
+ it('returns the most recent events when no cursor/since (tail behavior)', async () => {
124
124
  for (let i = 0; i < 5; i++) {
125
125
  await createEvent(env.DB, {
126
126
  channelId: 'test-channel',
@@ -129,7 +129,29 @@ describe('Event queries', () => {
129
129
  });
130
130
  }
131
131
 
132
- const page1 = await pollEvents(env.DB, 'test-channel', { limit: 2 });
132
+ const result = await pollEvents(env.DB, 'test-channel', { limit: 2 });
133
+ expect(result.events).toHaveLength(2);
134
+ // Should be the last 2 events (i=3, i=4) in chronological order
135
+ expect(JSON.parse(result.events[0].data).i).toBe(3);
136
+ expect(JSON.parse(result.events[1].data).i).toBe(4);
137
+ expect(result.has_more).toBe(false);
138
+ expect(result.cursor).toBeNull();
139
+ });
140
+
141
+ it('supports cursor-based forward pagination', async () => {
142
+ for (let i = 0; i < 5; i++) {
143
+ await createEvent(env.DB, {
144
+ channelId: 'test-channel',
145
+ type: 'evt',
146
+ data: { i },
147
+ });
148
+ }
149
+
150
+ // Use since to anchor pagination (ASC mode)
151
+ const page1 = await pollEvents(env.DB, 'test-channel', {
152
+ limit: 2,
153
+ since: '2000-01-01T00:00:00Z',
154
+ });
133
155
  expect(page1.events).toHaveLength(2);
134
156
  expect(page1.has_more).toBe(true);
135
157
  expect(page1.cursor).toBeTruthy();
@@ -492,9 +514,9 @@ describe('Server meta queries', () => {
492
514
  expect(meta.owner).toBe('bob');
493
515
 
494
516
  // Verify only one row exists
495
- const result = await env.DB
496
- .prepare('SELECT COUNT(*) as count FROM server_meta')
497
- .first<{ count: number }>();
517
+ const result = await env.DB.prepare(
518
+ 'SELECT COUNT(*) as count FROM server_meta',
519
+ ).first<{ count: number }>();
498
520
  expect(result!.count).toBe(1);
499
521
  });
500
522
  });
package/src/db/queries.ts CHANGED
@@ -1,4 +1,3 @@
1
-
2
1
  import type {
3
2
  Channel,
4
3
  ChannelListItem,
@@ -31,7 +30,15 @@ export async function createChannel(
31
30
  .prepare(
32
31
  `INSERT INTO channels (id, name, description, tags, is_public, schema, strict) VALUES (?, ?, ?, ?, ?, ?, ?)`,
33
32
  )
34
- .bind(channel.id, channel.name, channel.description ?? null, tags, isPublic, schema, strict)
33
+ .bind(
34
+ channel.id,
35
+ channel.name,
36
+ channel.description ?? null,
37
+ tags,
38
+ isPublic,
39
+ schema,
40
+ strict,
41
+ )
35
42
  .run();
36
43
 
37
44
  const row = await db
@@ -46,12 +53,13 @@ export async function getChannel(
46
53
  db: D1Database,
47
54
  id: string,
48
55
  ): Promise<Channel | null> {
49
- return db.prepare(`SELECT * FROM channels WHERE id = ?`).bind(id).first<Channel>();
56
+ return db
57
+ .prepare(`SELECT * FROM channels WHERE id = ?`)
58
+ .bind(id)
59
+ .first<Channel>();
50
60
  }
51
61
 
52
- export async function listChannels(
53
- db: D1Database,
54
- ): Promise<ChannelListItem[]> {
62
+ export async function listChannels(db: D1Database): Promise<ChannelListItem[]> {
55
63
  const rows = await db
56
64
  .prepare(
57
65
  `SELECT
@@ -120,9 +128,7 @@ export async function createPublisher(
120
128
  const id = generateUlid();
121
129
 
122
130
  await db
123
- .prepare(
124
- `INSERT INTO publishers (id, channel_id, name) VALUES (?, ?, ?)`,
125
- )
131
+ .prepare(`INSERT INTO publishers (id, channel_id, name) VALUES (?, ?, ?)`)
126
132
  .bind(id, channelId, name)
127
133
  .run();
128
134
 
@@ -249,7 +255,12 @@ export async function pollEvents(
249
255
  bindings.push(options.type);
250
256
  }
251
257
 
252
- const sql = `SELECT * FROM events WHERE ${conditions.join(' AND ')} ORDER BY id ASC LIMIT ?`;
258
+ // When no cursor/since anchor, fetch the most recent events (DESC) and reverse
259
+ // so the result is still in chronological order. With an anchor, fetch forward (ASC).
260
+ const hasAnchor = !!(options.cursor || options.since);
261
+ const order = hasAnchor ? 'ASC' : 'DESC';
262
+
263
+ const sql = `SELECT * FROM events WHERE ${conditions.join(' AND ')} ORDER BY id ${order} LIMIT ?`;
253
264
  bindings.push(limit + 1);
254
265
 
255
266
  const stmt = db.prepare(sql);
@@ -257,13 +268,16 @@ export async function pollEvents(
257
268
  const rows = result.results;
258
269
 
259
270
  const hasMore = rows.length > limit;
260
- const events = hasMore ? rows.slice(0, limit) : rows;
271
+ const trimmed = hasMore ? rows.slice(0, limit) : rows;
272
+
273
+ // DESC results need reversing to restore chronological order
274
+ const events = hasAnchor ? trimmed : trimmed.reverse();
261
275
  const cursor = events.length > 0 ? events[events.length - 1].id : null;
262
276
 
263
277
  return {
264
278
  events,
265
- cursor: hasMore ? cursor : null,
266
- has_more: hasMore,
279
+ cursor: hasAnchor && hasMore ? cursor : null,
280
+ has_more: hasAnchor ? hasMore : false,
267
281
  };
268
282
  }
269
283
 
@@ -315,9 +329,7 @@ export async function createWebhook(
315
329
 
316
330
  // Fetch back — could be the new row or the updated existing one
317
331
  const row = await db
318
- .prepare(
319
- `SELECT * FROM webhooks WHERE channel_id = ? AND url = ?`,
320
- )
332
+ .prepare(`SELECT * FROM webhooks WHERE channel_id = ? AND url = ?`)
321
333
  .bind(webhook.channelId, webhook.url)
322
334
  .first<Webhook>();
323
335
 
package/src/index.ts CHANGED
@@ -2,7 +2,11 @@ import { Hono } from 'hono';
2
2
  import { fromHono } from 'chanfana';
3
3
  import type { Bindings, Variables } from './types';
4
4
  import { wellKnown } from './routes/well-known';
5
- import { requireAuth, requireScope, requireSubscribeIfPrivate } from './middleware/auth';
5
+ import {
6
+ requireAuth,
7
+ requireScope,
8
+ requireSubscribeIfPrivate,
9
+ } from './middleware/auth';
6
10
  import { ListChannels, CreateChannel, AddPublisher } from './routes/channels';
7
11
  import { PublishEvents, PollEvents } from './routes/events';
8
12
  import { RegisterWebhook, DeleteWebhook } from './routes/webhooks';
@@ -20,7 +20,10 @@ const schema = {
20
20
 
21
21
  describe('validateEvent', () => {
22
22
  it('accepts a valid alert event', () => {
23
- const result = validateEvent(schema, 'alert', { level: 'info', message: 'hello' });
23
+ const result = validateEvent(schema, 'alert', {
24
+ level: 'info',
25
+ message: 'hello',
26
+ });
24
27
  expect(result.valid).toBe(true);
25
28
  });
26
29
 
@@ -30,15 +33,22 @@ describe('validateEvent', () => {
30
33
  });
31
34
 
32
35
  it('rejects event with no type', () => {
33
- const result = validateEvent(schema, null, { level: 'info', message: 'hello' });
36
+ const result = validateEvent(schema, null, {
37
+ level: 'info',
38
+ message: 'hello',
39
+ });
34
40
  expect(result.valid).toBe(false);
35
- expect(result.valid === false && result.error).toContain('must have a type');
41
+ expect(result.valid === false && result.error).toContain(
42
+ 'must have a type',
43
+ );
36
44
  });
37
45
 
38
46
  it('rejects event with undefined type', () => {
39
47
  const result = validateEvent(schema, undefined, {});
40
48
  expect(result.valid).toBe(false);
41
- expect(result.valid === false && result.error).toContain('must have a type');
49
+ expect(result.valid === false && result.error).toContain(
50
+ 'must have a type',
51
+ );
42
52
  });
43
53
 
44
54
  it('rejects unknown event type', () => {
@@ -60,7 +70,10 @@ describe('validateEvent', () => {
60
70
  });
61
71
 
62
72
  it('rejects wrong type for a field', () => {
63
- const result = validateEvent(schema, 'metric', { name: 'cpu', value: 'not-a-number' });
73
+ const result = validateEvent(schema, 'metric', {
74
+ name: 'cpu',
75
+ value: 'not-a-number',
76
+ });
64
77
  expect(result.valid).toBe(false);
65
78
  if (!result.valid) {
66
79
  expect(result.error).toContain('value');
@@ -68,7 +81,10 @@ describe('validateEvent', () => {
68
81
  });
69
82
 
70
83
  it('rejects invalid enum value', () => {
71
- const result = validateEvent(schema, 'alert', { level: 'critical', message: 'oops' });
84
+ const result = validateEvent(schema, 'alert', {
85
+ level: 'critical',
86
+ message: 'oops',
87
+ });
72
88
  expect(result.valid).toBe(false);
73
89
  if (!result.valid) {
74
90
  expect(result.error).toContain('level');
@@ -76,7 +92,11 @@ describe('validateEvent', () => {
76
92
  });
77
93
 
78
94
  it('accepts data with extra fields (no additionalProperties restriction)', () => {
79
- const result = validateEvent(schema, 'alert', { level: 'info', message: 'hi', extra: true });
95
+ const result = validateEvent(schema, 'alert', {
96
+ level: 'info',
97
+ message: 'hi',
98
+ extra: true,
99
+ });
80
100
  expect(result.valid).toBe(true);
81
101
  });
82
102
 
@@ -95,7 +115,11 @@ describe('validateEvent', () => {
95
115
  properties: { enabled: { type: 'boolean' } },
96
116
  },
97
117
  };
98
- expect(validateEvent(boolSchema, 'toggle', { enabled: true }).valid).toBe(true);
99
- expect(validateEvent(boolSchema, 'toggle', { enabled: 'yes' }).valid).toBe(false);
118
+ expect(validateEvent(boolSchema, 'toggle', { enabled: true }).valid).toBe(
119
+ true,
120
+ );
121
+ expect(validateEvent(boolSchema, 'toggle', { enabled: 'yes' }).valid).toBe(
122
+ false,
123
+ );
100
124
  });
101
125
  });
@@ -1,11 +1,13 @@
1
1
  import { Validator } from '@cfworker/json-schema';
2
2
 
3
- export type ValidationResult = {
4
- valid: true;
5
- } | {
6
- valid: false;
7
- error: string;
8
- };
3
+ export type ValidationResult =
4
+ | {
5
+ valid: true;
6
+ }
7
+ | {
8
+ valid: false;
9
+ error: string;
10
+ };
9
11
 
10
12
  /**
11
13
  * Validate an event against a channel's schema.
@@ -24,18 +26,27 @@ export type ValidationResult = {
24
26
  * using @cfworker/json-schema (Cloudflare Workers compatible).
25
27
  */
26
28
  export function validateEvent(
27
- schema: Record<string, { required?: string[]; properties?: Record<string, unknown> }>,
29
+ schema: Record<
30
+ string,
31
+ { required?: string[]; properties?: Record<string, unknown> }
32
+ >,
28
33
  type: string | null | undefined,
29
34
  data: unknown,
30
35
  ): ValidationResult {
31
36
  if (!type) {
32
- return { valid: false, error: 'Event must have a type when publishing to a strict channel' };
37
+ return {
38
+ valid: false,
39
+ error: 'Event must have a type when publishing to a strict channel',
40
+ };
33
41
  }
34
42
 
35
43
  const typeSchema = schema[type];
36
44
  if (!typeSchema) {
37
45
  const allowed = Object.keys(schema).join(', ');
38
- return { valid: false, error: `Unknown event type "${type}". Allowed types: ${allowed}` };
46
+ return {
47
+ valid: false,
48
+ error: `Unknown event type "${type}". Allowed types: ${allowed}`,
49
+ };
39
50
  }
40
51
 
41
52
  // Build a JSON Schema object from the type definition
@@ -53,11 +64,17 @@ export function validateEvent(
53
64
  if (!result.valid) {
54
65
  const errors = result.errors
55
66
  .map((e) => {
56
- const loc = e.instanceLocation === '#' ? 'data' : e.instanceLocation.replace('#/', 'data.');
67
+ const loc =
68
+ e.instanceLocation === '#'
69
+ ? 'data'
70
+ : e.instanceLocation.replace('#/', 'data.');
57
71
  return `${loc}: ${e.error}`;
58
72
  })
59
73
  .join('; ');
60
- return { valid: false, error: `Validation failed for type "${type}": ${errors}` };
74
+ return {
75
+ valid: false,
76
+ error: `Validation failed for type "${type}": ${errors}`,
77
+ };
61
78
  }
62
79
 
63
80
  return { valid: true };
@@ -37,7 +37,11 @@ describe('Auth middleware', () => {
37
37
  const app = createTestApp();
38
38
 
39
39
  it('allows unauthenticated access to public routes', async () => {
40
- const res = await app.request('/public', {}, { ZOOID_JWT_SECRET: JWT_SECRET });
40
+ const res = await app.request(
41
+ '/public',
42
+ {},
43
+ { ZOOID_JWT_SECRET: JWT_SECRET },
44
+ );
41
45
  expect(res.status).toBe(200);
42
46
  });
43
47
 
@@ -75,7 +75,9 @@ export function requireSubscribeIfPrivate(channelParam: string) {
75
75
 
76
76
  const authHeader = c.req.header('Authorization');
77
77
  const queryToken = c.req.query('token');
78
- const rawToken = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : queryToken ?? null;
78
+ const rawToken = authHeader?.startsWith('Bearer ')
79
+ ? authHeader.slice(7)
80
+ : (queryToken ?? null);
79
81
 
80
82
  if (!rawToken) {
81
83
  return c.json(
@@ -92,6 +92,22 @@ export class CreateChannel extends OpenAPIRoute {
92
92
  },
93
93
  },
94
94
  },
95
+ 401: {
96
+ description: 'Missing or invalid authentication',
97
+ content: {
98
+ 'application/json': {
99
+ schema: z.object({ error: z.string() }),
100
+ },
101
+ },
102
+ },
103
+ 403: {
104
+ description: 'Insufficient permissions',
105
+ content: {
106
+ 'application/json': {
107
+ schema: z.object({ error: z.string() }),
108
+ },
109
+ },
110
+ },
95
111
  409: {
96
112
  description: 'Channel already exists',
97
113
  content: {
@@ -180,6 +196,22 @@ export class AddPublisher extends OpenAPIRoute {
180
196
  },
181
197
  },
182
198
  },
199
+ 401: {
200
+ description: 'Missing or invalid authentication',
201
+ content: {
202
+ 'application/json': {
203
+ schema: z.object({ error: z.string() }),
204
+ },
205
+ },
206
+ },
207
+ 403: {
208
+ description: 'Insufficient permissions',
209
+ content: {
210
+ 'application/json': {
211
+ schema: z.object({ error: z.string() }),
212
+ },
213
+ },
214
+ },
183
215
  404: {
184
216
  description: 'Channel not found',
185
217
  content: {
@@ -3,7 +3,11 @@ import { env } from 'cloudflare:test';
3
3
  import app from '../index';
4
4
  import { setupTestDb, cleanTestDb } from '../test-utils';
5
5
  import { createToken } from '../lib/jwt';
6
- import { generateKeyPair, exportPublicKey, importPublicKey } from '../lib/signing';
6
+ import {
7
+ generateKeyPair,
8
+ exportPublicKey,
9
+ importPublicKey,
10
+ } from '../lib/signing';
7
11
 
8
12
  const JWT_SECRET = 'test-jwt-secret';
9
13
 
@@ -124,7 +128,12 @@ describe('Directory claim route', () => {
124
128
  const sigBytes = base64UrlToBytes(body.signature);
125
129
 
126
130
  // Verify with the public key
127
- const valid = await crypto.subtle.verify('Ed25519', PUBLIC_KEY, sigBytes, claimBytes);
131
+ const valid = await crypto.subtle.verify(
132
+ 'Ed25519',
133
+ PUBLIC_KEY,
134
+ sigBytes,
135
+ claimBytes,
136
+ );
128
137
  expect(valid).toBe(true);
129
138
  });
130
139
 
@@ -13,7 +13,10 @@ function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
13
13
  for (const byte of bytes) {
14
14
  binary += String.fromCharCode(byte);
15
15
  }
16
- return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
16
+ return btoa(binary)
17
+ .replace(/\+/g, '-')
18
+ .replace(/\//g, '_')
19
+ .replace(/=+$/, '');
17
20
  }
18
21
 
19
22
  function toBase64Url(str: string): string {
@@ -57,6 +60,22 @@ export class DirectoryClaim extends OpenAPIRoute {
57
60
  },
58
61
  },
59
62
  },
63
+ 401: {
64
+ description: 'Missing or invalid authentication',
65
+ content: {
66
+ 'application/json': {
67
+ schema: z.object({ error: z.string() }),
68
+ },
69
+ },
70
+ },
71
+ 403: {
72
+ description: 'Insufficient permissions',
73
+ content: {
74
+ 'application/json': {
75
+ schema: z.object({ error: z.string() }),
76
+ },
77
+ },
78
+ },
60
79
  500: {
61
80
  description: 'Server error',
62
81
  content: {
@@ -83,7 +102,10 @@ export class DirectoryClaim extends OpenAPIRoute {
83
102
  if (!ch) missing.push(id);
84
103
  }
85
104
  if (missing.length > 0) {
86
- return c.json({ error: `Channels not found: ${missing.join(', ')}` }, 400);
105
+ return c.json(
106
+ { error: `Channels not found: ${missing.join(', ')}` },
107
+ 400,
108
+ );
87
109
  }
88
110
 
89
111
  const serverUrl = new URL(c.req.url).origin;
@@ -99,7 +121,11 @@ export class DirectoryClaim extends OpenAPIRoute {
99
121
  const claimJson = JSON.stringify(claim);
100
122
  const claimBytes = new TextEncoder().encode(claimJson);
101
123
  const privateKey = await importPrivateKey(c.env.ZOOID_SIGNING_KEY);
102
- const signatureBuffer = await crypto.subtle.sign('Ed25519', privateKey, claimBytes);
124
+ const signatureBuffer = await crypto.subtle.sign(
125
+ 'Ed25519',
126
+ privateKey,
127
+ claimBytes,
128
+ );
103
129
 
104
130
  return c.json({
105
131
  claim: toBase64Url(claimJson),
@@ -1,8 +1,10 @@
1
- import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
1
+ import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest';
2
2
  import { env } from 'cloudflare:test';
3
3
  import app from '../index';
4
4
  import { setupTestDb, cleanTestDb } from '../test-utils';
5
5
  import { createToken } from '../lib/jwt';
6
+ import { deliverToWebhooks } from './events';
7
+ import { createWebhook } from '../db/queries';
6
8
 
7
9
  const JWT_SECRET = 'test-jwt-secret';
8
10
 
@@ -373,7 +375,7 @@ describe('Event routes', () => {
373
375
  expect(res.status).toBe(200);
374
376
  });
375
377
 
376
- it('supports limit query param', async () => {
378
+ it('supports limit query param (returns most recent events)', async () => {
377
379
  for (let i = 0; i < 5; i++) {
378
380
  await publishRequest(
379
381
  '/api/v1/channels/pub-channel/events',
@@ -391,16 +393,18 @@ describe('Event routes', () => {
391
393
  { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
392
394
  );
393
395
  const body = (await res.json()) as {
394
- events: unknown[];
396
+ events: { data: string }[];
395
397
  has_more: boolean;
396
- cursor: string;
398
+ cursor: string | null;
397
399
  };
398
400
  expect(body.events).toHaveLength(2);
399
- expect(body.has_more).toBe(true);
400
- expect(body.cursor).toBeTruthy();
401
+ // Should return the 2 most recent events in chronological order
402
+ expect(JSON.parse(body.events[0].data).i).toBe(3);
403
+ expect(JSON.parse(body.events[1].data).i).toBe(4);
404
+ expect(body.has_more).toBe(false);
401
405
  });
402
406
 
403
- it('supports cursor pagination', async () => {
407
+ it('supports cursor pagination (forward from anchor)', async () => {
404
408
  for (let i = 0; i < 3; i++) {
405
409
  await publishRequest(
406
410
  '/api/v1/channels/pub-channel/events',
@@ -412,15 +416,20 @@ describe('Event routes', () => {
412
416
  );
413
417
  }
414
418
 
419
+ // Use since to anchor pagination forward
415
420
  const page1 = await app.request(
416
- '/api/v1/channels/pub-channel/events?limit=2',
421
+ '/api/v1/channels/pub-channel/events?limit=2&since=2000-01-01T00:00:00Z',
417
422
  {},
418
423
  { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
419
424
  );
420
425
  const body1 = (await page1.json()) as {
421
426
  cursor: string;
422
427
  events: unknown[];
428
+ has_more: boolean;
423
429
  };
430
+ expect(body1.events).toHaveLength(2);
431
+ expect(body1.has_more).toBe(true);
432
+ expect(body1.cursor).toBeTruthy();
424
433
 
425
434
  const page2 = await app.request(
426
435
  `/api/v1/channels/pub-channel/events?limit=2&cursor=${body1.cursor}`,
@@ -474,4 +483,104 @@ describe('Event routes', () => {
474
483
  expect(res.status).toBe(404);
475
484
  });
476
485
  });
486
+
487
+ describe('webhook delivery headers', () => {
488
+ it('includes X-Zooid-Server header in webhook deliveries', async () => {
489
+ // Register a webhook for the channel
490
+ await createWebhook(env.DB, {
491
+ channelId: 'pub-channel',
492
+ url: 'https://consumer.example.com/hook',
493
+ });
494
+
495
+ const captured: { url: string; headers: Record<string, string> }[] = [];
496
+ const mockFetch = vi.fn(
497
+ async (url: string | URL | Request, init?: RequestInit) => {
498
+ const headers: Record<string, string> = {};
499
+ if (init?.headers) {
500
+ for (const [k, v] of Object.entries(
501
+ init.headers as Record<string, string>,
502
+ )) {
503
+ headers[k] = v;
504
+ }
505
+ }
506
+ captured.push({ url: url.toString(), headers });
507
+ return new Response('ok', { status: 200 });
508
+ },
509
+ ) as unknown as typeof fetch;
510
+
511
+ await deliverToWebhooks(
512
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET } as never,
513
+ 'pub-channel',
514
+ { id: '01TEST000000000000000000AA', type: 'signal' },
515
+ 'https://my-server.zooid.dev',
516
+ mockFetch,
517
+ );
518
+
519
+ expect(captured).toHaveLength(1);
520
+ expect(captured[0].headers['X-Zooid-Server']).toBe(
521
+ 'https://my-server.zooid.dev',
522
+ );
523
+ expect(captured[0].headers['X-Zooid-Channel']).toBe('pub-channel');
524
+ expect(captured[0].headers['X-Zooid-Event-Id']).toBe(
525
+ '01TEST000000000000000000AA',
526
+ );
527
+ expect(captured[0].headers['X-Zooid-Timestamp']).toBeTruthy();
528
+ expect(captured[0].url).toBe('https://consumer.example.com/hook');
529
+ });
530
+
531
+ it('includes X-Zooid-Signature when signing key is configured', async () => {
532
+ await createWebhook(env.DB, {
533
+ channelId: 'pub-channel',
534
+ url: 'https://consumer.example.com/hook',
535
+ });
536
+
537
+ // Generate a test signing key
538
+ const keyPair = await crypto.subtle.generateKey('Ed25519', true, [
539
+ 'sign',
540
+ 'verify',
541
+ ]);
542
+ const exported = await crypto.subtle.exportKey(
543
+ 'pkcs8',
544
+ keyPair.privateKey,
545
+ );
546
+ const bytes = new Uint8Array(exported);
547
+ let binary = '';
548
+ for (const byte of bytes) binary += String.fromCharCode(byte);
549
+ const signingKeyBase64 = btoa(binary);
550
+
551
+ const captured: { headers: Record<string, string> }[] = [];
552
+ const mockFetch = vi.fn(
553
+ async (_url: string | URL | Request, init?: RequestInit) => {
554
+ const headers: Record<string, string> = {};
555
+ if (init?.headers) {
556
+ for (const [k, v] of Object.entries(
557
+ init.headers as Record<string, string>,
558
+ )) {
559
+ headers[k] = v;
560
+ }
561
+ }
562
+ captured.push({ headers });
563
+ return new Response('ok', { status: 200 });
564
+ },
565
+ ) as unknown as typeof fetch;
566
+
567
+ await deliverToWebhooks(
568
+ {
569
+ ...env,
570
+ ZOOID_JWT_SECRET: JWT_SECRET,
571
+ ZOOID_SIGNING_KEY: signingKeyBase64,
572
+ } as never,
573
+ 'pub-channel',
574
+ { id: '01TEST000000000000000000BB', type: 'alert' },
575
+ 'https://my-server.zooid.dev',
576
+ mockFetch,
577
+ );
578
+
579
+ expect(captured).toHaveLength(1);
580
+ expect(captured[0].headers['X-Zooid-Server']).toBe(
581
+ 'https://my-server.zooid.dev',
582
+ );
583
+ expect(captured[0].headers['X-Zooid-Signature']).toBeTruthy();
584
+ });
585
+ });
477
586
  });
@@ -81,6 +81,22 @@ export class PublishEvents extends OpenAPIRoute {
81
81
  },
82
82
  },
83
83
  },
84
+ 401: {
85
+ description: 'Missing or invalid authentication',
86
+ content: {
87
+ 'application/json': {
88
+ schema: z.object({ error: z.string() }),
89
+ },
90
+ },
91
+ },
92
+ 403: {
93
+ description: 'Insufficient permissions',
94
+ content: {
95
+ 'application/json': {
96
+ schema: z.object({ error: z.string() }),
97
+ },
98
+ },
99
+ },
84
100
  404: {
85
101
  description: 'Channel not found',
86
102
  content: {
@@ -117,14 +133,13 @@ export class PublishEvents extends OpenAPIRoute {
117
133
  >)
118
134
  : null;
119
135
 
136
+ const serverUrl = new URL(c.req.url).origin;
137
+
120
138
  // Batch publish
121
139
  if ('events' in body && Array.isArray(body.events)) {
122
140
  for (const evt of body.events) {
123
141
  if (evt.data === undefined) {
124
- return c.json(
125
- { error: 'Each event must include a data field' },
126
- 400,
127
- );
142
+ return c.json({ error: 'Each event must include a data field' }, 400);
128
143
  }
129
144
  }
130
145
 
@@ -155,7 +170,7 @@ export class PublishEvents extends OpenAPIRoute {
155
170
  // CHANNEL_DO binding may not exist in tests
156
171
  }
157
172
  for (const event of created) {
158
- await deliverToWebhooks(c.env, channelId, event);
173
+ await deliverToWebhooks(c.env, channelId, event, serverUrl);
159
174
  }
160
175
  };
161
176
  try {
@@ -194,7 +209,7 @@ export class PublishEvents extends OpenAPIRoute {
194
209
  } catch {
195
210
  // CHANNEL_DO binding may not exist in tests
196
211
  }
197
- await deliverToWebhooks(c.env, channelId, event);
212
+ await deliverToWebhooks(c.env, channelId, event, serverUrl);
198
213
  };
199
214
  try {
200
215
  c.executionCtx.waitUntil(afterPublish());
@@ -271,10 +286,12 @@ export class PollEvents extends OpenAPIRoute {
271
286
  }
272
287
  }
273
288
 
274
- async function deliverToWebhooks(
289
+ export async function deliverToWebhooks(
275
290
  env: Bindings,
276
291
  channelId: string,
277
292
  event: { id: string; type: string | null },
293
+ serverUrl: string,
294
+ fetchFn: typeof fetch = fetch,
278
295
  ) {
279
296
  const webhooks = await getWebhooksForChannel(
280
297
  env.DB,
@@ -299,6 +316,7 @@ async function deliverToWebhooks(
299
316
  webhooks.map((webhook) => {
300
317
  const headers: Record<string, string> = {
301
318
  'Content-Type': 'application/json',
319
+ 'X-Zooid-Server': serverUrl,
302
320
  'X-Zooid-Timestamp': timestamp,
303
321
  'X-Zooid-Channel': channelId,
304
322
  'X-Zooid-Event-Id': event.id,
@@ -309,7 +327,7 @@ async function deliverToWebhooks(
309
327
  headers['X-Zooid-Signature'] = signature;
310
328
  }
311
329
 
312
- return fetch(webhook.url, { method: 'POST', headers, body });
330
+ return fetchFn(webhook.url, { method: 'POST', headers, body });
313
331
  }),
314
332
  );
315
333
  }
@@ -99,14 +99,17 @@ describe('JSON Feed routes', () => {
99
99
  expect(res.status).toBe(200);
100
100
  expect(res.headers.get('Content-Type')).toBe('application/feed+json');
101
101
 
102
- const body = await res.json() as FeedBody;
102
+ const body = (await res.json()) as FeedBody;
103
103
  expect(body.version).toBe('https://jsonfeed.org/version/1.1');
104
104
  expect(body.title).toBe('Feed Channel');
105
105
  expect(body.description).toBe('Test JSON feed');
106
106
  expect(body.items).toHaveLength(1);
107
107
  expect(body.items[0].id).toBeDefined();
108
108
  expect(body.items[0]._zooid).toBeDefined();
109
- expect(body.items[0]._zooid.data).toEqual({ market: 'test', shift: 0.05 });
109
+ expect(body.items[0]._zooid.data).toEqual({
110
+ market: 'test',
111
+ shift: 0.05,
112
+ });
110
113
  });
111
114
 
112
115
  it('formats data as YAML by default', async () => {
@@ -128,7 +131,7 @@ describe('JSON Feed routes', () => {
128
131
  { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
129
132
  );
130
133
 
131
- const body = await res.json() as FeedBody;
134
+ const body = (await res.json()) as FeedBody;
132
135
  // YAML-style: key: value
133
136
  expect(body.items[0].content_text).toContain('market: election');
134
137
  expect(body.items[0].content_text).toContain('shift: 0.07');
@@ -153,7 +156,7 @@ describe('JSON Feed routes', () => {
153
156
  { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
154
157
  );
155
158
 
156
- const body = await res.json() as FeedBody;
159
+ const body = (await res.json()) as FeedBody;
157
160
  expect(body.items[0].content_text).toContain('"market"');
158
161
  });
159
162
 
@@ -176,7 +179,7 @@ describe('JSON Feed routes', () => {
176
179
  { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
177
180
  );
178
181
 
179
- const body = await res.json() as FeedBody;
182
+ const body = (await res.json()) as FeedBody;
180
183
  expect(body.items[0].title).toContain('[odds_shift]');
181
184
  expect(body.items[0].title).toContain('test-publisher');
182
185
  expect(body.items[0].tags).toEqual(['odds_shift']);
@@ -190,7 +193,7 @@ describe('JSON Feed routes', () => {
190
193
  );
191
194
 
192
195
  expect(res.status).toBe(200);
193
- const body = await res.json() as FeedBody;
196
+ const body = (await res.json()) as FeedBody;
194
197
  expect(body.version).toBe('https://jsonfeed.org/version/1.1');
195
198
  expect(body.items).toEqual([]);
196
199
  });
@@ -46,7 +46,9 @@ feed.get('/channels/:channelId/feed.json', async (c) => {
46
46
  const result = await pollEvents(db, channelId, { limit: 50 });
47
47
  const format = c.req.query('format') || 'yaml';
48
48
 
49
- const items = result.events.map((event) => formatItem(event, channelId, format));
49
+ const items = result.events.map((event) =>
50
+ formatItem(event, channelId, format),
51
+ );
50
52
 
51
53
  const jsonFeed = {
52
54
  version: 'https://jsonfeed.org/version/1.1',
@@ -87,6 +87,22 @@ export class UpdateServerMeta extends OpenAPIRoute {
87
87
  },
88
88
  },
89
89
  },
90
+ 401: {
91
+ description: 'Missing or invalid authentication',
92
+ content: {
93
+ 'application/json': {
94
+ schema: z.object({ error: z.string() }),
95
+ },
96
+ },
97
+ },
98
+ 403: {
99
+ description: 'Insufficient permissions',
100
+ content: {
101
+ 'application/json': {
102
+ schema: z.object({ error: z.string() }),
103
+ },
104
+ },
105
+ },
90
106
  },
91
107
  };
92
108
 
@@ -85,6 +85,22 @@ export class DeleteWebhook extends OpenAPIRoute {
85
85
  204: {
86
86
  description: 'Webhook deleted',
87
87
  },
88
+ 401: {
89
+ description: 'Missing or invalid authentication',
90
+ content: {
91
+ 'application/json': {
92
+ schema: z.object({ error: z.string() }),
93
+ },
94
+ },
95
+ },
96
+ 403: {
97
+ description: 'Insufficient permissions',
98
+ content: {
99
+ 'application/json': {
100
+ schema: z.object({ error: z.string() }),
101
+ },
102
+ },
103
+ },
88
104
  404: {
89
105
  description: 'Webhook not found',
90
106
  content: {
@@ -44,7 +44,9 @@ wellKnown.get('/.well-known/zooid.json', async (c) => {
44
44
 
45
45
  return c.json({
46
46
  version: '0.1',
47
- public_key: c.env.ZOOID_PUBLIC_KEY ? rawKeyToSpkiBase64Url(c.env.ZOOID_PUBLIC_KEY) : '',
47
+ public_key: c.env.ZOOID_PUBLIC_KEY
48
+ ? rawKeyToSpkiBase64Url(c.env.ZOOID_PUBLIC_KEY)
49
+ : '',
48
50
  public_key_format: 'spki',
49
51
  algorithm: 'Ed25519',
50
52
  server_id: c.env.ZOOID_SERVER_ID || 'zooid-local',
@@ -231,8 +231,12 @@ describe('WebSocket routes', () => {
231
231
 
232
232
  const messages1: string[] = [];
233
233
  const messages2: string[] = [];
234
- ws1.addEventListener('message', (e: MessageEvent) => messages1.push(e.data as string));
235
- ws2.addEventListener('message', (e: MessageEvent) => messages2.push(e.data as string));
234
+ ws1.addEventListener('message', (e: MessageEvent) =>
235
+ messages1.push(e.data as string),
236
+ );
237
+ ws2.addEventListener('message', (e: MessageEvent) =>
238
+ messages2.push(e.data as string),
239
+ );
236
240
 
237
241
  // Publish an event
238
242
  const publishToken = await createToken(
@@ -303,8 +307,12 @@ describe('WebSocket routes', () => {
303
307
 
304
308
  const messages1: string[] = [];
305
309
  const messages2: string[] = [];
306
- ws1.addEventListener('message', (e: MessageEvent) => messages1.push(e.data as string));
307
- ws2.addEventListener('message', (e: MessageEvent) => messages2.push(e.data as string));
310
+ ws1.addEventListener('message', (e: MessageEvent) =>
311
+ messages1.push(e.data as string),
312
+ );
313
+ ws2.addEventListener('message', (e: MessageEvent) =>
314
+ messages2.push(e.data as string),
315
+ );
308
316
 
309
317
  const publishToken = await createToken(
310
318
  { scope: 'publish', channel: 'ws-channel', sub: 'test-pub' },
@@ -312,17 +320,14 @@ describe('WebSocket routes', () => {
312
320
  );
313
321
 
314
322
  // Publish a "signal" event — should only reach client 2
315
- await SELF.fetch(
316
- 'http://localhost/api/v1/channels/ws-channel/events',
317
- {
318
- method: 'POST',
319
- headers: {
320
- Authorization: `Bearer ${publishToken}`,
321
- 'Content-Type': 'application/json',
322
- },
323
- body: JSON.stringify({ type: 'signal', data: { v: 1 } }),
323
+ await SELF.fetch('http://localhost/api/v1/channels/ws-channel/events', {
324
+ method: 'POST',
325
+ headers: {
326
+ Authorization: `Bearer ${publishToken}`,
327
+ 'Content-Type': 'application/json',
324
328
  },
325
- );
329
+ body: JSON.stringify({ type: 'signal', data: { v: 1 } }),
330
+ });
326
331
 
327
332
  await new Promise((r) => setTimeout(r, 100));
328
333
 
@@ -330,17 +335,14 @@ describe('WebSocket routes', () => {
330
335
  expect(messages2.length).toBe(1);
331
336
 
332
337
  // Publish an "alert" event — should reach both clients
333
- await SELF.fetch(
334
- 'http://localhost/api/v1/channels/ws-channel/events',
335
- {
336
- method: 'POST',
337
- headers: {
338
- Authorization: `Bearer ${publishToken}`,
339
- 'Content-Type': 'application/json',
340
- },
341
- body: JSON.stringify({ type: 'alert', data: { v: 2 } }),
338
+ await SELF.fetch('http://localhost/api/v1/channels/ws-channel/events', {
339
+ method: 'POST',
340
+ headers: {
341
+ Authorization: `Bearer ${publishToken}`,
342
+ 'Content-Type': 'application/json',
342
343
  },
343
- );
344
+ body: JSON.stringify({ type: 'alert', data: { v: 2 } }),
345
+ });
344
346
 
345
347
  await new Promise((r) => setTimeout(r, 100));
346
348
 
@@ -381,7 +383,9 @@ describe('WebSocket routes', () => {
381
383
  ws.accept();
382
384
 
383
385
  const messages: string[] = [];
384
- ws.addEventListener('message', (e: MessageEvent) => messages.push(e.data as string));
386
+ ws.addEventListener('message', (e: MessageEvent) =>
387
+ messages.push(e.data as string),
388
+ );
385
389
 
386
390
  const publishToken = await createToken(
387
391
  { scope: 'publish', channel: 'ws-channel', sub: 'test-pub' },
@@ -389,43 +393,34 @@ describe('WebSocket routes', () => {
389
393
  );
390
394
 
391
395
  // Publish "info" — should not be received
392
- await SELF.fetch(
393
- 'http://localhost/api/v1/channels/ws-channel/events',
394
- {
395
- method: 'POST',
396
- headers: {
397
- Authorization: `Bearer ${publishToken}`,
398
- 'Content-Type': 'application/json',
399
- },
400
- body: JSON.stringify({ type: 'info', data: { v: 1 } }),
396
+ await SELF.fetch('http://localhost/api/v1/channels/ws-channel/events', {
397
+ method: 'POST',
398
+ headers: {
399
+ Authorization: `Bearer ${publishToken}`,
400
+ 'Content-Type': 'application/json',
401
401
  },
402
- );
402
+ body: JSON.stringify({ type: 'info', data: { v: 1 } }),
403
+ });
403
404
 
404
405
  // Publish "alert" — should be received
405
- await SELF.fetch(
406
- 'http://localhost/api/v1/channels/ws-channel/events',
407
- {
408
- method: 'POST',
409
- headers: {
410
- Authorization: `Bearer ${publishToken}`,
411
- 'Content-Type': 'application/json',
412
- },
413
- body: JSON.stringify({ type: 'alert', data: { v: 2 } }),
406
+ await SELF.fetch('http://localhost/api/v1/channels/ws-channel/events', {
407
+ method: 'POST',
408
+ headers: {
409
+ Authorization: `Bearer ${publishToken}`,
410
+ 'Content-Type': 'application/json',
414
411
  },
415
- );
412
+ body: JSON.stringify({ type: 'alert', data: { v: 2 } }),
413
+ });
416
414
 
417
415
  // Publish "signal" — should be received
418
- await SELF.fetch(
419
- 'http://localhost/api/v1/channels/ws-channel/events',
420
- {
421
- method: 'POST',
422
- headers: {
423
- Authorization: `Bearer ${publishToken}`,
424
- 'Content-Type': 'application/json',
425
- },
426
- body: JSON.stringify({ type: 'signal', data: { v: 3 } }),
416
+ await SELF.fetch('http://localhost/api/v1/channels/ws-channel/events', {
417
+ method: 'POST',
418
+ headers: {
419
+ Authorization: `Bearer ${publishToken}`,
420
+ 'Content-Type': 'application/json',
427
421
  },
428
- );
422
+ body: JSON.stringify({ type: 'signal', data: { v: 3 } }),
423
+ });
429
424
 
430
425
  await new Promise((r) => setTimeout(r, 150));
431
426
 
@@ -469,7 +464,9 @@ describe('WebSocket routes', () => {
469
464
  ws.accept();
470
465
 
471
466
  const messages: string[] = [];
472
- ws.addEventListener('message', (e: MessageEvent) => messages.push(e.data as string));
467
+ ws.addEventListener('message', (e: MessageEvent) =>
468
+ messages.push(e.data as string),
469
+ );
473
470
 
474
471
  // Publish an event via SELF
475
472
  const publishToken = await createToken(