open source is social v-it.org
1// SPDX-License-Identifier: MIT
2// Copyright (c) 2026 sol pbc
3
4const CORS_HEADERS = {
5 'Access-Control-Allow-Origin': '*',
6 'Access-Control-Allow-Methods': 'GET, OPTIONS',
7 'Access-Control-Allow-Headers': 'Content-Type',
8};
9
10function json(data, status = 200) {
11 return new Response(JSON.stringify(data), {
12 status,
13 headers: { 'Content-Type': 'application/json', ...CORS_HEADERS },
14 });
15}
16
17function parseLimit(value) {
18 const parsed = Number.parseInt(value ?? '50', 10);
19 if (!Number.isFinite(parsed) || parsed <= 0) {
20 return 50;
21 }
22 return Math.min(parsed, 100);
23}
24
25function parseCursor(value) {
26 if (!value) {
27 return null;
28 }
29
30 const parsed = Number.parseInt(value, 10);
31 return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
32}
33
34export async function handleRequest(request, env) {
35 if (request.method === 'OPTIONS') {
36 return new Response(null, { status: 204, headers: CORS_HEADERS });
37 }
38
39 const url = new URL(request.url);
40 const { pathname, searchParams } = url;
41
42 if (request.method !== 'GET') {
43 return json({ error: 'method not allowed' }, 405);
44 }
45
46 if (pathname === '/api/caps') {
47 const cursor = parseCursor(searchParams.get('cursor'));
48 const limit = parseLimit(searchParams.get('limit'));
49 const beacon = searchParams.get('beacon');
50 const kind = searchParams.get('kind');
51 const sort = searchParams.get('sort');
52
53 const conditions = [];
54 const bindings = [];
55
56 if (beacon) {
57 const beacons = beacon.split(',').filter(Boolean);
58 const placeholders = beacons.map(() => '?').join(', ');
59 conditions.push(`c.beacon IN (${placeholders})`);
60 bindings.push(...beacons);
61 }
62
63 if (kind) {
64 conditions.push('c.kind = ?');
65 bindings.push(kind);
66 }
67
68 if (cursor) {
69 conditions.push('c.id < ?');
70 bindings.push(cursor);
71 }
72
73 let sql = `SELECT c.*, h.handle,
74 (SELECT COUNT(*) FROM vouches v WHERE v.cap_uri = c.uri AND v.kind = 'want') as want_vouch_count
75 FROM caps c LEFT JOIN handles h ON c.did = h.did`;
76 if (conditions.length > 0) {
77 sql += ` WHERE ${conditions.join(' AND ')}`;
78 }
79 if (sort === 'want-vouches') {
80 sql += ' ORDER BY want_vouch_count DESC, c.id DESC';
81 } else {
82 sql += ' ORDER BY c.id DESC';
83 }
84 sql += ' LIMIT ?';
85 bindings.push(limit);
86
87 const { results } = await env.DB.prepare(sql).bind(...bindings).all();
88 return json({
89 caps: results,
90 cursor: results.length > 0 ? results[results.length - 1].id : null,
91 });
92 }
93
94 if (pathname === '/api/cap') {
95 const ref = searchParams.get('ref');
96 const uri = searchParams.get('uri');
97 const beacon = searchParams.get('beacon');
98
99 if (!ref && !uri) {
100 return json({ error: 'ref or uri is required' }, 400);
101 }
102
103 if (ref && uri) {
104 return json({ error: 'provide ref or uri, not both' }, 400);
105 }
106
107 const conditions = [];
108 const bindings = [];
109
110 if (uri) {
111 conditions.push('c.uri = ?');
112 bindings.push(uri);
113 }
114
115 if (ref) {
116 conditions.push('c.ref = ?');
117 bindings.push(ref);
118
119 if (beacon) {
120 const beacons = beacon.split(',').filter(Boolean);
121 const placeholders = beacons.map(() => '?').join(', ');
122 conditions.push(`c.beacon IN (${placeholders})`);
123 bindings.push(...beacons);
124 }
125 }
126
127 let sql = `SELECT c.*, h.handle,
128 (SELECT COUNT(*) FROM vouches v WHERE v.cap_uri = c.uri) as vouch_count
129 FROM caps c
130 LEFT JOIN handles h ON c.did = h.did
131 WHERE ${conditions.join(' AND ')}`;
132 sql += ' ORDER BY c.created_at DESC LIMIT 1';
133
134 const result = await env.DB.prepare(sql).bind(...bindings).first();
135 return json({ cap: result });
136 }
137
138 if (pathname === '/api/vouches') {
139 const capUri = searchParams.get('cap_uri');
140 if (!capUri) {
141 return json({ error: 'cap_uri is required' }, 400);
142 }
143
144 const { results } = await env.DB.prepare(
145 `SELECT v.*, h.handle
146 FROM vouches v
147 LEFT JOIN handles h ON v.did = h.did
148 WHERE v.cap_uri = ?
149 ORDER BY v.id DESC`,
150 )
151 .bind(capUri)
152 .all();
153
154 return json({ vouches: results });
155 }
156
157 if (pathname === '/api/beacons') {
158 const { results } = await env.DB.prepare('SELECT * FROM beacons ORDER BY last_activity DESC').all();
159 return json({ beacons: results });
160 }
161
162 if (pathname === '/api/skills') {
163 const cursor = parseCursor(searchParams.get('cursor'));
164 const limit = parseLimit(searchParams.get('limit'));
165 const tag = searchParams.get('tag');
166
167 const conditions = [];
168 const bindings = [];
169
170 if (tag) {
171 conditions.push('INSTR(s.tags, ?) > 0');
172 bindings.push(tag);
173 }
174
175 const dids = searchParams.getAll('did');
176 if (dids.length > 0) {
177 conditions.push('s.did IN (' + dids.map(function() { return '?'; }).join(', ') + ')');
178 bindings.push(...dids);
179 }
180
181 if (cursor) {
182 conditions.push('s.id < ?');
183 bindings.push(cursor);
184 }
185
186 let sql = 'SELECT s.*, h.handle FROM skills s LEFT JOIN handles h ON s.did = h.did';
187 if (conditions.length > 0) {
188 sql += ` WHERE ${conditions.join(' AND ')}`;
189 }
190 sql += ' ORDER BY s.id DESC LIMIT ?';
191 bindings.push(limit);
192
193 const { results } = await env.DB.prepare(sql).bind(...bindings).all();
194 return json({
195 skills: results,
196 cursor: results.length > 0 ? results[results.length - 1].id : null,
197 });
198 }
199
200 if (pathname === '/api/skill') {
201 const name = searchParams.get('name');
202 const uri = searchParams.get('uri');
203
204 if (!name && !uri) {
205 return json({ error: 'name or uri is required' }, 400);
206 }
207
208 if (name && uri) {
209 return json({ error: 'provide name or uri, not both' }, 400);
210 }
211
212 const conditions = [];
213 const bindings = [];
214
215 if (uri) {
216 conditions.push('s.uri = ?');
217 bindings.push(uri);
218 }
219
220 if (name) {
221 conditions.push('s.name = ?');
222 bindings.push(name);
223 }
224
225 let sql = `SELECT s.*, h.handle,
226 (SELECT COUNT(*) FROM vouches v WHERE v.cap_uri = s.uri) as vouch_count
227 FROM skills s
228 LEFT JOIN handles h ON s.did = h.did
229 WHERE ${conditions.join(' AND ')}`;
230 sql += ' ORDER BY s.created_at DESC LIMIT 1';
231
232 const result = await env.DB.prepare(sql).bind(...bindings).first();
233 return json({ skill: result });
234 }
235
236 if (pathname === '/api/stats') {
237 const [caps, vouches, beacons, dids, skills, skillPubs] = await env.DB.batch([
238 env.DB.prepare('SELECT COUNT(*) as count FROM caps'),
239 env.DB.prepare('SELECT COUNT(*) as count FROM vouches'),
240 env.DB.prepare('SELECT COUNT(*) as count FROM beacons'),
241 env.DB.prepare('SELECT COUNT(DISTINCT did) as count FROM caps'),
242 env.DB.prepare('SELECT COUNT(*) as count FROM skills'),
243 env.DB.prepare('SELECT COUNT(DISTINCT did) as count FROM skills'),
244 ]);
245
246 return json({
247 total_caps: caps.results[0]?.count ?? 0,
248 total_vouches: vouches.results[0]?.count ?? 0,
249 total_beacons: beacons.results[0]?.count ?? 0,
250 active_dids: dids.results[0]?.count ?? 0,
251 total_skills: skills.results[0]?.count ?? 0,
252 skill_publishers: skillPubs.results[0]?.count ?? 0,
253 });
254 }
255
256 return json({ error: 'not found' }, 404);
257}