@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,131 @@
1
+ import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
2
+ import { env } from 'cloudflare:test';
3
+ import app from '../index';
4
+ import { setupTestDb, cleanTestDb } from '../test-utils';
5
+ import { createToken } from '../lib/jwt';
6
+
7
+ const JWT_SECRET = 'test-jwt-secret';
8
+
9
+ async function adminRequest(path: string, options: RequestInit = {}) {
10
+ const token = await createToken({ scope: 'admin' }, JWT_SECRET);
11
+ const headers = new Headers(options.headers);
12
+ headers.set('Authorization', `Bearer ${token}`);
13
+ headers.set('Content-Type', 'application/json');
14
+ return app.request(
15
+ path,
16
+ { ...options, headers },
17
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
18
+ );
19
+ }
20
+
21
+ describe('OPML routes', () => {
22
+ beforeAll(async () => {
23
+ await setupTestDb();
24
+ });
25
+
26
+ beforeEach(async () => {
27
+ await cleanTestDb();
28
+ });
29
+
30
+ describe('GET /api/v1/opml', () => {
31
+ it('returns valid OPML with correct content type', async () => {
32
+ await adminRequest('/api/v1/channels', {
33
+ method: 'POST',
34
+ body: JSON.stringify({
35
+ id: 'signals',
36
+ name: 'Signals',
37
+ description: 'Market signals',
38
+ is_public: true,
39
+ }),
40
+ });
41
+
42
+ const res = await app.request(
43
+ '/api/v1/opml',
44
+ {},
45
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
46
+ );
47
+
48
+ expect(res.status).toBe(200);
49
+ expect(res.headers.get('Content-Type')).toBe('text/x-opml');
50
+
51
+ const xml = await res.text();
52
+ expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
53
+ expect(xml).toContain('<opml');
54
+ expect(xml).toContain('version="2.0"');
55
+ expect(xml).toContain('Signals');
56
+ expect(xml).toContain('/api/v1/channels/signals/rss');
57
+ expect(xml).toContain('/signals');
58
+ });
59
+
60
+ it('only includes public channels', async () => {
61
+ await adminRequest('/api/v1/channels', {
62
+ method: 'POST',
63
+ body: JSON.stringify({
64
+ id: 'public-ch',
65
+ name: 'Public',
66
+ is_public: true,
67
+ }),
68
+ });
69
+ await adminRequest('/api/v1/channels', {
70
+ method: 'POST',
71
+ body: JSON.stringify({
72
+ id: 'private-ch',
73
+ name: 'Private',
74
+ is_public: false,
75
+ }),
76
+ });
77
+
78
+ const res = await app.request(
79
+ '/api/v1/opml',
80
+ {},
81
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
82
+ );
83
+
84
+ const xml = await res.text();
85
+ expect(xml).toContain('Public');
86
+ expect(xml).not.toContain('Private');
87
+ });
88
+
89
+ it('returns empty body when no channels', async () => {
90
+ const res = await app.request(
91
+ '/api/v1/opml',
92
+ {},
93
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
94
+ );
95
+
96
+ expect(res.status).toBe(200);
97
+ const xml = await res.text();
98
+ expect(xml).toContain('<opml');
99
+ expect(xml).not.toContain('xmlUrl');
100
+ });
101
+
102
+ it('includes multiple channels as outlines', async () => {
103
+ await adminRequest('/api/v1/channels', {
104
+ method: 'POST',
105
+ body: JSON.stringify({
106
+ id: 'alpha',
107
+ name: 'Alpha',
108
+ is_public: true,
109
+ }),
110
+ });
111
+ await adminRequest('/api/v1/channels', {
112
+ method: 'POST',
113
+ body: JSON.stringify({
114
+ id: 'beta',
115
+ name: 'Beta',
116
+ is_public: true,
117
+ }),
118
+ });
119
+
120
+ const res = await app.request(
121
+ '/api/v1/opml',
122
+ {},
123
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
124
+ );
125
+
126
+ const xml = await res.text();
127
+ expect(xml).toContain('alpha/rss');
128
+ expect(xml).toContain('beta/rss');
129
+ });
130
+ });
131
+ });
@@ -0,0 +1,41 @@
1
+ import { Hono } from 'hono';
2
+ import type { Bindings, Variables } from '../types';
3
+ import { listChannels } from '../db/queries';
4
+ import { buildXml } from '../lib/xml';
5
+
6
+ type Env = { Bindings: Bindings; Variables: Variables };
7
+
8
+ export const opml = new Hono<Env>();
9
+
10
+ opml.get('/opml', async (c) => {
11
+ const db = c.env.DB;
12
+ const channels = await listChannels(db);
13
+ const url = new URL(c.req.url);
14
+ const baseUrl = `${url.protocol}//${url.host}`;
15
+
16
+ const outlines = channels
17
+ .filter((ch) => ch.is_public)
18
+ .map((ch) => ({
19
+ '@_text': ch.name,
20
+ '@_type': 'rss',
21
+ '@_xmlUrl': `${baseUrl}/api/v1/channels/${ch.id}/rss`,
22
+ '@_htmlUrl': `${baseUrl}/${ch.id}`,
23
+ ...(ch.description ? { '@_description': ch.description } : {}),
24
+ }));
25
+
26
+ const xml = buildXml({
27
+ opml: {
28
+ '@_version': '2.0',
29
+ head: {
30
+ title: `Zooid Channels — ${url.host}`,
31
+ },
32
+ body: {
33
+ outline: outlines,
34
+ },
35
+ },
36
+ });
37
+
38
+ return c.body(xml, 200, {
39
+ 'Content-Type': 'text/x-opml',
40
+ });
41
+ });
@@ -0,0 +1,224 @@
1
+ import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
2
+ import { env } from 'cloudflare:test';
3
+ import app from '../index';
4
+ import { setupTestDb, cleanTestDb } from '../test-utils';
5
+ import { createToken } from '../lib/jwt';
6
+
7
+ const JWT_SECRET = 'test-jwt-secret';
8
+
9
+ async function adminRequest(path: string, options: RequestInit = {}) {
10
+ const token = await createToken({ scope: 'admin' }, JWT_SECRET);
11
+ const headers = new Headers(options.headers);
12
+ headers.set('Authorization', `Bearer ${token}`);
13
+ headers.set('Content-Type', 'application/json');
14
+ return app.request(
15
+ path,
16
+ { ...options, headers },
17
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
18
+ );
19
+ }
20
+
21
+ async function publishRequest(
22
+ path: string,
23
+ options: RequestInit = {},
24
+ channel: string,
25
+ ) {
26
+ const token = await createToken(
27
+ { scope: 'publish', channel, sub: 'test-publisher' },
28
+ JWT_SECRET,
29
+ );
30
+ const headers = new Headers(options.headers);
31
+ headers.set('Authorization', `Bearer ${token}`);
32
+ headers.set('Content-Type', 'application/json');
33
+ return app.request(
34
+ path,
35
+ { ...options, headers },
36
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
37
+ );
38
+ }
39
+
40
+ describe('RSS routes', () => {
41
+ beforeAll(async () => {
42
+ await setupTestDb();
43
+ });
44
+
45
+ beforeEach(async () => {
46
+ await cleanTestDb();
47
+ await adminRequest('/api/v1/channels', {
48
+ method: 'POST',
49
+ body: JSON.stringify({
50
+ id: 'rss-channel',
51
+ name: 'RSS Channel',
52
+ description: 'Test RSS feed',
53
+ is_public: true,
54
+ }),
55
+ });
56
+ await adminRequest('/api/v1/channels', {
57
+ method: 'POST',
58
+ body: JSON.stringify({
59
+ id: 'priv-rss',
60
+ name: 'Private RSS',
61
+ is_public: false,
62
+ }),
63
+ });
64
+ });
65
+
66
+ describe('GET /channels/:channelId/rss', () => {
67
+ it('returns valid RSS XML with correct content type', async () => {
68
+ await publishRequest(
69
+ '/api/v1/channels/rss-channel/events',
70
+ {
71
+ method: 'POST',
72
+ body: JSON.stringify({
73
+ type: 'signal',
74
+ data: { market: 'test', shift: 0.05 },
75
+ }),
76
+ },
77
+ 'rss-channel',
78
+ );
79
+
80
+ const res = await app.request(
81
+ '/api/v1/channels/rss-channel/rss',
82
+ {},
83
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
84
+ );
85
+
86
+ expect(res.status).toBe(200);
87
+ expect(res.headers.get('Content-Type')).toBe('application/rss+xml');
88
+
89
+ const xml = await res.text();
90
+ expect(xml).toContain('<?xml version="1.0" encoding="UTF-8"?>');
91
+ expect(xml).toContain('<rss version="2.0">');
92
+ expect(xml).toContain('<title>RSS Channel</title>');
93
+ expect(xml).toContain('<description>Test RSS feed</description>');
94
+ expect(xml).toContain('<item>');
95
+ expect(xml).toContain('<guid>');
96
+ });
97
+
98
+ it('formats data as YAML by default', async () => {
99
+ await publishRequest(
100
+ '/api/v1/channels/rss-channel/events',
101
+ {
102
+ method: 'POST',
103
+ body: JSON.stringify({
104
+ type: 'signal',
105
+ data: { market: 'election', shift: 0.07 },
106
+ }),
107
+ },
108
+ 'rss-channel',
109
+ );
110
+
111
+ const res = await app.request(
112
+ '/api/v1/channels/rss-channel/rss',
113
+ {},
114
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
115
+ );
116
+
117
+ const xml = await res.text();
118
+ // YAML-style: key: value
119
+ expect(xml).toContain('market: election');
120
+ expect(xml).toContain('shift: 0.07');
121
+ });
122
+
123
+ it('formats data as JSON when format=json', async () => {
124
+ await publishRequest(
125
+ '/api/v1/channels/rss-channel/events',
126
+ {
127
+ method: 'POST',
128
+ body: JSON.stringify({
129
+ type: 'signal',
130
+ data: { market: 'election' },
131
+ }),
132
+ },
133
+ 'rss-channel',
134
+ );
135
+
136
+ const res = await app.request(
137
+ '/api/v1/channels/rss-channel/rss?format=json',
138
+ {},
139
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
140
+ );
141
+
142
+ const xml = await res.text();
143
+ // JSON stays raw inside CDATA
144
+ expect(xml).toContain('"market"');
145
+ });
146
+
147
+ it('includes event type and publisher in title', async () => {
148
+ await publishRequest(
149
+ '/api/v1/channels/rss-channel/events',
150
+ {
151
+ method: 'POST',
152
+ body: JSON.stringify({
153
+ type: 'odds_shift',
154
+ data: { v: 1 },
155
+ }),
156
+ },
157
+ 'rss-channel',
158
+ );
159
+
160
+ const res = await app.request(
161
+ '/api/v1/channels/rss-channel/rss',
162
+ {},
163
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
164
+ );
165
+
166
+ const xml = await res.text();
167
+ expect(xml).toContain('[odds_shift]');
168
+ expect(xml).toContain('test-publisher');
169
+ });
170
+
171
+ it('returns empty feed when no events', async () => {
172
+ const res = await app.request(
173
+ '/api/v1/channels/rss-channel/rss',
174
+ {},
175
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
176
+ );
177
+
178
+ expect(res.status).toBe(200);
179
+ const xml = await res.text();
180
+ expect(xml).toContain('<rss version="2.0">');
181
+ expect(xml).not.toContain('<item>');
182
+ });
183
+
184
+ it('allows public channel without auth', async () => {
185
+ const res = await app.request(
186
+ '/api/v1/channels/rss-channel/rss',
187
+ {},
188
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
189
+ );
190
+ expect(res.status).toBe(200);
191
+ });
192
+
193
+ it('requires token query param for private channel', async () => {
194
+ const res = await app.request(
195
+ '/api/v1/channels/priv-rss/rss',
196
+ {},
197
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
198
+ );
199
+ expect(res.status).toBe(401);
200
+ });
201
+
202
+ it('accepts token via query param for private channel', async () => {
203
+ const token = await createToken(
204
+ { scope: 'subscribe', channel: 'priv-rss', sub: 'sub-1' },
205
+ JWT_SECRET,
206
+ );
207
+ const res = await app.request(
208
+ `/api/v1/channels/priv-rss/rss?token=${token}`,
209
+ {},
210
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
211
+ );
212
+ expect(res.status).toBe(200);
213
+ });
214
+
215
+ it('returns 404 for non-existent channel', async () => {
216
+ const res = await app.request(
217
+ '/api/v1/channels/nonexistent/rss',
218
+ {},
219
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
220
+ );
221
+ expect(res.status).toBe(404);
222
+ });
223
+ });
224
+ });
@@ -0,0 +1,91 @@
1
+ import { Hono } from 'hono';
2
+ import { stringify } from 'yaml';
3
+ import type { Bindings, Variables, ZooidEvent } from '../types';
4
+ import { getChannel } from '../db/queries';
5
+ import { pollEvents, cleanupExpiredEvents } from '../db/queries';
6
+ import { verifyToken } from '../lib/jwt';
7
+ import { buildXml } from '../lib/xml';
8
+
9
+ type Env = { Bindings: Bindings; Variables: Variables };
10
+
11
+ export const rss = new Hono<Env>();
12
+
13
+ rss.get('/channels/:channelId/rss', async (c) => {
14
+ const channelId = c.req.param('channelId');
15
+ const db = c.env.DB;
16
+
17
+ const channel = await getChannel(db, channelId);
18
+ if (!channel) {
19
+ return c.json({ error: 'Channel not found' }, 404);
20
+ }
21
+
22
+ // Auth: public channels need no auth, private channels use ?token= query param
23
+ if (channel.is_public !== 1) {
24
+ const tokenStr = c.req.query('token');
25
+ if (!tokenStr) {
26
+ return c.json(
27
+ { error: 'Subscribe token required for private channel' },
28
+ 401,
29
+ );
30
+ }
31
+
32
+ try {
33
+ const payload = await verifyToken(tokenStr, c.env.ZOOID_JWT_SECRET);
34
+ if (payload.scope !== 'admin' && payload.scope !== 'subscribe') {
35
+ return c.json({ error: 'Insufficient permissions' }, 403);
36
+ }
37
+ if (payload.scope === 'subscribe' && payload.channel !== channelId) {
38
+ return c.json({ error: 'Token not valid for this channel' }, 403);
39
+ }
40
+ } catch {
41
+ return c.json({ error: 'Invalid or expired token' }, 401);
42
+ }
43
+ }
44
+
45
+ await cleanupExpiredEvents(db, channelId);
46
+
47
+ const result = await pollEvents(db, channelId, { limit: 50 });
48
+ const format = c.req.query('format') || 'yaml';
49
+
50
+ const items = result.events.map((event) => formatItem(event, format));
51
+
52
+ const xml = buildXml({
53
+ rss: {
54
+ '@_version': '2.0',
55
+ channel: {
56
+ title: channel.name,
57
+ description: channel.description || '',
58
+ ...(items.length > 0 ? { item: items } : {}),
59
+ },
60
+ },
61
+ });
62
+
63
+ return c.body(xml, 200, {
64
+ 'Content-Type': 'application/rss+xml',
65
+ });
66
+ });
67
+
68
+ function formatItem(
69
+ event: ZooidEvent,
70
+ format: string,
71
+ ): Record<string, unknown> {
72
+ const type = event.type || 'event';
73
+ const publisher = event.publisher_id || 'unknown';
74
+
75
+ let data: Record<string, unknown>;
76
+ try {
77
+ data = JSON.parse(event.data);
78
+ } catch {
79
+ data = {};
80
+ }
81
+
82
+ const description =
83
+ format === 'json' ? JSON.stringify(data, null, 2) : stringify(data).trim();
84
+
85
+ return {
86
+ title: `[${type}] ${publisher}`,
87
+ description: `<![CDATA[${description}]]>`,
88
+ pubDate: new Date(event.created_at).toUTCString(),
89
+ guid: event.id,
90
+ };
91
+ }
@@ -0,0 +1,157 @@
1
+ import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
2
+ import { env } from 'cloudflare:test';
3
+ import app from '../index';
4
+ import { setupTestDb, cleanTestDb } from '../test-utils';
5
+ import { createToken } from '../lib/jwt';
6
+
7
+ const JWT_SECRET = 'test-jwt-secret';
8
+
9
+ async function authRequest(
10
+ path: string,
11
+ options: RequestInit = {},
12
+ scope: 'admin' | 'publish' | 'subscribe' = 'admin',
13
+ channel?: string,
14
+ ) {
15
+ const token = await createToken({ scope, channel }, JWT_SECRET);
16
+ const headers = new Headers(options.headers);
17
+ headers.set('Authorization', `Bearer ${token}`);
18
+ headers.set('Content-Type', 'application/json');
19
+ return app.request(
20
+ path,
21
+ { ...options, headers },
22
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
23
+ );
24
+ }
25
+
26
+ describe('Server meta routes', () => {
27
+ beforeAll(async () => {
28
+ await setupTestDb();
29
+ });
30
+
31
+ beforeEach(async () => {
32
+ await cleanTestDb();
33
+ });
34
+
35
+ describe('GET /api/v1/server', () => {
36
+ it('returns defaults when no row exists', async () => {
37
+ const res = await app.request(
38
+ '/api/v1/server',
39
+ {},
40
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
41
+ );
42
+ expect(res.status).toBe(200);
43
+ const body = (await res.json()) as Record<string, unknown>;
44
+ expect(body.name).toBe('Zooid');
45
+ expect(body.description).toBeNull();
46
+ expect(body.tags).toEqual([]);
47
+ expect(body.owner).toBeNull();
48
+ expect(body.company).toBeNull();
49
+ expect(body.email).toBeNull();
50
+ expect(body.updated_at).toBeTruthy();
51
+ });
52
+
53
+ it('returns stored metadata after update', async () => {
54
+ await authRequest('/api/v1/server', {
55
+ method: 'PUT',
56
+ body: JSON.stringify({
57
+ name: 'My Server',
58
+ description: 'Test server',
59
+ tags: ['ai', 'agents'],
60
+ owner: 'tester',
61
+ }),
62
+ });
63
+
64
+ const res = await app.request(
65
+ '/api/v1/server',
66
+ {},
67
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
68
+ );
69
+ expect(res.status).toBe(200);
70
+ const body = (await res.json()) as Record<string, unknown>;
71
+ expect(body.name).toBe('My Server');
72
+ expect(body.description).toBe('Test server');
73
+ expect(body.tags).toEqual(['ai', 'agents']);
74
+ expect(body.owner).toBe('tester');
75
+ });
76
+
77
+ it('does not require authentication', async () => {
78
+ const res = await app.request(
79
+ '/api/v1/server',
80
+ {},
81
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
82
+ );
83
+ expect(res.status).toBe(200);
84
+ });
85
+ });
86
+
87
+ describe('PUT /api/v1/server', () => {
88
+ it('creates server metadata with admin token', async () => {
89
+ const res = await authRequest('/api/v1/server', {
90
+ method: 'PUT',
91
+ body: JSON.stringify({
92
+ name: 'My Zooid',
93
+ description: 'A cool server',
94
+ tags: ['crypto', 'trading'],
95
+ owner: 'alice',
96
+ company: 'Acme',
97
+ email: 'alice@acme.com',
98
+ }),
99
+ });
100
+
101
+ expect(res.status).toBe(200);
102
+ const body = (await res.json()) as Record<string, unknown>;
103
+ expect(body.name).toBe('My Zooid');
104
+ expect(body.description).toBe('A cool server');
105
+ expect(body.tags).toEqual(['crypto', 'trading']);
106
+ expect(body.owner).toBe('alice');
107
+ expect(body.company).toBe('Acme');
108
+ expect(body.email).toBe('alice@acme.com');
109
+ expect(body.updated_at).toBeTruthy();
110
+ });
111
+
112
+ it('updates existing metadata (upsert)', async () => {
113
+ await authRequest('/api/v1/server', {
114
+ method: 'PUT',
115
+ body: JSON.stringify({ name: 'First Name', owner: 'alice' }),
116
+ });
117
+
118
+ const res = await authRequest('/api/v1/server', {
119
+ method: 'PUT',
120
+ body: JSON.stringify({ name: 'Updated Name', owner: 'bob' }),
121
+ });
122
+
123
+ expect(res.status).toBe(200);
124
+ const body = (await res.json()) as Record<string, unknown>;
125
+ expect(body.name).toBe('Updated Name');
126
+ expect(body.owner).toBe('bob');
127
+ });
128
+
129
+ it('rejects without auth', async () => {
130
+ const res = await app.request(
131
+ '/api/v1/server',
132
+ {
133
+ method: 'PUT',
134
+ headers: { 'Content-Type': 'application/json' },
135
+ body: JSON.stringify({ name: 'No Auth' }),
136
+ },
137
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
138
+ );
139
+
140
+ expect(res.status).toBe(401);
141
+ });
142
+
143
+ it('rejects with non-admin token', async () => {
144
+ const res = await authRequest(
145
+ '/api/v1/server',
146
+ {
147
+ method: 'PUT',
148
+ body: JSON.stringify({ name: 'No Auth' }),
149
+ },
150
+ 'publish',
151
+ 'some-channel',
152
+ );
153
+
154
+ expect(res.status).toBe(403);
155
+ });
156
+ });
157
+ });