Add subscription support
4cbb91ee
8 file(s) · +467 −37
| 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 | } |
|
| 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", |
| 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" }); |
|
| 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 | } |
|
| 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`); |
|
| 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, "&") |
|
| 309 | + | .replace(/</g, "<") |
|
| 310 | + | .replace(/>/g, ">") |
|
| 311 | + | .replace(/"/g, """); |
|
| 312 | + | } |
|
| 313 | + | ||
| 314 | + | export default subscribe; |
| 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" |
| 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 | ||