@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,223 @@
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
+ import { generateKeyPair, exportPublicKey, importPublicKey } from '../lib/signing';
7
+
8
+ const JWT_SECRET = 'test-jwt-secret';
9
+
10
+ let SIGNING_KEY_BASE64: string;
11
+ let PUBLIC_KEY: CryptoKey;
12
+
13
+ function arrayBufferToBase64(buffer: ArrayBuffer): string {
14
+ const bytes = new Uint8Array(buffer);
15
+ let binary = '';
16
+ for (const byte of bytes) {
17
+ binary += String.fromCharCode(byte);
18
+ }
19
+ return btoa(binary);
20
+ }
21
+
22
+ function base64UrlToBytes(base64url: string): ArrayBuffer {
23
+ const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
24
+ const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
25
+ const binary = atob(padded);
26
+ const bytes = new Uint8Array(binary.length);
27
+ for (let i = 0; i < binary.length; i++) {
28
+ bytes[i] = binary.charCodeAt(i);
29
+ }
30
+ return bytes.buffer as ArrayBuffer;
31
+ }
32
+
33
+ async function authRequest(
34
+ path: string,
35
+ options: RequestInit = {},
36
+ scope: 'admin' | 'publish' | 'subscribe' = 'admin',
37
+ channel?: string,
38
+ ) {
39
+ const token = await createToken({ scope, channel }, JWT_SECRET);
40
+ const headers = new Headers(options.headers);
41
+ headers.set('Authorization', `Bearer ${token}`);
42
+ headers.set('Content-Type', 'application/json');
43
+ return app.request(
44
+ path,
45
+ { ...options, headers },
46
+ {
47
+ ...env,
48
+ ZOOID_JWT_SECRET: JWT_SECRET,
49
+ ZOOID_SIGNING_KEY: SIGNING_KEY_BASE64,
50
+ },
51
+ );
52
+ }
53
+
54
+ async function createTestChannel(id: string) {
55
+ await authRequest('/api/v1/channels', {
56
+ method: 'POST',
57
+ body: JSON.stringify({ id, name: id, is_public: true }),
58
+ });
59
+ }
60
+
61
+ describe('Directory claim route', () => {
62
+ beforeAll(async () => {
63
+ await setupTestDb();
64
+
65
+ // Generate a key pair for signing
66
+ const keyPair = await generateKeyPair();
67
+ const exported = await crypto.subtle.exportKey('pkcs8', keyPair.privateKey);
68
+ SIGNING_KEY_BASE64 = arrayBufferToBase64(exported);
69
+ PUBLIC_KEY = keyPair.publicKey;
70
+ });
71
+
72
+ beforeEach(async () => {
73
+ await cleanTestDb();
74
+ });
75
+
76
+ describe('POST /directory/claim', () => {
77
+ it('returns a signed claim for valid channels', async () => {
78
+ await createTestChannel('test-channel');
79
+
80
+ const res = await authRequest('/api/v1/directory/claim', {
81
+ method: 'POST',
82
+ body: JSON.stringify({ channels: ['test-channel'] }),
83
+ });
84
+
85
+ expect(res.status).toBe(200);
86
+ const body = (await res.json()) as { claim: string; signature: string };
87
+ expect(body.claim).toBeTruthy();
88
+ expect(body.signature).toBeTruthy();
89
+
90
+ // Decode and verify claim contents
91
+ const claimJson = new TextDecoder().decode(base64UrlToBytes(body.claim));
92
+ const claim = JSON.parse(claimJson);
93
+ expect(claim.channels).toEqual(['test-channel']);
94
+ expect(claim.server_url).toBeTruthy();
95
+ expect(claim.timestamp).toBeTruthy();
96
+ expect(claim.action).toBeUndefined();
97
+ });
98
+
99
+ it('includes action field when action=delete', async () => {
100
+ await createTestChannel('del-channel');
101
+
102
+ const res = await authRequest('/api/v1/directory/claim', {
103
+ method: 'POST',
104
+ body: JSON.stringify({ channels: ['del-channel'], action: 'delete' }),
105
+ });
106
+
107
+ expect(res.status).toBe(200);
108
+ const body = (await res.json()) as { claim: string; signature: string };
109
+ const claimJson = new TextDecoder().decode(base64UrlToBytes(body.claim));
110
+ const claim = JSON.parse(claimJson);
111
+ expect(claim.action).toBe('delete');
112
+ });
113
+
114
+ it('produces a valid Ed25519 signature', async () => {
115
+ await createTestChannel('sig-channel');
116
+
117
+ const res = await authRequest('/api/v1/directory/claim', {
118
+ method: 'POST',
119
+ body: JSON.stringify({ channels: ['sig-channel'] }),
120
+ });
121
+
122
+ const body = (await res.json()) as { claim: string; signature: string };
123
+ const claimBytes = base64UrlToBytes(body.claim);
124
+ const sigBytes = base64UrlToBytes(body.signature);
125
+
126
+ // Verify with the public key
127
+ const valid = await crypto.subtle.verify('Ed25519', PUBLIC_KEY, sigBytes, claimBytes);
128
+ expect(valid).toBe(true);
129
+ });
130
+
131
+ it('handles multiple channels', async () => {
132
+ await createTestChannel('channel-a');
133
+ await createTestChannel('channel-b');
134
+
135
+ const res = await authRequest('/api/v1/directory/claim', {
136
+ method: 'POST',
137
+ body: JSON.stringify({ channels: ['channel-a', 'channel-b'] }),
138
+ });
139
+
140
+ expect(res.status).toBe(200);
141
+ const body = (await res.json()) as { claim: string; signature: string };
142
+ const claimJson = new TextDecoder().decode(base64UrlToBytes(body.claim));
143
+ const claim = JSON.parse(claimJson);
144
+ expect(claim.channels).toEqual(['channel-a', 'channel-b']);
145
+ });
146
+
147
+ it('returns 400 for non-existent channels', async () => {
148
+ const res = await authRequest('/api/v1/directory/claim', {
149
+ method: 'POST',
150
+ body: JSON.stringify({ channels: ['nonexistent'] }),
151
+ });
152
+
153
+ expect(res.status).toBe(400);
154
+ const body = (await res.json()) as { error: string };
155
+ expect(body.error).toContain('nonexistent');
156
+ });
157
+
158
+ it('returns 400 when some channels exist and some do not', async () => {
159
+ await createTestChannel('real-channel');
160
+
161
+ const res = await authRequest('/api/v1/directory/claim', {
162
+ method: 'POST',
163
+ body: JSON.stringify({ channels: ['real-channel', 'fake-channel'] }),
164
+ });
165
+
166
+ expect(res.status).toBe(400);
167
+ const body = (await res.json()) as { error: string };
168
+ expect(body.error).toContain('fake-channel');
169
+ });
170
+
171
+ it('rejects without auth', async () => {
172
+ const res = await app.request(
173
+ '/api/v1/directory/claim',
174
+ {
175
+ method: 'POST',
176
+ headers: { 'Content-Type': 'application/json' },
177
+ body: JSON.stringify({ channels: ['test'] }),
178
+ },
179
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
180
+ );
181
+
182
+ expect(res.status).toBe(401);
183
+ });
184
+
185
+ it('rejects with non-admin token', async () => {
186
+ const res = await authRequest(
187
+ '/api/v1/directory/claim',
188
+ {
189
+ method: 'POST',
190
+ body: JSON.stringify({ channels: ['test'] }),
191
+ },
192
+ 'publish',
193
+ 'test',
194
+ );
195
+
196
+ expect(res.status).toBe(403);
197
+ });
198
+
199
+ it('returns 500 when signing key is not configured', async () => {
200
+ await createTestChannel('no-key-channel');
201
+
202
+ const token = await createToken({ scope: 'admin' }, JWT_SECRET);
203
+ const headers = new Headers();
204
+ headers.set('Authorization', `Bearer ${token}`);
205
+ headers.set('Content-Type', 'application/json');
206
+
207
+ const res = await app.request(
208
+ '/api/v1/directory/claim',
209
+ {
210
+ method: 'POST',
211
+ headers,
212
+ body: JSON.stringify({ channels: ['no-key-channel'] }),
213
+ },
214
+ { ...env, ZOOID_JWT_SECRET: JWT_SECRET },
215
+ // No ZOOID_SIGNING_KEY
216
+ );
217
+
218
+ expect(res.status).toBe(500);
219
+ const body = (await res.json()) as { error: string };
220
+ expect(body.error).toContain('signing key');
221
+ });
222
+ });
223
+ });
@@ -0,0 +1,109 @@
1
+ import { OpenAPIRoute } from 'chanfana';
2
+ import { z } from 'zod';
3
+ import type { Context } from 'hono';
4
+ import type { Bindings, Variables } from '../types';
5
+ import { getChannel } from '../db/queries';
6
+ import { importPrivateKey } from '../lib/signing';
7
+
8
+ type Env = { Bindings: Bindings; Variables: Variables };
9
+
10
+ function arrayBufferToBase64Url(buffer: ArrayBuffer): string {
11
+ const bytes = new Uint8Array(buffer);
12
+ let binary = '';
13
+ for (const byte of bytes) {
14
+ binary += String.fromCharCode(byte);
15
+ }
16
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
17
+ }
18
+
19
+ function toBase64Url(str: string): string {
20
+ return btoa(str).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
21
+ }
22
+
23
+ export class DirectoryClaim extends OpenAPIRoute {
24
+ schema = {
25
+ summary: 'Generate a signed claim for the Zooid Directory',
26
+ tags: ['Directory'],
27
+ security: [{ bearerAuth: [] }],
28
+ request: {
29
+ body: {
30
+ content: {
31
+ 'application/json': {
32
+ schema: z.object({
33
+ channels: z.array(z.string()).min(1),
34
+ action: z.enum(['delete']).optional(),
35
+ }),
36
+ },
37
+ },
38
+ },
39
+ },
40
+ responses: {
41
+ 200: {
42
+ description: 'Signed claim',
43
+ content: {
44
+ 'application/json': {
45
+ schema: z.object({
46
+ claim: z.string(),
47
+ signature: z.string(),
48
+ }),
49
+ },
50
+ },
51
+ },
52
+ 400: {
53
+ description: 'Validation error',
54
+ content: {
55
+ 'application/json': {
56
+ schema: z.object({ error: z.string() }),
57
+ },
58
+ },
59
+ },
60
+ 500: {
61
+ description: 'Server error',
62
+ content: {
63
+ 'application/json': {
64
+ schema: z.object({ error: z.string() }),
65
+ },
66
+ },
67
+ },
68
+ },
69
+ };
70
+
71
+ async handle(c: Context<Env>) {
72
+ const data = await this.getValidatedData<typeof this.schema>();
73
+ const { channels, action } = data.body;
74
+
75
+ if (!c.env.ZOOID_SIGNING_KEY) {
76
+ return c.json({ error: 'Server signing key not configured' }, 500);
77
+ }
78
+
79
+ // Validate all channels exist
80
+ const missing: string[] = [];
81
+ for (const id of channels) {
82
+ const ch = await getChannel(c.env.DB, id);
83
+ if (!ch) missing.push(id);
84
+ }
85
+ if (missing.length > 0) {
86
+ return c.json({ error: `Channels not found: ${missing.join(', ')}` }, 400);
87
+ }
88
+
89
+ const serverUrl = new URL(c.req.url).origin;
90
+ const claim: Record<string, unknown> = {
91
+ server_url: serverUrl,
92
+ channels,
93
+ timestamp: new Date().toISOString(),
94
+ };
95
+ if (action) {
96
+ claim.action = action;
97
+ }
98
+
99
+ const claimJson = JSON.stringify(claim);
100
+ const claimBytes = new TextEncoder().encode(claimJson);
101
+ const privateKey = await importPrivateKey(c.env.ZOOID_SIGNING_KEY);
102
+ const signatureBuffer = await crypto.subtle.sign('Ed25519', privateKey, claimBytes);
103
+
104
+ return c.json({
105
+ claim: toBase64Url(claimJson),
106
+ signature: arrayBufferToBase64Url(signatureBuffer),
107
+ });
108
+ }
109
+ }