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

Check existing subs and offer to unsubscribe #37

merged opened by heaths.dev targeting main from heaths.dev/sequoia: unsubscribe
Labels

None yet.

assignee

None yet.

Participants 2
AT URI
at://did:plc:tg3tb5wukiml4xmxml6qm637/sh.tangled.repo.pull/3mfvxonem6y22
+163 -16
Interdiff #1 โ†’ #2
bun.lock

This file has not been changed.

docs/package.json

This file has not been changed.

+1 -2
docs/src/lib/session.ts
··· 11 11 const hostname = new URL(clientUrl).hostname; 12 12 return { 13 13 httpOnly: true as const, 14 - // Allow the SESSION_COOKIE_NAME to be sent for existing subscription checks. 15 - sameSite: "None" as const, 14 + sameSite: "Lax" as const, 16 15 path: "/", 17 16 ...(isLocalhost ? {} : { domain: `.${hostname}`, secure: true }), 18 17 };
+40 -5
docs/src/routes/subscribe.ts
··· 42 42 // ============================================================================ 43 43 44 44 /** 45 + * Append a query parameter to a returnTo URL, preserving existing params. 46 + */ 47 + function withReturnToParam( 48 + returnTo: string | undefined, 49 + key: string, 50 + value: string, 51 + ): string | undefined { 52 + if (!returnTo) return undefined; 53 + try { 54 + const url = new URL(returnTo); 55 + url.searchParams.set(key, value); 56 + return url.toString(); 57 + } catch { 58 + return returnTo; 59 + } 60 + } 61 + 62 + /** 45 63 * Scan the user's repo for an existing site.standard.graph.subscription 46 64 * matching the given publication URI. Returns the record AT-URI if found. 47 65 */ ··· 201 219 rkey, 202 220 }); 203 221 } 222 + 223 + // Strip sequoia_did from returnTo so the component doesn't re-store it 224 + let cleanReturnTo = returnTo; 225 + if (cleanReturnTo) { 226 + try { 227 + const rtUrl = new URL(cleanReturnTo); 228 + rtUrl.searchParams.delete("sequoia_did"); 229 + cleanReturnTo = rtUrl.toString(); 230 + } catch { 231 + // keep as-is 232 + } 233 + } 234 + 204 235 return c.html( 205 236 renderSuccess( 206 237 publicationUri, ··· 210 241 ? "You've successfully unsubscribed!" 211 242 : "You weren't subscribed to this publication.", 212 243 styleHref, 213 - returnTo, 244 + withReturnToParam(cleanReturnTo, "sequoia_unsubscribed", "1"), 214 245 ), 215 246 ); 216 247 } ··· 220 251 did, 221 252 publicationUri, 222 253 ); 254 + const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did); 255 + 223 256 if (existingUri) { 224 257 return c.html( 225 258 renderSuccess( ··· 228 261 "Subscribed โœ“", 229 262 "You're already subscribed to this publication.", 230 263 styleHref, 231 - returnTo, 264 + returnToWithDid, 232 265 ), 233 266 ); 234 267 } ··· 249 282 "Subscribed โœ“", 250 283 "You've successfully subscribed!", 251 284 styleHref, 252 - returnTo, 285 + returnToWithDid, 253 286 ), 254 287 ); 255 288 } catch (error) { ··· 286 319 return c.json({ error: "Missing or invalid publicationUri" }, 400); 287 320 } 288 321 289 - const did = getSessionDid(c); 290 - if (!did) { 322 + // Prefer the server-side session DID; fall back to a client-provided DID 323 + // (stored by the web component from a previous subscribe flow). 324 + const did = getSessionDid(c) ?? c.req.query("did") ?? null; 325 + if (!did || !did.startsWith("did:")) { 291 326 return c.json({ authenticated: false }, 401); 292 327 } 293 328
+116 -6
packages/cli/src/components/sequoia-subscribe.js
··· 111 111 </svg>`; 112 112 113 113 // ============================================================================ 114 + // DID Storage 115 + // ============================================================================ 116 + 117 + /** 118 + * Store the subscriber DID. Tries a cookie first; falls back to localStorage. 119 + * @param {string} did 120 + */ 121 + function storeSubscriberDid(did) { 122 + try { 123 + const expires = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toUTCString(); 124 + document.cookie = `sequoia_did=${encodeURIComponent(did)}; expires=${expires}; path=/; SameSite=Lax`; 125 + } catch { 126 + // Cookie write may fail in some embedded contexts 127 + } 128 + try { 129 + localStorage.setItem("sequoia_did", did); 130 + } catch { 131 + // localStorage may be unavailable 132 + } 133 + } 134 + 135 + /** 136 + * Retrieve the stored subscriber DID. Checks cookie first, then localStorage. 137 + * @returns {string | null} 138 + */ 139 + function getStoredSubscriberDid() { 140 + try { 141 + const match = document.cookie.match(/(?:^|;\s*)sequoia_did=([^;]+)/); 142 + if (match) { 143 + const did = decodeURIComponent(match[1]); 144 + if (did.startsWith("did:")) return did; 145 + } 146 + } catch { 147 + // ignore 148 + } 149 + try { 150 + const did = localStorage.getItem("sequoia_did"); 151 + if (did?.startsWith("did:")) return did; 152 + } catch { 153 + // ignore 154 + } 155 + return null; 156 + } 157 + 158 + /** 159 + * Remove the stored subscriber DID from both cookie and localStorage. 160 + */ 161 + function clearSubscriberDid() { 162 + try { 163 + document.cookie = "sequoia_did=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/; SameSite=Lax"; 164 + } catch { 165 + // ignore 166 + } 167 + try { 168 + localStorage.removeItem("sequoia_did"); 169 + } catch { 170 + // ignore 171 + } 172 + } 173 + 174 + /** 175 + * Check the current page URL for sequoia_did / sequoia_unsubscribed params 176 + * set by the subscribe redirect flow. Consumes them by removing from the URL. 177 + */ 178 + function consumeReturnParams() { 179 + const url = new URL(window.location.href); 180 + const did = url.searchParams.get("sequoia_did"); 181 + const unsubscribed = url.searchParams.get("sequoia_unsubscribed"); 182 + 183 + let changed = false; 184 + 185 + if (unsubscribed === "1") { 186 + clearSubscriberDid(); 187 + url.searchParams.delete("sequoia_unsubscribed"); 188 + changed = true; 189 + } 190 + 191 + if (did && did.startsWith("did:")) { 192 + storeSubscriberDid(did); 193 + url.searchParams.delete("sequoia_did"); 194 + changed = true; 195 + } 196 + 197 + if (changed) { 198 + const cleanUrl = url.pathname + (url.search || "") + (url.hash || ""); 199 + try { 200 + window.history.replaceState(null, "", cleanUrl); 201 + } catch { 202 + // ignore 203 + } 204 + } 205 + } 206 + 207 + // ============================================================================ 114 208 // AT Protocol Functions 115 209 116 210 ··· 177 271 } 178 272 179 273 connectedCallback() { 274 + consumeReturnParams(); 180 275 this.checkPublication(); 181 276 } 182 277 ··· 223 318 224 319 async checkSubscription(publicationUri) { 225 320 try { 226 - const res = await fetch( 227 - `${this.callbackUri}/check?publicationUri=${encodeURIComponent(publicationUri)}`, 228 - { 229 - credentials: "include", 230 - }, 231 - ); 321 + const checkUrl = new URL(`${this.callbackUri}/check`); 322 + checkUrl.searchParams.set("publicationUri", publicationUri); 323 + 324 + // Pass the stored DID so the server can check without a session cookie 325 + const storedDid = getStoredSubscriberDid(); 326 + if (storedDid) { 327 + checkUrl.searchParams.set("did", storedDid); 328 + } 329 + 330 + const res = await fetch(checkUrl.toString(), { 331 + credentials: "include", 332 + }); 232 333 if (!res.ok) return; 233 334 const data = await res.json(); 234 335 if (data.subscribed) { ··· 287 388 } 288 389 289 390 const { recordUri } = data; 391 + 392 + // Store the DID from the record URI (at://did:aaa:bbb/...) 393 + if (recordUri) { 394 + const didMatch = recordUri.match(/^at:\/\/(did:[^/]+)/); 395 + if (didMatch) { 396 + storeSubscriberDid(didMatch[1]); 397 + } 398 + } 399 + 290 400 this.subscribed = true; 291 401 this.state = { type: "idle" }; 292 402 this.render();
+3
docs/src/lib/oauth-client.ts
··· 3 3 import { AtprotoDohHandleResolver } from "@atproto-labs/handle-resolver"; 4 4 import { createStateStore, createSessionStore } from "./kv-stores"; 5 5 6 + export const OAUTH_SCOPE = 7 + "atproto repo:site.standard.graph.subscription?action=create&action=delete"; 8 + 6 9 export function createOAuthClient(kv: KVNamespace, clientUrl: string) { 7 10 const clientId = `${clientUrl}/oauth/client-metadata.json`; 8 11 const redirectUri = `${clientUrl}/oauth/callback`;
+3 -3
docs/src/routes/auth.ts
··· 1 1 import { Hono } from "hono"; 2 - import { createOAuthClient } from "../lib/oauth-client"; 2 + import { createOAuthClient, OAUTH_SCOPE } from "../lib/oauth-client"; 3 3 import { 4 4 getSessionDid, 5 5 setSessionCookie, ··· 27 27 redirect_uris: [redirectUri], 28 28 grant_types: ["authorization_code", "refresh_token"], 29 29 response_types: ["code"], 30 - scope: "atproto repo:site.standard.graph.subscription?action=create", 30 + scope: OAUTH_SCOPE, 31 31 token_endpoint_auth_method: "none", 32 32 application_type: "web", 33 33 dpop_bound_access_tokens: true, ··· 44 44 45 45 const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL); 46 46 const authUrl = await client.authorize(handle, { 47 - scope: "atproto repo:site.standard.graph.subscription?action=create", 47 + scope: OAUTH_SCOPE, 48 48 }); 49 49 50 50 return c.redirect(authUrl.toString());

History

3 rounds 10 comments
sign up or login to add to the discussion
3 commits
expand
Check existing subs and offer to unsubscribe
Add separate /subscribe/check route
Pass DID through query parameter
expand 2 comments

Not sure if storing it in localStorage is worth it assuming document.cookie always works. This is what Opus 4.6 wanted to do so maybe there's some good reason? If not, we could certainly clean up the code some.

Tested it with reverse-proxied sites and works well. Record gets deleted as expected and the button changes state as expected.

Awesome!! Thanks for this work; sick feature :)

pull request successfully merged
2 commits
expand
Check existing subs and offer to unsubscribe
Add separate /subscribe/check route
expand 7 comments

Made a separate route to check credentials. Cleaner and doesn't radically alter the behavior of the existing route. Note, however, that this doesn't work. Seems CORS is preventing it despite setting SameSite=None; Secure on the session_id cookie, which seems like it'd be safe in this case.

Using both curl and the ngrok traffic inspector (reverse-proxied for https endpoints as needed) confirm the right CORS headers are being sent from the Api endpoint, and I made sure to change my cookie settings for that endpoint but the cookie just isn't being sent it seems. I had some console.log statements in there which confirmed the DID wasn't found i.e., no session_id cookie.

I think the only reasonable way this could work is to host the script on the same origin as the /oauth and /subscribe APIs, which means either self-hosting (separate issue) or centralized hosting via sequoia.pub or at least a CDN.

I might have a solution that's not perfect, but I guess better than nothing. I realized that we could probably fetch this state client side since we are storing DID as part of the session cookie. However since we can't generally access those specific cookies, we can still store the state in local storage. An approach I took was to use the query params to store the DID as well as the subscribe state. With the DID we can make a client side call to the PDS for the site.standard.graph.subscription record, and if it matches the publication of the current site, then we show the unsubscribe button.

The downside is that since we're using local storage, clearing browser data or using another device means it will show the subscribe button, and worse cast scenario they try to create a record that already exists. I did try it out and it does work, so if you're interested I've created a diff patch file that can be applied to your branch:

curl -o unsubscribe.patch https://files.stevedylan.dev/unsubscribe.patch

git apply unsubscribe.patch

(git patches slap and I'm glad I messed around with dwm in linux to learn them lol)

Feel free to take it or leave it! Also we will need to update the OAuth scopes again to include the delete functionality for unsubscribing.

scope: "atproto repo:site.standard.graph.subscription?action=create&action=delete",

I would commit directly with the updates but since you're currently working off a fork I thought that might get messy. To that end I've added you as a collaborator on Tangled for Sequoia, so next time you need to make a PR feel free to make a branch directly on the repo and push that way :)

Thanks! I'll take a look at this tomorrow. Good call on the scopes: totally forgot about that.

The local storage idea came up when I was looking for solutions as well. That said, it at least roams with online profiles like Apple IDs, Chrome and Edge profiles, etc., right? I think so. So not terrible but, yeah, there are holes. not any worse than not having it, though. At least the flow tells you you're already sub'd so it doesn't hurt anything.

Was there a reason you deleted the /subscribe/check route? Don't we still need that for just the check, or is that the part that can't possibly work anyway? Given that, the routing to return to the original post actually was already working so I don't think modifying the URL is necessary further.

Or was the point to send those back to the publication itself for the sequoia-subscribe.js component to store in local storage?

I made that change - actually store it in a cookie via JS - and it works. New update incoming.

I think I deleted it since we switched to the client side call, but I was under the impression that was the only use case. Totally fine keeping it if we need it / want to use it later!

2 commits
expand
Send referrer path and origin through login flow
Check existing subs and offer to unsubscribe
expand 1 comment

This includes the other PR only because I needed some of the changes therein to make the diff less messy. That other PR should be taken first then this one. Even if we make other changes to the other PR, this should keep this PR more easily mergable.