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

feat: Add subscription component #25

merged opened by heaths.dev targeting main from heaths.dev/sequoia: issue16

Resolves #16

Labels

None yet.

assignee

None yet.

Participants 2
AT URI
at://did:plc:tg3tb5wukiml4xmxml6qm637/sh.tangled.repo.pull/3mfe5ptlpci22
+467 -37
Interdiff #0 โ†’ #1
packages/cli/src/commands/add.ts

This file has not been changed.

+72 -9
packages/cli/src/components/sequoia-subscribe.js
··· 12 12 * 13 13 * Attributes: 14 14 * - publication-uri: Override the publication AT URI (optional) 15 + * - callback-uri: Redirect URI after OAuth authentication (default: "https://sequoia.pub/subscribe") 15 16 * - label: Button label text (default: "Subscribe on Bluesky") 17 + * - hide: Set to "auto" to hide if no publication URI is detected 16 18 * 17 19 * CSS Custom Properties: 18 20 * - --sequoia-fg-color: Text color (default: #1f2937) ··· 262 264 263 265 this.wrapper = wrapper; 264 266 this.state = { type: "idle" }; 267 + this.abortController = null; 265 268 this.render(); 266 269 } 267 270 268 271 static get observedAttributes() { 269 - return ["publication-uri", "label"]; 272 + return ["publication-uri", "callback-uri", "label", "hide"]; 273 + } 274 + 275 + connectedCallback() { 276 + // Pre-check publication availability so hide="auto" can take effect 277 + if (!this.publicationUri) { 278 + this.checkPublication(); 279 + } 280 + } 281 + 282 + disconnectedCallback() { 283 + this.abortController?.abort(); 270 284 } 271 285 272 286 attributeChangedCallback() { 273 287 // Reset to idle if attributes change after an error or success 274 288 if ( 275 289 this.state.type === "error" || 276 - this.state.type === "subscribed" 290 + this.state.type === "subscribed" || 291 + this.state.type === "no-publication" 277 292 ) { 278 293 this.state = { type: "idle" }; 279 294 } ··· 284 299 return this.getAttribute("publication-uri") ?? null; 285 300 } 286 301 302 + get callbackUri() { 303 + return this.getAttribute("callback-uri") ?? "https://sequoia.pub/subscribe"; 304 + } 305 + 287 306 get label() { 288 307 return this.getAttribute("label") ?? "Subscribe on Bluesky"; 289 308 } 290 309 310 + get hide() { 311 + const hideAttr = this.getAttribute("hide"); 312 + return hideAttr === "auto"; 313 + } 314 + 315 + async checkPublication() { 316 + this.abortController?.abort(); 317 + this.abortController = new AbortController(); 318 + 319 + try { 320 + await fetchPublicationUri(); 321 + } catch { 322 + this.state = { type: "no-publication" }; 323 + this.render(); 324 + } 325 + } 326 + 291 327 async handleClick() { 292 328 if (this.state.type === "loading" || this.state.type === "subscribed") { 293 329 return; ··· 297 333 this.render(); 298 334 299 335 try { 300 - // Resolve the publication AT URI 301 336 const publicationUri = 302 337 this.publicationUri ?? (await fetchPublicationUri()); 303 338 304 - // TODO: resolve authenticated DID and access token before calling createRecord 305 - const { uri: recordUri } = await createRecord( 306 - /* did */ undefined, 307 - /* accessToken */ undefined, 308 - publicationUri, 309 - ); 339 + // POST to the callbackUri (e.g. https://sequoia.pub/subscribe). 340 + // If the server reports the user isn't authenticated it returns a 341 + // subscribeUrl for the full-page OAuth + subscription flow. 342 + const response = await fetch(this.callbackUri, { 343 + method: "POST", 344 + headers: { "Content-Type": "application/json" }, 345 + credentials: "include", 346 + body: JSON.stringify({ publicationUri }), 347 + }); 348 + 349 + const data = await response.json(); 350 + 351 + if (response.status === 401 && data.authenticated === false) { 352 + // Redirect to the hosted subscribe page to complete OAuth 353 + window.location.href = data.subscribeUrl; 354 + return; 355 + } 356 + 357 + if (!response.ok) { 358 + throw new Error(data.error ?? `HTTP ${response.status}`); 359 + } 310 360 361 + const { recordUri } = data; 311 362 this.state = { type: "subscribed", recordUri, publicationUri }; 312 363 this.render(); 313 364 ··· 319 370 }), 320 371 ); 321 372 } catch (error) { 373 + // Don't overwrite state if we already navigated away 374 + if (this.state.type !== "loading") return; 375 + 322 376 const message = 323 377 error instanceof Error ? error.message : "Failed to subscribe"; 324 378 this.state = { type: "error", message }; ··· 336 390 337 391 render() { 338 392 const { type } = this.state; 393 + 394 + if (type === "no-publication") { 395 + if (this.hide) { 396 + this.wrapper.innerHTML = ""; 397 + this.wrapper.style.display = "none"; 398 + } 399 + return; 400 + } 401 + 339 402 const isLoading = type === "loading"; 340 403 const isSubscribed = type === "subscribed"; 341 404
+21 -6
bun.lock
··· 14 14 "version": "0.0.0", 15 15 "dependencies": { 16 16 "@atproto-labs/handle-resolver": "latest", 17 + "@atproto/api": "latest", 17 18 "@atproto/jwk-jose": "latest", 18 19 "@atproto/oauth-client": "latest", 19 20 "hono": "latest", ··· 78 79 79 80 "@atproto-labs/simple-store-memory": ["@atproto-labs/simple-store-memory@0.1.4", "", { "dependencies": { "@atproto-labs/simple-store": "0.3.0", "lru-cache": "^10.2.0" } }, "sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw=="], 80 81 81 - "@atproto/api": ["@atproto/api@0.18.17", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/lexicon": "^0.6.1", "@atproto/syntax": "^0.4.3", "@atproto/xrpc": "^0.7.7", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-TeJkLGPkiK3jblwTDSNTH+CnS6WgaOiHDZeVVzywtxomyyF0FpQVSMz5eP3sDhxyHJqpI3E2AOYD7PO/JSbzJw=="], 82 + "@atproto/api": ["@atproto/api@0.19.0", "", { "dependencies": { "@atproto/common-web": "^0.4.17", "@atproto/lexicon": "^0.6.1", "@atproto/syntax": "^0.4.3", "@atproto/xrpc": "^0.7.7", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-7u/EGgkIj4bbslGer2RMQPtMWCPvREcpH0mVagaf5om+NcPzUIZeIacWKANVv95BdMJ7jlcHS7xrkEMPmg2dFw=="], 82 83 83 - "@atproto/common-web": ["@atproto/common-web@0.4.13", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "@atproto/lex-json": "0.0.9", "@atproto/syntax": "0.4.3", "zod": "^3.23.8" } }, "sha512-TewRUyB/dVJ5PtI3QmJzEgT3wDsvpnLJ+48hPl+LuUueJPamZevXKJN6dFjtbKAMFRnl2bKfdsf79qwvdSaLKQ=="], 84 + "@atproto/common-web": ["@atproto/common-web@0.4.17", "", { "dependencies": { "@atproto/lex-data": "^0.0.12", "@atproto/lex-json": "^0.0.12", "@atproto/syntax": "^0.4.3", "zod": "^3.23.8" } }, "sha512-sfxD8NGxyoxhxmM9EUshEFbWcJ3+JHEOZF4Quk6HsCh1UxpHBmLabT/vEsAkDWl+C/8U0ine0+c/gHyE/OZiQQ=="], 84 85 85 86 "@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="], 86 87 ··· 90 91 91 92 "@atproto/jwk-webcrypto": ["@atproto/jwk-webcrypto@0.2.0", "", { "dependencies": { "@atproto/jwk": "0.6.0", "@atproto/jwk-jose": "0.1.11", "zod": "^3.23.8" } }, "sha512-UmgRrrEAkWvxwhlwe30UmDOdTEFidlIzBC7C3cCbeJMcBN1x8B3KH+crXrsTqfWQBG58mXgt8wgSK3Kxs2LhFg=="], 92 93 93 - "@atproto/lex-data": ["@atproto/lex-data@0.0.9", "", { "dependencies": { "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-1slwe4sG0cyWtsq16+rBoWIxNDqGPkkvN+PV6JuzA7dgUK9bjUmXBGQU4eZlUPSS43X1Nhmr/9VjgKmEzU9vDw=="], 94 + "@atproto/lex-data": ["@atproto/lex-data@0.0.12", "", { "dependencies": { "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-aekJudcK1p6sbTqUv2bJMJBAGZaOJS0mgDclpK3U6VuBREK/au4B6ffunBFWgrDfg0Vwj2JGyEA7E51WZkJcRw=="], 94 95 95 - "@atproto/lex-json": ["@atproto/lex-json@0.0.9", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "tslib": "^2.8.1" } }, "sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw=="], 96 + "@atproto/lex-json": ["@atproto/lex-json@0.0.12", "", { "dependencies": { "@atproto/lex-data": "^0.0.12", "tslib": "^2.8.1" } }, "sha512-XlEpnWWZdDJ5BIgG25GyH+6iBfyrFL18BI5JSE6rUfMObbFMrQRaCuRLQfryRXNysVz3L3U+Qb9y8KcXbE8AcA=="], 96 97 97 98 "@atproto/lexicon": ["@atproto/lexicon@0.6.1", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/syntax": "^0.4.3", "iso-datestring-validator": "^2.2.2", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw=="], 98 99 ··· 182 183 183 184 "@clack/prompts": ["@clack/prompts@1.0.0", "", { "dependencies": { "@clack/core": "1.0.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-rWPXg9UaCFqErJVQ+MecOaWsozjaxol4yjnmYcGNipAWzdaWa2x+VJmKfGq7L0APwBohQOYdHC+9RO4qRXej+A=="], 184 185 185 - "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260228.0", "", {}, "sha512-9LfRg93ncQq6Oc4MFpqGSs+PmPhqWvg8TspXwbiYNR201IhXB4WqHR/aTSudPI0ujsf/NLc8E9fF3C+aA2g8KQ=="], 186 + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260303.0", "", {}, "sha512-soUlr4NJVkh5dR09RwtziTMbBQ+lbdoEesTGw8WUlvmnQ2M4h7CmJzAjC6a7IivUodiiCSjbLcGV/8PyZpvZkA=="], 186 187 187 188 "@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="], 188 189 ··· 962 963 963 964 "hastscript": ["hastscript@8.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-parse-selector": "^4.0.0", "property-information": "^6.0.0", "space-separated-tokens": "^2.0.0" } }, "sha512-dMOtzCEd3ABUeSIISmrETiKuyydk1w0pa+gE/uormcTpSYuaNJPbX1NU3JLyscSLjwAQM8bWMhhIlnCqnRvDTw=="], 964 965 965 - "hono": ["hono@4.12.1", "", {}, "sha512-hi9afu8g0lfJVLolxElAZGANCTTl6bewIdsRNhaywfP9K8BPf++F2z6OLrYGIinUwpRKzbZHMhPwvc0ZEpAwGw=="], 966 + "hono": ["hono@4.12.2", "", {}, "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg=="], 966 967 967 968 "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], 968 969 ··· 1540 1541 1541 1542 "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], 1542 1543 1544 + "@atproto/lexicon/@atproto/common-web": ["@atproto/common-web@0.4.13", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "@atproto/lex-json": "0.0.9", "@atproto/syntax": "0.4.3", "zod": "^3.23.8" } }, "sha512-TewRUyB/dVJ5PtI3QmJzEgT3wDsvpnLJ+48hPl+LuUueJPamZevXKJN6dFjtbKAMFRnl2bKfdsf79qwvdSaLKQ=="], 1545 + 1543 1546 "@atproto/oauth-client-node/@atproto/oauth-client": ["@atproto/oauth-client@0.5.14", "", { "dependencies": { "@atproto-labs/did-resolver": "0.2.6", "@atproto-labs/fetch": "0.2.3", "@atproto-labs/handle-resolver": "0.3.6", "@atproto-labs/identity-resolver": "0.3.6", "@atproto-labs/simple-store": "0.3.0", "@atproto-labs/simple-store-memory": "0.1.4", "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "@atproto/oauth-types": "0.6.2", "@atproto/xrpc": "0.7.7", "core-js": "^3", "multiformats": "^9.9.0", "zod": "^3.23.8" } }, "sha512-sPH+vcdq9maTEAhJI0HzmFcFAMrkCS19np+RUssNkX6kS8Xr3OYr57tvYRCbkcnIyYTfYcxKQgpwHKx3RVEaYw=="], 1544 1547 1545 1548 "@atproto/oauth-client-node/@atproto/oauth-types": ["@atproto/oauth-types@0.6.2", "", { "dependencies": { "@atproto/did": "0.3.0", "@atproto/jwk": "0.6.0", "zod": "^3.23.8" } }, "sha512-2cuboM4RQBCYR8NQC5uGRkW6KgCgKyq/B5/+tnMmWZYtZGVUQvsUWQHK/ZiMCnVXbcDNtc/RIEJQJDZ8FXMoxg=="], ··· 1616 1619 1617 1620 "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], 1618 1621 1622 + "sequoia-cli/@atproto/api": ["@atproto/api@0.18.17", "", { "dependencies": { "@atproto/common-web": "^0.4.13", "@atproto/lexicon": "^0.6.1", "@atproto/syntax": "^0.4.3", "@atproto/xrpc": "^0.7.7", "await-lock": "^2.2.2", "multiformats": "^9.9.0", "tlds": "^1.234.0", "zod": "^3.23.8" } }, "sha512-TeJkLGPkiK3jblwTDSNTH+CnS6WgaOiHDZeVVzywtxomyyF0FpQVSMz5eP3sDhxyHJqpI3E2AOYD7PO/JSbzJw=="], 1623 + 1619 1624 "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], 1620 1625 1621 1626 "vocs/hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="], 1627 + 1628 + "@atproto/lexicon/@atproto/common-web/@atproto/lex-data": ["@atproto/lex-data@0.0.9", "", { "dependencies": { "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-1slwe4sG0cyWtsq16+rBoWIxNDqGPkkvN+PV6JuzA7dgUK9bjUmXBGQU4eZlUPSS43X1Nhmr/9VjgKmEzU9vDw=="], 1629 + 1630 + "@atproto/lexicon/@atproto/common-web/@atproto/lex-json": ["@atproto/lex-json@0.0.9", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "tslib": "^2.8.1" } }, "sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw=="], 1622 1631 1623 1632 "@radix-ui/react-label/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], 1624 1633 ··· 1643 1652 "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], 1644 1653 1645 1654 "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], 1655 + 1656 + "sequoia-cli/@atproto/api/@atproto/common-web": ["@atproto/common-web@0.4.13", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "@atproto/lex-json": "0.0.9", "@atproto/syntax": "0.4.3", "zod": "^3.23.8" } }, "sha512-TewRUyB/dVJ5PtI3QmJzEgT3wDsvpnLJ+48hPl+LuUueJPamZevXKJN6dFjtbKAMFRnl2bKfdsf79qwvdSaLKQ=="], 1657 + 1658 + "sequoia-cli/@atproto/api/@atproto/common-web/@atproto/lex-data": ["@atproto/lex-data@0.0.9", "", { "dependencies": { "multiformats": "^9.9.0", "tslib": "^2.8.1", "uint8arrays": "3.0.0", "unicode-segmenter": "^0.14.0" } }, "sha512-1slwe4sG0cyWtsq16+rBoWIxNDqGPkkvN+PV6JuzA7dgUK9bjUmXBGQU4eZlUPSS43X1Nhmr/9VjgKmEzU9vDw=="], 1659 + 1660 + "sequoia-cli/@atproto/api/@atproto/common-web/@atproto/lex-json": ["@atproto/lex-json@0.0.9", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "tslib": "^2.8.1" } }, "sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw=="], 1646 1661 } 1647 1662 }
+1
docs/package.json
··· 12 12 "preview": "vocs preview" 13 13 }, 14 14 "dependencies": { 15 + "@atproto/api": "latest", 15 16 "@atproto/oauth-client": "latest", 16 17 "@atproto/jwk-jose": "latest", 17 18 "@atproto-labs/handle-resolver": "latest",
+2
docs/src/index.ts
··· 1 1 import { Hono } from "hono"; 2 2 import auth from "./routes/auth"; 3 + import subscribe from "./routes/subscribe"; 3 4 4 5 type Bindings = { 5 6 ASSETS: Fetcher; ··· 10 11 const app = new Hono<{ Bindings: Bindings }>(); 11 12 12 13 app.route("/oauth", auth); 14 + app.route("/subscribe", subscribe); 13 15 14 16 app.get("/api/health", (c) => { 15 17 return c.json({ status: "ok" });
+48 -20
docs/src/lib/session.ts
··· 1 1 import type { Context } from "hono"; 2 + import { deleteCookie, getCookie, setCookie } from "hono/cookie"; 2 3 3 4 const SESSION_COOKIE_NAME = "session_id"; 5 + const RETURN_TO_COOKIE_NAME = "login_return_to"; 4 6 const SESSION_TTL = 60 * 60 * 24 * 14; // 14 days in seconds 7 + const RETURN_TO_TTL = 600; // 10 minutes in seconds 8 + 9 + function baseCookieOptions(clientUrl: string) { 10 + const isLocalhost = clientUrl.includes("localhost"); 11 + return { 12 + httpOnly: true as const, 13 + sameSite: "Lax" as const, 14 + path: "/", 15 + ...(isLocalhost ? {} : { domain: ".sequoia.pub", secure: true }), 16 + }; 17 + } 5 18 6 19 /** 7 20 * Get DID from session cookie 8 21 */ 9 22 export function getSessionDid(c: Context): string | null { 10 - const cookie = c.req.header("Cookie"); 11 - if (!cookie) return null; 12 - 13 - const match = cookie.match(new RegExp(`${SESSION_COOKIE_NAME}=([^;]+)`)); 14 - return match ? decodeURIComponent(match[1]) : null; 23 + const value = getCookie(c, SESSION_COOKIE_NAME); 24 + return value ? decodeURIComponent(value) : null; 15 25 } 16 26 17 27 /** ··· 22 32 did: string, 23 33 clientUrl: string, 24 34 ): void { 25 - const isLocalhost = clientUrl.includes("localhost"); 26 - const domain = isLocalhost ? "" : "; Domain=.sequoia.pub"; 27 - const secure = isLocalhost ? "" : "; Secure"; 28 - 29 - c.header( 30 - "Set-Cookie", 31 - `${SESSION_COOKIE_NAME}=${encodeURIComponent(did)}; HttpOnly; SameSite=Lax; Path=/${domain}${secure}; Max-Age=${SESSION_TTL}`, 32 - ); 35 + setCookie(c, SESSION_COOKIE_NAME, encodeURIComponent(did), { 36 + ...baseCookieOptions(clientUrl), 37 + maxAge: SESSION_TTL, 38 + }); 33 39 } 34 40 35 41 /** 36 42 * Clear session cookie 37 43 */ 38 44 export function clearSessionCookie(c: Context, clientUrl: string): void { 39 - const isLocalhost = clientUrl.includes("localhost"); 40 - const domain = isLocalhost ? "" : "; Domain=.sequoia.pub"; 41 - const secure = isLocalhost ? "" : "; Secure"; 45 + deleteCookie(c, SESSION_COOKIE_NAME, baseCookieOptions(clientUrl)); 46 + } 47 + 48 + /** 49 + * Get the post-OAuth return-to URL from the short-lived cookie 50 + */ 51 + export function getReturnToCookie(c: Context): string | null { 52 + const value = getCookie(c, RETURN_TO_COOKIE_NAME); 53 + return value ? decodeURIComponent(value) : null; 54 + } 42 55 43 - c.header( 44 - "Set-Cookie", 45 - `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/${domain}${secure}; Max-Age=0`, 46 - ); 56 + /** 57 + * Set a short-lived cookie that redirects back after OAuth completes 58 + */ 59 + export function setReturnToCookie( 60 + c: Context, 61 + returnTo: string, 62 + clientUrl: string, 63 + ): void { 64 + setCookie(c, RETURN_TO_COOKIE_NAME, encodeURIComponent(returnTo), { 65 + ...baseCookieOptions(clientUrl), 66 + maxAge: RETURN_TO_TTL, 67 + }); 68 + } 69 + 70 + /** 71 + * Clear the return-to cookie 72 + */ 73 + export function clearReturnToCookie(c: Context, clientUrl: string): void { 74 + deleteCookie(c, RETURN_TO_COOKIE_NAME, baseCookieOptions(clientUrl)); 47 75 }
+8 -1
docs/src/routes/auth.ts
··· 4 4 getSessionDid, 5 5 setSessionCookie, 6 6 clearSessionCookie, 7 + getReturnToCookie, 8 + clearReturnToCookie, 7 9 } from "../lib/session"; 8 10 9 11 interface Env { ··· 85 87 } 86 88 87 89 setSessionCookie(c, session.did, c.env.CLIENT_URL); 88 - return c.redirect(`${c.env.CLIENT_URL}/`); 90 + 91 + // If a subscribe flow set a return URL before initiating OAuth, honor it 92 + const returnTo = getReturnToCookie(c); 93 + clearReturnToCookie(c, c.env.CLIENT_URL); 94 + 95 + return c.redirect(returnTo ?? `${c.env.CLIENT_URL}/`); 89 96 } catch (error) { 90 97 console.error("Callback error:", error); 91 98 return c.redirect(`${c.env.CLIENT_URL}/?error=callback_failed`);
+314
docs/src/routes/subscribe.ts
··· 1 + import { Agent } from "@atproto/api"; 2 + import { Hono } from "hono"; 3 + import { createOAuthClient } from "../lib/oauth-client"; 4 + import { getSessionDid, setReturnToCookie } from "../lib/session"; 5 + 6 + interface 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). 13 + let _vocsStyleHref: string | null = null; 14 + 15 + async function getVocsStyleHref(assets: Fetcher, baseUrl: string): Promise<string> { 16 + if (_vocsStyleHref) return _vocsStyleHref; 17 + try { 18 + const indexUrl = new URL("/", baseUrl).toString(); 19 + const res = await assets.fetch(indexUrl); 20 + const html = await res.text(); 21 + const match = html.match(/<link[^>]+href="(\/assets\/style[^"]+\.css)"/); 22 + if (match?.[1]) { 23 + _vocsStyleHref = match[1]; 24 + return match[1]; 25 + } 26 + } catch { 27 + // Fall back to the custom stylesheet which at least provides --sequoia-* vars 28 + } 29 + return "/styles.css"; 30 + } 31 + 32 + const subscribe = new Hono<{ Bindings: Env }>(); 33 + 34 + const COLLECTION = "site.standard.graph.subscription"; 35 + 36 + // ============================================================================ 37 + // Helpers 38 + // ============================================================================ 39 + 40 + /** 41 + * Scan the user's repo for an existing site.standard.graph.subscription 42 + * matching the given publication URI. Returns the record AT-URI if found. 43 + */ 44 + async function findExistingSubscription( 45 + agent: Agent, 46 + did: string, 47 + publicationUri: string, 48 + ): Promise<string | null> { 49 + let cursor: string | undefined; 50 + 51 + do { 52 + const result = await agent.com.atproto.repo.listRecords({ 53 + repo: did, 54 + collection: COLLECTION, 55 + limit: 100, 56 + cursor, 57 + }); 58 + 59 + for (const record of result.data.records) { 60 + const value = record.value as { publication?: string }; 61 + if (value.publication === publicationUri) { 62 + return record.uri; 63 + } 64 + } 65 + 66 + cursor = result.data.cursor; 67 + } while (cursor); 68 + 69 + return null; 70 + } 71 + 72 + // ============================================================================ 73 + // POST /subscribe 74 + // 75 + // Called via fetch() from the sequoia-subscribe web component. 76 + // Body JSON: { publicationUri: string } 77 + // 78 + // Responses: 79 + // 200 { subscribed: true, existing: boolean, recordUri: string } 80 + // 400 { error: string } 81 + // 401 { authenticated: false, subscribeUrl: string } 82 + // ============================================================================ 83 + 84 + subscribe.post("/", async (c) => { 85 + let publicationUri: string; 86 + try { 87 + const body = await c.req.json<{ publicationUri?: string }>(); 88 + publicationUri = body.publicationUri ?? ""; 89 + } catch { 90 + return c.json({ error: "Invalid JSON body" }, 400); 91 + } 92 + 93 + if (!publicationUri || !publicationUri.startsWith("at://")) { 94 + return c.json({ error: "Missing or invalid publicationUri" }, 400); 95 + } 96 + 97 + const did = getSessionDid(c); 98 + if (!did) { 99 + const subscribeUrl = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`; 100 + return c.json({ authenticated: false, subscribeUrl }, 401); 101 + } 102 + 103 + try { 104 + const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); 105 + const session = await client.restore(did); 106 + const agent = new Agent(session); 107 + 108 + const existingUri = await findExistingSubscription(agent, did, publicationUri); 109 + if (existingUri) { 110 + return c.json({ subscribed: true, existing: true, recordUri: existingUri }); 111 + } 112 + 113 + const result = await agent.com.atproto.repo.createRecord({ 114 + repo: did, 115 + collection: COLLECTION, 116 + record: { 117 + $type: COLLECTION, 118 + publication: publicationUri, 119 + }, 120 + }); 121 + 122 + return c.json({ subscribed: true, existing: false, recordUri: result.data.uri }); 123 + } catch (error) { 124 + console.error("Subscribe POST error:", error); 125 + // Treat expired/missing session as unauthenticated 126 + const subscribeUrl = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`; 127 + return c.json({ authenticated: false, subscribeUrl }, 401); 128 + } 129 + }); 130 + 131 + // ============================================================================ 132 + // GET /subscribe?publicationUri=at://... 133 + // 134 + // Full-page OAuth + subscription flow. Unauthenticated users land here after 135 + // the component redirects them, and authenticated users land here after the 136 + // OAuth callback (via the login_return_to cookie set in POST /subscribe/login). 137 + // ============================================================================ 138 + 139 + subscribe.get("/", async (c) => { 140 + const publicationUri = c.req.query("publicationUri"); 141 + const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url); 142 + 143 + if (!publicationUri || !publicationUri.startsWith("at://")) { 144 + return c.html(renderError("Missing or invalid publication URI.", styleHref), 400); 145 + } 146 + 147 + const did = getSessionDid(c); 148 + if (!did) { 149 + return c.html(renderHandleForm(publicationUri, styleHref)); 150 + } 151 + 152 + try { 153 + const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); 154 + const session = await client.restore(did); 155 + const agent = new Agent(session); 156 + 157 + const existingUri = await findExistingSubscription(agent, did, publicationUri); 158 + if (existingUri) { 159 + return c.html(renderSuccess(publicationUri, existingUri, true, styleHref)); 160 + } 161 + 162 + const result = await agent.com.atproto.repo.createRecord({ 163 + repo: did, 164 + collection: COLLECTION, 165 + record: { 166 + $type: COLLECTION, 167 + publication: publicationUri, 168 + }, 169 + }); 170 + 171 + return c.html(renderSuccess(publicationUri, result.data.uri, false, styleHref)); 172 + } catch (error) { 173 + console.error("Subscribe GET error:", error); 174 + // Session expired - ask the user to sign in again 175 + return c.html(renderHandleForm(publicationUri, styleHref, "Session expired. Please sign in again.")); 176 + } 177 + }); 178 + 179 + // ============================================================================ 180 + // POST /subscribe/login 181 + // 182 + // Handles the handle-entry form submission. Stores the return URL in a cookie 183 + // so the OAuth callback in auth.ts can redirect back to /subscribe after auth. 184 + // ============================================================================ 185 + 186 + subscribe.post("/login", async (c) => { 187 + const body = await c.req.parseBody(); 188 + const handle = (body["handle"] as string | undefined)?.trim(); 189 + const publicationUri = body["publicationUri"] as string | undefined; 190 + 191 + if (!handle || !publicationUri) { 192 + const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url); 193 + return c.html(renderError("Missing handle or publication URI.", styleHref), 400); 194 + } 195 + 196 + const returnTo = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`; 197 + setReturnToCookie(c, returnTo, c.env.CLIENT_URL); 198 + 199 + return c.redirect( 200 + `${c.env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`, 201 + ); 202 + }); 203 + 204 + // ============================================================================ 205 + // HTML rendering 206 + // ============================================================================ 207 + 208 + function renderHandleForm(publicationUri: string, styleHref: string, error?: string): string { 209 + const errorHtml = error 210 + ? `<p class="vocs_Paragraph error">${escapeHtml(error)}</p>` 211 + : ""; 212 + 213 + return page(` 214 + <h1 class="vocs_H1 vocs_Heading">Subscribe on Bluesky</h1> 215 + <p class="vocs_Paragraph">Enter your Bluesky handle to subscribe to this publication.</p> 216 + ${errorHtml} 217 + <form method="POST" action="/subscribe/login"> 218 + <input type="hidden" name="publicationUri" value="${escapeHtml(publicationUri)}" /> 219 + <label> 220 + Bluesky handle 221 + <input 222 + type="text" 223 + name="handle" 224 + placeholder="you.bsky.social" 225 + autocomplete="username" 226 + required 227 + autofocus 228 + /> 229 + </label> 230 + <button type="submit" class="vocs_Button_button vocs_Button_button_accent">Continue on Bluesky</button> 231 + </form> 232 + `, styleHref); 233 + } 234 + 235 + function renderSuccess( 236 + publicationUri: string, 237 + recordUri: string, 238 + existing: boolean, 239 + styleHref: string, 240 + ): string { 241 + const msg = existing 242 + ? "You're already subscribed to this publication." 243 + : "You've successfully subscribed!"; 244 + return page(` 245 + <h1 class="vocs_H1 vocs_Heading">Subscribed โœ“</h1> 246 + <p class="vocs_Paragraph">${msg}</p> 247 + <p class="vocs_Paragraph"><small>Publication: <code class="vocs_Code">${escapeHtml(publicationUri)}</code></small></p> 248 + <p class="vocs_Paragraph"><small>Record: <code class="vocs_Code">${escapeHtml(recordUri)}</code></small></p> 249 + `, styleHref); 250 + } 251 + 252 + function renderError(message: string, styleHref: string): string { 253 + return page(`<h1 class="vocs_H1 vocs_Heading">Error</h1><p class="vocs_Paragraph error">${escapeHtml(message)}</p>`, styleHref); 254 + } 255 + 256 + function page(body: string, styleHref: string): string { 257 + return `<!DOCTYPE html> 258 + <html lang="en"> 259 + <head> 260 + <meta charset="UTF-8" /> 261 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 262 + <title>Sequoia ยท Subscribe</title> 263 + <link rel="stylesheet" href="${styleHref}" /> 264 + <script>if(window.matchMedia('(prefers-color-scheme: dark)').matches)document.documentElement.classList.add('dark')</script> 265 + <style> 266 + .page-container { 267 + max-width: 480px; 268 + margin: 4rem auto; 269 + padding: 0 var(--vocs-space_20, 1.25rem); 270 + } 271 + .vocs_Heading { margin-bottom: var(--vocs-space_12, .75rem); } 272 + .vocs_Paragraph { margin-bottom: var(--vocs-space_16, 1rem); } 273 + label { 274 + display: flex; 275 + flex-direction: column; 276 + gap: var(--vocs-space_6, .375rem); 277 + margin-bottom: var(--vocs-space_20, 1.25rem); 278 + font-weight: var(--vocs-fontWeight_medium, 400); 279 + font-size: var(--vocs-fontSize_15, .9375rem); 280 + } 281 + input[type="text"] { 282 + padding: var(--vocs-space_8, .5rem) var(--vocs-space_12, .75rem); 283 + border: 1px solid var(--vocs-color_border, #D5D1C8); 284 + border-radius: var(--vocs-borderRadius_6, 6px); 285 + font-size: var(--vocs-fontSize_16, 1rem); 286 + font-family: inherit; 287 + background: var(--vocs-color_background, #F5F3EF); 288 + color: var(--vocs-color_text, #2C2C2C); 289 + } 290 + input[type="text"]:focus { 291 + border-color: var(--vocs-color_borderAccent, #3A5A40); 292 + outline: 2px solid var(--vocs-color_borderAccent, #3A5A40); 293 + outline-offset: 2px; 294 + } 295 + .error { color: var(--vocs-color_dangerText, #8B3A3A); } 296 + </style> 297 + </head> 298 + <body> 299 + <div class="page-container"> 300 + ${body} 301 + </div> 302 + </body> 303 + </html>`; 304 + } 305 + 306 + function escapeHtml(text: string): string { 307 + return text 308 + .replace(/&/g, "&amp;") 309 + .replace(/</g, "&lt;") 310 + .replace(/>/g, "&gt;") 311 + .replace(/"/g, "&quot;"); 312 + } 313 + 314 + export default subscribe;
+1 -1
docs/wrangler.toml
··· 8 8 binding = "ASSETS" 9 9 not_found_handling = "single-page-application" 10 10 html_handling = "auto-trailing-slash" 11 - run_worker_first = ["/api/*", "/oauth/*"] 11 + run_worker_first = ["/api/*", "/oauth/*", "/subscribe", "/subscribe/*"] 12 12 13 13 [[kv_namespaces]] 14 14 binding = "SEQUOIA_SESSIONS"

History

4 rounds 14 comments
sign up or login to add to the discussion
5 commits
expand
feat: Add subscription component
Add subscription support
Add cors support
Subscribe UI improvements
Add subscribe section to documentation and update navigation links
expand 2 comments

Excellent; thank you!! I'll make some follow up changes with the fetch-patch for the worker. Really appreciate your help here!

oop nvm, see its there now; we're good!

pull request successfully merged
4 commits
expand
feat: Add subscription component
Add subscription support
Add cors support
Subscribe UI improvements
expand 1 comment

I wasn't able to test e2e. I can get to the /subscribe page and authenticate, but with the static and API sites running on different ports I don't get a proper callback.

2 commits
expand
feat: Add subscription component
Add subscription support
expand 11 comments

@stevedylan.dev this is closed, but I'm having trouble verifying it e2e. I swapped out the CLIENT_URL variable for my local instance, but seems I need both the static site and wrangler site (on separate ports) running simultaneously and I'm not sure the flow is working right between the two as it might for Cloudflare. Is this something you can test easily, or maybe have some pointers? Does Cloudflare somehow proxy the calls to seem like a single site?

All that said, the flow seems to almost work right. I had to disable CORS for localhost on my machine but that should just be because it's localhost. I explicitly set the callback-uri in my SSG to test this all out. The rendered pages are somewhat themed. Not sure how vocs is setting the class on the HTML root element, but I have a script doing it here.

The auto-hide functionality also works if the publication-uri isn't set or isn't discoverable from /.well-known.

I suppose I should add a help topic before calling this "done", too. Something short and sweet like the Comments topic.

I suppose I should add a help topic before calling this "done", too. Something short and sweet like the Comments topic.

@heaths.dev Awesome!! Yeah I can definitely test this later. I think if you run bun dev:api inside the docs folder it should spin up the actual API and render the site as the same time, so that should make it easier to test!

We also probably need to update the CORS on the index.ts file too, something like seen here: https://hono.dev/docs/middleware/builtin/cors

Whew, ok had to do some deep testing and deployment of code that still got me pretty stuck. Here's where we're at:

  • We need to add these cors settings to the index.ts file
import { cors } from "hono/cors";
// Other imports and initial hono app
app.use(
	"/subscribe/*",
	cors({
		origin: (origin) => origin,
		credentials: true,
	}),
);
app.use(
	"/subscribe",
	cors({
		origin: (origin) => origin,
		credentials: true,
	}),
);
  • For some reason the OAuth client really doesn't play well with cloudflare workers. Here's an example error:
GET https://sequoia.pub/oauth/login?handle=stevedylan.dev - Ok @ 2/24/2026, 8:11:56 PM
  (error) Identity resolution failed: DidError did-unknown-error (did:plc:ia2zdnhjaokf5lazhxrmj6eu): Invalid redirect value, must be one of "follow" or "manual" ("error" won't be implemented since it does not make sense at the edge; use "manual" and check the response status code).
  (error) Login error: Error: Failed to resolve identity: stevedylan.dev

I know with the CLI we use the @atproto/oauth-client-node so maybe that's the answer. Will see if I can take another crack at it later.

Figured it out!! What a pain in the ass lol. Ok here's what we need to do:

  • Create a new file docs/src/lib/path-redirect.ts with these contents and then import it to docs/src/index.ts like so import "./lib/patch-redirect";. This will solve the weird cloudflare worker issues with the atproto oauth client library and how it handles redirects.

  • Update all instances of scope: "atproto transition:generic" to scope: "atproto site.standard.graph.subscription"

  • The cors updates seen in the previous comment

I made these changes locally and deployed it so you can try it out from localhost and it should work! We just need to get the actual code committed and merged.

Also on the final screen where it shows the user that we created the records, could be nice to link to a pds.ls URL like so

https://pds.ls/at://did:plc:ia2zdnhjaokf5lazhxrmj6eu/site.standard.graph.subscription/3mfnrenwnps2h

Hold off on changing the scopes; started having weird issues with it so reverted to what you already have

Ok tested some more with scopes and I'm not sure what I was doing wrong before, perhaps an old session, but I confirmed it's working as expected. Just need to update the following lines:

docs/src/routes/auth.ts:30,47 docs/src/lib/oauth-client.ts:22

scope: "atproto repo:site.standard.graph.subscription"

Hey, sorry for not responding earlier and thanks so much for testing this with Cloudflare! I was building the docs site okay. The problem I was running into was that the static site was served from a different port than the API, and crossing between the two was having additional CORS issues than just the ones I expected tested against localhost that I temporarily worked around.

Since you already have the changes, do you just want to push them to this PR since you can actually test them? Given our previous thread, I don't mind that at all - it's collaboration! :) That was different than the other PR.

That said, not sure if you're online right now so I'll take a look through your changes and incorporate what I can. I'll even try to test but, like I said, without testing in an actual test environment on Cloudflare (or something like it), not sure how realistic testing would be.

1 commit
expand
feat: Add subscription component
expand 0 comments