@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.
- package/LICENSE +21 -0
- package/package.json +37 -0
- package/src/cloudflare-test.d.ts +4 -0
- package/src/db/queries.test.ts +501 -0
- package/src/db/queries.ts +450 -0
- package/src/db/schema.sql +56 -0
- package/src/do/channel.ts +69 -0
- package/src/index.ts +88 -0
- package/src/lib/jwt.test.ts +89 -0
- package/src/lib/jwt.ts +28 -0
- package/src/lib/schema-validator.test.ts +101 -0
- package/src/lib/schema-validator.ts +64 -0
- package/src/lib/signing.test.ts +73 -0
- package/src/lib/signing.ts +60 -0
- package/src/lib/ulid.test.ts +25 -0
- package/src/lib/ulid.ts +8 -0
- package/src/lib/validation.test.ts +35 -0
- package/src/lib/validation.ts +8 -0
- package/src/lib/xml.ts +13 -0
- package/src/middleware/auth.test.ts +125 -0
- package/src/middleware/auth.ts +103 -0
- package/src/routes/channels.test.ts +335 -0
- package/src/routes/channels.ts +220 -0
- package/src/routes/directory.test.ts +223 -0
- package/src/routes/directory.ts +109 -0
- package/src/routes/events.test.ts +477 -0
- package/src/routes/events.ts +315 -0
- package/src/routes/feed.test.ts +238 -0
- package/src/routes/feed.ts +101 -0
- package/src/routes/opml.test.ts +131 -0
- package/src/routes/opml.ts +41 -0
- package/src/routes/rss.test.ts +224 -0
- package/src/routes/rss.ts +91 -0
- package/src/routes/server-meta.test.ts +157 -0
- package/src/routes/server-meta.ts +100 -0
- package/src/routes/webhooks.test.ts +238 -0
- package/src/routes/webhooks.ts +111 -0
- package/src/routes/well-known.test.ts +34 -0
- package/src/routes/well-known.ts +58 -0
- package/src/routes/ws.test.ts +503 -0
- package/src/routes/ws.ts +25 -0
- package/src/test-utils.ts +79 -0
- package/src/types.ts +63 -0
- package/wrangler.toml +26 -0
|
@@ -0,0 +1,100 @@
|
|
|
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 { getServerMeta, upsertServerMeta } from '../db/queries';
|
|
6
|
+
|
|
7
|
+
type Env = { Bindings: Bindings; Variables: Variables };
|
|
8
|
+
|
|
9
|
+
export class GetServerMeta extends OpenAPIRoute {
|
|
10
|
+
schema = {
|
|
11
|
+
summary: 'Get server metadata',
|
|
12
|
+
tags: ['Server'],
|
|
13
|
+
responses: {
|
|
14
|
+
200: {
|
|
15
|
+
description: 'Server metadata',
|
|
16
|
+
content: {
|
|
17
|
+
'application/json': {
|
|
18
|
+
schema: z.object({
|
|
19
|
+
name: z.string(),
|
|
20
|
+
description: z.string().nullable(),
|
|
21
|
+
tags: z.array(z.string()),
|
|
22
|
+
owner: z.string().nullable(),
|
|
23
|
+
company: z.string().nullable(),
|
|
24
|
+
email: z.string().nullable(),
|
|
25
|
+
updated_at: z.string(),
|
|
26
|
+
}),
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
async handle(c: Context<Env>) {
|
|
34
|
+
const meta = await getServerMeta(c.env.DB);
|
|
35
|
+
|
|
36
|
+
if (!meta) {
|
|
37
|
+
return c.json({
|
|
38
|
+
name: 'Zooid',
|
|
39
|
+
description: null,
|
|
40
|
+
tags: [],
|
|
41
|
+
owner: null,
|
|
42
|
+
company: null,
|
|
43
|
+
email: null,
|
|
44
|
+
updated_at: new Date().toISOString(),
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return c.json(meta);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export class UpdateServerMeta extends OpenAPIRoute {
|
|
53
|
+
schema = {
|
|
54
|
+
summary: 'Update server metadata',
|
|
55
|
+
tags: ['Server'],
|
|
56
|
+
security: [{ bearerAuth: [] }],
|
|
57
|
+
request: {
|
|
58
|
+
body: {
|
|
59
|
+
content: {
|
|
60
|
+
'application/json': {
|
|
61
|
+
schema: z.object({
|
|
62
|
+
name: z.string().min(1).optional(),
|
|
63
|
+
description: z.string().nullable().optional(),
|
|
64
|
+
tags: z.array(z.string()).optional(),
|
|
65
|
+
owner: z.string().nullable().optional(),
|
|
66
|
+
company: z.string().nullable().optional(),
|
|
67
|
+
email: z.string().nullable().optional(),
|
|
68
|
+
}),
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
responses: {
|
|
74
|
+
200: {
|
|
75
|
+
description: 'Updated server metadata',
|
|
76
|
+
content: {
|
|
77
|
+
'application/json': {
|
|
78
|
+
schema: z.object({
|
|
79
|
+
name: z.string(),
|
|
80
|
+
description: z.string().nullable(),
|
|
81
|
+
tags: z.array(z.string()),
|
|
82
|
+
owner: z.string().nullable(),
|
|
83
|
+
company: z.string().nullable(),
|
|
84
|
+
email: z.string().nullable(),
|
|
85
|
+
updated_at: z.string(),
|
|
86
|
+
}),
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
async handle(c: Context<Env>) {
|
|
94
|
+
const data = await this.getValidatedData<typeof this.schema>();
|
|
95
|
+
const body = data.body;
|
|
96
|
+
|
|
97
|
+
const meta = await upsertServerMeta(c.env.DB, body);
|
|
98
|
+
return c.json(meta);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
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
|
+
{
|
|
18
|
+
...env,
|
|
19
|
+
ZOOID_JWT_SECRET: JWT_SECRET,
|
|
20
|
+
},
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('Webhook routes', () => {
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
await setupTestDb();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
beforeEach(async () => {
|
|
30
|
+
await cleanTestDb();
|
|
31
|
+
await adminRequest('/api/v1/channels', {
|
|
32
|
+
method: 'POST',
|
|
33
|
+
body: JSON.stringify({
|
|
34
|
+
id: 'wh-channel',
|
|
35
|
+
name: 'WH Channel',
|
|
36
|
+
is_public: true,
|
|
37
|
+
}),
|
|
38
|
+
});
|
|
39
|
+
await adminRequest('/api/v1/channels', {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
body: JSON.stringify({
|
|
42
|
+
id: 'priv-wh',
|
|
43
|
+
name: 'Private WH',
|
|
44
|
+
is_public: false,
|
|
45
|
+
}),
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('POST /channels/:channelId/webhooks', () => {
|
|
50
|
+
it('registers a webhook on a public channel (no auth) with default 3-day TTL', async () => {
|
|
51
|
+
const res = await app.request(
|
|
52
|
+
'/api/v1/channels/wh-channel/webhooks',
|
|
53
|
+
{
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'Content-Type': 'application/json' },
|
|
56
|
+
body: JSON.stringify({
|
|
57
|
+
url: 'https://example.com/hook',
|
|
58
|
+
event_types: ['signal'],
|
|
59
|
+
}),
|
|
60
|
+
},
|
|
61
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
expect(res.status).toBe(201);
|
|
65
|
+
const body = (await res.json()) as {
|
|
66
|
+
id: string;
|
|
67
|
+
channel_id: string;
|
|
68
|
+
url: string;
|
|
69
|
+
expires_at: string;
|
|
70
|
+
};
|
|
71
|
+
expect(body.id).toBeTruthy();
|
|
72
|
+
expect(body.channel_id).toBe('wh-channel');
|
|
73
|
+
expect(body.url).toBe('https://example.com/hook');
|
|
74
|
+
expect(body.expires_at).toBeTruthy();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('registers a webhook with custom ttl_seconds', async () => {
|
|
78
|
+
const res = await app.request(
|
|
79
|
+
'/api/v1/channels/wh-channel/webhooks',
|
|
80
|
+
{
|
|
81
|
+
method: 'POST',
|
|
82
|
+
headers: { 'Content-Type': 'application/json' },
|
|
83
|
+
body: JSON.stringify({
|
|
84
|
+
url: 'https://example.com/hook',
|
|
85
|
+
ttl_seconds: 3600,
|
|
86
|
+
}),
|
|
87
|
+
},
|
|
88
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(res.status).toBe(201);
|
|
92
|
+
const body = (await res.json()) as { expires_at: string };
|
|
93
|
+
const expiresAt = new Date(body.expires_at).getTime();
|
|
94
|
+
const oneHourFromNow = Date.now() + 3600 * 1000;
|
|
95
|
+
expect(Math.abs(expiresAt - oneHourFromNow)).toBeLessThan(5000);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('re-registering same URL extends TTL (upsert)', async () => {
|
|
99
|
+
const res1 = await app.request(
|
|
100
|
+
'/api/v1/channels/wh-channel/webhooks',
|
|
101
|
+
{
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: { 'Content-Type': 'application/json' },
|
|
104
|
+
body: JSON.stringify({
|
|
105
|
+
url: 'https://example.com/hook',
|
|
106
|
+
ttl_seconds: 3600,
|
|
107
|
+
}),
|
|
108
|
+
},
|
|
109
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
110
|
+
);
|
|
111
|
+
const body1 = (await res1.json()) as { id: string; expires_at: string };
|
|
112
|
+
|
|
113
|
+
const res2 = await app.request(
|
|
114
|
+
'/api/v1/channels/wh-channel/webhooks',
|
|
115
|
+
{
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { 'Content-Type': 'application/json' },
|
|
118
|
+
body: JSON.stringify({
|
|
119
|
+
url: 'https://example.com/hook',
|
|
120
|
+
ttl_seconds: 86400,
|
|
121
|
+
}),
|
|
122
|
+
},
|
|
123
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
expect(res2.status).toBe(201);
|
|
127
|
+
const body2 = (await res2.json()) as { id: string; expires_at: string };
|
|
128
|
+
expect(body2.id).toBe(body1.id);
|
|
129
|
+
expect(new Date(body2.expires_at).getTime()).toBeGreaterThan(
|
|
130
|
+
new Date(body1.expires_at).getTime(),
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('requires subscribe token for private channel', async () => {
|
|
135
|
+
const res = await app.request(
|
|
136
|
+
'/api/v1/channels/priv-wh/webhooks',
|
|
137
|
+
{
|
|
138
|
+
method: 'POST',
|
|
139
|
+
headers: { 'Content-Type': 'application/json' },
|
|
140
|
+
body: JSON.stringify({ url: 'https://example.com/hook' }),
|
|
141
|
+
},
|
|
142
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
143
|
+
);
|
|
144
|
+
expect(res.status).toBe(401);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('allows subscribe token for private channel', async () => {
|
|
148
|
+
const token = await createToken(
|
|
149
|
+
{ scope: 'subscribe', channel: 'priv-wh', sub: 'sub-1' },
|
|
150
|
+
JWT_SECRET,
|
|
151
|
+
);
|
|
152
|
+
const res = await app.request(
|
|
153
|
+
'/api/v1/channels/priv-wh/webhooks',
|
|
154
|
+
{
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers: {
|
|
157
|
+
Authorization: `Bearer ${token}`,
|
|
158
|
+
'Content-Type': 'application/json',
|
|
159
|
+
},
|
|
160
|
+
body: JSON.stringify({ url: 'https://example.com/hook' }),
|
|
161
|
+
},
|
|
162
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
163
|
+
);
|
|
164
|
+
expect(res.status).toBe(201);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it('rejects missing url', async () => {
|
|
168
|
+
const res = await app.request(
|
|
169
|
+
'/api/v1/channels/wh-channel/webhooks',
|
|
170
|
+
{
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: { 'Content-Type': 'application/json' },
|
|
173
|
+
body: JSON.stringify({}),
|
|
174
|
+
},
|
|
175
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
176
|
+
);
|
|
177
|
+
expect(res.status).toBe(400);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('returns 404 for non-existent channel', async () => {
|
|
181
|
+
const res = await app.request(
|
|
182
|
+
'/api/v1/channels/nonexistent/webhooks',
|
|
183
|
+
{
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: { 'Content-Type': 'application/json' },
|
|
186
|
+
body: JSON.stringify({ url: 'https://example.com/hook' }),
|
|
187
|
+
},
|
|
188
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
189
|
+
);
|
|
190
|
+
expect(res.status).toBe(404);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('DELETE /channels/:channelId/webhooks/:webhookId', () => {
|
|
195
|
+
it('deletes a webhook with admin token', async () => {
|
|
196
|
+
const createRes = await app.request(
|
|
197
|
+
'/api/v1/channels/wh-channel/webhooks',
|
|
198
|
+
{
|
|
199
|
+
method: 'POST',
|
|
200
|
+
headers: { 'Content-Type': 'application/json' },
|
|
201
|
+
body: JSON.stringify({ url: 'https://example.com/hook' }),
|
|
202
|
+
},
|
|
203
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
204
|
+
);
|
|
205
|
+
const { id: webhookId } = (await createRes.json()) as { id: string };
|
|
206
|
+
|
|
207
|
+
const res = await adminRequest(
|
|
208
|
+
`/api/v1/channels/wh-channel/webhooks/${webhookId}`,
|
|
209
|
+
{ method: 'DELETE' },
|
|
210
|
+
);
|
|
211
|
+
expect(res.status).toBe(204);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('rejects without admin token', async () => {
|
|
215
|
+
const token = await createToken(
|
|
216
|
+
{ scope: 'subscribe', channel: 'wh-channel', sub: 'sub-1' },
|
|
217
|
+
JWT_SECRET,
|
|
218
|
+
);
|
|
219
|
+
const res = await app.request(
|
|
220
|
+
'/api/v1/channels/wh-channel/webhooks/some-id',
|
|
221
|
+
{
|
|
222
|
+
method: 'DELETE',
|
|
223
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
224
|
+
},
|
|
225
|
+
{ ...env, ZOOID_JWT_SECRET: JWT_SECRET },
|
|
226
|
+
);
|
|
227
|
+
expect(res.status).toBe(403);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('returns 404 for non-existent webhook', async () => {
|
|
231
|
+
const res = await adminRequest(
|
|
232
|
+
'/api/v1/channels/wh-channel/webhooks/nonexistent',
|
|
233
|
+
{ method: 'DELETE' },
|
|
234
|
+
);
|
|
235
|
+
expect(res.status).toBe(404);
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
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 { createWebhook, deleteWebhook } from '../db/queries';
|
|
6
|
+
|
|
7
|
+
type Env = { Bindings: Bindings; Variables: Variables };
|
|
8
|
+
|
|
9
|
+
export class RegisterWebhook extends OpenAPIRoute {
|
|
10
|
+
schema = {
|
|
11
|
+
summary: 'Register a webhook',
|
|
12
|
+
tags: ['Webhooks'],
|
|
13
|
+
request: {
|
|
14
|
+
params: z.object({
|
|
15
|
+
channelId: z.string(),
|
|
16
|
+
}),
|
|
17
|
+
body: {
|
|
18
|
+
content: {
|
|
19
|
+
'application/json': {
|
|
20
|
+
schema: z.object({
|
|
21
|
+
url: z.string().url(),
|
|
22
|
+
event_types: z.array(z.string()).optional(),
|
|
23
|
+
ttl_seconds: z.number().int().positive().optional(),
|
|
24
|
+
}),
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
responses: {
|
|
30
|
+
201: {
|
|
31
|
+
description: 'Webhook registered',
|
|
32
|
+
content: {
|
|
33
|
+
'application/json': {
|
|
34
|
+
schema: z.object({
|
|
35
|
+
id: z.string(),
|
|
36
|
+
channel_id: z.string(),
|
|
37
|
+
url: z.string(),
|
|
38
|
+
event_types: z.array(z.string()).nullable(),
|
|
39
|
+
expires_at: z.string(),
|
|
40
|
+
created_at: z.string(),
|
|
41
|
+
}),
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
400: {
|
|
46
|
+
description: 'Validation error',
|
|
47
|
+
content: {
|
|
48
|
+
'application/json': {
|
|
49
|
+
schema: z.object({ error: z.string() }),
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
async handle(c: Context<Env>) {
|
|
57
|
+
const data = await this.getValidatedData<typeof this.schema>();
|
|
58
|
+
const { channelId } = data.params;
|
|
59
|
+
const body = data.body;
|
|
60
|
+
const db = c.env.DB;
|
|
61
|
+
|
|
62
|
+
const webhook = await createWebhook(db, {
|
|
63
|
+
channelId,
|
|
64
|
+
url: body.url,
|
|
65
|
+
eventTypes: body.event_types,
|
|
66
|
+
ttlSeconds: body.ttl_seconds,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
return c.json(webhook, 201);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export class DeleteWebhook extends OpenAPIRoute {
|
|
74
|
+
schema = {
|
|
75
|
+
summary: 'Delete a webhook',
|
|
76
|
+
tags: ['Webhooks'],
|
|
77
|
+
security: [{ bearerAuth: [] }],
|
|
78
|
+
request: {
|
|
79
|
+
params: z.object({
|
|
80
|
+
channelId: z.string(),
|
|
81
|
+
webhookId: z.string(),
|
|
82
|
+
}),
|
|
83
|
+
},
|
|
84
|
+
responses: {
|
|
85
|
+
204: {
|
|
86
|
+
description: 'Webhook deleted',
|
|
87
|
+
},
|
|
88
|
+
404: {
|
|
89
|
+
description: 'Webhook not found',
|
|
90
|
+
content: {
|
|
91
|
+
'application/json': {
|
|
92
|
+
schema: z.object({ error: z.string() }),
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
async handle(c: Context<Env>) {
|
|
100
|
+
const data = await this.getValidatedData<typeof this.schema>();
|
|
101
|
+
const { channelId, webhookId } = data.params;
|
|
102
|
+
const db = c.env.DB;
|
|
103
|
+
|
|
104
|
+
const deleted = await deleteWebhook(db, webhookId, channelId);
|
|
105
|
+
if (!deleted) {
|
|
106
|
+
return c.json({ error: 'Webhook not found' }, 404);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return c.body(null, 204);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll } from 'vitest';
|
|
2
|
+
import { env } from 'cloudflare:test';
|
|
3
|
+
import app from '../index';
|
|
4
|
+
import { setupTestDb } from '../test-utils';
|
|
5
|
+
|
|
6
|
+
describe('GET /.well-known/zooid.json', () => {
|
|
7
|
+
beforeAll(() => setupTestDb());
|
|
8
|
+
it('returns 200 with server metadata', async () => {
|
|
9
|
+
const res = await app.request('/.well-known/zooid.json', {}, env);
|
|
10
|
+
expect(res.status).toBe(200);
|
|
11
|
+
|
|
12
|
+
const body = (await res.json()) as {
|
|
13
|
+
version: string;
|
|
14
|
+
algorithm: string;
|
|
15
|
+
public_key_format: string;
|
|
16
|
+
server_id: string;
|
|
17
|
+
poll_interval: number;
|
|
18
|
+
delivery: string[];
|
|
19
|
+
};
|
|
20
|
+
expect(body.version).toBe('0.1');
|
|
21
|
+
expect(body.algorithm).toBe('Ed25519');
|
|
22
|
+
expect(body.public_key_format).toBe('spki');
|
|
23
|
+
expect(body.server_id).toBeTruthy();
|
|
24
|
+
expect(body.poll_interval).toBeTypeOf('number');
|
|
25
|
+
expect(body.delivery).toEqual(
|
|
26
|
+
expect.arrayContaining(['poll', 'webhook', 'websocket', 'rss']),
|
|
27
|
+
);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('returns valid JSON content type', async () => {
|
|
31
|
+
const res = await app.request('/.well-known/zooid.json', {}, env);
|
|
32
|
+
expect(res.headers.get('content-type')).toContain('application/json');
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import type { Bindings, Variables } from '../types';
|
|
3
|
+
import { getServerMeta } from '../db/queries';
|
|
4
|
+
|
|
5
|
+
const wellKnown = new Hono<{ Bindings: Bindings; Variables: Variables }>();
|
|
6
|
+
|
|
7
|
+
// Fixed ASN.1 DER prefix for Ed25519 SPKI (12 bytes)
|
|
8
|
+
const ED25519_SPKI_PREFIX = new Uint8Array([
|
|
9
|
+
0x30, 0x2a, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x03, 0x21, 0x00,
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Convert a raw Ed25519 public key (base64) to base64url SPKI format.
|
|
14
|
+
* If the key is already SPKI-length (44 bytes), just convert to base64url.
|
|
15
|
+
*/
|
|
16
|
+
function rawKeyToSpkiBase64Url(base64Key: string): string {
|
|
17
|
+
const binary = atob(base64Key);
|
|
18
|
+
const keyBytes = new Uint8Array(binary.length);
|
|
19
|
+
for (let i = 0; i < binary.length; i++) {
|
|
20
|
+
keyBytes[i] = binary.charCodeAt(i);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let spkiBytes: Uint8Array;
|
|
24
|
+
if (keyBytes.length === 32) {
|
|
25
|
+
// Raw key — wrap in SPKI
|
|
26
|
+
spkiBytes = new Uint8Array(ED25519_SPKI_PREFIX.length + keyBytes.length);
|
|
27
|
+
spkiBytes.set(ED25519_SPKI_PREFIX);
|
|
28
|
+
spkiBytes.set(keyBytes, ED25519_SPKI_PREFIX.length);
|
|
29
|
+
} else {
|
|
30
|
+
// Already SPKI (44 bytes) or other format — pass through
|
|
31
|
+
spkiBytes = keyBytes;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let b64 = '';
|
|
35
|
+
for (const byte of spkiBytes) {
|
|
36
|
+
b64 += String.fromCharCode(byte);
|
|
37
|
+
}
|
|
38
|
+
return btoa(b64).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
wellKnown.get('/.well-known/zooid.json', async (c) => {
|
|
42
|
+
const pollInterval = parseInt(c.env.ZOOID_POLL_INTERVAL || '30', 10);
|
|
43
|
+
const meta = await getServerMeta(c.env.DB);
|
|
44
|
+
|
|
45
|
+
return c.json({
|
|
46
|
+
version: '0.1',
|
|
47
|
+
public_key: c.env.ZOOID_PUBLIC_KEY ? rawKeyToSpkiBase64Url(c.env.ZOOID_PUBLIC_KEY) : '',
|
|
48
|
+
public_key_format: 'spki',
|
|
49
|
+
algorithm: 'Ed25519',
|
|
50
|
+
server_id: c.env.ZOOID_SERVER_ID || 'zooid-local',
|
|
51
|
+
server_name: meta?.name || c.env.ZOOID_SERVER_NAME || 'Zooid',
|
|
52
|
+
server_description: meta?.description || c.env.ZOOID_SERVER_DESC || null,
|
|
53
|
+
poll_interval: pollInterval,
|
|
54
|
+
delivery: ['poll', 'webhook', 'websocket', 'rss'],
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
export { wellKnown };
|