Something went wrong. Try again.
A CLI for publishing standard.site documents to ATProto
Something went wrong. Try again.
TypeScript
at sourcemaps
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547import { Agent } from "@atproto/api";import { Hono } from "hono";import { createOAuthClient } from "../lib/oauth-client";import { getSessionDid, setReturnToCookie } from "../lib/session";
interface Env { ASSETS: Fetcher; SEQUOIA_SESSIONS: KVNamespace; CLIENT_URL: string;}
// Cache the vocs-generated stylesheet href across requests (changes on rebuild).let _vocsStyleHref: string | null = null;
async function getVocsStyleHref( assets: Fetcher, baseUrl: string,): Promise<string> { if (_vocsStyleHref) return _vocsStyleHref; try { const indexUrl = new URL("/", baseUrl).toString(); const res = await assets.fetch(indexUrl); const html = await res.text(); const match = html.match(/<link[^>]+href="(\/assets\/style[^"]+\.css)"/); if (match?.[1]) { _vocsStyleHref = match[1]; return match[1]; } } catch { // Fall back to the custom stylesheet which at least provides --sequoia-* vars } return "/styles.css";}
const subscribe = new Hono<{ Bindings: Env }>();
const COLLECTION = "site.standard.graph.subscription";const REDIRECT_DELAY_SECONDS = 5;
// ============================================================================// Helpers// ============================================================================
/** * Append a query parameter to a returnTo URL, preserving existing params. */function withReturnToParam( returnTo: string | undefined, key: string, value: string,): string | undefined { if (!returnTo) return undefined; try { const url = new URL(returnTo); url.searchParams.set(key, value); return url.toString(); } catch { return returnTo; }}
/** * Scan the user's repo for an existing site.standard.graph.subscription * matching the given publication URI. Returns the record AT-URI if found. */async function findExistingSubscription( agent: Agent, did: string, publicationUri: string,): Promise<string | null> { let cursor: string | undefined;
do { const result = await agent.com.atproto.repo.listRecords({ repo: did, collection: COLLECTION, limit: 100, cursor, });
for (const record of result.data.records) { const value = record.value as { publication?: string }; if (value.publication === publicationUri) { return record.uri; } }
cursor = result.data.cursor; } while (cursor);
return null;}
// ============================================================================// POST /subscribe//// Called via fetch() from the sequoia-subscribe web component.// Body JSON: { publicationUri: string }//// Responses:// 200 { subscribed: true, existing: boolean, recordUri: string }// 400 { error: string }// 401 { authenticated: false, subscribeUrl: string }// ============================================================================
subscribe.post("/", async (c) => { let publicationUri: string; try { const body = await c.req.json<{ publicationUri?: string }>(); publicationUri = body.publicationUri ?? ""; } catch { return c.json({ error: "Invalid JSON body" }, 400); }
if (!publicationUri || !publicationUri.startsWith("at://")) { return c.json({ error: "Missing or invalid publicationUri" }, 400); }
const did = getSessionDid(c); if (!did) { const subscribeUrl = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`; return c.json({ authenticated: false, subscribeUrl }, 401); }
try { const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); const session = await client.restore(did); const agent = new Agent(session);
const existingUri = await findExistingSubscription( agent, did, publicationUri, ); if (existingUri) { return c.json({ subscribed: true, existing: true, recordUri: existingUri, }); }
const result = await agent.com.atproto.repo.createRecord({ repo: did, collection: COLLECTION, record: { $type: COLLECTION, publication: publicationUri, }, });
return c.json({ subscribed: true, existing: false, recordUri: result.data.uri, }); } catch (error) { console.error("Subscribe POST error:", error); // Treat expired/missing session as unauthenticated const subscribeUrl = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`; return c.json({ authenticated: false, subscribeUrl }, 401); }});
// ============================================================================// GET /subscribe?publicationUri=at://...//// Full-page OAuth + subscription flow. Unauthenticated users land here after// the component redirects them, and authenticated users land here after the// OAuth callback (via the login_return_to cookie set in POST /subscribe/login).// ============================================================================
subscribe.get("/", async (c) => { const publicationUri = c.req.query("publicationUri"); const action = c.req.query("action"); const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url);
if (action && action !== "unsubscribe") { return c.html(renderError(`Unsupported action: ${action}`, styleHref), 400); }
if (!publicationUri || !publicationUri.startsWith("at://")) { return c.html( renderError("Missing or invalid publication URI.", styleHref), 400, ); }
// Prefer an explicit returnTo query param (survives the OAuth round-trip); // fall back to the Referer header on the first visit, ignoring self-referrals. const referer = c.req.header("referer"); const returnTo = c.req.query("returnTo") ?? (referer && !referer.includes("/subscribe") ? referer : undefined);
const did = getSessionDid(c); if (!did) { return c.html( renderHandleForm(publicationUri, styleHref, returnTo, undefined, action), ); }
try { const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); const session = await client.restore(did); const agent = new Agent(session);
if (action === "unsubscribe") { const existingUri = await findExistingSubscription( agent, did, publicationUri, ); if (existingUri) { const rkey = existingUri.split("/").pop()!; await agent.com.atproto.repo.deleteRecord({ repo: did, collection: COLLECTION, rkey, }); }
// Strip sequoia_did from returnTo so the component doesn't re-store it let cleanReturnTo = returnTo; if (cleanReturnTo) { try { const rtUrl = new URL(cleanReturnTo); rtUrl.searchParams.delete("sequoia_did"); cleanReturnTo = rtUrl.toString(); } catch { // keep as-is } }
return c.html( renderSuccess( publicationUri, null, "Unsubscribed ✓", existingUri ? "You've successfully unsubscribed!" : "You weren't subscribed to this publication.", styleHref, withReturnToParam(cleanReturnTo, "sequoia_unsubscribed", "1"), ), ); }
const existingUri = await findExistingSubscription( agent, did, publicationUri, ); const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did);
if (existingUri) { return c.html( renderSuccess( publicationUri, existingUri, "Subscribed ✓", "You're already subscribed to this publication.", styleHref, returnToWithDid, ), ); }
const result = await agent.com.atproto.repo.createRecord({ repo: did, collection: COLLECTION, record: { $type: COLLECTION, publication: publicationUri, }, });
return c.html( renderSuccess( publicationUri, result.data.uri, "Subscribed ✓", "You've successfully subscribed!", styleHref, returnToWithDid, ), ); } catch (error) { console.error("Subscribe GET error:", error); // Session expired - ask the user to sign in again return c.html( renderHandleForm( publicationUri, styleHref, returnTo, "Session expired. Please sign in again.", action, ), ); }});
// ============================================================================// GET /subscribe/check?publicationUri=at://...//// JSON-only endpoint for the web component to check subscription status.//// Responses:// 200 { subscribed: true, recordUri: string }// 200 { subscribed: false }// 400 { error: string }// 401 { authenticated: false }// ============================================================================
subscribe.get("/check", async (c) => { const publicationUri = c.req.query("publicationUri");
if (!publicationUri || !publicationUri.startsWith("at://")) { return c.json({ error: "Missing or invalid publicationUri" }, 400); }
// Prefer the server-side session DID; fall back to a client-provided DID // (stored by the web component from a previous subscribe flow). const did = getSessionDid(c) ?? c.req.query("did") ?? null; if (!did || !did.startsWith("did:")) { return c.json({ authenticated: false }, 401); }
try { const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); const session = await client.restore(did); const agent = new Agent(session); const recordUri = await findExistingSubscription( agent, did, publicationUri, ); return recordUri ? c.json({ subscribed: true, recordUri }) : c.json({ subscribed: false }); } catch { return c.json({ authenticated: false }, 401); }});
// ============================================================================// POST /subscribe/login//// Handles the handle-entry form submission. Stores the return URL in a cookie// so the OAuth callback in auth.ts can redirect back to /subscribe after auth.// ============================================================================
subscribe.post("/login", async (c) => { const body = await c.req.parseBody(); const handle = (body["handle"] as string | undefined)?.trim(); const publicationUri = body["publicationUri"] as string | undefined; const formReturnTo = (body["returnTo"] as string | undefined) || undefined; const formAction = (body["action"] as string | undefined) || undefined;
if (!handle || !publicationUri) { const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url); return c.html( renderError("Missing handle or publication URI.", styleHref), 400, ); }
const returnTo = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}` + (formAction ? `&action=${encodeURIComponent(formAction)}` : "") + (formReturnTo ? `&returnTo=${encodeURIComponent(formReturnTo)}` : ""); setReturnToCookie(c, returnTo, c.env.CLIENT_URL);
return c.redirect( `${c.env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`, );});
// ============================================================================// HTML rendering// ============================================================================
function renderHandleForm( publicationUri: string, styleHref: string, returnTo?: string, error?: string, action?: string,): string { const errorHtml = error ? `<p class="vocs_Paragraph error">${escapeHtml(error)}</p>` : ""; const returnToInput = returnTo ? `<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />` : ""; const actionInput = action ? `<input type="hidden" name="action" value="${escapeHtml(action)}" />` : "";
return page( ` <h1 class="vocs_H1 vocs_Heading">Subscribe on Bluesky</h1> <p class="vocs_Paragraph">Enter your Bluesky handle to subscribe to this publication.</p> ${errorHtml} <form method="POST" action="/subscribe/login"> <input type="hidden" name="publicationUri" value="${escapeHtml(publicationUri)}" /> ${returnToInput} ${actionInput} <input type="text" name="handle" placeholder="you.bsky.social" autocomplete="username" required autofocus /> <button type="submit" class="vocs_Button_button vocs_Button_button_accent">Continue on Bluesky</button> </form> `, styleHref, );}
function renderSuccess( publicationUri: string, recordUri: string | null, heading: string, msg: string, styleHref: string, returnTo?: string,): string { const escapedPublicationUri = escapeHtml(publicationUri); const escapedReturnTo = returnTo ? escapeHtml(returnTo) : "";
const redirectHtml = returnTo ? `<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> <script> (function(){ var secs = ${REDIRECT_DELAY_SECONDS}; var el = document.getElementById('countdown'); var iv = setInterval(function(){ secs--; if (el) el.textContent = String(secs); if (secs <= 0) { clearInterval(iv); location.href = ${JSON.stringify(returnTo)}; } }, 1000); })(); </script>` : ""; const headExtra = returnTo ? `<meta http-equiv="refresh" content="${REDIRECT_DELAY_SECONDS};url=${escapedReturnTo}" />` : "";
return page( ` <h1 class="vocs_H1 vocs_Heading">${escapeHtml(heading)}</h1> <p class="vocs_Paragraph">${msg}</p> ${redirectHtml} <table class="vocs_Table" style="display:table;table-layout:fixed;width:100%;overflow:hidden;"> <colgroup><col style="width:7rem;"><col></colgroup> <tbody> <tr class="vocs_TableRow"> <td class="vocs_TableCell">Publication</td> <td class="vocs_TableCell" style="overflow:hidden;"> <div style="overflow-x:auto;white-space:nowrap;"><code class="vocs_Code"><a href="https://pds.ls/${escapedPublicationUri}">${escapedPublicationUri}</a></code></div> </td> </tr> ${ recordUri ? `<tr class="vocs_TableRow"> <td class="vocs_TableCell">Record</td> <td class="vocs_TableCell" style="overflow:hidden;"> <div style="overflow-x:auto;white-space:nowrap;"><code class="vocs_Code"><a href="https://pds.ls/${escapeHtml(recordUri)}">${escapeHtml(recordUri)}</a></code></div> </td> </tr>` : "" } </tbody> </table> `, styleHref, headExtra, );}
function renderError(message: string, styleHref: string): string { return page( `<h1 class="vocs_H1 vocs_Heading">Error</h1><p class="vocs_Paragraph error">${escapeHtml(message)}</p>`, styleHref, );}
function page(body: string, styleHref: string, headExtra = ""): string { return `<!DOCTYPE html><html lang="en"><head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Sequoia · Subscribe</title> <link rel="stylesheet" href="${styleHref}" /> <script>if(window.matchMedia('(prefers-color-scheme: dark)').matches)document.documentElement.classList.add('dark')</script> ${headExtra} <style> .page-container { max-width: calc(var(--vocs-content_width, 480px) / 1.6); margin: 4rem auto; padding: 0 var(--vocs-space_20, 1.25rem); } .vocs_Heading { margin-bottom: var(--vocs-space_12, .75rem); } .vocs_Paragraph { margin-bottom: var(--vocs-space_16, 1rem); } input[type="text"] { padding: var(--vocs-space_8, .5rem) var(--vocs-space_12, .75rem); border: 1px solid var(--vocs-color_border, #D5D1C8); border-radius: var(--vocs-borderRadius_6, 6px); margin-bottom: var(--vocs-space_20, 1.25rem); min-width: 30vh; width: 100%; font-size: var(--vocs-fontSize_16, 1rem); font-family: inherit; background: var(--vocs-color_background, #F5F3EF); color: var(--vocs-color_text, #2C2C2C); } input[type="text"]:focus { border-color: var(--vocs-color_borderAccent, #3A5A40); outline: 2px solid var(--vocs-color_borderAccent, #3A5A40); outline-offset: 2px; } .error { color: var(--vocs-color_dangerText, #8B3A3A); } </style></head><body> <div class="page-container"> ${body} </div></body></html>`;}
function escapeHtml(text: string): string { return text .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, """);}
export default subscribe;