A CLI for publishing standard.site documents to ATProto sequoia.pub
standard site lexicon cli publishing
58
fork

Configure Feed

Select the types of activity you want to include in your feed.

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;