A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing
at main 546 lines 16 kB view raw
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, "&amp;") 541 .replace(/</g, "&lt;") 542 .replace(/>/g, "&gt;") 543 .replace(/"/g, "&quot;"); 544} 545 546export default subscribe;