tangled
alpha
login
or
join now
heaths.dev
/
sequoia
forked from
stevedylan.dev/sequoia
0
fork
atom
A CLI for publishing standard.site documents to ATProto
0
fork
atom
overview
issues
pulls
pipelines
Add subscription support
Heath Stewart
1 month ago
ead0f9eb
9bdabf99
+467
-37
8 changed files
expand all
collapse all
unified
split
bun.lock
docs
package.json
src
index.ts
lib
session.ts
routes
auth.ts
subscribe.ts
wrangler.toml
packages
cli
src
components
sequoia-subscribe.js
+21
-6
bun.lock
···
14
14
"version": "0.0.0",
15
15
"dependencies": {
16
16
"@atproto-labs/handle-resolver": "latest",
17
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
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
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
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
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
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
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
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
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
185
-
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20260228.0", "", {}, "sha512-9LfRg93ncQq6Oc4MFpqGSs+PmPhqWvg8TspXwbiYNR201IhXB4WqHR/aTSudPI0ujsf/NLc8E9fF3C+aA2g8KQ=="],
186
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
965
-
"hono": ["hono@4.12.1", "", {}, "sha512-hi9afu8g0lfJVLolxElAZGANCTTl6bewIdsRNhaywfP9K8BPf++F2z6OLrYGIinUwpRKzbZHMhPwvc0ZEpAwGw=="],
966
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
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
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
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
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
1627
+
1628
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
1629
+
1630
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
1655
+
1656
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
1657
+
1658
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
1659
+
1660
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
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
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
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
2
+
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
2
3
3
4
const SESSION_COOKIE_NAME = "session_id";
5
5
+
const RETURN_TO_COOKIE_NAME = "login_return_to";
4
6
const SESSION_TTL = 60 * 60 * 24 * 14; // 14 days in seconds
7
7
+
const RETURN_TO_TTL = 600; // 10 minutes in seconds
8
8
+
9
9
+
function baseCookieOptions(clientUrl: string) {
10
10
+
const isLocalhost = clientUrl.includes("localhost");
11
11
+
return {
12
12
+
httpOnly: true as const,
13
13
+
sameSite: "Lax" as const,
14
14
+
path: "/",
15
15
+
...(isLocalhost ? {} : { domain: ".sequoia.pub", secure: true }),
16
16
+
};
17
17
+
}
5
18
6
19
/**
7
20
* Get DID from session cookie
8
21
*/
9
22
export function getSessionDid(c: Context): string | null {
10
10
-
const cookie = c.req.header("Cookie");
11
11
-
if (!cookie) return null;
12
12
-
13
13
-
const match = cookie.match(new RegExp(`${SESSION_COOKIE_NAME}=([^;]+)`));
14
14
-
return match ? decodeURIComponent(match[1]) : null;
23
23
+
const value = getCookie(c, SESSION_COOKIE_NAME);
24
24
+
return value ? decodeURIComponent(value) : null;
15
25
}
16
26
17
27
/**
···
22
32
did: string,
23
33
clientUrl: string,
24
34
): void {
25
25
-
const isLocalhost = clientUrl.includes("localhost");
26
26
-
const domain = isLocalhost ? "" : "; Domain=.sequoia.pub";
27
27
-
const secure = isLocalhost ? "" : "; Secure";
28
28
-
29
29
-
c.header(
30
30
-
"Set-Cookie",
31
31
-
`${SESSION_COOKIE_NAME}=${encodeURIComponent(did)}; HttpOnly; SameSite=Lax; Path=/${domain}${secure}; Max-Age=${SESSION_TTL}`,
32
32
-
);
35
35
+
setCookie(c, SESSION_COOKIE_NAME, encodeURIComponent(did), {
36
36
+
...baseCookieOptions(clientUrl),
37
37
+
maxAge: SESSION_TTL,
38
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
39
-
const isLocalhost = clientUrl.includes("localhost");
40
40
-
const domain = isLocalhost ? "" : "; Domain=.sequoia.pub";
41
41
-
const secure = isLocalhost ? "" : "; Secure";
45
45
+
deleteCookie(c, SESSION_COOKIE_NAME, baseCookieOptions(clientUrl));
46
46
+
}
47
47
+
48
48
+
/**
49
49
+
* Get the post-OAuth return-to URL from the short-lived cookie
50
50
+
*/
51
51
+
export function getReturnToCookie(c: Context): string | null {
52
52
+
const value = getCookie(c, RETURN_TO_COOKIE_NAME);
53
53
+
return value ? decodeURIComponent(value) : null;
54
54
+
}
42
55
43
43
-
c.header(
44
44
-
"Set-Cookie",
45
45
-
`${SESSION_COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/${domain}${secure}; Max-Age=0`,
46
46
-
);
56
56
+
/**
57
57
+
* Set a short-lived cookie that redirects back after OAuth completes
58
58
+
*/
59
59
+
export function setReturnToCookie(
60
60
+
c: Context,
61
61
+
returnTo: string,
62
62
+
clientUrl: string,
63
63
+
): void {
64
64
+
setCookie(c, RETURN_TO_COOKIE_NAME, encodeURIComponent(returnTo), {
65
65
+
...baseCookieOptions(clientUrl),
66
66
+
maxAge: RETURN_TO_TTL,
67
67
+
});
68
68
+
}
69
69
+
70
70
+
/**
71
71
+
* Clear the return-to cookie
72
72
+
*/
73
73
+
export function clearReturnToCookie(c: Context, clientUrl: string): void {
74
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
7
+
getReturnToCookie,
8
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
88
-
return c.redirect(`${c.env.CLIENT_URL}/`);
90
90
+
91
91
+
// If a subscribe flow set a return URL before initiating OAuth, honor it
92
92
+
const returnTo = getReturnToCookie(c);
93
93
+
clearReturnToCookie(c, c.env.CLIENT_URL);
94
94
+
95
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
1
+
import { Agent } from "@atproto/api";
2
2
+
import { Hono } from "hono";
3
3
+
import { createOAuthClient } from "../lib/oauth-client";
4
4
+
import { getSessionDid, setReturnToCookie } from "../lib/session";
5
5
+
6
6
+
interface Env {
7
7
+
ASSETS: Fetcher;
8
8
+
SEQUOIA_SESSIONS: KVNamespace;
9
9
+
CLIENT_URL: string;
10
10
+
}
11
11
+
12
12
+
// Cache the vocs-generated stylesheet href across requests (changes on rebuild).
13
13
+
let _vocsStyleHref: string | null = null;
14
14
+
15
15
+
async function getVocsStyleHref(assets: Fetcher, baseUrl: string): Promise<string> {
16
16
+
if (_vocsStyleHref) return _vocsStyleHref;
17
17
+
try {
18
18
+
const indexUrl = new URL("/", baseUrl).toString();
19
19
+
const res = await assets.fetch(indexUrl);
20
20
+
const html = await res.text();
21
21
+
const match = html.match(/<link[^>]+href="(\/assets\/style[^"]+\.css)"/);
22
22
+
if (match?.[1]) {
23
23
+
_vocsStyleHref = match[1];
24
24
+
return match[1];
25
25
+
}
26
26
+
} catch {
27
27
+
// Fall back to the custom stylesheet which at least provides --sequoia-* vars
28
28
+
}
29
29
+
return "/styles.css";
30
30
+
}
31
31
+
32
32
+
const subscribe = new Hono<{ Bindings: Env }>();
33
33
+
34
34
+
const COLLECTION = "site.standard.graph.subscription";
35
35
+
36
36
+
// ============================================================================
37
37
+
// Helpers
38
38
+
// ============================================================================
39
39
+
40
40
+
/**
41
41
+
* Scan the user's repo for an existing site.standard.graph.subscription
42
42
+
* matching the given publication URI. Returns the record AT-URI if found.
43
43
+
*/
44
44
+
async function findExistingSubscription(
45
45
+
agent: Agent,
46
46
+
did: string,
47
47
+
publicationUri: string,
48
48
+
): Promise<string | null> {
49
49
+
let cursor: string | undefined;
50
50
+
51
51
+
do {
52
52
+
const result = await agent.com.atproto.repo.listRecords({
53
53
+
repo: did,
54
54
+
collection: COLLECTION,
55
55
+
limit: 100,
56
56
+
cursor,
57
57
+
});
58
58
+
59
59
+
for (const record of result.data.records) {
60
60
+
const value = record.value as { publication?: string };
61
61
+
if (value.publication === publicationUri) {
62
62
+
return record.uri;
63
63
+
}
64
64
+
}
65
65
+
66
66
+
cursor = result.data.cursor;
67
67
+
} while (cursor);
68
68
+
69
69
+
return null;
70
70
+
}
71
71
+
72
72
+
// ============================================================================
73
73
+
// POST /subscribe
74
74
+
//
75
75
+
// Called via fetch() from the sequoia-subscribe web component.
76
76
+
// Body JSON: { publicationUri: string }
77
77
+
//
78
78
+
// Responses:
79
79
+
// 200 { subscribed: true, existing: boolean, recordUri: string }
80
80
+
// 400 { error: string }
81
81
+
// 401 { authenticated: false, subscribeUrl: string }
82
82
+
// ============================================================================
83
83
+
84
84
+
subscribe.post("/", async (c) => {
85
85
+
let publicationUri: string;
86
86
+
try {
87
87
+
const body = await c.req.json<{ publicationUri?: string }>();
88
88
+
publicationUri = body.publicationUri ?? "";
89
89
+
} catch {
90
90
+
return c.json({ error: "Invalid JSON body" }, 400);
91
91
+
}
92
92
+
93
93
+
if (!publicationUri || !publicationUri.startsWith("at://")) {
94
94
+
return c.json({ error: "Missing or invalid publicationUri" }, 400);
95
95
+
}
96
96
+
97
97
+
const did = getSessionDid(c);
98
98
+
if (!did) {
99
99
+
const subscribeUrl = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`;
100
100
+
return c.json({ authenticated: false, subscribeUrl }, 401);
101
101
+
}
102
102
+
103
103
+
try {
104
104
+
const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL);
105
105
+
const session = await client.restore(did);
106
106
+
const agent = new Agent(session);
107
107
+
108
108
+
const existingUri = await findExistingSubscription(agent, did, publicationUri);
109
109
+
if (existingUri) {
110
110
+
return c.json({ subscribed: true, existing: true, recordUri: existingUri });
111
111
+
}
112
112
+
113
113
+
const result = await agent.com.atproto.repo.createRecord({
114
114
+
repo: did,
115
115
+
collection: COLLECTION,
116
116
+
record: {
117
117
+
$type: COLLECTION,
118
118
+
publication: publicationUri,
119
119
+
},
120
120
+
});
121
121
+
122
122
+
return c.json({ subscribed: true, existing: false, recordUri: result.data.uri });
123
123
+
} catch (error) {
124
124
+
console.error("Subscribe POST error:", error);
125
125
+
// Treat expired/missing session as unauthenticated
126
126
+
const subscribeUrl = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`;
127
127
+
return c.json({ authenticated: false, subscribeUrl }, 401);
128
128
+
}
129
129
+
});
130
130
+
131
131
+
// ============================================================================
132
132
+
// GET /subscribe?publicationUri=at://...
133
133
+
//
134
134
+
// Full-page OAuth + subscription flow. Unauthenticated users land here after
135
135
+
// the component redirects them, and authenticated users land here after the
136
136
+
// OAuth callback (via the login_return_to cookie set in POST /subscribe/login).
137
137
+
// ============================================================================
138
138
+
139
139
+
subscribe.get("/", async (c) => {
140
140
+
const publicationUri = c.req.query("publicationUri");
141
141
+
const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url);
142
142
+
143
143
+
if (!publicationUri || !publicationUri.startsWith("at://")) {
144
144
+
return c.html(renderError("Missing or invalid publication URI.", styleHref), 400);
145
145
+
}
146
146
+
147
147
+
const did = getSessionDid(c);
148
148
+
if (!did) {
149
149
+
return c.html(renderHandleForm(publicationUri, styleHref));
150
150
+
}
151
151
+
152
152
+
try {
153
153
+
const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL);
154
154
+
const session = await client.restore(did);
155
155
+
const agent = new Agent(session);
156
156
+
157
157
+
const existingUri = await findExistingSubscription(agent, did, publicationUri);
158
158
+
if (existingUri) {
159
159
+
return c.html(renderSuccess(publicationUri, existingUri, true, styleHref));
160
160
+
}
161
161
+
162
162
+
const result = await agent.com.atproto.repo.createRecord({
163
163
+
repo: did,
164
164
+
collection: COLLECTION,
165
165
+
record: {
166
166
+
$type: COLLECTION,
167
167
+
publication: publicationUri,
168
168
+
},
169
169
+
});
170
170
+
171
171
+
return c.html(renderSuccess(publicationUri, result.data.uri, false, styleHref));
172
172
+
} catch (error) {
173
173
+
console.error("Subscribe GET error:", error);
174
174
+
// Session expired - ask the user to sign in again
175
175
+
return c.html(renderHandleForm(publicationUri, styleHref, "Session expired. Please sign in again."));
176
176
+
}
177
177
+
});
178
178
+
179
179
+
// ============================================================================
180
180
+
// POST /subscribe/login
181
181
+
//
182
182
+
// Handles the handle-entry form submission. Stores the return URL in a cookie
183
183
+
// so the OAuth callback in auth.ts can redirect back to /subscribe after auth.
184
184
+
// ============================================================================
185
185
+
186
186
+
subscribe.post("/login", async (c) => {
187
187
+
const body = await c.req.parseBody();
188
188
+
const handle = (body["handle"] as string | undefined)?.trim();
189
189
+
const publicationUri = body["publicationUri"] as string | undefined;
190
190
+
191
191
+
if (!handle || !publicationUri) {
192
192
+
const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url);
193
193
+
return c.html(renderError("Missing handle or publication URI.", styleHref), 400);
194
194
+
}
195
195
+
196
196
+
const returnTo = `${c.env.CLIENT_URL}/subscribe?publicationUri=${encodeURIComponent(publicationUri)}`;
197
197
+
setReturnToCookie(c, returnTo, c.env.CLIENT_URL);
198
198
+
199
199
+
return c.redirect(
200
200
+
`${c.env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`,
201
201
+
);
202
202
+
});
203
203
+
204
204
+
// ============================================================================
205
205
+
// HTML rendering
206
206
+
// ============================================================================
207
207
+
208
208
+
function renderHandleForm(publicationUri: string, styleHref: string, error?: string): string {
209
209
+
const errorHtml = error
210
210
+
? `<p class="vocs_Paragraph error">${escapeHtml(error)}</p>`
211
211
+
: "";
212
212
+
213
213
+
return page(`
214
214
+
<h1 class="vocs_H1 vocs_Heading">Subscribe on Bluesky</h1>
215
215
+
<p class="vocs_Paragraph">Enter your Bluesky handle to subscribe to this publication.</p>
216
216
+
${errorHtml}
217
217
+
<form method="POST" action="/subscribe/login">
218
218
+
<input type="hidden" name="publicationUri" value="${escapeHtml(publicationUri)}" />
219
219
+
<label>
220
220
+
Bluesky handle
221
221
+
<input
222
222
+
type="text"
223
223
+
name="handle"
224
224
+
placeholder="you.bsky.social"
225
225
+
autocomplete="username"
226
226
+
required
227
227
+
autofocus
228
228
+
/>
229
229
+
</label>
230
230
+
<button type="submit" class="vocs_Button_button vocs_Button_button_accent">Continue on Bluesky</button>
231
231
+
</form>
232
232
+
`, styleHref);
233
233
+
}
234
234
+
235
235
+
function renderSuccess(
236
236
+
publicationUri: string,
237
237
+
recordUri: string,
238
238
+
existing: boolean,
239
239
+
styleHref: string,
240
240
+
): string {
241
241
+
const msg = existing
242
242
+
? "You're already subscribed to this publication."
243
243
+
: "You've successfully subscribed!";
244
244
+
return page(`
245
245
+
<h1 class="vocs_H1 vocs_Heading">Subscribed ✓</h1>
246
246
+
<p class="vocs_Paragraph">${msg}</p>
247
247
+
<p class="vocs_Paragraph"><small>Publication: <code class="vocs_Code">${escapeHtml(publicationUri)}</code></small></p>
248
248
+
<p class="vocs_Paragraph"><small>Record: <code class="vocs_Code">${escapeHtml(recordUri)}</code></small></p>
249
249
+
`, styleHref);
250
250
+
}
251
251
+
252
252
+
function renderError(message: string, styleHref: string): string {
253
253
+
return page(`<h1 class="vocs_H1 vocs_Heading">Error</h1><p class="vocs_Paragraph error">${escapeHtml(message)}</p>`, styleHref);
254
254
+
}
255
255
+
256
256
+
function page(body: string, styleHref: string): string {
257
257
+
return `<!DOCTYPE html>
258
258
+
<html lang="en">
259
259
+
<head>
260
260
+
<meta charset="UTF-8" />
261
261
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
262
262
+
<title>Sequoia · Subscribe</title>
263
263
+
<link rel="stylesheet" href="${styleHref}" />
264
264
+
<script>if(window.matchMedia('(prefers-color-scheme: dark)').matches)document.documentElement.classList.add('dark')</script>
265
265
+
<style>
266
266
+
.page-container {
267
267
+
max-width: 480px;
268
268
+
margin: 4rem auto;
269
269
+
padding: 0 var(--vocs-space_20, 1.25rem);
270
270
+
}
271
271
+
.vocs_Heading { margin-bottom: var(--vocs-space_12, .75rem); }
272
272
+
.vocs_Paragraph { margin-bottom: var(--vocs-space_16, 1rem); }
273
273
+
label {
274
274
+
display: flex;
275
275
+
flex-direction: column;
276
276
+
gap: var(--vocs-space_6, .375rem);
277
277
+
margin-bottom: var(--vocs-space_20, 1.25rem);
278
278
+
font-weight: var(--vocs-fontWeight_medium, 400);
279
279
+
font-size: var(--vocs-fontSize_15, .9375rem);
280
280
+
}
281
281
+
input[type="text"] {
282
282
+
padding: var(--vocs-space_8, .5rem) var(--vocs-space_12, .75rem);
283
283
+
border: 1px solid var(--vocs-color_border, #D5D1C8);
284
284
+
border-radius: var(--vocs-borderRadius_6, 6px);
285
285
+
font-size: var(--vocs-fontSize_16, 1rem);
286
286
+
font-family: inherit;
287
287
+
background: var(--vocs-color_background, #F5F3EF);
288
288
+
color: var(--vocs-color_text, #2C2C2C);
289
289
+
}
290
290
+
input[type="text"]:focus {
291
291
+
border-color: var(--vocs-color_borderAccent, #3A5A40);
292
292
+
outline: 2px solid var(--vocs-color_borderAccent, #3A5A40);
293
293
+
outline-offset: 2px;
294
294
+
}
295
295
+
.error { color: var(--vocs-color_dangerText, #8B3A3A); }
296
296
+
</style>
297
297
+
</head>
298
298
+
<body>
299
299
+
<div class="page-container">
300
300
+
${body}
301
301
+
</div>
302
302
+
</body>
303
303
+
</html>`;
304
304
+
}
305
305
+
306
306
+
function escapeHtml(text: string): string {
307
307
+
return text
308
308
+
.replace(/&/g, "&")
309
309
+
.replace(/</g, "<")
310
310
+
.replace(/>/g, ">")
311
311
+
.replace(/"/g, """);
312
312
+
}
313
313
+
314
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
11
-
run_worker_first = ["/api/*", "/oauth/*"]
11
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
15
+
* - callback-uri: Redirect URI after OAuth authentication (default: "https://sequoia.pub/subscribe")
15
16
* - label: Button label text (default: "Subscribe on Bluesky")
17
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
267
+
this.abortController = null;
265
268
this.render();
266
269
}
267
270
268
271
static get observedAttributes() {
269
269
-
return ["publication-uri", "label"];
272
272
+
return ["publication-uri", "callback-uri", "label", "hide"];
273
273
+
}
274
274
+
275
275
+
connectedCallback() {
276
276
+
// Pre-check publication availability so hide="auto" can take effect
277
277
+
if (!this.publicationUri) {
278
278
+
this.checkPublication();
279
279
+
}
280
280
+
}
281
281
+
282
282
+
disconnectedCallback() {
283
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
276
-
this.state.type === "subscribed"
290
290
+
this.state.type === "subscribed" ||
291
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
302
+
get callbackUri() {
303
303
+
return this.getAttribute("callback-uri") ?? "https://sequoia.pub/subscribe";
304
304
+
}
305
305
+
287
306
get label() {
288
307
return this.getAttribute("label") ?? "Subscribe on Bluesky";
289
308
}
290
309
310
310
+
get hide() {
311
311
+
const hideAttr = this.getAttribute("hide");
312
312
+
return hideAttr === "auto";
313
313
+
}
314
314
+
315
315
+
async checkPublication() {
316
316
+
this.abortController?.abort();
317
317
+
this.abortController = new AbortController();
318
318
+
319
319
+
try {
320
320
+
await fetchPublicationUri();
321
321
+
} catch {
322
322
+
this.state = { type: "no-publication" };
323
323
+
this.render();
324
324
+
}
325
325
+
}
326
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
300
-
// Resolve the publication AT URI
301
336
const publicationUri =
302
337
this.publicationUri ?? (await fetchPublicationUri());
303
338
304
304
-
// TODO: resolve authenticated DID and access token before calling createRecord
305
305
-
const { uri: recordUri } = await createRecord(
306
306
-
/* did */ undefined,
307
307
-
/* accessToken */ undefined,
308
308
-
publicationUri,
309
309
-
);
339
339
+
// POST to the callbackUri (e.g. https://sequoia.pub/subscribe).
340
340
+
// If the server reports the user isn't authenticated it returns a
341
341
+
// subscribeUrl for the full-page OAuth + subscription flow.
342
342
+
const response = await fetch(this.callbackUri, {
343
343
+
method: "POST",
344
344
+
headers: { "Content-Type": "application/json" },
345
345
+
credentials: "include",
346
346
+
body: JSON.stringify({ publicationUri }),
347
347
+
});
348
348
+
349
349
+
const data = await response.json();
350
350
+
351
351
+
if (response.status === 401 && data.authenticated === false) {
352
352
+
// Redirect to the hosted subscribe page to complete OAuth
353
353
+
window.location.href = data.subscribeUrl;
354
354
+
return;
355
355
+
}
356
356
+
357
357
+
if (!response.ok) {
358
358
+
throw new Error(data.error ?? `HTTP ${response.status}`);
359
359
+
}
310
360
361
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
373
+
// Don't overwrite state if we already navigated away
374
374
+
if (this.state.type !== "loading") return;
375
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
393
+
394
394
+
if (type === "no-publication") {
395
395
+
if (this.hide) {
396
396
+
this.wrapper.innerHTML = "";
397
397
+
this.wrapper.style.display = "none";
398
398
+
}
399
399
+
return;
400
400
+
}
401
401
+
339
402
const isLoading = type === "loading";
340
403
const isSubscribed = type === "subscribed";
341
404