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

Configure Feed

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

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