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

Configure Feed

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

Pass DID through query parameter

Stores as a cookie and in local storage as a fallback. Passes from origin to sequoia.pub.

Co-Authored-By: @stevedylan.dev

authored by

Heath Stewart and committed by tangled.org b6b1f627 b82b1952

+163 -16
+3
docs/src/lib/oauth-client.ts
··· 3 3 import { AtprotoDohHandleResolver } from "@atproto-labs/handle-resolver"; 4 4 import { createStateStore, createSessionStore } from "./kv-stores"; 5 5 6 + export const OAUTH_SCOPE = 7 + "atproto repo:site.standard.graph.subscription?action=create&action=delete"; 8 + 6 9 export function createOAuthClient(kv: KVNamespace, clientUrl: string) { 7 10 const clientId = `${clientUrl}/oauth/client-metadata.json`; 8 11 const redirectUri = `${clientUrl}/oauth/callback`;
+1 -2
docs/src/lib/session.ts
··· 11 11 const hostname = new URL(clientUrl).hostname; 12 12 return { 13 13 httpOnly: true as const, 14 - // Allow the SESSION_COOKIE_NAME to be sent for existing subscription checks. 15 - sameSite: "None" as const, 14 + sameSite: "Lax" as const, 16 15 path: "/", 17 16 ...(isLocalhost ? {} : { domain: `.${hostname}`, secure: true }), 18 17 };
+3 -3
docs/src/routes/auth.ts
··· 1 1 import { Hono } from "hono"; 2 - import { createOAuthClient } from "../lib/oauth-client"; 2 + import { createOAuthClient, OAUTH_SCOPE } from "../lib/oauth-client"; 3 3 import { 4 4 getSessionDid, 5 5 setSessionCookie, ··· 27 27 redirect_uris: [redirectUri], 28 28 grant_types: ["authorization_code", "refresh_token"], 29 29 response_types: ["code"], 30 - scope: "atproto repo:site.standard.graph.subscription?action=create", 30 + scope: OAUTH_SCOPE, 31 31 token_endpoint_auth_method: "none", 32 32 application_type: "web", 33 33 dpop_bound_access_tokens: true, ··· 44 44 45 45 const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); 46 46 const authUrl = await client.authorize(handle, { 47 - scope: "atproto repo:site.standard.graph.subscription?action=create", 47 + scope: OAUTH_SCOPE, 48 48 }); 49 49 50 50 return c.redirect(authUrl.toString());
+40 -5
docs/src/routes/subscribe.ts
··· 42 42 // ============================================================================ 43 43 44 44 /** 45 + * Append a query parameter to a returnTo URL, preserving existing params. 46 + */ 47 + function 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 + /** 45 63 * Scan the user's repo for an existing site.standard.graph.subscription 46 64 * matching the given publication URI. Returns the record AT-URI if found. 47 65 */ ··· 201 219 rkey, 202 220 }); 203 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 + 204 235 return c.html( 205 236 renderSuccess( 206 237 publicationUri, ··· 210 241 ? "You've successfully unsubscribed!" 211 242 : "You weren't subscribed to this publication.", 212 243 styleHref, 213 - returnTo, 244 + withReturnToParam(cleanReturnTo, "sequoia_unsubscribed", "1"), 214 245 ), 215 246 ); 216 247 } ··· 220 251 did, 221 252 publicationUri, 222 253 ); 254 + const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did); 255 + 223 256 if (existingUri) { 224 257 return c.html( 225 258 renderSuccess( ··· 228 261 "Subscribed ✓", 229 262 "You're already subscribed to this publication.", 230 263 styleHref, 231 - returnTo, 264 + returnToWithDid, 232 265 ), 233 266 ); 234 267 } ··· 249 282 "Subscribed ✓", 250 283 "You've successfully subscribed!", 251 284 styleHref, 252 - returnTo, 285 + returnToWithDid, 253 286 ), 254 287 ); 255 288 } catch (error) { ··· 286 319 return c.json({ error: "Missing or invalid publicationUri" }, 400); 287 320 } 288 321 289 - const did = getSessionDid(c); 290 - if (!did) { 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:")) { 291 326 return c.json({ authenticated: false }, 401); 292 327 } 293 328
+116 -6
packages/cli/src/components/sequoia-subscribe.js
··· 111 111 </svg>`; 112 112 113 113 // ============================================================================ 114 + // DID Storage 115 + // ============================================================================ 116 + 117 + /** 118 + * Store the subscriber DID. Tries a cookie first; falls back to localStorage. 119 + * @param {string} did 120 + */ 121 + function storeSubscriberDid(did) { 122 + try { 123 + const expires = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toUTCString(); 124 + document.cookie = `sequoia_did=${encodeURIComponent(did)}; expires=${expires}; path=/; SameSite=Lax`; 125 + } catch { 126 + // Cookie write may fail in some embedded contexts 127 + } 128 + try { 129 + localStorage.setItem("sequoia_did", did); 130 + } catch { 131 + // localStorage may be unavailable 132 + } 133 + } 134 + 135 + /** 136 + * Retrieve the stored subscriber DID. Checks cookie first, then localStorage. 137 + * @returns {string | null} 138 + */ 139 + function getStoredSubscriberDid() { 140 + try { 141 + const match = document.cookie.match(/(?:^|;\s*)sequoia_did=([^;]+)/); 142 + if (match) { 143 + const did = decodeURIComponent(match[1]); 144 + if (did.startsWith("did:")) return did; 145 + } 146 + } catch { 147 + // ignore 148 + } 149 + try { 150 + const did = localStorage.getItem("sequoia_did"); 151 + if (did?.startsWith("did:")) return did; 152 + } catch { 153 + // ignore 154 + } 155 + return null; 156 + } 157 + 158 + /** 159 + * Remove the stored subscriber DID from both cookie and localStorage. 160 + */ 161 + function clearSubscriberDid() { 162 + try { 163 + document.cookie = "sequoia_did=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax"; 164 + } catch { 165 + // ignore 166 + } 167 + try { 168 + localStorage.removeItem("sequoia_did"); 169 + } catch { 170 + // ignore 171 + } 172 + } 173 + 174 + /** 175 + * Check the current page URL for sequoia_did / sequoia_unsubscribed params 176 + * set by the subscribe redirect flow. Consumes them by removing from the URL. 177 + */ 178 + function consumeReturnParams() { 179 + const url = new URL(window.location.href); 180 + const did = url.searchParams.get("sequoia_did"); 181 + const unsubscribed = url.searchParams.get("sequoia_unsubscribed"); 182 + 183 + let changed = false; 184 + 185 + if (unsubscribed === "1") { 186 + clearSubscriberDid(); 187 + url.searchParams.delete("sequoia_unsubscribed"); 188 + changed = true; 189 + } 190 + 191 + if (did && did.startsWith("did:")) { 192 + storeSubscriberDid(did); 193 + url.searchParams.delete("sequoia_did"); 194 + changed = true; 195 + } 196 + 197 + if (changed) { 198 + const cleanUrl = url.pathname + (url.search || "") + (url.hash || ""); 199 + try { 200 + window.history.replaceState(null, "", cleanUrl); 201 + } catch { 202 + // ignore 203 + } 204 + } 205 + } 206 + 207 + // ============================================================================ 114 208 // AT Protocol Functions 115 209 // ============================================================================ 116 210 ··· 177 271 } 178 272 179 273 connectedCallback() { 274 + consumeReturnParams(); 180 275 this.checkPublication(); 181 276 } 182 277 ··· 223 318 224 319 async checkSubscription(publicationUri) { 225 320 try { 226 - const res = await fetch( 227 - `${this.callbackUri}/check?publicationUri=${encodeURIComponent(publicationUri)}`, 228 - { 229 - credentials: "include", 230 - }, 231 - ); 321 + const checkUrl = new URL(`${this.callbackUri}/check`); 322 + checkUrl.searchParams.set("publicationUri", publicationUri); 323 + 324 + // Pass the stored DID so the server can check without a session cookie 325 + const storedDid = getStoredSubscriberDid(); 326 + if (storedDid) { 327 + checkUrl.searchParams.set("did", storedDid); 328 + } 329 + 330 + const res = await fetch(checkUrl.toString(), { 331 + credentials: "include", 332 + }); 232 333 if (!res.ok) return; 233 334 const data = await res.json(); 234 335 if (data.subscribed) { ··· 287 388 } 288 389 289 390 const { recordUri } = data; 391 + 392 + // Store the DID from the record URI (at://did:aaa:bbb/...) 393 + if (recordUri) { 394 + const didMatch = recordUri.match(/^at:\/\/(did:[^/]+)/); 395 + if (didMatch) { 396 + storeSubscriberDid(didMatch[1]); 397 + } 398 + } 399 + 290 400 this.subscribed = true; 291 401 this.state = { type: "idle" }; 292 402 this.render();