@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 +4 -3
- package/src/db/queries.test.ts +27 -5
- package/src/db/queries.ts +28 -16
- package/src/index.ts +5 -1
- package/src/lib/schema-validator.test.ts +33 -9
- package/src/lib/schema-validator.ts +28 -11
- package/src/middleware/auth.test.ts +5 -1
- package/src/middleware/auth.ts +3 -1
- package/src/routes/channels.ts +32 -0
- package/src/routes/directory.test.ts +11 -2
- package/src/routes/directory.ts +29 -3
- package/src/routes/events.test.ts +117 -8
- package/src/routes/events.ts +26 -8
- package/src/routes/feed.test.ts +9 -6
- package/src/routes/feed.ts +3 -1
- package/src/routes/server-meta.ts +16 -0
- package/src/routes/webhooks.ts +16 -0
- package/src/routes/well-known.ts +3 -1
- package/src/routes/ws.test.ts +53 -56
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zooid/server",
|
|
3
|
-
"version": "0.0.
|
|
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/
|
|
25
|
-
"@zooid/
|
|
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": {
|
package/src/db/queries.test.ts
CHANGED
|
@@ -120,7 +120,7 @@ describe('Event queries', () => {
|
|
|
120
120
|
expect(result.events[0].type).toBe('signal');
|
|
121
121
|
});
|
|
122
122
|
|
|
123
|
-
it('
|
|
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
|
|
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
|
-
|
|
497
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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', {
|
|
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, {
|
|
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(
|
|
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(
|
|
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', {
|
|
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', {
|
|
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', {
|
|
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(
|
|
99
|
-
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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<
|
|
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 {
|
|
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 {
|
|
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 =
|
|
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 {
|
|
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(
|
|
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
|
|
package/src/middleware/auth.ts
CHANGED
|
@@ -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 ')
|
|
78
|
+
const rawToken = authHeader?.startsWith('Bearer ')
|
|
79
|
+
? authHeader.slice(7)
|
|
80
|
+
: (queryToken ?? null);
|
|
79
81
|
|
|
80
82
|
if (!rawToken) {
|
|
81
83
|
return c.json(
|
package/src/routes/channels.ts
CHANGED
|
@@ -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 {
|
|
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(
|
|
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
|
|
package/src/routes/directory.ts
CHANGED
|
@@ -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)
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
-
|
|
400
|
-
expect(body.
|
|
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
|
});
|
package/src/routes/events.ts
CHANGED
|
@@ -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
|
|
330
|
+
return fetchFn(webhook.url, { method: 'POST', headers, body });
|
|
313
331
|
}),
|
|
314
332
|
);
|
|
315
333
|
}
|
package/src/routes/feed.test.ts
CHANGED
|
@@ -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({
|
|
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
|
});
|
package/src/routes/feed.ts
CHANGED
|
@@ -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) =>
|
|
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
|
|
package/src/routes/webhooks.ts
CHANGED
|
@@ -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: {
|
package/src/routes/well-known.ts
CHANGED
|
@@ -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
|
|
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',
|
package/src/routes/ws.test.ts
CHANGED
|
@@ -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) =>
|
|
235
|
-
|
|
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) =>
|
|
307
|
-
|
|
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
|
-
'
|
|
317
|
-
{
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
'
|
|
335
|
-
{
|
|
336
|
-
|
|
337
|
-
|
|
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) =>
|
|
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
|
-
'
|
|
394
|
-
{
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
'
|
|
407
|
-
{
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
'
|
|
420
|
-
{
|
|
421
|
-
|
|
422
|
-
|
|
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) =>
|
|
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(
|