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 { createOAuthClient } from "../lib/oauth-client";
4import { getSessionDid, setReturnToCookie } from "../lib/session";
5
6interface Env {
7 ASSETS: Fetcher;
8 SEQUOIA_SESSIONS: KVNamespace;
9 CLIENT_URL: string;
10}
11
12// Cache the vocs-generated stylesheet href across requests (changes on rebuild).
13let _vocsStyleHref: string | null = null;
14
15async function getVocsStyleHref(
16 assets: Fetcher,
17 baseUrl: string,
18): Promise<string> {
19 if (_vocsStyleHref) return _vocsStyleHref;
20 try {
21 const indexUrl = new URL("/", baseUrl).toString();
22 const res = await assets.fetch(indexUrl);
23 const html = await res.text();
24 const match = html.match(/<link[^>]+href="(\/assets\/style[^"]+\.css)"/);
25 if (match?.[1]) {
26 _vocsStyleHref = match[1];
27 return match[1];
28 }
29 } catch {
30 // Fall back to the custom stylesheet which at least provides --sequoia-* vars
31 }
32 return "/styles.css";
33}
34
35const subscribe = new Hono<{ Bindings: Env }>();
36
37const COLLECTION = "site.standard.graph.subscription";
38const REDIRECT_DELAY_SECONDS = 5;
39
40// ============================================================================
41// Helpers
42// ============================================================================
43
44/**
45 * Append a query parameter to a returnTo URL, preserving existing params.
46 */
47function withReturnToParam(
48 returnTo: string | undefined,
49 key: string,
50 value: string,
51): string | undefined {
52 if (!returnTo) return undefined;
53 try {
54 const url = new URL(returnTo);
55 url.searchParams.set(key, value);
56 return url.toString();
57 } catch {
58 return returnTo;
59 }
60}
61
62/**
63 * Scan the user's repo for an existing site.standard.graph.subscription
64 * matching the given publication URI. Returns the record AT-URI if found.
65 */
66async function findExistingSubscription(
67 agent: Agent,
68 did: string,
69 publicationUri: string,
70): Promise<string | null> {
71 let cursor: string | undefined;
72
73 do {
74 const result = await agent.com.atproto.repo.listRecords({
75 repo: did,
76 collection: COLLECTION,
77 limit: 100,
78 cursor,
79 });
80
81 for (const record of result.data.records) {
82 const value = record.value as { publication?: string };
83 if (value.publication === publicationUri) {
84 return record.uri;
85 }
86 }
87
88 cursor = result.data.cursor;
89 } while (cursor);
90
91 return null;
92}
93
94// ============================================================================
95// POST /subscribe
96//
97// Called via fetch() from the sequoia-subscribe web component.
98// Body JSON: { publicationUri: string }
99//
100// Responses:
101// 200 { subscribed: true, existing: boolean, recordUri: string }
102// 400 { error: string }
103// 401 { authenticated: false, subscribeUrl: string }
104// ============================================================================
105
106subscribe.post("/", async (c) => {
107 let publicationUri: string;
108 try {
109 const body = await c.req.json<{ publicationUri?: string }>();
110 publicationUri = body.publicationUri ?? "";
111 } catch {
112 return c.json({ error: "Invalid JSON body" }, 400);
113 }
114
115 if (!publicationUri || !publicationUri.startsWith("at://")) {
116 return c.json({ error: "Missing or invalid publicationUri" }, 400);
117 }
118
119 const did = getSessionDid(c);
120 if (!did) {
121 const subscribeUrl = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`;
122 return c.json({ authenticated: false, subscribeUrl }, 401);
123 }
124
125 try {
126 const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL);
127 const session = await client.restore(did);
128 const agent = new Agent(session);
129
130 const existingUri = await findExistingSubscription(
131 agent,
132 did,
133 publicationUri,
134 );
135 if (existingUri) {
136 return c.json({
137 subscribed: true,
138 existing: true,
139 recordUri: existingUri,
140 });
141 }
142
143 const result = await agent.com.atproto.repo.createRecord({
144 repo: did,
145 collection: COLLECTION,
146 record: {
147 $type: COLLECTION,
148 publication: publicationUri,
149 },
150 });
151
152 return c.json({
153 subscribed: true,
154 existing: false,
155 recordUri: result.data.uri,
156 });
157 } catch (error) {
158 console.error("Subscribe POST error:", error);
159 // Treat expired/missing session as unauthenticated
160 const subscribeUrl = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`;
161 return c.json({ authenticated: false, subscribeUrl }, 401);
162 }
163});
164
165// ============================================================================
166// GET /subscribe?publicationUri=at://...
167//
168// Full-page OAuth + subscription flow. Unauthenticated users land here after
169// the component redirects them, and authenticated users land here after the
170// OAuth callback (via the login_return_to cookie set in POST /subscribe/login).
171// ============================================================================
172
173subscribe.get("/", async (c) => {
174 const publicationUri = c.req.query("publicationUri");
175 const action = c.req.query("action");
176 const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url);
177
178 if (action && action !== "unsubscribe") {
179 return c.html(renderError(`Unsupported action: ${action}`, styleHref), 400);
180 }
181
182 if (!publicationUri || !publicationUri.startsWith("at://")) {
183 return c.html(
184 renderError("Missing or invalid publication URI.", styleHref),
185 400,
186 );
187 }
188
189 // Prefer an explicit returnTo query param (survives the OAuth round-trip);
190 // fall back to the Referer header on the first visit, ignoring self-referrals.
191 const referer = c.req.header("referer");
192 const returnTo =
193 c.req.query("returnTo") ??
194 (referer && !referer.includes("/subscribe") ? referer : undefined);
195
196 const did = getSessionDid(c);
197 if (!did) {
198 return c.html(
199 renderHandleForm(publicationUri, styleHref, returnTo, undefined, action),
200 );
201 }
202
203 try {
204 const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL);
205 const session = await client.restore(did);
206 const agent = new Agent(session);
207
208 if (action === "unsubscribe") {
209 const existingUri = await findExistingSubscription(
210 agent,
211 did,
212 publicationUri,
213 );
214 if (existingUri) {
215 const rkey = existingUri.split("/").pop()!;
216 await agent.com.atproto.repo.deleteRecord({
217 repo: did,
218 collection: COLLECTION,
219 rkey,
220 });
221 }
222
223 // Strip sequoia_did from returnTo so the component doesn't re-store it
224 let cleanReturnTo = returnTo;
225 if (cleanReturnTo) {
226 try {
227 const rtUrl = new URL(cleanReturnTo);
228 rtUrl.searchParams.delete("sequoia_did");
229 cleanReturnTo = rtUrl.toString();
230 } catch {
231 // keep as-is
232 }
233 }
234
235 return c.html(
236 renderSuccess(
237 publicationUri,
238 null,
239 "Unsubscribed ✓",
240 existingUri
241 ? "You've successfully unsubscribed!"
242 : "You weren't subscribed to this publication.",
243 styleHref,
244 withReturnToParam(cleanReturnTo, "sequoia_unsubscribed", "1"),
245 ),
246 );
247 }
248
249 const existingUri = await findExistingSubscription(
250 agent,
251 did,
252 publicationUri,
253 );
254 const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did);
255
256 if (existingUri) {
257 return c.html(
258 renderSuccess(
259 publicationUri,
260 existingUri,
261 "Subscribed ✓",
262 "You're already subscribed to this publication.",
263 styleHref,
264 returnToWithDid,
265 ),
266 );
267 }
268
269 const result = await agent.com.atproto.repo.createRecord({
270 repo: did,
271 collection: COLLECTION,
272 record: {
273 $type: COLLECTION,
274 publication: publicationUri,
275 },
276 });
277
278 return c.html(
279 renderSuccess(
280 publicationUri,
281 result.data.uri,
282 "Subscribed ✓",
283 "You've successfully subscribed!",
284 styleHref,
285 returnToWithDid,
286 ),
287 );
288 } catch (error) {
289 console.error("Subscribe GET error:", error);
290 // Session expired - ask the user to sign in again
291 return c.html(
292 renderHandleForm(
293 publicationUri,
294 styleHref,
295 returnTo,
296 "Session expired. Please sign in again.",
297 action,
298 ),
299 );
300 }
301});
302
303// ============================================================================
304// GET /subscribe/check?publicationUri=at://...
305//
306// JSON-only endpoint for the web component to check subscription status.
307//
308// Responses:
309// 200 { subscribed: true, recordUri: string }
310// 200 { subscribed: false }
311// 400 { error: string }
312// 401 { authenticated: false }
313// ============================================================================
314
315subscribe.get("/check", async (c) => {
316 const publicationUri = c.req.query("publicationUri");
317
318 if (!publicationUri || !publicationUri.startsWith("at://")) {
319 return c.json({ error: "Missing or invalid publicationUri" }, 400);
320 }
321
322 // Prefer the server-side session DID; fall back to a client-provided DID
323 // (stored by the web component from a previous subscribe flow).
324 const did = getSessionDid(c) ?? c.req.query("did") ?? null;
325 if (!did || !did.startsWith("did:")) {
326 return c.json({ authenticated: false }, 401);
327 }
328
329 try {
330 const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL);
331 const session = await client.restore(did);
332 const agent = new Agent(session);
333 const recordUri = await findExistingSubscription(
334 agent,
335 did,
336 publicationUri,
337 );
338 return recordUri
339 ? c.json({ subscribed: true, recordUri })
340 : c.json({ subscribed: false });
341 } catch {
342 return c.json({ authenticated: false }, 401);
343 }
344});
345
346// ============================================================================
347// POST /subscribe/login
348//
349// Handles the handle-entry form submission. Stores the return URL in a cookie
350// so the OAuth callback in auth.ts can redirect back to /subscribe after auth.
351// ============================================================================
352
353subscribe.post("/login", async (c) => {
354 const body = await c.req.parseBody();
355 const handle = (body["handle"] as string | undefined)?.trim();
356 const publicationUri = body["publicationUri"] as string | undefined;
357 const formReturnTo = (body["returnTo"] as string | undefined) || undefined;
358 const formAction = (body["action"] as string | undefined) || undefined;
359
360 if (!handle || !publicationUri) {
361 const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url);
362 return c.html(
363 renderError("Missing handle or publication URI.", styleHref),
364 400,
365 );
366 }
367
368 const returnTo =
369 `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}` +
370 (formAction ? `&action=${encodeURIComponent(formAction)}` : "") +
371 (formReturnTo ? `&returnTo=${encodeURIComponent(formReturnTo)}` : "");
372 setReturnToCookie(c, returnTo, c.env.CLIENT_URL);
373
374 return c.redirect(
375 `${c.env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`,
376 );
377});
378
379// ============================================================================
380// HTML rendering
381// ============================================================================
382
383function renderHandleForm(
384 publicationUri: string,
385 styleHref: string,
386 returnTo?: string,
387 error?: string,
388 action?: string,
389): string {
390 const errorHtml = error
391 ? `<p class="vocs_Paragraph error">${escapeHtml(error)}</p>`
392 : "";
393 const returnToInput = returnTo
394 ? `<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />`
395 : "";
396 const actionInput = action
397 ? `<input type="hidden" name="action" value="${escapeHtml(action)}" />`
398 : "";
399
400 return page(
401 `
402 <h1 class="vocs_H1 vocs_Heading">Subscribe on Bluesky</h1>
403 <p class="vocs_Paragraph">Enter your Bluesky handle to subscribe to this publication.</p>
404 ${errorHtml}
405 <form method="POST" action="/subscribe/login">
406 <input type="hidden" name="publicationUri" value="${escapeHtml(publicationUri)}" />
407 ${returnToInput}
408 ${actionInput}
409 <input
410 type="text"
411 name="handle"
412 placeholder="you.bsky.social"
413 autocomplete="username"
414 required
415 autofocus
416 />
417 <button type="submit" class="vocs_Button_button vocs_Button_button_accent">Continue on Bluesky</button>
418 </form>
419 `,
420 styleHref,
421 );
422}
423
424function renderSuccess(
425 publicationUri: string,
426 recordUri: string | null,
427 heading: string,
428 msg: string,
429 styleHref: string,
430 returnTo?: string,
431): string {
432 const escapedPublicationUri = escapeHtml(publicationUri);
433 const escapedReturnTo = returnTo ? escapeHtml(returnTo) : "";
434
435 const redirectHtml = returnTo
436 ? `<p class="vocs_Paragraph" id="redirect-msg">Redirecting to <a class="vocs_Anchor" href="${escapedReturnTo}">${escapedReturnTo}</a> in <span id="countdown">${REDIRECT_DELAY_SECONDS}</span>\u00a0seconds\u2026</p>
437 <script>
438 (function(){
439 var secs = ${REDIRECT_DELAY_SECONDS};
440 var el = document.getElementById('countdown');
441 var iv = setInterval(function(){
442 secs--;
443 if (el) el.textContent = String(secs);
444 if (secs <= 0) { clearInterval(iv); location.href = ${JSON.stringify(returnTo)}; }
445 }, 1000);
446 })();
447 </script>`
448 : "";
449 const headExtra = returnTo
450 ? `<meta http-equiv="refresh" content="${REDIRECT_DELAY_SECONDS};url=${escapedReturnTo}" />`
451 : "";
452
453 return page(
454 `
455 <h1 class="vocs_H1 vocs_Heading">${escapeHtml(heading)}</h1>
456 <p class="vocs_Paragraph">${msg}</p>
457 ${redirectHtml}
458 <table class="vocs_Table" style="display:table;table-layout:fixed;width:100%;overflow:hidden;">
459 <colgroup><col style="width:7rem;"><col></colgroup>
460 <tbody>
461 <tr class="vocs_TableRow">
462 <td class="vocs_TableCell">Publication</td>
463 <td class="vocs_TableCell" style="overflow:hidden;">
464 <div style="overflow-x:auto;white-space:nowrap;"><code class="vocs_Code"><a href="https://pds.ls/${escapedPublicationUri}">${escapedPublicationUri}</a></code></div>
465 </td>
466 </tr>
467 ${
468 recordUri
469 ? `<tr class="vocs_TableRow">
470 <td class="vocs_TableCell">Record</td>
471 <td class="vocs_TableCell" style="overflow:hidden;">
472 <div style="overflow-x:auto;white-space:nowrap;"><code class="vocs_Code"><a href="https://pds.ls/${escapeHtml(recordUri)}">${escapeHtml(recordUri)}</a></code></div>
473 </td>
474 </tr>`
475 : ""
476 }
477 </tbody>
478 </table>
479 `,
480 styleHref,
481 headExtra,
482 );
483}
484
485function renderError(message: string, styleHref: string): string {
486 return page(
487 `<h1 class="vocs_H1 vocs_Heading">Error</h1><p class="vocs_Paragraph error">${escapeHtml(message)}</p>`,
488 styleHref,
489 );
490}
491
492function page(body: string, styleHref: string, headExtra = ""): string {
493 return `<!DOCTYPE html>
494<html lang="en">
495<head>
496 <meta charset="UTF-8" />
497 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
498 <title>Sequoia · Subscribe</title>
499 <link rel="stylesheet" href="${styleHref}" />
500 <script>if(window.matchMedia('(prefers-color-scheme: dark)').matches)document.documentElement.classList.add('dark')</script>
501 ${headExtra}
502 <style>
503 .page-container {
504 max-width: calc(var(--vocs-content_width, 480px) / 1.6);
505 margin: 4rem auto;
506 padding: 0 var(--vocs-space_20, 1.25rem);
507 }
508 .vocs_Heading { margin-bottom: var(--vocs-space_12, .75rem); }
509 .vocs_Paragraph { margin-bottom: var(--vocs-space_16, 1rem); }
510 input[type="text"] {
511 padding: var(--vocs-space_8, .5rem) var(--vocs-space_12, .75rem);
512 border: 1px solid var(--vocs-color_border, #D5D1C8);
513 border-radius: var(--vocs-borderRadius_6, 6px);
514 margin-bottom: var(--vocs-space_20, 1.25rem);
515 min-width: 30vh;
516 width: 100%;
517 font-size: var(--vocs-fontSize_16, 1rem);
518 font-family: inherit;
519 background: var(--vocs-color_background, #F5F3EF);
520 color: var(--vocs-color_text, #2C2C2C);
521 }
522 input[type="text"]:focus {
523 border-color: var(--vocs-color_borderAccent, #3A5A40);
524 outline: 2px solid var(--vocs-color_borderAccent, #3A5A40);
525 outline-offset: 2px;
526 }
527 .error { color: var(--vocs-color_dangerText, #8B3A3A); }
528 </style>
529</head>
530<body>
531 <div class="page-container">
532 ${body}
533 </div>
534</body>
535</html>`;
536}
537
538function escapeHtml(text: string): string {
539 return text
540 .replace(/&/g, "&")
541 .replace(/</g, "<")
542 .replace(/>/g, ">")
543 .replace(/"/g, """);
544}
545
546export default subscribe;