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

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();