This repository has no description
1// SPDX-License-Identifier: AGPL-3.0-only
2// Copyright (c) 2026 sol pbc
3
4import { CAP_COLLECTION, PROFILE_COLLECTION, VOUCH_COLLECTION } from './atproto.js';
5import { json, parseCursor, parseLimit } from './http.js';
6import { axisCounts, rookByDid, shippedCapPredicate } from './reputation.js';
7
8const PROFILE_CURSOR_RE = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} did:[^\s]+$/;
9
10export async function handleExplorer(request, env, url) {
11 const { pathname } = url;
12 if (pathname === '/api/explorer/summary') return summary(env);
13 if (pathname === '/api/explorer/records') return records(url.searchParams, env);
14 if (pathname === '/api/explorer/record') return record(url.searchParams, env);
15 if (pathname === '/api/explorer/actor') return actor(url.searchParams, env);
16 return json({ error: 'not found' }, 404);
17}
18
19async function summary(env) {
20 const [capsShipped, requests, vouches, profiles] = await env.DB.batch([
21 env.DB.prepare(`SELECT COUNT(*) count, MAX(indexed_at) latestIndexedAt FROM caps c WHERE ${shippedCapPredicate('c')}`),
22 env.DB.prepare("SELECT COUNT(*) count, MAX(indexed_at) latestIndexedAt FROM caps WHERE kind = 'request'"),
23 env.DB.prepare('SELECT COUNT(*) count, MAX(indexed_at) latestIndexedAt FROM vouches'),
24 env.DB.prepare('SELECT COUNT(*) count, MAX(indexed_at) latestIndexedAt FROM profiles'),
25 ]);
26 return json({
27 capsShipped: countShape(capsShipped.results?.[0]),
28 requests: countShape(requests.results?.[0]),
29 vouches: countShape(vouches.results?.[0]),
30 profiles: countShape(profiles.results?.[0]),
31 });
32}
33
34async function records(searchParams, env) {
35 const collection = searchParams.get('collection');
36 if (!isCollection(collection)) return json({ error: 'bad collection' }, 400);
37
38 const did = searchParams.get('did');
39 const kind = searchParams.get('kind');
40 const limit = parseLimit(searchParams.get('limit'));
41 const cursorParam = searchParams.get('cursor');
42
43 if (collection === CAP_COLLECTION) {
44 const conditions = [];
45 const bindings = [];
46 if (did) { conditions.push('c.did = ?'); bindings.push(did); }
47 if (kind) { conditions.push('c.kind = ?'); bindings.push(kind); }
48 const cursor = parseCursor(cursorParam);
49 if (cursor) { conditions.push('c.id < ?'); bindings.push(cursor); }
50 const { results } = await env.DB.prepare(
51 `SELECT c.*, h.handle
52 FROM caps c LEFT JOIN handles h ON c.did = h.did
53 ${whereClause(conditions)}
54 ORDER BY c.id DESC
55 LIMIT ?`,
56 ).bind(...bindings, limit).all();
57 return json(recordsShape(results ?? [], limit, (row) => String(row.id)));
58 }
59
60 if (collection === VOUCH_COLLECTION) {
61 const conditions = [];
62 const bindings = [];
63 if (did) { conditions.push('v.did = ?'); bindings.push(did); }
64 if (kind) { conditions.push('v.kind = ?'); bindings.push(kind); }
65 const cursor = parseCursor(cursorParam);
66 if (cursor) { conditions.push('v.id < ?'); bindings.push(cursor); }
67 const { results } = await env.DB.prepare(
68 `SELECT v.*, h.handle
69 FROM vouches v LEFT JOIN handles h ON v.did = h.did
70 ${whereClause(conditions)}
71 ORDER BY v.id DESC
72 LIMIT ?`,
73 ).bind(...bindings, limit).all();
74 return json(recordsShape(results ?? [], limit, (row) => String(row.id)));
75 }
76
77 const conditions = [];
78 const bindings = [];
79 if (did) { conditions.push('p.did = ?'); bindings.push(did); }
80 const cursor = decodeProfileCursor(cursorParam);
81 if (cursor) {
82 conditions.push('(p.indexed_at < ? OR (p.indexed_at = ? AND p.did < ?))');
83 bindings.push(cursor.indexedAt, cursor.indexedAt, cursor.did);
84 }
85 const { results } = await env.DB.prepare(
86 `SELECT p.*, h.handle
87 FROM profiles p LEFT JOIN handles h ON p.did = h.did
88 ${whereClause(conditions)}
89 ORDER BY p.indexed_at DESC, p.did DESC
90 LIMIT ?`,
91 ).bind(...bindings, limit).all();
92 return json(recordsShape(results ?? [], limit, (row) => encodeProfileCursor(row.indexed_at, row.did)));
93}
94
95async function record(searchParams, env) {
96 const uri = searchParams.get('uri');
97 if (!uri) return json({ error: 'uri required' }, 400);
98
99 const cap = await env.DB.prepare(
100 `SELECT c.*, h.handle
101 FROM caps c LEFT JOIN handles h ON c.did = h.did
102 WHERE c.uri = ?`,
103 ).bind(uri).first();
104 if (cap) {
105 const { results: vouches } = await env.DB.prepare(
106 `SELECT v.*, h.handle
107 FROM vouches v LEFT JOIN handles h ON v.did = h.did
108 WHERE v.cap_uri = ?
109 ORDER BY v.id DESC`,
110 ).bind(uri).all();
111 return json({
112 collection: CAP_COLLECTION,
113 record: shapeRow(cap),
114 context: { vouches: (vouches ?? []).map(shapeRow) },
115 });
116 }
117
118 const vouch = await env.DB.prepare(
119 `SELECT v.*, h.handle
120 FROM vouches v LEFT JOIN handles h ON v.did = h.did
121 WHERE v.uri = ?`,
122 ).bind(uri).first();
123 if (vouch) {
124 const subject = await env.DB.prepare(
125 `SELECT c.uri, c.did, h.handle, c.title, c.ref, c.kind, c.created_at
126 FROM caps c LEFT JOIN handles h ON c.did = h.did
127 WHERE c.uri = ?`,
128 ).bind(vouch.cap_uri).first();
129 return json({
130 collection: VOUCH_COLLECTION,
131 record: shapeRow(vouch),
132 context: { subject: subject ?? { uri: vouch.cap_uri } },
133 });
134 }
135
136 const profile = await env.DB.prepare(
137 `SELECT p.*, h.handle
138 FROM profiles p LEFT JOIN handles h ON p.did = h.did
139 WHERE p.uri = ?`,
140 ).bind(uri).first();
141 if (profile) {
142 return json({
143 collection: PROFILE_COLLECTION,
144 record: shapeRow(profile),
145 context: { handle: profile.handle ?? null },
146 });
147 }
148
149 return json({ error: 'not found' }, 404);
150}
151
152async function actor(searchParams, env) {
153 let did = searchParams.get('did');
154 const handle = searchParams.get('handle');
155 let handleRow = null;
156
157 if (!did && !handle) return json({ error: 'did or handle required' }, 400);
158 if (!did && handle) {
159 handleRow = await env.DB.prepare('SELECT did, handle FROM handles WHERE handle = ?').bind(handle).first();
160 if (!handleRow) return json({ error: 'not found' }, 404);
161 did = handleRow.did;
162 } else if (did) {
163 handleRow = await env.DB.prepare('SELECT did, handle FROM handles WHERE did = ?').bind(did).first();
164 }
165
166 const presence = await env.DB.prepare(
167 `SELECT 1 found FROM profiles WHERE did = ?
168 UNION ALL SELECT 1 FROM caps WHERE did = ?
169 UNION ALL SELECT 1 FROM vouches WHERE did = ?
170 LIMIT 1`,
171 ).bind(did, did, did).first();
172 if (!presence) return json({ error: 'not found' }, 404);
173
174 const [profile, counts, capsShipped, endorsementsReceived, vouchesGiven] = await Promise.all([
175 rookByDid(env, did),
176 axisCounts(env, did),
177 env.DB.prepare(
178 `SELECT c.*, h.handle
179 FROM caps c LEFT JOIN handles h ON c.did = h.did
180 WHERE c.did = ? AND ${shippedCapPredicate('c')}
181 ORDER BY c.id DESC`,
182 ).bind(did).all(),
183 env.DB.prepare(
184 `SELECT v.*, hv.handle, c.title AS cap_title, c.ref AS cap_ref, c.kind AS cap_kind
185 FROM vouches v
186 JOIN caps c ON v.cap_uri = c.uri
187 LEFT JOIN handles hv ON v.did = hv.did
188 WHERE v.kind = 'endorse' AND c.did = ? AND ${shippedCapPredicate('c')}
189 ORDER BY v.id DESC`,
190 ).bind(did).all(),
191 env.DB.prepare(
192 `SELECT v.*, h.handle, c.title AS cap_title, c.ref AS cap_ref, c.kind AS cap_kind
193 FROM vouches v
194 LEFT JOIN handles h ON v.did = h.did
195 LEFT JOIN caps c ON v.cap_uri = c.uri
196 WHERE v.did = ? AND v.kind = 'endorse'
197 ORDER BY v.id DESC`,
198 ).bind(did).all(),
199 ]);
200
201 return json({
202 did,
203 handle: profile?.handle ?? handleRow?.handle ?? handle ?? null,
204 profile,
205 counts,
206 records: {
207 capsShipped: (capsShipped.results ?? []).map(shapeRow),
208 endorsementsReceived: (endorsementsReceived.results ?? []).map(shapeRow),
209 vouchesGiven: (vouchesGiven.results ?? []).map(shapeRow),
210 },
211 });
212}
213
214function isCollection(collection) {
215 return collection === CAP_COLLECTION || collection === VOUCH_COLLECTION || collection === PROFILE_COLLECTION;
216}
217
218function countShape(row) {
219 return {
220 count: row?.count ?? 0,
221 latestIndexedAt: row?.latestIndexedAt ?? null,
222 };
223}
224
225function recordsShape(rows, limit, cursorFor) {
226 const records = rows.map(shapeRow);
227 return {
228 records,
229 cursor: rows.length < limit || rows.length === 0 ? null : cursorFor(rows[rows.length - 1]),
230 };
231}
232
233function whereClause(conditions) {
234 return conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
235}
236
237function shapeRow(row) {
238 const out = { ...row, value: parseRecordJson(row.record_json) };
239 return out;
240}
241
242function parseRecordJson(value) {
243 if (!value) return null;
244 try {
245 return JSON.parse(value);
246 } catch {
247 return null;
248 }
249}
250
251function encodeProfileCursor(indexedAt, did) {
252 if (!indexedAt || !did) return null;
253 return btoa(`${indexedAt} ${did}`).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
254}
255
256function decodeProfileCursor(token) {
257 if (!token) return null;
258 try {
259 const padded = token.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (token.length % 4)) % 4);
260 const decoded = atob(padded);
261 if (!PROFILE_CURSOR_RE.test(decoded)) return null;
262 return { indexedAt: decoded.slice(0, 19), did: decoded.slice(20) };
263 } catch {
264 return null;
265 }
266}