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

Add subscription support

authored by

Heath Stewart and committed by tangled.org 4cbb91ee 597d4cb3

+467 -37
+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"
+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