@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,89 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createToken, verifyToken } from './jwt';
3
+
4
+ const TEST_SECRET = 'test-secret-key-for-jwt-testing-purposes';
5
+
6
+ describe('JWT', () => {
7
+ describe('createToken', () => {
8
+ it('creates a valid admin token', async () => {
9
+ const token = await createToken({ scope: 'admin' }, TEST_SECRET);
10
+ expect(token).toBeTruthy();
11
+ expect(typeof token).toBe('string');
12
+ expect(token.split('.')).toHaveLength(3);
13
+ });
14
+
15
+ it('creates a publish token scoped to a channel', async () => {
16
+ const token = await createToken(
17
+ { scope: 'publish', channel: 'test-channel' },
18
+ TEST_SECRET,
19
+ );
20
+ const payload = await verifyToken(token, TEST_SECRET);
21
+ expect(payload.scope).toBe('publish');
22
+ expect(payload.channel).toBe('test-channel');
23
+ });
24
+
25
+ it('creates a subscribe token scoped to a channel', async () => {
26
+ const token = await createToken(
27
+ { scope: 'subscribe', channel: 'test-channel' },
28
+ TEST_SECRET,
29
+ );
30
+ const payload = await verifyToken(token, TEST_SECRET);
31
+ expect(payload.scope).toBe('subscribe');
32
+ expect(payload.channel).toBe('test-channel');
33
+ });
34
+
35
+ it('includes sub claim when provided', async () => {
36
+ const token = await createToken(
37
+ { scope: 'publish', channel: 'test-channel', sub: 'bot-1' },
38
+ TEST_SECRET,
39
+ );
40
+ const payload = await verifyToken(token, TEST_SECRET);
41
+ expect(payload.sub).toBe('bot-1');
42
+ });
43
+
44
+ it('includes iat claim', async () => {
45
+ const token = await createToken({ scope: 'admin' }, TEST_SECRET);
46
+ const payload = await verifyToken(token, TEST_SECRET);
47
+ expect(payload.iat).toBeTypeOf('number');
48
+ });
49
+
50
+ it('includes exp claim when expiry is provided', async () => {
51
+ const token = await createToken({ scope: 'admin' }, TEST_SECRET, {
52
+ expiresIn: 3600,
53
+ });
54
+ const payload = await verifyToken(token, TEST_SECRET);
55
+ expect(payload.exp).toBeTypeOf('number');
56
+ expect(payload.exp! - payload.iat).toBe(3600);
57
+ });
58
+
59
+ it('omits exp claim when no expiry', async () => {
60
+ const token = await createToken({ scope: 'admin' }, TEST_SECRET);
61
+ const payload = await verifyToken(token, TEST_SECRET);
62
+ expect(payload.exp).toBeUndefined();
63
+ });
64
+ });
65
+
66
+ describe('verifyToken', () => {
67
+ it('verifies a valid token', async () => {
68
+ const token = await createToken({ scope: 'admin' }, TEST_SECRET);
69
+ const payload = await verifyToken(token, TEST_SECRET);
70
+ expect(payload.scope).toBe('admin');
71
+ });
72
+
73
+ it('rejects a token signed with a different secret', async () => {
74
+ const token = await createToken({ scope: 'admin' }, TEST_SECRET);
75
+ await expect(verifyToken(token, 'wrong-secret')).rejects.toThrow();
76
+ });
77
+
78
+ it('rejects a malformed token', async () => {
79
+ await expect(verifyToken('not-a-jwt', TEST_SECRET)).rejects.toThrow();
80
+ });
81
+
82
+ it('rejects an expired token', async () => {
83
+ const token = await createToken({ scope: 'admin' }, TEST_SECRET, {
84
+ expiresIn: -1,
85
+ });
86
+ await expect(verifyToken(token, TEST_SECRET)).rejects.toThrow();
87
+ });
88
+ });
89
+ });
package/src/lib/jwt.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { sign, verify } from 'hono/jwt';
2
+ import type { ZooidJWT } from '../types';
3
+
4
+ export async function createToken(
5
+ claims: Partial<ZooidJWT>,
6
+ secret: string,
7
+ options?: { expiresIn?: number },
8
+ ): Promise<string> {
9
+ const now = Math.floor(Date.now() / 1000);
10
+ const payload: Record<string, unknown> = {
11
+ ...claims,
12
+ iat: now,
13
+ };
14
+
15
+ if (options?.expiresIn !== undefined) {
16
+ payload.exp = now + options.expiresIn;
17
+ }
18
+
19
+ return sign(payload, secret, 'HS256');
20
+ }
21
+
22
+ export async function verifyToken(
23
+ token: string,
24
+ secret: string,
25
+ ): Promise<ZooidJWT> {
26
+ const payload = await verify(token, secret, 'HS256');
27
+ return payload as unknown as ZooidJWT;
28
+ }
@@ -0,0 +1,101 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { validateEvent } from './schema-validator';
3
+
4
+ const schema = {
5
+ alert: {
6
+ required: ['level', 'message'],
7
+ properties: {
8
+ level: { type: 'string', enum: ['info', 'warn', 'error'] },
9
+ message: { type: 'string' },
10
+ },
11
+ },
12
+ metric: {
13
+ required: ['name', 'value'],
14
+ properties: {
15
+ name: { type: 'string' },
16
+ value: { type: 'number' },
17
+ },
18
+ },
19
+ };
20
+
21
+ describe('validateEvent', () => {
22
+ it('accepts a valid alert event', () => {
23
+ const result = validateEvent(schema, 'alert', { level: 'info', message: 'hello' });
24
+ expect(result.valid).toBe(true);
25
+ });
26
+
27
+ it('accepts a valid metric event', () => {
28
+ const result = validateEvent(schema, 'metric', { name: 'cpu', value: 42 });
29
+ expect(result.valid).toBe(true);
30
+ });
31
+
32
+ it('rejects event with no type', () => {
33
+ const result = validateEvent(schema, null, { level: 'info', message: 'hello' });
34
+ expect(result.valid).toBe(false);
35
+ expect(result.valid === false && result.error).toContain('must have a type');
36
+ });
37
+
38
+ it('rejects event with undefined type', () => {
39
+ const result = validateEvent(schema, undefined, {});
40
+ expect(result.valid).toBe(false);
41
+ expect(result.valid === false && result.error).toContain('must have a type');
42
+ });
43
+
44
+ it('rejects unknown event type', () => {
45
+ const result = validateEvent(schema, 'unknown', { foo: 'bar' });
46
+ expect(result.valid).toBe(false);
47
+ if (!result.valid) {
48
+ expect(result.error).toContain('Unknown event type');
49
+ expect(result.error).toContain('alert');
50
+ expect(result.error).toContain('metric');
51
+ }
52
+ });
53
+
54
+ it('rejects missing required field', () => {
55
+ const result = validateEvent(schema, 'alert', { level: 'info' });
56
+ expect(result.valid).toBe(false);
57
+ if (!result.valid) {
58
+ expect(result.error).toContain('message');
59
+ }
60
+ });
61
+
62
+ it('rejects wrong type for a field', () => {
63
+ const result = validateEvent(schema, 'metric', { name: 'cpu', value: 'not-a-number' });
64
+ expect(result.valid).toBe(false);
65
+ if (!result.valid) {
66
+ expect(result.error).toContain('value');
67
+ }
68
+ });
69
+
70
+ it('rejects invalid enum value', () => {
71
+ const result = validateEvent(schema, 'alert', { level: 'critical', message: 'oops' });
72
+ expect(result.valid).toBe(false);
73
+ if (!result.valid) {
74
+ expect(result.error).toContain('level');
75
+ }
76
+ });
77
+
78
+ it('accepts data with extra fields (no additionalProperties restriction)', () => {
79
+ const result = validateEvent(schema, 'alert', { level: 'info', message: 'hi', extra: true });
80
+ expect(result.valid).toBe(true);
81
+ });
82
+
83
+ it('accepts empty data when no required fields', () => {
84
+ const schemaNoRequired = {
85
+ ping: { properties: { ts: { type: 'number' } } },
86
+ };
87
+ const result = validateEvent(schemaNoRequired, 'ping', {});
88
+ expect(result.valid).toBe(true);
89
+ });
90
+
91
+ it('validates boolean type correctly', () => {
92
+ const boolSchema = {
93
+ toggle: {
94
+ required: ['enabled'],
95
+ properties: { enabled: { type: 'boolean' } },
96
+ },
97
+ };
98
+ expect(validateEvent(boolSchema, 'toggle', { enabled: true }).valid).toBe(true);
99
+ expect(validateEvent(boolSchema, 'toggle', { enabled: 'yes' }).valid).toBe(false);
100
+ });
101
+ });
@@ -0,0 +1,64 @@
1
+ import { Validator } from '@cfworker/json-schema';
2
+
3
+ export type ValidationResult = {
4
+ valid: true;
5
+ } | {
6
+ valid: false;
7
+ error: string;
8
+ };
9
+
10
+ /**
11
+ * Validate an event against a channel's schema.
12
+ *
13
+ * The schema is a map of event types to JSON Schema-like property definitions:
14
+ * ```json
15
+ * {
16
+ * "alert": {
17
+ * "required": ["level", "message"],
18
+ * "properties": { "level": { "type": "string" }, "message": { "type": "string" } }
19
+ * }
20
+ * }
21
+ * ```
22
+ *
23
+ * This function converts the type entry into a proper JSON Schema and validates
24
+ * using @cfworker/json-schema (Cloudflare Workers compatible).
25
+ */
26
+ export function validateEvent(
27
+ schema: Record<string, { required?: string[]; properties?: Record<string, unknown> }>,
28
+ type: string | null | undefined,
29
+ data: unknown,
30
+ ): ValidationResult {
31
+ if (!type) {
32
+ return { valid: false, error: 'Event must have a type when publishing to a strict channel' };
33
+ }
34
+
35
+ const typeSchema = schema[type];
36
+ if (!typeSchema) {
37
+ const allowed = Object.keys(schema).join(', ');
38
+ return { valid: false, error: `Unknown event type "${type}". Allowed types: ${allowed}` };
39
+ }
40
+
41
+ // Build a JSON Schema object from the type definition
42
+ const jsonSchema: Record<string, unknown> = {
43
+ type: 'object',
44
+ properties: typeSchema.properties ?? {},
45
+ };
46
+ if (typeSchema.required) {
47
+ jsonSchema.required = typeSchema.required;
48
+ }
49
+
50
+ const validator = new Validator(jsonSchema, '7', false);
51
+ const result = validator.validate(data);
52
+
53
+ if (!result.valid) {
54
+ const errors = result.errors
55
+ .map((e) => {
56
+ const loc = e.instanceLocation === '#' ? 'data' : e.instanceLocation.replace('#/', 'data.');
57
+ return `${loc}: ${e.error}`;
58
+ })
59
+ .join('; ');
60
+ return { valid: false, error: `Validation failed for type "${type}": ${errors}` };
61
+ }
62
+
63
+ return { valid: true };
64
+ }
@@ -0,0 +1,73 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import {
3
+ generateKeyPair,
4
+ signPayload,
5
+ verifySignature,
6
+ exportPublicKey,
7
+ } from './signing';
8
+
9
+ describe('Ed25519 signing', () => {
10
+ it('generates a key pair', async () => {
11
+ const keyPair = await generateKeyPair();
12
+ expect(keyPair.privateKey).toBeTruthy();
13
+ expect(keyPair.publicKey).toBeTruthy();
14
+ });
15
+
16
+ it('signs a payload and produces a base64 signature', async () => {
17
+ const keyPair = await generateKeyPair();
18
+ const timestamp = '2026-02-17T14:30:00Z';
19
+ const body = '{"test":"data"}';
20
+ const signature = await signPayload(keyPair.privateKey, timestamp, body);
21
+ expect(typeof signature).toBe('string');
22
+ expect(signature.length).toBeGreaterThan(0);
23
+ });
24
+
25
+ it('verifies a valid signature', async () => {
26
+ const keyPair = await generateKeyPair();
27
+ const timestamp = '2026-02-17T14:30:00Z';
28
+ const body = '{"test":"data"}';
29
+ const signature = await signPayload(keyPair.privateKey, timestamp, body);
30
+ const valid = await verifySignature(
31
+ keyPair.publicKey,
32
+ signature,
33
+ timestamp,
34
+ body,
35
+ );
36
+ expect(valid).toBe(true);
37
+ });
38
+
39
+ it('rejects a tampered body', async () => {
40
+ const keyPair = await generateKeyPair();
41
+ const timestamp = '2026-02-17T14:30:00Z';
42
+ const body = '{"test":"data"}';
43
+ const signature = await signPayload(keyPair.privateKey, timestamp, body);
44
+ const valid = await verifySignature(
45
+ keyPair.publicKey,
46
+ signature,
47
+ timestamp,
48
+ '{"test":"tampered"}',
49
+ );
50
+ expect(valid).toBe(false);
51
+ });
52
+
53
+ it('rejects a tampered timestamp', async () => {
54
+ const keyPair = await generateKeyPair();
55
+ const timestamp = '2026-02-17T14:30:00Z';
56
+ const body = '{"test":"data"}';
57
+ const signature = await signPayload(keyPair.privateKey, timestamp, body);
58
+ const valid = await verifySignature(
59
+ keyPair.publicKey,
60
+ signature,
61
+ '2026-02-17T15:00:00Z',
62
+ body,
63
+ );
64
+ expect(valid).toBe(false);
65
+ });
66
+
67
+ it('exports public key as base64 SPKI', async () => {
68
+ const keyPair = await generateKeyPair();
69
+ const exported = await exportPublicKey(keyPair.publicKey);
70
+ expect(typeof exported).toBe('string');
71
+ expect(exported).toMatch(/^[A-Za-z0-9+/]+=*$/);
72
+ });
73
+ });
@@ -0,0 +1,60 @@
1
+ export async function generateKeyPair(): Promise<CryptoKeyPair> {
2
+ return crypto.subtle.generateKey('Ed25519', true, [
3
+ 'sign',
4
+ 'verify',
5
+ ]) as Promise<CryptoKeyPair>;
6
+ }
7
+
8
+ export async function signPayload(
9
+ privateKey: CryptoKey,
10
+ timestamp: string,
11
+ body: string,
12
+ ): Promise<string> {
13
+ const message = new TextEncoder().encode(`${timestamp}.${body}`);
14
+ const signature = await crypto.subtle.sign('Ed25519', privateKey, message);
15
+ return arrayBufferToBase64(signature);
16
+ }
17
+
18
+ export async function verifySignature(
19
+ publicKey: CryptoKey,
20
+ signature: string,
21
+ timestamp: string,
22
+ body: string,
23
+ ): Promise<boolean> {
24
+ const message = new TextEncoder().encode(`${timestamp}.${body}`);
25
+ const sigBytes = base64ToArrayBuffer(signature);
26
+ return crypto.subtle.verify('Ed25519', publicKey, sigBytes, message);
27
+ }
28
+
29
+ export async function exportPublicKey(publicKey: CryptoKey): Promise<string> {
30
+ const exported = await crypto.subtle.exportKey('spki', publicKey);
31
+ return arrayBufferToBase64(exported);
32
+ }
33
+
34
+ export async function importPrivateKey(base64: string): Promise<CryptoKey> {
35
+ const keyData = base64ToArrayBuffer(base64);
36
+ return crypto.subtle.importKey('pkcs8', keyData, 'Ed25519', true, ['sign']);
37
+ }
38
+
39
+ export async function importPublicKey(base64: string): Promise<CryptoKey> {
40
+ const keyData = base64ToArrayBuffer(base64);
41
+ return crypto.subtle.importKey('spki', keyData, 'Ed25519', true, ['verify']);
42
+ }
43
+
44
+ function arrayBufferToBase64(buffer: ArrayBuffer): string {
45
+ const bytes = new Uint8Array(buffer);
46
+ let binary = '';
47
+ for (const byte of bytes) {
48
+ binary += String.fromCharCode(byte);
49
+ }
50
+ return btoa(binary);
51
+ }
52
+
53
+ function base64ToArrayBuffer(base64: string): ArrayBuffer {
54
+ const binary = atob(base64);
55
+ const bytes = new Uint8Array(binary.length);
56
+ for (let i = 0; i < binary.length; i++) {
57
+ bytes[i] = binary.charCodeAt(i);
58
+ }
59
+ return bytes.buffer;
60
+ }
@@ -0,0 +1,25 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateUlid } from './ulid';
3
+
4
+ describe('ULID generation', () => {
5
+ it('generates a 26-character string', () => {
6
+ const id = generateUlid();
7
+ expect(id).toHaveLength(26);
8
+ });
9
+
10
+ it('generates unique IDs', () => {
11
+ const ids = new Set(Array.from({ length: 100 }, () => generateUlid()));
12
+ expect(ids.size).toBe(100);
13
+ });
14
+
15
+ it('generates time-ordered IDs (lexicographic sort = chronological)', () => {
16
+ const id1 = generateUlid();
17
+ const id2 = generateUlid();
18
+ expect(id2 > id1).toBe(true);
19
+ });
20
+
21
+ it('contains only valid Crockford Base32 characters', () => {
22
+ const id = generateUlid();
23
+ expect(id).toMatch(/^[0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26}$/);
24
+ });
25
+ });
@@ -0,0 +1,8 @@
1
+ export { monotonicFactory } from 'ulidx';
2
+ import { monotonicFactory } from 'ulidx';
3
+
4
+ const monotonic = monotonicFactory();
5
+
6
+ export function generateUlid(): string {
7
+ return monotonic();
8
+ }
@@ -0,0 +1,35 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { isValidChannelId } from './validation';
3
+
4
+ describe('Channel ID validation', () => {
5
+ it('accepts valid slugs', () => {
6
+ expect(isValidChannelId('polymarket-signals')).toBe(true);
7
+ expect(isValidChannelId('abc')).toBe(true);
8
+ expect(isValidChannelId('my-channel-123')).toBe(true);
9
+ });
10
+
11
+ it('rejects too short (< 3 chars)', () => {
12
+ expect(isValidChannelId('ab')).toBe(false);
13
+ expect(isValidChannelId('a')).toBe(false);
14
+ expect(isValidChannelId('')).toBe(false);
15
+ });
16
+
17
+ it('rejects too long (> 64 chars)', () => {
18
+ expect(isValidChannelId('a'.repeat(65))).toBe(false);
19
+ });
20
+
21
+ it('rejects uppercase', () => {
22
+ expect(isValidChannelId('MyChannel')).toBe(false);
23
+ });
24
+
25
+ it('rejects special characters', () => {
26
+ expect(isValidChannelId('my_channel')).toBe(false);
27
+ expect(isValidChannelId('my.channel')).toBe(false);
28
+ expect(isValidChannelId('my channel')).toBe(false);
29
+ });
30
+
31
+ it('rejects leading/trailing hyphens', () => {
32
+ expect(isValidChannelId('-my-channel')).toBe(false);
33
+ expect(isValidChannelId('my-channel-')).toBe(false);
34
+ });
35
+ });
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Validate a channel ID slug.
3
+ * Rules: lowercase alphanumeric + hyphens, 3-64 chars, no leading/trailing hyphens.
4
+ */
5
+ export function isValidChannelId(id: string): boolean {
6
+ if (id.length < 3 || id.length > 64) return false;
7
+ return /^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(id);
8
+ }
package/src/lib/xml.ts ADDED
@@ -0,0 +1,13 @@
1
+ import { XMLBuilder } from 'fast-xml-parser';
2
+
3
+ const builder = new XMLBuilder({
4
+ ignoreAttributes: false,
5
+ attributeNamePrefix: '@_',
6
+ format: true,
7
+ suppressEmptyNode: true,
8
+ processEntities: false,
9
+ });
10
+
11
+ export function buildXml(obj: Record<string, unknown>): string {
12
+ return '<?xml version="1.0" encoding="UTF-8"?>\n' + builder.build(obj);
13
+ }
@@ -0,0 +1,125 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { Hono } from 'hono';
3
+ import { requireAuth, requireScope } from './auth';
4
+ import { createToken } from '../lib/jwt';
5
+
6
+ const JWT_SECRET = 'test-jwt-secret';
7
+
8
+ function createTestApp() {
9
+ const app = new Hono<{
10
+ Bindings: { ZOOID_JWT_SECRET: string };
11
+ Variables: { jwtPayload: { scope: string; channel?: string } };
12
+ }>();
13
+
14
+ app.get('/public', (c) => c.json({ ok: true }));
15
+
16
+ app.get('/protected', requireAuth(), (c) => {
17
+ return c.json({ scope: c.get('jwtPayload').scope });
18
+ });
19
+
20
+ app.get('/admin', requireAuth(), requireScope('admin'), (c) => {
21
+ return c.json({ admin: true });
22
+ });
23
+
24
+ app.post(
25
+ '/publish/:channelId',
26
+ requireAuth(),
27
+ requireScope('publish', { channelParam: 'channelId' }),
28
+ (c) => {
29
+ return c.json({ published: true });
30
+ },
31
+ );
32
+
33
+ return app;
34
+ }
35
+
36
+ describe('Auth middleware', () => {
37
+ const app = createTestApp();
38
+
39
+ it('allows unauthenticated access to public routes', async () => {
40
+ const res = await app.request('/public', {}, { ZOOID_JWT_SECRET: JWT_SECRET });
41
+ expect(res.status).toBe(200);
42
+ });
43
+
44
+ it('rejects requests without Authorization header', async () => {
45
+ const res = await app.request(
46
+ '/protected',
47
+ {},
48
+ { ZOOID_JWT_SECRET: JWT_SECRET },
49
+ );
50
+ expect(res.status).toBe(401);
51
+ });
52
+
53
+ it('rejects requests with invalid token', async () => {
54
+ const res = await app.request(
55
+ '/protected',
56
+ { headers: { Authorization: 'Bearer invalid-token' } },
57
+ { ZOOID_JWT_SECRET: JWT_SECRET },
58
+ );
59
+ expect(res.status).toBe(401);
60
+ });
61
+
62
+ it('allows requests with valid token', async () => {
63
+ const token = await createToken({ scope: 'admin' }, JWT_SECRET);
64
+ const res = await app.request(
65
+ '/protected',
66
+ { headers: { Authorization: `Bearer ${token}` } },
67
+ { ZOOID_JWT_SECRET: JWT_SECRET },
68
+ );
69
+ expect(res.status).toBe(200);
70
+ const body = (await res.json()) as { scope: string };
71
+ expect(body.scope).toBe('admin');
72
+ });
73
+
74
+ it('enforces admin scope', async () => {
75
+ const token = await createToken(
76
+ { scope: 'publish', channel: 'test' },
77
+ JWT_SECRET,
78
+ );
79
+ const res = await app.request(
80
+ '/admin',
81
+ { headers: { Authorization: `Bearer ${token}` } },
82
+ { ZOOID_JWT_SECRET: JWT_SECRET },
83
+ );
84
+ expect(res.status).toBe(403);
85
+ });
86
+
87
+ it('allows admin scope on admin route', async () => {
88
+ const token = await createToken({ scope: 'admin' }, JWT_SECRET);
89
+ const res = await app.request(
90
+ '/admin',
91
+ { headers: { Authorization: `Bearer ${token}` } },
92
+ { ZOOID_JWT_SECRET: JWT_SECRET },
93
+ );
94
+ expect(res.status).toBe(200);
95
+ });
96
+
97
+ it('rejects publish token for wrong channel', async () => {
98
+ const token = await createToken(
99
+ { scope: 'publish', channel: 'channel-a' },
100
+ JWT_SECRET,
101
+ );
102
+ const res = await app.request(
103
+ '/publish/channel-b',
104
+ {
105
+ method: 'POST',
106
+ headers: { Authorization: `Bearer ${token}` },
107
+ },
108
+ { ZOOID_JWT_SECRET: JWT_SECRET },
109
+ );
110
+ expect(res.status).toBe(403);
111
+ });
112
+
113
+ it('allows admin token on any channel-scoped route', async () => {
114
+ const token = await createToken({ scope: 'admin' }, JWT_SECRET);
115
+ const res = await app.request(
116
+ '/publish/any-channel',
117
+ {
118
+ method: 'POST',
119
+ headers: { Authorization: `Bearer ${token}` },
120
+ },
121
+ { ZOOID_JWT_SECRET: JWT_SECRET },
122
+ );
123
+ expect(res.status).toBe(200);
124
+ });
125
+ });