diff --git a/bun.lock b/bun.lock --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "version": "0.0.0", "dependencies": { "@atproto-labs/handle-resolver": "latest", + "@atproto/api": "latest", "@atproto/jwk-jose": "latest", "@atproto/oauth-client": "latest", "hono": "latest", @@ -78,9 +79,9 @@ "@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=="], - "@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=="], + "@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=="], - "@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=="], + "@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=="], "@atproto/did": ["@atproto/did@0.3.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA=="], @@ -90,9 +91,9 @@ "@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=="], - "@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=="], + "@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=="], - "@atproto/lex-json": ["@atproto/lex-json@0.0.9", "", { "dependencies": { "@atproto/lex-data": "0.0.9", "tslib": "^2.8.1" } }, "sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw=="], + "@atproto/lex-json": ["@atproto/lex-json@0.0.12", "", { "dependencies": { "@atproto/lex-data": "^0.0.12", "tslib": "^2.8.1" } }, "sha512-XlEpnWWZdDJ5BIgG25GyH+6iBfyrFL18BI5JSE6rUfMObbFMrQRaCuRLQfryRXNysVz3L3U+Qb9y8KcXbE8AcA=="], "@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=="], @@ -182,7 +183,7 @@ "@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=="], - "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260228.0", "", {}, "sha512-9LfRg93ncQq6Oc4MFpqGSs+PmPhqWvg8TspXwbiYNR201IhXB4WqHR/aTSudPI0ujsf/NLc8E9fF3C+aA2g8KQ=="], + "@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260303.0", "", {}, "sha512-soUlr4NJVkh5dR09RwtziTMbBQ+lbdoEesTGw8WUlvmnQ2M4h7CmJzAjC6a7IivUodiiCSjbLcGV/8PyZpvZkA=="], "@emotion/hash": ["@emotion/hash@0.9.2", "", {}, "sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g=="], @@ -962,7 +963,7 @@ "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=="], - "hono": ["hono@4.12.1", "", {}, "sha512-hi9afu8g0lfJVLolxElAZGANCTTl6bewIdsRNhaywfP9K8BPf++F2z6OLrYGIinUwpRKzbZHMhPwvc0ZEpAwGw=="], + "hono": ["hono@4.12.2", "", {}, "sha512-gJnaDHXKDayjt8ue0n8Gs0A007yKXj4Xzb8+cNjZeYsSzzwKc0Lr+OZgYwVfB0pHfUs17EPoLvrOsEaJ9mj+Tg=="], "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], @@ -1540,6 +1541,8 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@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=="], + "@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=="], "@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,9 +1619,15 @@ "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], + "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=="], + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "vocs/hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="], + + "@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=="], + + "@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=="], "@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=="], @@ -1643,5 +1652,11 @@ "p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], + + "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=="], + + "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=="], + + "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=="], } } diff --git a/docs/package.json b/docs/package.json --- a/docs/package.json +++ b/docs/package.json @@ -12,6 +12,7 @@ "preview": "vocs preview" }, "dependencies": { + "@atproto/api": "latest", "@atproto/oauth-client": "latest", "@atproto/jwk-jose": "latest", "@atproto-labs/handle-resolver": "latest", diff --git a/docs/wrangler.toml b/docs/wrangler.toml --- a/docs/wrangler.toml +++ b/docs/wrangler.toml @@ -8,7 +8,7 @@ binding = "ASSETS" not_found_handling = "single-page-application" html_handling = "auto-trailing-slash" -run_worker_first = ["/api/*", "/oauth/*"] +run_worker_first = ["/api/*", "/oauth/*", "/subscribe", "/subscribe/*"] [[kv_namespaces]] binding = "SEQUOIA_SESSIONS" diff --git a/docs/src/index.ts b/docs/src/index.ts --- a/docs/src/index.ts +++ b/docs/src/index.ts @@ -1,5 +1,6 @@ import { Hono } from "hono"; import auth from "./routes/auth"; +import subscribe from "./routes/subscribe"; type Bindings = { ASSETS: Fetcher; @@ -10,6 +11,7 @@ const app = new Hono<{ Bindings: Bindings }>(); app.route("/oauth", auth); +app.route("/subscribe", subscribe); app.get("/api/health", (c) => { return c.json({ status: "ok" }); diff --git a/docs/src/lib/session.ts b/docs/src/lib/session.ts --- a/docs/src/lib/session.ts +++ b/docs/src/lib/session.ts @@ -1,17 +1,27 @@ import type { Context } from "hono"; +import { deleteCookie, getCookie, setCookie } from "hono/cookie"; const SESSION_COOKIE_NAME = "session_id"; +const RETURN_TO_COOKIE_NAME = "login_return_to"; const SESSION_TTL = 60 * 60 * 24 * 14; // 14 days in seconds +const RETURN_TO_TTL = 600; // 10 minutes in seconds + +function baseCookieOptions(clientUrl: string) { + const isLocalhost = clientUrl.includes("localhost"); + return { + httpOnly: true as const, + sameSite: "Lax" as const, + path: "/", + ...(isLocalhost ? {} : { domain: ".sequoia.pub", secure: true }), + }; +} /** * Get DID from session cookie */ export function getSessionDid(c: Context): string | null { - const cookie = c.req.header("Cookie"); - if (!cookie) return null; - - const match = cookie.match(new RegExp(`${SESSION_COOKIE_NAME}=([^;]+)`)); - return match ? decodeURIComponent(match[1]) : null; + const value = getCookie(c, SESSION_COOKIE_NAME); + return value ? decodeURIComponent(value) : null; } /** @@ -22,26 +32,44 @@ did: string, clientUrl: string, ): void { - const isLocalhost = clientUrl.includes("localhost"); - const domain = isLocalhost ? "" : "; Domain=.sequoia.pub"; - const secure = isLocalhost ? "" : "; Secure"; - - c.header( - "Set-Cookie", - `${SESSION_COOKIE_NAME}=${encodeURIComponent(did)}; HttpOnly; SameSite=Lax; Path=/${domain}${secure}; Max-Age=${SESSION_TTL}`, - ); + setCookie(c, SESSION_COOKIE_NAME, encodeURIComponent(did), { + ...baseCookieOptions(clientUrl), + maxAge: SESSION_TTL, + }); } /** * Clear session cookie */ export function clearSessionCookie(c: Context, clientUrl: string): void { - const isLocalhost = clientUrl.includes("localhost"); - const domain = isLocalhost ? "" : "; Domain=.sequoia.pub"; - const secure = isLocalhost ? "" : "; Secure"; + deleteCookie(c, SESSION_COOKIE_NAME, baseCookieOptions(clientUrl)); +} - c.header( - "Set-Cookie", - `${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/${domain}${secure}; Max-Age=0`, - ); +/** + * Get the post-OAuth return-to URL from the short-lived cookie + */ +export function getReturnToCookie(c: Context): string | null { + const value = getCookie(c, RETURN_TO_COOKIE_NAME); + return value ? decodeURIComponent(value) : null; +} + +/** + * Set a short-lived cookie that redirects back after OAuth completes + */ +export function setReturnToCookie( + c: Context, + returnTo: string, + clientUrl: string, +): void { + setCookie(c, RETURN_TO_COOKIE_NAME, encodeURIComponent(returnTo), { + ...baseCookieOptions(clientUrl), + maxAge: RETURN_TO_TTL, + }); +} + +/** + * Clear the return-to cookie + */ +export function clearReturnToCookie(c: Context, clientUrl: string): void { + deleteCookie(c, RETURN_TO_COOKIE_NAME, baseCookieOptions(clientUrl)); } diff --git a/docs/src/routes/auth.ts b/docs/src/routes/auth.ts --- a/docs/src/routes/auth.ts +++ b/docs/src/routes/auth.ts @@ -4,6 +4,8 @@ getSessionDid, setSessionCookie, clearSessionCookie, + getReturnToCookie, + clearReturnToCookie, } from "../lib/session"; interface Env { @@ -85,7 +87,12 @@ } setSessionCookie(c, session.did, c.env.CLIENT_URL); - return c.redirect(`${c.env.CLIENT_URL}/`); + + // If a subscribe flow set a return URL before initiating OAuth, honor it + const returnTo = getReturnToCookie(c); + clearReturnToCookie(c, c.env.CLIENT_URL); + + return c.redirect(returnTo ?? `${c.env.CLIENT_URL}/`); } catch (error) { console.error("Callback error:", error); return c.redirect(`${c.env.CLIENT_URL}/?error=callback_failed`); diff --git a/docs/src/routes/subscribe.ts b/docs/src/routes/subscribe.ts new file mode 100644 --- /dev/null +++ b/docs/src/routes/subscribe.ts @@ -0,0 +1,314 @@ +import { Agent } from "@atproto/api"; +import { Hono } from "hono"; +import { createOAuthClient } from "../lib/oauth-client"; +import { getSessionDid, setReturnToCookie } from "../lib/session"; + +interface Env { + ASSETS: Fetcher; + SEQUOIA_SESSIONS: KVNamespace; + CLIENT_URL: string; +} + +// Cache the vocs-generated stylesheet href across requests (changes on rebuild). +let _vocsStyleHref: string | null = null; + +async function getVocsStyleHref(assets: Fetcher, baseUrl: string): Promise { + if (_vocsStyleHref) return _vocsStyleHref; + try { + const indexUrl = new URL("/", baseUrl).toString(); + const res = await assets.fetch(indexUrl); + const html = await res.text(); + const match = html.match(/]+href="(\/assets\/style[^"]+\.css)"/); + if (match?.[1]) { + _vocsStyleHref = match[1]; + return match[1]; + } + } catch { + // Fall back to the custom stylesheet which at least provides --sequoia-* vars + } + return "/styles.css"; +} + +const subscribe = new Hono<{ Bindings: Env }>(); + +const COLLECTION = "site.standard.graph.subscription"; + +// ============================================================================ +// Helpers +// ============================================================================ + +/** + * Scan the user's repo for an existing site.standard.graph.subscription + * matching the given publication URI. Returns the record AT-URI if found. + */ +async function findExistingSubscription( + agent: Agent, + did: string, + publicationUri: string, +): Promise { + let cursor: string | undefined; + + do { + const result = await agent.com.atproto.repo.listRecords({ + repo: did, + collection: COLLECTION, + limit: 100, + cursor, + }); + + for (const record of result.data.records) { + const value = record.value as { publication?: string }; + if (value.publication === publicationUri) { + return record.uri; + } + } + + cursor = result.data.cursor; + } while (cursor); + + return null; +} + +// ============================================================================ +// POST /subscribe +// +// Called via fetch() from the sequoia-subscribe web component. +// Body JSON: { publicationUri: string } +// +// Responses: +// 200 { subscribed: true, existing: boolean, recordUri: string } +// 400 { error: string } +// 401 { authenticated: false, subscribeUrl: string } +// ============================================================================ + +subscribe.post("/", async (c) => { + let publicationUri: string; + try { + const body = await c.req.json<{ publicationUri?: string }>(); + publicationUri = body.publicationUri ?? ""; + } catch { + return c.json({ error: "Invalid JSON body" }, 400); + } + + if (!publicationUri || !publicationUri.startsWith("at://")) { + return c.json({ error: "Missing or invalid publicationUri" }, 400); + } + + const did = getSessionDid(c); + if (!did) { + const subscribeUrl = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`; + return c.json({ authenticated: false, subscribeUrl }, 401); + } + + try { + const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); + const session = await client.restore(did); + const agent = new Agent(session); + + const existingUri = await findExistingSubscription(agent, did, publicationUri); + if (existingUri) { + return c.json({ subscribed: true, existing: true, recordUri: existingUri }); + } + + const result = await agent.com.atproto.repo.createRecord({ + repo: did, + collection: COLLECTION, + record: { + $type: COLLECTION, + publication: publicationUri, + }, + }); + + return c.json({ subscribed: true, existing: false, recordUri: result.data.uri }); + } catch (error) { + console.error("Subscribe POST error:", error); + // Treat expired/missing session as unauthenticated + const subscribeUrl = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`; + return c.json({ authenticated: false, subscribeUrl }, 401); + } +}); + +// ============================================================================ +// GET /subscribe?publicationUri=at://... +// +// Full-page OAuth + subscription flow. Unauthenticated users land here after +// the component redirects them, and authenticated users land here after the +// OAuth callback (via the login_return_to cookie set in POST /subscribe/login). +// ============================================================================ + +subscribe.get("/", async (c) => { + const publicationUri = c.req.query("publicationUri"); + const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url); + + if (!publicationUri || !publicationUri.startsWith("at://")) { + return c.html(renderError("Missing or invalid publication URI.", styleHref), 400); + } + + const did = getSessionDid(c); + if (!did) { + return c.html(renderHandleForm(publicationUri, styleHref)); + } + + try { + const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); + const session = await client.restore(did); + const agent = new Agent(session); + + const existingUri = await findExistingSubscription(agent, did, publicationUri); + if (existingUri) { + return c.html(renderSuccess(publicationUri, existingUri, true, styleHref)); + } + + const result = await agent.com.atproto.repo.createRecord({ + repo: did, + collection: COLLECTION, + record: { + $type: COLLECTION, + publication: publicationUri, + }, + }); + + return c.html(renderSuccess(publicationUri, result.data.uri, false, styleHref)); + } catch (error) { + console.error("Subscribe GET error:", error); + // Session expired - ask the user to sign in again + return c.html(renderHandleForm(publicationUri, styleHref, "Session expired. Please sign in again.")); + } +}); + +// ============================================================================ +// POST /subscribe/login +// +// Handles the handle-entry form submission. Stores the return URL in a cookie +// so the OAuth callback in auth.ts can redirect back to /subscribe after auth. +// ============================================================================ + +subscribe.post("/login", async (c) => { + const body = await c.req.parseBody(); + const handle = (body["handle"] as string | undefined)?.trim(); + const publicationUri = body["publicationUri"] as string | undefined; + + if (!handle || !publicationUri) { + const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url); + return c.html(renderError("Missing handle or publication URI.", styleHref), 400); + } + + const returnTo = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`; + setReturnToCookie(c, returnTo, c.env.CLIENT_URL); + + return c.redirect( + `${c.env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`, + ); +}); + +// ============================================================================ +// HTML rendering +// ============================================================================ + +function renderHandleForm(publicationUri: string, styleHref: string, error?: string): string { + const errorHtml = error + ? `

${escapeHtml(error)}

` + : ""; + + return page(` +

Subscribe on Bluesky

+

Enter your Bluesky handle to subscribe to this publication.

+ ${errorHtml} +
+ + + +
+ `, styleHref); +} + +function renderSuccess( + publicationUri: string, + recordUri: string, + existing: boolean, + styleHref: string, +): string { + const msg = existing + ? "You're already subscribed to this publication." + : "You've successfully subscribed!"; + return page(` +

Subscribed ✓

+

${msg}

+

Publication: ${escapeHtml(publicationUri)}

+

Record: ${escapeHtml(recordUri)}

+ `, styleHref); +} + +function renderError(message: string, styleHref: string): string { + return page(`

Error

${escapeHtml(message)}

`, styleHref); +} + +function page(body: string, styleHref: string): string { + return ` + + + + + Sequoia · Subscribe + + + + + +
+ ${body} +
+ +`; +} + +function escapeHtml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +export default subscribe; diff --git a/packages/cli/src/components/sequoia-subscribe.js b/packages/cli/src/components/sequoia-subscribe.js --- a/packages/cli/src/components/sequoia-subscribe.js +++ b/packages/cli/src/components/sequoia-subscribe.js @@ -12,7 +12,9 @@ * * Attributes: * - publication-uri: Override the publication AT URI (optional) + * - callback-uri: Redirect URI after OAuth authentication (default: "https://sequoia.pub/subscribe") * - label: Button label text (default: "Subscribe on Bluesky") + * - hide: Set to "auto" to hide if no publication URI is detected * * CSS Custom Properties: * - --sequoia-fg-color: Text color (default: #1f2937) @@ -262,18 +264,31 @@ this.wrapper = wrapper; this.state = { type: "idle" }; + this.abortController = null; this.render(); } static get observedAttributes() { - return ["publication-uri", "label"]; + return ["publication-uri", "callback-uri", "label", "hide"]; + } + + connectedCallback() { + // Pre-check publication availability so hide="auto" can take effect + if (!this.publicationUri) { + this.checkPublication(); + } + } + + disconnectedCallback() { + this.abortController?.abort(); } attributeChangedCallback() { // Reset to idle if attributes change after an error or success if ( this.state.type === "error" || - this.state.type === "subscribed" + this.state.type === "subscribed" || + this.state.type === "no-publication" ) { this.state = { type: "idle" }; } @@ -284,8 +299,29 @@ return this.getAttribute("publication-uri") ?? null; } + get callbackUri() { + return this.getAttribute("callback-uri") ?? "https://sequoia.pub/subscribe"; + } + get label() { return this.getAttribute("label") ?? "Subscribe on Bluesky"; + } + + get hide() { + const hideAttr = this.getAttribute("hide"); + return hideAttr === "auto"; + } + + async checkPublication() { + this.abortController?.abort(); + this.abortController = new AbortController(); + + try { + await fetchPublicationUri(); + } catch { + this.state = { type: "no-publication" }; + this.render(); + } } async handleClick() { @@ -297,17 +333,32 @@ this.render(); try { - // Resolve the publication AT URI const publicationUri = this.publicationUri ?? (await fetchPublicationUri()); - // TODO: resolve authenticated DID and access token before calling createRecord - const { uri: recordUri } = await createRecord( - /* did */ undefined, - /* accessToken */ undefined, - publicationUri, - ); + // POST to the callbackUri (e.g. https://sequoia.pub/subscribe). + // If the server reports the user isn't authenticated it returns a + // subscribeUrl for the full-page OAuth + subscription flow. + const response = await fetch(this.callbackUri, { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ publicationUri }), + }); + const data = await response.json(); + + if (response.status === 401 && data.authenticated === false) { + // Redirect to the hosted subscribe page to complete OAuth + window.location.href = data.subscribeUrl; + return; + } + + if (!response.ok) { + throw new Error(data.error ?? `HTTP ${response.status}`); + } + + const { recordUri } = data; this.state = { type: "subscribed", recordUri, publicationUri }; this.render(); @@ -319,6 +370,9 @@ }), ); } catch (error) { + // Don't overwrite state if we already navigated away + if (this.state.type !== "loading") return; + const message = error instanceof Error ? error.message : "Failed to subscribe"; this.state = { type: "error", message }; @@ -336,6 +390,15 @@ render() { const { type } = this.state; + + if (type === "no-publication") { + if (this.hide) { + this.wrapper.innerHTML = ""; + this.wrapper.style.display = "none"; + } + return; + } + const isLoading = type === "loading"; const isSubscribed = type === "subscribed";