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
+880 -38
Diff #1
+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"
+21 -10
packages/cli/src/commands/add.ts
··· 14 14 15 15 const DEFAULT_COMPONENTS_PATH = "src/components"; 16 16 17 - const AVAILABLE_COMPONENTS = ["sequoia-comments"]; 17 + const AVAILABLE_COMPONENTS: { name: string; notes?: string }[] = [ 18 + { 19 + name: "sequoia-comments", 20 + notes: 21 + `The component will automatically read the document URI from:\n` + 22 + `<link rel="site.standard.document" href="at://...">`, 23 + }, 24 + { 25 + name: "sequoia-subscribe", 26 + }, 27 + ]; 18 28 19 29 export const addCommand = command({ 20 30 name: "add", ··· 30 40 intro("Add Sequoia Component"); 31 41 32 42 // Validate component name 33 - if (!AVAILABLE_COMPONENTS.includes(componentName)) { 43 + const component = AVAILABLE_COMPONENTS.find((c) => c.name === componentName); 44 + if (!component) { 34 45 log.error(`Component '${componentName}' not found`); 35 46 log.info("Available components:"); 36 47 for (const comp of AVAILABLE_COMPONENTS) { 37 - log.info(` - ${comp}`); 48 + log.info(` - ${comp.name}`); 38 49 } 39 50 process.exit(1); 40 51 } ··· 143 154 } 144 155 145 156 // Show usage instructions 146 - note( 157 + let notes = 147 158 `Add to your HTML:\n\n` + 148 - `<script type="module" src="${componentsDir}/${componentName}.js"></script>\n` + 149 - `<${componentName}></${componentName}>\n\n` + 150 - `The component will automatically read the document URI from:\n` + 151 - `<link rel="site.standard.document" href="at://...">`, 152 - "Usage", 153 - ); 159 + `<script type="module" src="${componentsDir}/${componentName}.js"></script>\n` + 160 + `<${componentName}></${componentName}>\n`; 161 + if (component.notes) { 162 + notes += `\n${component.notes}`; 163 + } 164 + note(notes, "Usage"); 154 165 155 166 outro(`${componentName} added successfully!`); 156 167 },
+464
packages/cli/src/components/sequoia-subscribe.js
··· 1 + /** 2 + * Sequoia Subscribe - A Bluesky-powered subscribe component 3 + * 4 + * A self-contained Web Component that lets users subscribe to a publication 5 + * via the AT Protocol by creating a site.standard.graph.subscription record. 6 + * 7 + * Usage: 8 + * <sequoia-subscribe></sequoia-subscribe> 9 + * 10 + * The component resolves the publication AT URI from the host site's 11 + * /.well-known/site.standard.publication endpoint. 12 + * 13 + * Attributes: 14 + * - publication-uri: Override the publication AT URI (optional) 15 + * - callback-uri: Redirect URI after OAuth authentication (default: "https://sequoia.pub/subscribe") 16 + * - label: Button label text (default: "Subscribe on Bluesky") 17 + * - hide: Set to "auto" to hide if no publication URI is detected 18 + * 19 + * CSS Custom Properties: 20 + * - --sequoia-fg-color: Text color (default: #1f2937) 21 + * - --sequoia-bg-color: Background color (default: #ffffff) 22 + * - --sequoia-border-color: Border color (default: #e5e7eb) 23 + * - --sequoia-accent-color: Accent/button color (default: #2563eb) 24 + * - --sequoia-secondary-color: Secondary text color (default: #6b7280) 25 + * - --sequoia-border-radius: Border radius (default: 8px) 26 + * 27 + * Events: 28 + * - sequoia-subscribed: Fired when the subscription is created successfully. 29 + * detail: { publicationUri: string, recordUri: string } 30 + * - sequoia-subscribe-error: Fired when the subscription fails. 31 + * detail: { message: string } 32 + */ 33 + 34 + // ============================================================================ 35 + // Styles 36 + // ============================================================================ 37 + 38 + const styles = ` 39 + :host { 40 + display: inline-block; 41 + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 42 + color: var(--sequoia-fg-color, #1f2937); 43 + line-height: 1.5; 44 + } 45 + 46 + * { 47 + box-sizing: border-box; 48 + } 49 + 50 + .sequoia-subscribe-button { 51 + display: inline-flex; 52 + align-items: center; 53 + gap: 0.375rem; 54 + padding: 0.5rem 1rem; 55 + background: var(--sequoia-accent-color, #2563eb); 56 + color: #ffffff; 57 + border: none; 58 + border-radius: var(--sequoia-border-radius, 8px); 59 + font-size: 0.875rem; 60 + font-weight: 500; 61 + cursor: pointer; 62 + text-decoration: none; 63 + transition: background-color 0.15s ease; 64 + font-family: inherit; 65 + } 66 + 67 + .sequoia-subscribe-button:hover:not(:disabled) { 68 + background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black); 69 + } 70 + 71 + .sequoia-subscribe-button:disabled { 72 + opacity: 0.6; 73 + cursor: not-allowed; 74 + } 75 + 76 + .sequoia-subscribe-button svg { 77 + width: 1rem; 78 + height: 1rem; 79 + flex-shrink: 0; 80 + } 81 + 82 + .sequoia-subscribe-button--success { 83 + background: #16a34a; 84 + } 85 + 86 + .sequoia-subscribe-button--success:hover:not(:disabled) { 87 + background: color-mix(in srgb, #16a34a 85%, black); 88 + } 89 + 90 + .sequoia-loading-spinner { 91 + display: inline-block; 92 + width: 1rem; 93 + height: 1rem; 94 + border: 2px solid rgba(255, 255, 255, 0.4); 95 + border-top-color: #ffffff; 96 + border-radius: 50%; 97 + animation: sequoia-spin 0.8s linear infinite; 98 + flex-shrink: 0; 99 + } 100 + 101 + @keyframes sequoia-spin { 102 + to { transform: rotate(360deg); } 103 + } 104 + 105 + .sequoia-error-message { 106 + display: inline-block; 107 + font-size: 0.8125rem; 108 + color: #dc2626; 109 + margin-top: 0.375rem; 110 + } 111 + `; 112 + 113 + // ============================================================================ 114 + // Icons 115 + // ============================================================================ 116 + 117 + const BLUESKY_ICON = `<svg class="sequoia-bsky-logo" viewBox="0 0 600 530" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> 118 + <path d="m135.72 44.03c66.496 49.921 138.02 151.14 164.28 205.46 26.262-54.316 97.782-155.54 164.28-205.46 47.98-36.021 125.72-63.892 125.72 24.795 0 17.712-10.155 148.79-16.111 170.07-20.703 73.984-96.144 92.854-163.25 81.433 117.3 19.964 147.14 86.092 82.697 152.22-122.39 125.59-175.91-31.511-189.63-71.766-2.514-7.3797-3.6904-10.832-3.7077-7.8964-0.0174-2.9357-1.1937 0.51669-3.7077 7.8964-13.714 40.255-67.233 197.36-189.63 71.766-64.444-66.128-34.605-132.26 82.697-152.22-67.108 11.421-142.55-7.4491-163.25-81.433-5.9562-21.282-16.111-152.36-16.111-170.07 0-88.687 77.742-60.816 125.72-24.795z"/> 119 + </svg>`; 120 + 121 + const CHECK_ICON = `<svg viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> 122 + <path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/> 123 + </svg>`; 124 + 125 + // ============================================================================ 126 + // AT Protocol Functions 127 + // ============================================================================ 128 + 129 + /** 130 + * Resolve a DID to its PDS URL. 131 + * Supports did:plc and did:web methods. 132 + * @param {string} did - Decentralized Identifier 133 + * @returns {Promise<string>} PDS URL 134 + */ 135 + async function resolvePDS(did) { 136 + let pdsUrl; 137 + 138 + if (did.startsWith("did:plc:")) { 139 + const didDocUrl = `https://plc.directory/${did}`; 140 + const didDocResponse = await fetch(didDocUrl); 141 + if (!didDocResponse.ok) { 142 + throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); 143 + } 144 + const didDoc = await didDocResponse.json(); 145 + 146 + const pdsService = didDoc.service?.find( 147 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 148 + ); 149 + pdsUrl = pdsService?.serviceEndpoint; 150 + } else if (did.startsWith("did:web:")) { 151 + const domain = did.replace("did:web:", ""); 152 + const didDocUrl = `https://${domain}/.well-known/did.json`; 153 + const didDocResponse = await fetch(didDocUrl); 154 + if (!didDocResponse.ok) { 155 + throw new Error(`Could not fetch DID document: ${didDocResponse.status}`); 156 + } 157 + const didDoc = await didDocResponse.json(); 158 + 159 + const pdsService = didDoc.service?.find( 160 + (s) => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer", 161 + ); 162 + pdsUrl = pdsService?.serviceEndpoint; 163 + } else { 164 + throw new Error(`Unsupported DID method: ${did}`); 165 + } 166 + 167 + if (!pdsUrl) { 168 + throw new Error("Could not find PDS URL for user"); 169 + } 170 + 171 + return pdsUrl; 172 + } 173 + 174 + /** 175 + * Create a site.standard.graph.subscription record in the subscriber's PDS. 176 + * @param {string} did - DID of the subscriber 177 + * @param {string} accessToken - AT Protocol access token 178 + * @param {string} publicationUri - AT URI of the publication to subscribe to 179 + * @returns {Promise<{uri: string, cid: string}>} The created record's URI and CID 180 + */ 181 + async function createRecord(did, accessToken, publicationUri) { 182 + const pdsUrl = await resolvePDS(did); 183 + 184 + const collection = "site.standard.graph.subscription"; 185 + const url = `${pdsUrl}/xrpc/com.atproto.repo.createRecord`; 186 + const response = await fetch(url, { 187 + method: "POST", 188 + headers: { 189 + "Content-Type": "application/json", 190 + Authorization: `Bearer ${accessToken}`, 191 + }, 192 + body: JSON.stringify({ 193 + repo: did, 194 + collection, 195 + record: { 196 + $type: "site.standard.graph.subscription", 197 + publication: publicationUri, 198 + }, 199 + }), 200 + }); 201 + 202 + if (!response.ok) { 203 + const body = await response.json().catch(() => ({})); 204 + const message = body?.message ?? body?.error ?? `HTTP ${response.status}`; 205 + throw new Error(`Failed to create record: ${message}`); 206 + } 207 + 208 + const data = await response.json(); 209 + return { uri: data.uri, cid: data.cid }; 210 + } 211 + 212 + /** 213 + * Fetch the publication AT URI from the host site's well-known endpoint. 214 + * @param {string} [origin] - Origin to fetch from (defaults to current page origin) 215 + * @returns {Promise<string>} Publication AT URI 216 + */ 217 + async function fetchPublicationUri(origin) { 218 + const base = origin ?? window.location.origin; 219 + const url = `${base}/.well-known/site.standard.publication`; 220 + const response = await fetch(url); 221 + if (!response.ok) { 222 + throw new Error( 223 + `Could not fetch publication URI: ${response.status}`, 224 + ); 225 + } 226 + 227 + // Accept either plain text (the AT URI itself) or JSON with a `uri` field. 228 + const contentType = response.headers.get("content-type") ?? ""; 229 + if (contentType.includes("application/json")) { 230 + const data = await response.json(); 231 + const uri = data?.uri ?? data?.atUri ?? data?.publication; 232 + if (!uri) { 233 + throw new Error("Publication response did not contain a URI"); 234 + } 235 + return uri; 236 + } 237 + 238 + const text = (await response.text()).trim(); 239 + if (!text.startsWith("at://")) { 240 + throw new Error(`Unexpected publication URI format: ${text}`); 241 + } 242 + return text; 243 + } 244 + 245 + // ============================================================================ 246 + // Web Component 247 + // ============================================================================ 248 + 249 + // SSR-safe base class - use HTMLElement in browser, empty class in Node.js 250 + const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {}; 251 + 252 + class SequoiaSubscribe extends BaseElement { 253 + constructor() { 254 + super(); 255 + const shadow = this.attachShadow({ mode: "open" }); 256 + 257 + const styleTag = document.createElement("style"); 258 + styleTag.innerText = styles; 259 + shadow.appendChild(styleTag); 260 + 261 + const wrapper = document.createElement("div"); 262 + shadow.appendChild(wrapper); 263 + wrapper.part = "container"; 264 + 265 + this.wrapper = wrapper; 266 + this.state = { type: "idle" }; 267 + this.abortController = null; 268 + this.render(); 269 + } 270 + 271 + static get observedAttributes() { 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(); 284 + } 285 + 286 + attributeChangedCallback() { 287 + // Reset to idle if attributes change after an error or success 288 + if ( 289 + this.state.type === "error" || 290 + this.state.type === "subscribed" || 291 + this.state.type === "no-publication" 292 + ) { 293 + this.state = { type: "idle" }; 294 + } 295 + this.render(); 296 + } 297 + 298 + get publicationUri() { 299 + return this.getAttribute("publication-uri") ?? null; 300 + } 301 + 302 + get callbackUri() { 303 + return this.getAttribute("callback-uri") ?? "https://sequoia.pub/subscribe"; 304 + } 305 + 306 + get label() { 307 + return this.getAttribute("label") ?? "Subscribe on Bluesky"; 308 + } 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 + 327 + async handleClick() { 328 + if (this.state.type === "loading" || this.state.type === "subscribed") { 329 + return; 330 + } 331 + 332 + this.state = { type: "loading" }; 333 + this.render(); 334 + 335 + try { 336 + const publicationUri = 337 + this.publicationUri ?? (await fetchPublicationUri()); 338 + 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 + } 360 + 361 + const { recordUri } = data; 362 + this.state = { type: "subscribed", recordUri, publicationUri }; 363 + this.render(); 364 + 365 + this.dispatchEvent( 366 + new CustomEvent("sequoia-subscribed", { 367 + bubbles: true, 368 + composed: true, 369 + detail: { publicationUri, recordUri }, 370 + }), 371 + ); 372 + } catch (error) { 373 + // Don't overwrite state if we already navigated away 374 + if (this.state.type !== "loading") return; 375 + 376 + const message = 377 + error instanceof Error ? error.message : "Failed to subscribe"; 378 + this.state = { type: "error", message }; 379 + this.render(); 380 + 381 + this.dispatchEvent( 382 + new CustomEvent("sequoia-subscribe-error", { 383 + bubbles: true, 384 + composed: true, 385 + detail: { message }, 386 + }), 387 + ); 388 + } 389 + } 390 + 391 + render() { 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 + 402 + const isLoading = type === "loading"; 403 + const isSubscribed = type === "subscribed"; 404 + 405 + const icon = isLoading 406 + ? `<span class="sequoia-loading-spinner"></span>` 407 + : isSubscribed 408 + ? CHECK_ICON 409 + : BLUESKY_ICON; 410 + 411 + const label = isSubscribed ? "Subscribed" : this.label; 412 + const buttonClass = [ 413 + "sequoia-subscribe-button", 414 + isSubscribed ? "sequoia-subscribe-button--success" : "", 415 + ] 416 + .filter(Boolean) 417 + .join(" "); 418 + 419 + const errorHtml = 420 + type === "error" 421 + ? `<span class="sequoia-error-message">${escapeHtml(this.state.message)}</span>` 422 + : ""; 423 + 424 + this.wrapper.innerHTML = ` 425 + <button 426 + class="${buttonClass}" 427 + type="button" 428 + part="button" 429 + ${isLoading || isSubscribed ? "disabled" : ""} 430 + aria-label="${isSubscribed ? "Subscribed" : this.label}" 431 + > 432 + ${icon} 433 + ${label} 434 + </button> 435 + ${errorHtml} 436 + `; 437 + 438 + if (type !== "subscribed") { 439 + const btn = this.wrapper.querySelector("button"); 440 + btn?.addEventListener("click", () => this.handleClick()); 441 + } 442 + } 443 + } 444 + 445 + /** 446 + * Escape HTML special characters (no DOM dependency for SSR). 447 + * @param {string} text 448 + * @returns {string} 449 + */ 450 + function escapeHtml(text) { 451 + return text 452 + .replace(/&/g, "&amp;") 453 + .replace(/</g, "&lt;") 454 + .replace(/>/g, "&gt;") 455 + .replace(/"/g, "&quot;"); 456 + } 457 + 458 + // Register the custom element 459 + if (typeof customElements !== "undefined") { 460 + customElements.define("sequoia-subscribe", SequoiaSubscribe); 461 + } 462 + 463 + // Export for module usage 464 + export { SequoiaSubscribe };

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