Add subscription support 4cbb91ee
Heath Stewart · 2026-02-24 00:27 8 file(s) · +467 −37
bun.lock +21 −6
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
}
docs/package.json +1 −0
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",
docs/src/index.ts +2 −0
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" });
docs/src/lib/session.ts +48 −20
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
}
docs/src/routes/auth.ts +8 −1
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`);
docs/src/routes/subscribe.ts (added) +314 −0
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;
docs/wrangler.toml +1 −1
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"
packages/cli/src/components/sequoia-subscribe.js +72 −9
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