A CLI for publishing standard.site documents to ATProto
sequoia.pub
standard
site
lexicon
cli
publishing
1import { Agent } from "@atproto/api";
2import { Hono } from "hono";
3import type { Database } from "bun:sqlite";
4import { createOAuthClient } from "../lib/oauth-client";
5import { getSessionDid, setReturnToCookie } from "../lib/session";
6import { page, escapeHtml } from "../lib/theme";
7import type { Env } from "../env";
8
9type Variables = { env: Env; db: Database };
10
11const subscribe = new Hono<{ Variables: Variables }>();
12
13const COLLECTION = "site.standard.graph.subscription";
14const REDIRECT_DELAY_SECONDS = 5;
15
16// ============================================================================
17// Helpers
18// ============================================================================
19
20function withReturnToParam(
21 returnTo: string | undefined,
22 key: string,
23 value: string,
24): string | undefined {
25 if (!returnTo) return undefined;
26 try {
27 const url = new URL(returnTo);
28 url.searchParams.set(key, value);
29 return url.toString();
30 } catch {
31 return returnTo;
32 }
33}
34
35async function findExistingSubscription(
36 agent: Agent,
37 did: string,
38 publicationUri: string,
39): Promise<string | null> {
40 let cursor: string | undefined;
41
42 do {
43 const result = await agent.com.atproto.repo.listRecords({
44 repo: did,
45 collection: COLLECTION,
46 limit: 100,
47 cursor,
48 });
49
50 for (const record of result.data.records) {
51 const value = record.value as { publication?: string };
52 if (value.publication === publicationUri) {
53 return record.uri;
54 }
55 }
56
57 cursor = result.data.cursor;
58 } while (cursor);
59
60 return null;
61}
62
63// ============================================================================
64// POST /subscribe
65// ============================================================================
66
67subscribe.post("/", async (c) => {
68 const env = c.get("env");
69 const db = c.get("db");
70
71 let publicationUri: string;
72 try {
73 const body = await c.req.json<{ publicationUri?: string }>();
74 publicationUri = body.publicationUri ?? "";
75 } catch {
76 return c.json({ error: "Invalid JSON body" }, 400);
77 }
78
79 if (!publicationUri || !publicationUri.startsWith("at://")) {
80 return c.json({ error: "Missing or invalid publicationUri" }, 400);
81 }
82
83 const did = getSessionDid(c);
84 if (!did) {
85 const subscribeUrl = `${env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`;
86 return c.json({ authenticated: false, subscribeUrl }, 401);
87 }
88
89 try {
90 const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME);
91 const session = await client.restore(did);
92 const agent = new Agent(session);
93
94 const existingUri = await findExistingSubscription(
95 agent,
96 did,
97 publicationUri,
98 );
99 if (existingUri) {
100 return c.json({
101 subscribed: true,
102 existing: true,
103 recordUri: existingUri,
104 });
105 }
106
107 const result = await agent.com.atproto.repo.createRecord({
108 repo: did,
109 collection: COLLECTION,
110 record: {
111 $type: COLLECTION,
112 publication: publicationUri,
113 },
114 });
115
116 return c.json({
117 subscribed: true,
118 existing: false,
119 recordUri: result.data.uri,
120 });
121 } catch (error) {
122 console.error("Subscribe POST error:", error);
123 const subscribeUrl = `${env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`;
124 return c.json({ authenticated: false, subscribeUrl }, 401);
125 }
126});
127
128// ============================================================================
129// GET /subscribe
130// ============================================================================
131
132subscribe.get("/", async (c) => {
133 const env = c.get("env");
134 const db = c.get("db");
135
136 const publicationUri = c.req.query("publicationUri");
137 const action = c.req.query("action");
138
139 if (action && action !== "unsubscribe") {
140 return c.html(renderError(`Unsupported action: ${action}`), 400);
141 }
142
143 if (!publicationUri || !publicationUri.startsWith("at://")) {
144 return c.html(renderError("Missing or invalid publication URI."), 400);
145 }
146
147 const referer = c.req.header("referer");
148 const returnTo =
149 c.req.query("returnTo") ??
150 (referer && !referer.includes("/subscribe") ? referer : undefined);
151
152 const did = getSessionDid(c);
153 if (!did) {
154 return c.html(
155 renderHandleForm(publicationUri, returnTo, undefined, action),
156 );
157 }
158
159 try {
160 const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME);
161 const session = await client.restore(did);
162 const agent = new Agent(session);
163
164 if (action === "unsubscribe") {
165 const existingUri = await findExistingSubscription(
166 agent,
167 did,
168 publicationUri,
169 );
170 if (existingUri) {
171 const rkey = existingUri.split("/").pop()!;
172 await agent.com.atproto.repo.deleteRecord({
173 repo: did,
174 collection: COLLECTION,
175 rkey,
176 });
177 }
178
179 let cleanReturnTo = returnTo;
180 if (cleanReturnTo) {
181 try {
182 const rtUrl = new URL(cleanReturnTo);
183 rtUrl.searchParams.delete("sequoia_did");
184 cleanReturnTo = rtUrl.toString();
185 } catch {
186 // keep as-is
187 }
188 }
189
190 return c.html(
191 renderSuccess(
192 publicationUri,
193 null,
194 "Unsubscribed",
195 existingUri
196 ? "You've successfully unsubscribed!"
197 : "You weren't subscribed to this publication.",
198 withReturnToParam(cleanReturnTo, "sequoia_unsubscribed", "1"),
199 ),
200 );
201 }
202
203 const existingUri = await findExistingSubscription(
204 agent,
205 did,
206 publicationUri,
207 );
208 const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did);
209
210 if (existingUri) {
211 return c.html(
212 renderSuccess(
213 publicationUri,
214 existingUri,
215 "Subscribed",
216 "You're already subscribed to this publication.",
217 returnToWithDid,
218 ),
219 );
220 }
221
222 const result = await agent.com.atproto.repo.createRecord({
223 repo: did,
224 collection: COLLECTION,
225 record: {
226 $type: COLLECTION,
227 publication: publicationUri,
228 },
229 });
230
231 return c.html(
232 renderSuccess(
233 publicationUri,
234 result.data.uri,
235 "Subscribed",
236 "You've successfully subscribed!",
237 returnToWithDid,
238 ),
239 );
240 } catch (error) {
241 console.error("Subscribe GET error:", error);
242 return c.html(
243 renderHandleForm(
244 publicationUri,
245 returnTo,
246 "Session expired. Please sign in again.",
247 action,
248 ),
249 );
250 }
251});
252
253// ============================================================================
254// GET /subscribe/check
255// ============================================================================
256
257subscribe.get("/check", async (c) => {
258 const env = c.get("env");
259 const db = c.get("db");
260
261 const publicationUri = c.req.query("publicationUri");
262
263 if (!publicationUri || !publicationUri.startsWith("at://")) {
264 return c.json({ error: "Missing or invalid publicationUri" }, 400);
265 }
266
267 const did = getSessionDid(c) ?? c.req.query("did") ?? null;
268 if (!did || !did.startsWith("did:")) {
269 return c.json({ authenticated: false }, 401);
270 }
271
272 try {
273 const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME);
274 const session = await client.restore(did);
275 const agent = new Agent(session);
276 const recordUri = await findExistingSubscription(
277 agent,
278 did,
279 publicationUri,
280 );
281 return recordUri
282 ? c.json({ subscribed: true, recordUri })
283 : c.json({ subscribed: false });
284 } catch {
285 return c.json({ authenticated: false }, 401);
286 }
287});
288
289// ============================================================================
290// POST /subscribe/login
291// ============================================================================
292
293subscribe.post("/login", async (c) => {
294 const env = c.get("env");
295
296 const body = await c.req.parseBody();
297 const handle = (body["handle"] as string | undefined)?.trim();
298 const publicationUri = body["publicationUri"] as string | undefined;
299 const formReturnTo = (body["returnTo"] as string | undefined) || undefined;
300 const formAction = (body["action"] as string | undefined) || undefined;
301
302 if (!handle || !publicationUri) {
303 return c.html(renderError("Missing handle or publication URI."), 400);
304 }
305
306 const returnTo =
307 `${env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}` +
308 (formAction ? `&action=${encodeURIComponent(formAction)}` : "") +
309 (formReturnTo ? `&returnTo=${encodeURIComponent(formReturnTo)}` : "");
310 setReturnToCookie(c, returnTo, env.CLIENT_URL);
311
312 return c.redirect(
313 `${env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`,
314 );
315});
316
317// ============================================================================
318// HTML rendering
319// ============================================================================
320
321function renderHandleForm(
322 publicationUri: string,
323 returnTo?: string,
324 error?: string,
325 action?: string,
326): string {
327 const errorHtml = error ? `<p class="error">${escapeHtml(error)}</p>` : "";
328 const returnToInput = returnTo
329 ? `<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />`
330 : "";
331 const actionInput = action
332 ? `<input type="hidden" name="action" value="${escapeHtml(action)}" />`
333 : "";
334
335 return page(`
336 <h1>Subscribe on Bluesky</h1>
337 <p>Enter your Bluesky handle to subscribe to this publication.</p>
338 ${errorHtml}
339 <form method="POST" action="/subscribe/login">
340 <input type="hidden" name="publicationUri" value="${escapeHtml(publicationUri)}" />
341 ${returnToInput}
342 ${actionInput}
343 <input
344 type="text"
345 name="handle"
346 placeholder="you.bsky.social"
347 autocomplete="username"
348 required
349 autofocus
350 />
351 <button type="submit">Continue on Bluesky</button>
352 </form>
353 `);
354}
355
356function renderSuccess(
357 publicationUri: string,
358 recordUri: string | null,
359 heading: string,
360 msg: string,
361 returnTo?: string,
362): string {
363 const escapedPublicationUri = escapeHtml(publicationUri);
364 const escapedReturnTo = returnTo ? escapeHtml(returnTo) : "";
365
366 const redirectHtml = returnTo
367 ? `<p id="redirect-msg">Redirecting to <a href="${escapedReturnTo}">${escapedReturnTo}</a> in <span id="countdown">${REDIRECT_DELAY_SECONDS}</span>\u00a0seconds\u2026</p>
368 <script>
369 (function(){
370 var secs = ${REDIRECT_DELAY_SECONDS};
371 var el = document.getElementById('countdown');
372 var iv = setInterval(function(){
373 secs--;
374 if (el) el.textContent = String(secs);
375 if (secs <= 0) { clearInterval(iv); location.href = ${JSON.stringify(returnTo)}; }
376 }, 1000);
377 })();
378 </script>`
379 : "";
380 const headExtra = returnTo
381 ? `<meta http-equiv="refresh" content="${REDIRECT_DELAY_SECONDS};url=${escapedReturnTo}" />`
382 : "";
383
384 return page(
385 `
386 <h1>${escapeHtml(heading)}</h1>
387 <p>${msg}</p>
388 ${redirectHtml}
389 <table>
390 <colgroup><col style="width:7rem;"><col></colgroup>
391 <tbody>
392 <tr>
393 <td>Publication</td>
394 <td>
395 <div><code><a href="https://pds.ls/${escapedPublicationUri}">${escapedPublicationUri}</a></code></div>
396 </td>
397 </tr>
398 ${
399 recordUri
400 ? `<tr>
401 <td>Record</td>
402 <td>
403 <div><code><a href="https://pds.ls/${escapeHtml(recordUri)}">${escapeHtml(recordUri)}</a></code></div>
404 </td>
405 </tr>`
406 : ""
407 }
408 </tbody>
409 </table>
410 `,
411 headExtra,
412 );
413}
414
415function renderError(message: string): string {
416 return page(`<h1>Error</h1><p class="error">${escapeHtml(message)}</p>`);
417}
418
419export default subscribe;