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

feat: Add subscription component #25

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

Resolves #16

Labels

None yet.

assignee

None yet.

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

This file has not been changed.

docs/package.json

This file has not been changed.

+10
docs/src/index.ts
··· 1 1 import { Hono } from "hono"; 2 + import { cors } from "hono/cors"; 2 3 import auth from "./routes/auth"; 3 4 import subscribe from "./routes/subscribe"; 5 + import "./lib/path-redirect"; 4 6 5 7 type Bindings = { 6 8 ASSETS: Fetcher; ··· 12 14 13 15 app.route("/oauth", auth); 14 16 app.route("/subscribe", subscribe); 17 + app.use("/subscribe", cors({ 18 + origin: (origin) => origin, 19 + credentials: true, 20 + })); 21 + app.use("/subscribe/*", cors({ 22 + origin: (origin) => origin, 23 + credentials: true, 24 + })); 15 25 16 26 app.get("/api/health", (c) => { 17 27 return c.json({ status: "ok" });
docs/src/lib/session.ts

This file has not been changed.

+2 -2
docs/src/routes/auth.ts
··· 27 27 redirect_uris: [redirectUri], 28 28 grant_types: ["authorization_code", "refresh_token"], 29 29 response_types: ["code"], 30 - scope: "atproto transition:generic", 30 + scope: "atproto site.standard.graph.subscription", 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 transition:generic", 47 + scope: "atproto site.standard.graph.subscription", 48 48 }); 49 49 50 50 return c.redirect(authUrl.toString());
+17 -23
docs/src/routes/subscribe.ts
··· 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 - <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> 218 + <input type="hidden" name="publicationUri" value="${escapeHtml(publicationUri)}" /> 219 + <input 220 + type="text" 221 + name="handle" 222 + placeholder="you.bsky.social" 223 + autocomplete="username" 224 + required 225 + autofocus 226 + /> 230 227 <button type="submit" class="vocs_Button_button vocs_Button_button_accent">Continue on Bluesky</button> 231 228 </form> 232 229 `, styleHref); ··· 241 238 const msg = existing 242 239 ? "You're already subscribed to this publication." 243 240 : "You've successfully subscribed!"; 241 + const escapedPublicationUri = escapeHtml(publicationUri); 242 + const escapedRecordUri = escapeHtml(recordUri); 244 243 return page(` 245 244 <h1 class="vocs_H1 vocs_Heading">Subscribed โœ“</h1> 246 245 <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> 246 + <p class="vocs_Paragraph"><small>Publication: <code class="vocs_Code"><a href="https://pds.ls/${escapedPublicationUri}">${escapedPublicationUri}</a></code></small></p> 247 + <p class="vocs_Paragraph"><small>Record: <code class="vocs_Code"><a href="https://pds.ls/${escapedRecordUri}">${escapedRecordUri}</a></code></small></p> 249 248 `, styleHref); 250 249 } 251 250 ··· 264 263 <script>if(window.matchMedia('(prefers-color-scheme: dark)').matches)document.documentElement.classList.add('dark')</script> 265 264 <style> 266 265 .page-container { 267 - max-width: 480px; 266 + max-width: calc(var(--vocs-content_width, 480px) / 1.6); 268 267 margin: 4rem auto; 269 268 padding: 0 var(--vocs-space_20, 1.25rem); 270 269 } 271 270 .vocs_Heading { margin-bottom: var(--vocs-space_12, .75rem); } 272 271 .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 272 input[type="text"] { 282 273 padding: var(--vocs-space_8, .5rem) var(--vocs-space_12, .75rem); 283 274 border: 1px solid var(--vocs-color_border, #D5D1C8); 284 275 border-radius: var(--vocs-borderRadius_6, 6px); 276 + margin-bottom: var(--vocs-space_20, 1.25rem); 277 + min-width: 30vh; 278 + width: 100%; 285 279 font-size: var(--vocs-fontSize_16, 1rem); 286 280 font-family: inherit; 287 281 background: var(--vocs-color_background, #F5F3EF);
docs/wrangler.toml

This file has not been changed.

packages/cli/src/commands/add.ts

This file has not been changed.

packages/cli/src/components/sequoia-subscribe.js

This file has not been changed.

+1 -1
docs/src/lib/oauth-client.ts
··· 19 19 redirect_uris: [redirectUri], 20 20 grant_types: ["authorization_code", "refresh_token"], 21 21 response_types: ["code"], 22 - scope: "atproto transition:generic", 22 + scope: "atproto site.standard.graph.subscription", 23 23 token_endpoint_auth_method: "none", 24 24 application_type: "web", 25 25 dpop_bound_access_tokens: true,
+51
docs/src/lib/path-redirect.ts
··· 1 + // Cloudflare Workers compatibility patches for @atproto libraries. 2 + // 3 + // 1. Workers don't support `redirect: 'error'` โ€” simulate it with 'manual'. 4 + // 2. Workers don't support the standard `cache` option in Request โ€” strip it. 5 + 6 + function sanitizeInit(init?: RequestInit): RequestInit | undefined { 7 + if (!init) return init; 8 + const { cache, redirect, ...rest } = init; 9 + return { 10 + ...rest, 11 + // Workers only support 'follow' and 'manual' 12 + redirect: redirect === "error" ? "manual" : redirect, 13 + // Workers don't support standard cache modes โ€” omit entirely 14 + ...(cache ? {} : {}), 15 + }; 16 + } 17 + 18 + const errorRedirectRequests = new WeakSet<Request>(); 19 + const OriginalRequest = globalThis.Request; 20 + 21 + globalThis.Request = class extends OriginalRequest { 22 + constructor( 23 + input: RequestInfo | URL, 24 + init?: RequestInit, 25 + ) { 26 + super(input, sanitizeInit(init)); 27 + if (init?.redirect === "error") { 28 + errorRedirectRequests.add(this); 29 + } 30 + } 31 + } as typeof Request; 32 + 33 + const originalFetch = globalThis.fetch; 34 + globalThis.fetch = (async ( 35 + input: RequestInfo | URL, 36 + init?: RequestInit, 37 + ): Promise<Response> => { 38 + const cleanInit = sanitizeInit(init); 39 + const response = await originalFetch(input, cleanInit); 40 + 41 + // Simulate redirect: 'error' โ€” throw on 3xx 42 + const wantsRedirectError = 43 + init?.redirect === "error" || 44 + (input instanceof Request && errorRedirectRequests.has(input)); 45 + 46 + if (wantsRedirectError && response.status >= 300 && response.status < 400) { 47 + throw new TypeError("unexpected redirect"); 48 + } 49 + 50 + return response; 51 + }) as typeof fetch;
-1
packages/cli/src/commands/publish.ts
··· 359 359 bskyPostRef = await createBlueskyPost(agent, { 360 360 title: post.frontmatter.title, 361 361 description: post.frontmatter.description, 362 - bskyPost: post.frontmatter.bskyPost, 363 362 canonicalUrl, 364 363 coverImage, 365 364 publishedAt: post.frontmatter.publishDate,
+39 -25
packages/cli/src/lib/atproto.ts
··· 328 328 textContent = stripMarkdownForText(post.content); 329 329 } 330 330 331 - // Fetch existing record to preserve PDS-side fields (e.g. bskyPostRef) 332 - const existingResponse = await agent.com.atproto.repo.getRecord({ 333 - repo: agent.did!, 334 - collection: collection!, 335 - rkey: rkey!, 336 - }); 337 - const existingRecord = existingResponse.data.value as Record<string, unknown>; 338 - 339 331 const record: Record<string, unknown> = { 340 - ...existingRecord, 341 332 $type: "site.standard.document", 342 333 title: post.frontmatter.title, 343 334 site: config.publicationUri, ··· 578 569 export interface CreateBlueskyPostOptions { 579 570 title: string; 580 571 description?: string; 581 - bskyPost?: string; 582 572 canonicalUrl: string; 583 573 coverImage?: BlobObject; 584 574 publishedAt: string; // Used as createdAt for the post ··· 622 612 agent: Agent, 623 613 options: CreateBlueskyPostOptions, 624 614 ): Promise<StrongRef> { 625 - const { title, description, bskyPost, canonicalUrl, coverImage, publishedAt } = options; 615 + const { title, description, canonicalUrl, coverImage, publishedAt } = options; 626 616 627 - // Build post text: title + description 617 + // Build post text: title + description + URL 628 618 // Max 300 graphemes for Bluesky posts 629 619 const MAX_GRAPHEMES = 300; 630 620 631 621 let postText: string; 622 + const urlPart = `\n\n${canonicalUrl}`; 623 + const urlGraphemes = countGraphemes(urlPart); 632 624 633 - if (bskyPost) { 634 - // Custom bsky post overrides any default behavior 635 - postText = bskyPost; 636 - } 637 - else if (description) { 638 - // Try: title + description 639 - const fullText = `${title}\n\n${description}`; 625 + if (description) { 626 + // Try: title + description + URL 627 + const fullText = `${title}\n\n${description}${urlPart}`; 640 628 if (countGraphemes(fullText) <= MAX_GRAPHEMES) { 641 629 postText = fullText; 642 630 } else { ··· 644 632 const availableForDesc = 645 633 MAX_GRAPHEMES - 646 634 countGraphemes(title) - 635 + countGraphemes("\n\n") - 636 + urlGraphemes - 647 637 countGraphemes("\n\n"); 648 638 if (availableForDesc > 10) { 649 639 const truncatedDesc = truncateToGraphemes( 650 640 description, 651 641 availableForDesc, 652 642 ); 653 - postText = `${title}\n\n${truncatedDesc}`; 643 + postText = `${title}\n\n${truncatedDesc}${urlPart}`; 654 644 } else { 655 - // Just title 656 - postText = `${title}`; 645 + // Just title + URL 646 + postText = `${title}${urlPart}`; 657 647 } 658 648 } 659 649 } else { 660 - // Just title 661 - postText = `${title}`; 650 + // Just title + URL 651 + postText = `${title}${urlPart}`; 662 652 } 663 653 664 - // Final truncation in case title or bskyPost are longer than expected 654 + // Final truncation if still too long (shouldn't happen but safety check) 665 655 if (countGraphemes(postText) > MAX_GRAPHEMES) { 666 656 postText = truncateToGraphemes(postText, MAX_GRAPHEMES); 667 657 } 668 658 659 + // Calculate byte indices for the URL facet 660 + const encoder = new TextEncoder(); 661 + const urlStartInText = postText.lastIndexOf(canonicalUrl); 662 + const beforeUrl = postText.substring(0, urlStartInText); 663 + const byteStart = encoder.encode(beforeUrl).length; 664 + const byteEnd = byteStart + encoder.encode(canonicalUrl).length; 665 + 666 + // Build facets for the URL link 667 + const facets = [ 668 + { 669 + index: { 670 + byteStart, 671 + byteEnd, 672 + }, 673 + features: [ 674 + { 675 + $type: "app.bsky.richtext.facet#link", 676 + uri: canonicalUrl, 677 + }, 678 + ], 679 + }, 680 + ]; 681 + 669 682 // Build external embed 670 683 const embed: Record<string, unknown> = { 671 684 $type: "app.bsky.embed.external", ··· 685 698 const record: Record<string, unknown> = { 686 699 $type: "app.bsky.feed.post", 687 700 text: postText, 701 + facets, 688 702 embed, 689 703 createdAt: new Date(publishedAt).toISOString(), 690 704 };
-1
packages/cli/src/lib/types.ts
··· 87 87 export interface PostFrontmatter { 88 88 title: string; 89 89 description?: string; 90 - bskyPost?: string; 91 90 publishDate: string; 92 91 tags?: string[]; 93 92 ogImage?: string;

History

4 rounds 14 comments
sign up or login to add to the discussion
5 commits
expand
feat: Add subscription component
Add subscription support
Add cors support
Subscribe UI improvements
Add subscribe section to documentation and update navigation links
expand 2 comments

Excellent; thank you!! I'll make some follow up changes with the fetch-patch for the worker. Really appreciate your help here!

oop nvm, see its there now; we're good!

pull request successfully merged
4 commits
expand
feat: Add subscription component
Add subscription support
Add cors support
Subscribe UI improvements
expand 1 comment

I wasn't able to test e2e. I can get to the /subscribe page and authenticate, but with the static and API sites running on different ports I don't get a proper callback.

2 commits
expand
feat: Add subscription component
Add subscription support
expand 11 comments

@stevedylan.dev this is closed, but I'm having trouble verifying it e2e. I swapped out the CLIENT_URL variable for my local instance, but seems I need both the static site and wrangler site (on separate ports) running simultaneously and I'm not sure the flow is working right between the two as it might for Cloudflare. Is this something you can test easily, or maybe have some pointers? Does Cloudflare somehow proxy the calls to seem like a single site?

All that said, the flow seems to almost work right. I had to disable CORS for localhost on my machine but that should just be because it's localhost. I explicitly set the callback-uri in my SSG to test this all out. The rendered pages are somewhat themed. Not sure how vocs is setting the class on the HTML root element, but I have a script doing it here.

The auto-hide functionality also works if the publication-uri isn't set or isn't discoverable from /.well-known.

I suppose I should add a help topic before calling this "done", too. Something short and sweet like the Comments topic.

I suppose I should add a help topic before calling this "done", too. Something short and sweet like the Comments topic.

@heaths.dev Awesome!! Yeah I can definitely test this later. I think if you run bun dev:api inside the docs folder it should spin up the actual API and render the site as the same time, so that should make it easier to test!

We also probably need to update the CORS on the index.ts file too, something like seen here: https://hono.dev/docs/middleware/builtin/cors

Whew, ok had to do some deep testing and deployment of code that still got me pretty stuck. Here's where we're at:

  • We need to add these cors settings to the index.ts file
import { cors } from "hono/cors";
// Other imports and initial hono app
app.use(
	"/subscribe/*",
	cors({
		origin: (origin) => origin,
		credentials: true,
	}),
);
app.use(
	"/subscribe",
	cors({
		origin: (origin) => origin,
		credentials: true,
	}),
);
  • For some reason the OAuth client really doesn't play well with cloudflare workers. Here's an example error:
GET https://sequoia.pub/oauth/login?handle=stevedylan.dev - Ok @ 2/24/2026, 8:11:56 PM
  (error) Identity resolution failed: DidError did-unknown-error (did:plc:ia2zdnhjaokf5lazhxrmj6eu): Invalid redirect value, must be one of "follow" or "manual" ("error" won't be implemented since it does not make sense at the edge; use "manual" and check the response status code).
  (error) Login error: Error: Failed to resolve identity: stevedylan.dev

I know with the CLI we use the @atproto/oauth-client-node so maybe that's the answer. Will see if I can take another crack at it later.

Figured it out!! What a pain in the ass lol. Ok here's what we need to do:

  • Create a new file docs/src/lib/path-redirect.ts with these contents and then import it to docs/src/index.ts like so import "./lib/patch-redirect";. This will solve the weird cloudflare worker issues with the atproto oauth client library and how it handles redirects.

  • Update all instances of scope: "atproto transition:generic" to scope: "atproto site.standard.graph.subscription"

  • The cors updates seen in the previous comment

I made these changes locally and deployed it so you can try it out from localhost and it should work! We just need to get the actual code committed and merged.

Also on the final screen where it shows the user that we created the records, could be nice to link to a pds.ls URL like so

https://pds.ls/at://did:plc:ia2zdnhjaokf5lazhxrmj6eu/site.standard.graph.subscription/3mfnrenwnps2h

Hold off on changing the scopes; started having weird issues with it so reverted to what you already have

Ok tested some more with scopes and I'm not sure what I was doing wrong before, perhaps an old session, but I confirmed it's working as expected. Just need to update the following lines:

docs/src/routes/auth.ts:30,47 docs/src/lib/oauth-client.ts:22

scope: "atproto repo:site.standard.graph.subscription"

Hey, sorry for not responding earlier and thanks so much for testing this with Cloudflare! I was building the docs site okay. The problem I was running into was that the static site was served from a different port than the API, and crossing between the two was having additional CORS issues than just the ones I expected tested against localhost that I temporarily worked around.

Since you already have the changes, do you just want to push them to this PR since you can actually test them? Given our previous thread, I don't mind that at all - it's collaboration! :) That was different than the other PR.

That said, not sure if you're online right now so I'll take a look through your changes and incorporate what I can. I'll even try to test but, like I said, without testing in an actual test environment on Cloudflare (or something like it), not sure how realistic testing would be.

1 commit
expand
feat: Add subscription component
expand 0 comments