Add sequoia-recommend component fee58dda
Resolves #31
Heath Stewart · 2026-05-28 00:01 16 file(s) · +1897 −537
docs/docs/pages/recommend.mdx (added) +238 −0
1 +
# Recommend
2 +
3 +
Sequoia provides a recommend button web component that lets your readers recommend a document directly from your site using their AT Protocol account.
4 +
5 +
## Setup
6 +
7 +
The recommend component is bundled in the same file as the subscribe component. Run the following command to install it if you haven't already:
8 +
9 +
```bash [Terminal]
10 +
sequoia add sequoia-subscribe
11 +
```
12 +
13 +
The component will look for your document AT URI from a `<link rel="site.standard.document">` tag in the page `<head>` automatically, so no additional configuration is required for most setups.
14 +
15 +
:::tip
16 +
`sequoia-subscribe.js` registers both the `<sequoia-subscribe>` and `<sequoia-recommend>` custom elements. You only need to import it once even if you use both components on the same page.
17 +
:::
18 +
19 +
## Usage
20 +
21 +
Since `sequoia-recommend` is a standard Web Component, it works with any framework. Choose your setup below:
22 +
23 +
:::code-group
24 +
25 +
```html [HTML]
26 +
<head>
27 +
  <!-- Optional: set the document URI via a link tag -->
28 +
  <link rel="site.standard.document" href="at://did:plc:example/app.bsky.feed.post/rkey" />
29 +
</head>
30 +
<body>
31 +
  <h1>My Post</h1>
32 +
  <!--Content-->
33 +
34 +
  <sequoia-recommend></sequoia-recommend>
35 +
  <script type="module" src="./src/components/sequoia-subscribe.js"></script>
36 +
</body>
37 +
```
38 +
39 +
```tsx [React]
40 +
// Import the component (registers both custom elements)
41 +
import './components/sequoia-subscribe.js';
42 +
43 +
function PostPage() {
44 +
  return (
45 +
    <main>
46 +
      <h1>My Post</h1>
47 +
      {/* Content */}
48 +
      <sequoia-recommend document-uri="at://did:plc:example/app.bsky.feed.post/rkey" />
49 +
    </main>
50 +
  );
51 +
}
52 +
```
53 +
54 +
```vue [Vue]
55 +
<script setup>
56 +
import './components/sequoia-subscribe.js';
57 +
</script>
58 +
59 +
<template>
60 +
  <main>
61 +
    <h1>My Post</h1>
62 +
    <!-- Content -->
63 +
    <sequoia-recommend document-uri="at://did:plc:example/app.bsky.feed.post/rkey" />
64 +
  </main>
65 +
</template>
66 +
```
67 +
68 +
```svelte [Svelte]
69 +
<script>
70 +
  import './components/sequoia-subscribe.js';
71 +
</script>
72 +
73 +
<main>
74 +
  <h1>My Post</h1>
75 +
  <!-- Content -->
76 +
  <sequoia-recommend document-uri="at://did:plc:example/app.bsky.feed.post/rkey" />
77 +
</main>
78 +
```
79 +
80 +
```astro [Astro]
81 +
<main>
82 +
  <h1>My Post</h1>
83 +
  <!-- Content -->
84 +
  <sequoia-recommend document-uri="at://did:plc:example/app.bsky.feed.post/rkey" />
85 +
  <script>
86 +
    import './components/sequoia-subscribe.js';
87 +
  </script>
88 +
</main>
89 +
```
90 +
91 +
:::
92 +
93 +
### TypeScript Support
94 +
95 +
If you're using TypeScript with React, add this type declaration to avoid JSX errors:
96 +
97 +
```ts [custom-elements.d.ts]
98 +
declare namespace JSX {
99 +
  interface IntrinsicElements {
100 +
    'sequoia-recommend': React.DetailedHTMLProps<
101 +
      React.HTMLAttributes<HTMLElement> & {
102 +
        'document-uri'?: string;
103 +
        'callback-uri'?: string;
104 +
        'button-type'?: 'heart' | 'star' | 'thumbs-up';
105 +
        hide?: string;
106 +
      },
107 +
      HTMLElement
108 +
    >;
109 +
  }
110 +
}
111 +
```
112 +
113 +
### Vue Configuration
114 +
115 +
For Vue, you may need to configure the compiler to recognize custom elements:
116 +
117 +
```ts [vite.config.ts]
118 +
export default defineConfig({
119 +
  plugins: [
120 +
    vue({
121 +
      template: {
122 +
        compilerOptions: {
123 +
          isCustomElement: (tag) => tag === 'sequoia-recommend'
124 +
        }
125 +
      }
126 +
    })
127 +
  ]
128 +
});
129 +
```
130 +
131 +
## Configuration
132 +
133 +
The recommend web component has several configuration options available.
134 +
135 +
### Attributes
136 +
137 +
The `<sequoia-recommend>` component accepts the following attributes:
138 +
139 +
| Attribute | Type | Default | Description |
140 +
|-----------|------|---------|-------------|
141 +
| `document-uri` | `string` | - | AT Protocol URI for the document to recommend. Optional if a `<link rel="site.standard.document">` tag exists in the page `<head>`. |
142 +
| `callback-uri` | `string` | `https://sequoia.pub/recommend` | Redirect URI used for the OAuth authentication flow. |
143 +
| `button-type` | `string` | `heart` | Icon style for the button. Accepted values: `heart`, `star`, `thumbs-up`. |
144 +
| `hide` | `string` | - | Set to `"auto"` to hide the component if no document URI is detected. |
145 +
146 +
#### Button Types
147 +
148 +
The `button-type` attribute controls which icon the button uses. Icons are outlined when not yet recommended and filled once recommended.
149 +
150 +
| Type | Icon | Aria Label |
151 +
|------|------|-----------|
152 +
| `heart` | Heart | Recommend / Unrecommend |
153 +
| `star` | Star | Recommend / Unrecommend |
154 +
| `thumbs-up` | Thumbs up | Recommend / Unrecommend |
155 +
156 +
```html
157 +
<!-- Default heart icon -->
158 +
<sequoia-recommend></sequoia-recommend>
159 +
160 +
<!-- Star icon -->
161 +
<sequoia-recommend button-type="star"></sequoia-recommend>
162 +
163 +
<!-- Thumbs-up icon -->
164 +
<sequoia-recommend button-type="thumbs-up"></sequoia-recommend>
165 +
166 +
<!-- Explicit document URI -->
167 +
<sequoia-recommend
168 +
  document-uri="at://did:plc:example/app.bsky.feed.post/rkey">
169 +
</sequoia-recommend>
170 +
```
171 +
172 +
#### Resolving the Document URI
173 +
174 +
If `document-uri` is not set on the element, the component looks for a `<link>` tag in the page `<head>`:
175 +
176 +
```html
177 +
<link rel="site.standard.document" href="at://did:plc:example/app.bsky.feed.post/rkey" />
178 +
```
179 +
180 +
This lets you set the document URI once per page without having to pass it to every component instance.
181 +
182 +
### Events
183 +
184 +
The component dispatches custom events you can listen to:
185 +
186 +
| Event | Description | Detail |
187 +
|-------|-------------|--------|
188 +
| `sequoia-recommended` | Fired when the recommendation is created successfully. | `{ documentUri: string, recordUri: string }` |
189 +
| `sequoia-recommend-error` | Fired when the recommendation fails. | `{ message: string }` |
190 +
191 +
```js
192 +
const btn = document.querySelector('sequoia-recommend');
193 +
194 +
btn.addEventListener('sequoia-recommended', (e) => {
195 +
  console.log('Recommended!', e.detail.recordUri);
196 +
});
197 +
198 +
btn.addEventListener('sequoia-recommend-error', (e) => {
199 +
  console.error('Recommendation failed:', e.detail.message);
200 +
});
201 +
```
202 +
203 +
### Styling
204 +
205 +
The component uses the same CSS custom properties as `<sequoia-subscribe>` for theming, plus a `part="button"` attribute for direct CSS targeting:
206 +
207 +
| CSS Property | Default | Description |
208 +
|--------------|---------|-------------|
209 +
| `--sequoia-fg-color` | `#1f2937` | Text color |
210 +
| `--sequoia-bg-color` | `#ffffff` | Background color |
211 +
| `--sequoia-border-color` | `#e5e7eb` | Border color |
212 +
| `--sequoia-accent-color` | `#2563eb` | Button background color |
213 +
| `--sequoia-secondary-color` | `#6b7280` | Secondary text color |
214 +
| `--sequoia-border-radius` | `8px` | Border radius for the button |
215 +
| `--sequoia-icon-display` | `inline-block` | Set to `none` to hide the button icon |
216 +
217 +
### Example: Match Site Theme
218 +
219 +
```css
220 +
:root {
221 +
  --sequoia-accent-color: #3A5A40;
222 +
  --sequoia-border-radius: 6px;
223 +
  --sequoia-bg-color: #F5F3EF;
224 +
  --sequoia-fg-color: #2C2C2C;
225 +
  --sequoia-border-color: #D5D1C8;
226 +
  --sequoia-secondary-color: #8B7355;
227 +
}
228 +
```
229 +
230 +
### Using Both Components Together
231 +
232 +
Because `sequoia-subscribe.js` only needs to be imported once, you can use both components on the same page with a single script tag:
233 +
234 +
```html
235 +
<sequoia-subscribe></sequoia-subscribe>
236 +
<sequoia-recommend></sequoia-recommend>
237 +
<script type="module" src="./src/components/sequoia-subscribe.js"></script>
238 +
```
docs/docs/pages/subscribe.mdx +4 −0
12 12
13 13
The component will look for your publication AT URI from your site's `/.well-known/site.standard.publication` endpoint automatically, so no additional configuration is required for most setups.
14 14
15 +
:::tip
16 +
`sequoia-subscribe.js` registers both the `<sequoia-subscribe>` and `<sequoia-recommend>` custom elements. You only need to import it once even if you use both components on the same page.
17 +
:::
18 +
15 19
## Usage
16 20
17 21
Since `sequoia-subscribe` is a standard Web Component, it works with any framework. Choose your setup below:
docs/src/index.ts +17 −0
2 2
import { cors } from "hono/cors";
3 3
import auth from "./routes/auth";
4 4
import subscribe from "./routes/subscribe";
5 +
import recommend from "./routes/recommend";
5 6
import "./lib/path-redirect";
6 7
7 8
type Bindings = {
29 30
30 31
app.route("/oauth", auth);
31 32
app.route("/subscribe", subscribe);
33 +
34 +
app.use(
35 +
	"/recommend",
36 +
	cors({
37 +
		origin: (origin) => origin,
38 +
		credentials: true,
39 +
	}),
40 +
);
41 +
app.use(
42 +
	"/recommend/*",
43 +
	cors({
44 +
		origin: (origin) => origin,
45 +
		credentials: true,
46 +
	}),
47 +
);
48 +
app.route("/recommend", recommend);
32 49
33 50
app.get("/api/health", (c) => {
34 51
	return c.json({ status: "ok" });
docs/src/lib/oauth-client.ts +2 −2
4 4
import { createStateStore, createSessionStore } from "./kv-stores";
5 5
6 6
export const OAUTH_SCOPE =
7 -
	"atproto repo:site.standard.graph.subscription?action=create&action=delete";
7 +
	"atproto repo:site.standard.graph.recommend?action=create&action=delete repo:site.standard.graph.subscription?action=create&action=delete";
8 8
9 9
export function createOAuthClient(kv: KVNamespace, clientUrl: string) {
10 10
	const clientId = `${clientUrl}/oauth/client-metadata.json`;
22 22
			redirect_uris: [redirectUri],
23 23
			grant_types: ["authorization_code", "refresh_token"],
24 24
			response_types: ["code"],
25 -
			scope: "atproto repo:site.standard.graph.subscription?action=create",
25 +
			scope: OAUTH_SCOPE,
26 26
			token_endpoint_auth_method: "none",
27 27
			application_type: "web",
28 28
			dpop_bound_access_tokens: true,
docs/src/routes/lib.ts (added) +247 −0
1 +
import type { Agent } from "@atproto/api";
2 +
3 +
export const REDIRECT_DELAY_SECONDS = 5;
4 +
5 +
// ============================================================================
6 +
// Helpers
7 +
// ============================================================================
8 +
9 +
export function withReturnToParam(
10 +
	returnTo: string | undefined,
11 +
	key: string,
12 +
	value: string,
13 +
): string | undefined {
14 +
	if (!returnTo) return undefined;
15 +
	try {
16 +
		const url = new URL(returnTo);
17 +
		url.searchParams.set(key, value);
18 +
		return url.toString();
19 +
	} catch {
20 +
		return returnTo;
21 +
	}
22 +
}
23 +
24 +
/**
25 +
 * Scan a repo for a record in `collection` where `record[field] === uri`.
26 +
 * Returns the record AT-URI, or null if not found.
27 +
 */
28 +
export async function findExistingRecord(
29 +
	agent: Agent,
30 +
	did: string,
31 +
	collection: string,
32 +
	field: string,
33 +
	uri: string,
34 +
): Promise<string | null> {
35 +
	let cursor: string | undefined;
36 +
37 +
	do {
38 +
		const result = await agent.com.atproto.repo.listRecords({
39 +
			repo: did,
40 +
			collection,
41 +
			limit: 100,
42 +
			cursor,
43 +
		});
44 +
45 +
		for (const record of result.data.records) {
46 +
			const value = record.value as Record<string, unknown>;
47 +
			if (value[field] === uri) {
48 +
				return record.uri;
49 +
			}
50 +
		}
51 +
52 +
		cursor = result.data.cursor;
53 +
	} while (cursor);
54 +
55 +
	return null;
56 +
}
57 +
58 +
// ============================================================================
59 +
// HTML rendering
60 +
// ============================================================================
61 +
62 +
export function renderHandleForm(
63 +
	params: {
64 +
		resourceUri: string;
65 +
		resourceField: string;
66 +
		loginPath: string;
67 +
		title: string;
68 +
		description: string;
69 +
		buttonLabel: string;
70 +
		returnTo?: string;
71 +
		error?: string;
72 +
		action?: string;
73 +
	},
74 +
	styleHref: string,
75 +
): string {
76 +
	const {
77 +
		resourceUri,
78 +
		resourceField,
79 +
		loginPath,
80 +
		title,
81 +
		description,
82 +
		buttonLabel,
83 +
		returnTo,
84 +
		error,
85 +
		action,
86 +
	} = params;
87 +
88 +
	const errorHtml = error
89 +
		? `<p class="vocs_Paragraph error">${escapeHtml(error)}</p>`
90 +
		: "";
91 +
	const returnToInput = returnTo
92 +
		? `<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />`
93 +
		: "";
94 +
	const actionInput = action
95 +
		? `<input type="hidden" name="action" value="${escapeHtml(action)}" />`
96 +
		: "";
97 +
98 +
	return page(
99 +
		`
100 +
		<h1 class="vocs_H1 vocs_Heading">${escapeHtml(title)}</h1>
101 +
		<p class="vocs_Paragraph">${escapeHtml(description)}</p>
102 +
		${errorHtml}
103 +
		<form method="POST" action="${escapeHtml(loginPath)}">
104 +
			<input type="hidden" name="${escapeHtml(resourceField)}" value="${escapeHtml(resourceUri)}" />
105 +
			${returnToInput}
106 +
			${actionInput}
107 +
			<input
108 +
				type="text"
109 +
				name="handle"
110 +
				placeholder="you.bsky.social"
111 +
				autocomplete="username"
112 +
				required
113 +
				autofocus
114 +
			/>
115 +
			<button type="submit" class="vocs_Button_button vocs_Button_button_accent">${escapeHtml(buttonLabel)}</button>
116 +
		</form>
117 +
	`,
118 +
		styleHref,
119 +
	);
120 +
}
121 +
122 +
export function renderSuccess(
123 +
	params: {
124 +
		resourceUri: string;
125 +
		resourceLabel: string;
126 +
		recordUri: string | null;
127 +
		heading: string;
128 +
		msg: string;
129 +
		returnTo?: string;
130 +
	},
131 +
	styleHref: string,
132 +
): string {
133 +
	const { resourceUri, resourceLabel, recordUri, heading, msg, returnTo } =
134 +
		params;
135 +
	const escapedResourceUri = escapeHtml(resourceUri);
136 +
	const escapedReturnTo = returnTo ? escapeHtml(returnTo) : "";
137 +
138 +
	const redirectHtml = returnTo
139 +
		? `<p class="vocs_Paragraph" id="redirect-msg">Redirecting to <a class="vocs_Anchor" href="${escapedReturnTo}">${escapedReturnTo}</a> in <span id="countdown">${REDIRECT_DELAY_SECONDS}</span>\u00a0seconds\u2026</p>
140 +
		<script>
141 +
		(function(){
142 +
			var secs = ${REDIRECT_DELAY_SECONDS};
143 +
			var el = document.getElementById('countdown');
144 +
			var iv = setInterval(function(){
145 +
				secs--;
146 +
				if (el) el.textContent = String(secs);
147 +
				if (secs <= 0) { clearInterval(iv); location.href = ${JSON.stringify(returnTo)}; }
148 +
			}, 1000);
149 +
		})();
150 +
		</script>`
151 +
		: "";
152 +
	const headExtra = returnTo
153 +
		? `<meta http-equiv="refresh" content="${REDIRECT_DELAY_SECONDS};url=${escapedReturnTo}" />`
154 +
		: "";
155 +
156 +
	return page(
157 +
		`
158 +
		<h1 class="vocs_H1 vocs_Heading">${escapeHtml(heading)}</h1>
159 +
		<p class="vocs_Paragraph">${msg}</p>
160 +
		${redirectHtml}
161 +
		<table class="vocs_Table" style="display:table;table-layout:fixed;width:100%;overflow:hidden;">
162 +
			<colgroup><col style="width:7rem;"><col></colgroup>
163 +
			<tbody>
164 +
				<tr class="vocs_TableRow">
165 +
					<td class="vocs_TableCell">${escapeHtml(resourceLabel)}</td>
166 +
					<td class="vocs_TableCell" style="overflow:hidden;">
167 +
						<div style="overflow-x:auto;white-space:nowrap;"><code class="vocs_Code"><a href="https://pds.ls/${escapedResourceUri}">${escapedResourceUri}</a></code></div>
168 +
					</td>
169 +
				</tr>
170 +
				${
171 +
					recordUri
172 +
						? `<tr class="vocs_TableRow">
173 +
					<td class="vocs_TableCell">Record</td>
174 +
					<td class="vocs_TableCell" style="overflow:hidden;">
175 +
						<div style="overflow-x:auto;white-space:nowrap;"><code class="vocs_Code"><a href="https://pds.ls/${escapeHtml(recordUri)}">${escapeHtml(recordUri)}</a></code></div>
176 +
					</td>
177 +
				</tr>`
178 +
						: ""
179 +
				}
180 +
			</tbody>
181 +
		</table>
182 +
	`,
183 +
		styleHref,
184 +
		headExtra,
185 +
	);
186 +
}
187 +
188 +
export function renderError(message: string, styleHref: string): string {
189 +
	return page(
190 +
		`<h1 class="vocs_H1 vocs_Heading">Error</h1><p class="vocs_Paragraph error">${escapeHtml(message)}</p>`,
191 +
		styleHref,
192 +
	);
193 +
}
194 +
195 +
export function page(body: string, styleHref: string, headExtra = ""): string {
196 +
	return `<!DOCTYPE html>
197 +
<html lang="en">
198 +
<head>
199 +
  <meta charset="UTF-8" />
200 +
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
201 +
  <title>Sequoia</title>
202 +
  <link rel="stylesheet" href="${styleHref}" />
203 +
  <script>if(window.matchMedia('(prefers-color-scheme: dark)').matches)document.documentElement.classList.add('dark')</script>
204 +
  ${headExtra}
205 +
  <style>
206 +
    .page-container {
207 +
      max-width: calc(var(--vocs-content_width, 480px) / 1.6);
208 +
      margin: 4rem auto;
209 +
      padding: 0 var(--vocs-space_20, 1.25rem);
210 +
    }
211 +
    .vocs_Heading { margin-bottom: var(--vocs-space_12, .75rem); }
212 +
    .vocs_Paragraph { margin-bottom: var(--vocs-space_16, 1rem); }
213 +
    input[type="text"] {
214 +
      padding: var(--vocs-space_8, .5rem) var(--vocs-space_12, .75rem);
215 +
      border: 1px solid var(--vocs-color_border, #D5D1C8);
216 +
      border-radius: var(--vocs-borderRadius_6, 6px);
217 +
      margin-bottom: var(--vocs-space_20, 1.25rem);
218 +
	  min-width: 30vh;
219 +
	  width: 100%;
220 +
      font-size: var(--vocs-fontSize_16, 1rem);
221 +
      font-family: inherit;
222 +
      background: var(--vocs-color_background, #F5F3EF);
223 +
      color: var(--vocs-color_text, #2C2C2C);
224 +
    }
225 +
    input[type="text"]:focus {
226 +
      border-color: var(--vocs-color_borderAccent, #3A5A40);
227 +
      outline: 2px solid var(--vocs-color_borderAccent, #3A5A40);
228 +
      outline-offset: 2px;
229 +
    }
230 +
    .error { color: var(--vocs-color_dangerText, #8B3A3A); }
231 +
  </style>
232 +
</head>
233 +
<body>
234 +
  <div class="page-container">
235 +
    ${body}
236 +
  </div>
237 +
</body>
238 +
</html>`;
239 +
}
240 +
241 +
export function escapeHtml(text: string): string {
242 +
	return text
243 +
		.replace(/&/g, "&amp;")
244 +
		.replace(/</g, "&lt;")
245 +
		.replace(/>/g, "&gt;")
246 +
		.replace(/"/g, "&quot;");
247 +
}
docs/src/routes/recommend.ts (added) +348 −0
1 +
import { Agent } from "@atproto/api";
2 +
import { Hono } from "hono";
3 +
import { createOAuthClient } from "../lib/oauth-client";
4 +
import { getSessionDid, setReturnToCookie } from "../lib/session";
5 +
import {
6 +
	findExistingRecord,
7 +
	renderError,
8 +
	renderHandleForm,
9 +
	renderSuccess,
10 +
	withReturnToParam,
11 +
} from "./lib";
12 +
13 +
interface Env {
14 +
	ASSETS: Fetcher;
15 +
	SEQUOIA_SESSIONS: KVNamespace;
16 +
	CLIENT_URL: string;
17 +
}
18 +
19 +
// Cache the vocs-generated stylesheet href across requests (changes on rebuild).
20 +
let _vocsStyleHref: string | null = null;
21 +
22 +
async function getVocsStyleHref(
23 +
	assets: Fetcher,
24 +
	baseUrl: string,
25 +
): Promise<string> {
26 +
	if (_vocsStyleHref) return _vocsStyleHref;
27 +
	try {
28 +
		const indexUrl = new URL("/", baseUrl).toString();
29 +
		const res = await assets.fetch(indexUrl);
30 +
		const html = await res.text();
31 +
		const match = html.match(/<link[^>]+href="(\/assets\/style[^"]+\.css)"/);
32 +
		if (match?.[1]) {
33 +
			_vocsStyleHref = match[1];
34 +
			return match[1];
35 +
		}
36 +
	} catch {
37 +
		// Fall back to the custom stylesheet which at least provides --sequoia-* vars
38 +
	}
39 +
	return "/styles.css";
40 +
}
41 +
42 +
const recommend = new Hono<{ Bindings: Env }>();
43 +
44 +
const COLLECTION = "site.standard.graph.recommend";
45 +
46 +
// ============================================================================
47 +
// POST /recommend
48 +
// ============================================================================
49 +
50 +
recommend.post("/", async (c) => {
51 +
	let documentUri: string;
52 +
	try {
53 +
		const body = await c.req.json<{ documentUri?: string }>();
54 +
		documentUri = body.documentUri ?? "";
55 +
	} catch {
56 +
		return c.json({ error: "Invalid JSON body" }, 400);
57 +
	}
58 +
59 +
	if (!documentUri || !documentUri.startsWith("at://")) {
60 +
		return c.json({ error: "Missing or invalid documentUri" }, 400);
61 +
	}
62 +
63 +
	const did = getSessionDid(c);
64 +
	if (!did) {
65 +
		const subscribeUrl = `${c.env.CLIENT_URL}/recommend?documentUri=${encodeURIComponent(documentUri)}`;
66 +
		return c.json({ authenticated: false, subscribeUrl }, 401);
67 +
	}
68 +
69 +
	try {
70 +
		const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL);
71 +
		const session = await client.restore(did);
72 +
		const agent = new Agent(session);
73 +
74 +
		const existingUri = await findExistingRecord(
75 +
			agent,
76 +
			did,
77 +
			COLLECTION,
78 +
			"document",
79 +
			documentUri,
80 +
		);
81 +
		if (existingUri) {
82 +
			return c.json({
83 +
				recommended: true,
84 +
				existing: true,
85 +
				recordUri: existingUri,
86 +
			});
87 +
		}
88 +
89 +
		const result = await agent.com.atproto.repo.createRecord({
90 +
			repo: did,
91 +
			collection: COLLECTION,
92 +
			record: {
93 +
				$type: COLLECTION,
94 +
				document: documentUri,
95 +
				createdAt: new Date().toISOString(),
96 +
			},
97 +
		});
98 +
99 +
		return c.json({
100 +
			recommended: true,
101 +
			existing: false,
102 +
			recordUri: result.data.uri,
103 +
		});
104 +
	} catch (error) {
105 +
		console.error("Recommend POST error:", error);
106 +
		const subscribeUrl = `${c.env.CLIENT_URL}/recommend?documentUri=${encodeURIComponent(documentUri)}`;
107 +
		return c.json({ authenticated: false, subscribeUrl }, 401);
108 +
	}
109 +
});
110 +
111 +
// ============================================================================
112 +
// GET /recommend?documentUri=at://...
113 +
//
114 +
// Full-page OAuth + recommendation flow. Unauthenticated users land here after
115 +
// the component redirects them, and authenticated users land here after the
116 +
// OAuth callback (via the login_return_to cookie set in POST /recommend/login).
117 +
// ============================================================================
118 +
119 +
recommend.get("/", async (c) => {
120 +
	const documentUri = c.req.query("documentUri");
121 +
	const action = c.req.query("action");
122 +
	const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url);
123 +
124 +
	if (action && action !== "remove") {
125 +
		return c.html(renderError(`Unsupported action: ${action}`, styleHref), 400);
126 +
	}
127 +
128 +
	if (!documentUri || !documentUri.startsWith("at://")) {
129 +
		return c.html(
130 +
			renderError("Missing or invalid document URI.", styleHref),
131 +
			400,
132 +
		);
133 +
	}
134 +
135 +
	// Prefer an explicit returnTo query param (survives the OAuth round-trip);
136 +
	// fall back to the Referer header on the first visit, ignoring self-referrals.
137 +
	const referer = c.req.header("referer");
138 +
	const returnTo =
139 +
		c.req.query("returnTo") ??
140 +
		(referer && !referer.includes("/recommend") ? referer : undefined);
141 +
142 +
	const did = getSessionDid(c);
143 +
	if (!did) {
144 +
		return c.html(
145 +
			renderHandleForm(
146 +
				{
147 +
					resourceUri: documentUri,
148 +
					resourceField: "documentUri",
149 +
					loginPath: "/recommend/login",
150 +
					title: "Recommend on Sequoia",
151 +
					description: "Enter your Bluesky handle to recommend this document.",
152 +
					buttonLabel: "Continue on Bluesky",
153 +
					returnTo,
154 +
					action,
155 +
				},
156 +
				styleHref,
157 +
			),
158 +
		);
159 +
	}
160 +
161 +
	try {
162 +
		const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL);
163 +
		const session = await client.restore(did);
164 +
		const agent = new Agent(session);
165 +
166 +
		if (action === "remove") {
167 +
			const existingUri = await findExistingRecord(
168 +
				agent,
169 +
				did,
170 +
				COLLECTION,
171 +
				"document",
172 +
				documentUri,
173 +
			);
174 +
			if (existingUri) {
175 +
				const rkey = existingUri.split("/").pop()!;
176 +
				await agent.com.atproto.repo.deleteRecord({
177 +
					repo: did,
178 +
					collection: COLLECTION,
179 +
					rkey,
180 +
				});
181 +
			}
182 +
183 +
			return c.html(
184 +
				renderSuccess(
185 +
					{
186 +
						resourceUri: documentUri,
187 +
						resourceLabel: "Document",
188 +
						recordUri: null,
189 +
						heading: "Recommendation Removed \u2713",
190 +
						msg: existingUri
191 +
							? "You've successfully removed your recommendation."
192 +
							: "You hadn't recommended this document.",
193 +
						returnTo: withReturnToParam(returnTo, "sequoia_did", did),
194 +
					},
195 +
					styleHref,
196 +
				),
197 +
			);
198 +
		}
199 +
200 +
		const existingUri = await findExistingRecord(
201 +
			agent,
202 +
			did,
203 +
			COLLECTION,
204 +
			"document",
205 +
			documentUri,
206 +
		);
207 +
		const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did);
208 +
209 +
		if (existingUri) {
210 +
			return c.html(
211 +
				renderSuccess(
212 +
					{
213 +
						resourceUri: documentUri,
214 +
						resourceLabel: "Document",
215 +
						recordUri: existingUri,
216 +
						heading: "Recommended \u2713",
217 +
						msg: "You've already recommended this document.",
218 +
						returnTo: returnToWithDid,
219 +
					},
220 +
					styleHref,
221 +
				),
222 +
			);
223 +
		}
224 +
225 +
		const result = await agent.com.atproto.repo.createRecord({
226 +
			repo: did,
227 +
			collection: COLLECTION,
228 +
			record: {
229 +
				$type: COLLECTION,
230 +
				document: documentUri,
231 +
				createdAt: new Date().toISOString(),
232 +
			},
233 +
		});
234 +
235 +
		return c.html(
236 +
			renderSuccess(
237 +
				{
238 +
					resourceUri: documentUri,
239 +
					resourceLabel: "Document",
240 +
					recordUri: result.data.uri,
241 +
					heading: "Recommended \u2713",
242 +
					msg: "You've successfully recommended this document!",
243 +
					returnTo: returnToWithDid,
244 +
				},
245 +
				styleHref,
246 +
			),
247 +
		);
248 +
	} catch (error) {
249 +
		console.error("Recommend GET error:", error);
250 +
		// Session expired - ask the user to sign in again
251 +
		return c.html(
252 +
			renderHandleForm(
253 +
				{
254 +
					resourceUri: documentUri,
255 +
					resourceField: "documentUri",
256 +
					loginPath: "/recommend/login",
257 +
					title: "Recommend on Sequoia",
258 +
					description: "Enter your Bluesky handle to recommend this document.",
259 +
					buttonLabel: "Continue on Bluesky",
260 +
					returnTo,
261 +
					error: "Session expired. Please sign in again.",
262 +
					action,
263 +
				},
264 +
				styleHref,
265 +
			),
266 +
		);
267 +
	}
268 +
});
269 +
270 +
// ============================================================================
271 +
// GET /recommend/check?documentUri=at://...
272 +
//
273 +
// JSON-only endpoint for the web component to check recommendation status.
274 +
//
275 +
// Responses:
276 +
//   200 { recommended: true, recordUri: string }
277 +
//   200 { recommended: false }
278 +
//   400 { error: string }
279 +
//   401 { authenticated: false }
280 +
// ============================================================================
281 +
282 +
recommend.get("/check", async (c) => {
283 +
	const documentUri = c.req.query("documentUri");
284 +
285 +
	if (!documentUri || !documentUri.startsWith("at://")) {
286 +
		return c.json({ error: "Missing or invalid documentUri" }, 400);
287 +
	}
288 +
289 +
	// Prefer the server-side session DID; fall back to a client-provided DID
290 +
	// (stored by the web component from a previous recommend flow).
291 +
	const did = getSessionDid(c) ?? c.req.query("did") ?? null;
292 +
	if (!did || !did.startsWith("did:")) {
293 +
		return c.json({ authenticated: false }, 401);
294 +
	}
295 +
296 +
	try {
297 +
		const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL);
298 +
		const session = await client.restore(did);
299 +
		const agent = new Agent(session);
300 +
		const recordUri = await findExistingRecord(
301 +
			agent,
302 +
			did,
303 +
			COLLECTION,
304 +
			"document",
305 +
			documentUri,
306 +
		);
307 +
		return recordUri
308 +
			? c.json({ recommended: true, recordUri })
309 +
			: c.json({ recommended: false });
310 +
	} catch {
311 +
		return c.json({ authenticated: false }, 401);
312 +
	}
313 +
});
314 +
315 +
// ============================================================================
316 +
// POST /recommend/login
317 +
//
318 +
// Handles the handle-entry form submission. Stores the return URL in a cookie
319 +
// so the OAuth callback in auth.ts can redirect back to /recommend after auth.
320 +
// ============================================================================
321 +
322 +
recommend.post("/login", async (c) => {
323 +
	const body = await c.req.parseBody();
324 +
	const handle = (body.handle as string | undefined)?.trim();
325 +
	const documentUri = body.documentUri as string | undefined;
326 +
	const formReturnTo = (body.returnTo as string | undefined) || undefined;
327 +
	const formAction = (body.action as string | undefined) || undefined;
328 +
329 +
	if (!handle || !documentUri) {
330 +
		const styleHref = await getVocsStyleHref(c.env.ASSETS, c.req.url);
331 +
		return c.html(
332 +
			renderError("Missing handle or document URI.", styleHref),
333 +
			400,
334 +
		);
335 +
	}
336 +
337 +
	const returnTo =
338 +
		`${c.env.CLIENT_URL}/recommend?documentUri=${encodeURIComponent(documentUri)}` +
339 +
		(formAction ? `&action=${encodeURIComponent(formAction)}` : "") +
340 +
		(formReturnTo ? `&returnTo=${encodeURIComponent(formReturnTo)}` : "");
341 +
	setReturnToCookie(c, returnTo, c.env.CLIENT_URL);
342 +
343 +
	return c.redirect(
344 +
		`${c.env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`,
345 +
	);
346 +
});
347 +
348 +
export default recommend;
docs/src/routes/subscribe.ts +75 −244
2 2
import { Hono } from "hono";
3 3
import { createOAuthClient } from "../lib/oauth-client";
4 4
import { getSessionDid, setReturnToCookie } from "../lib/session";
5 +
import {
6 +
	findExistingRecord,
7 +
	renderError,
8 +
	renderHandleForm,
9 +
	renderSuccess,
10 +
	withReturnToParam,
11 +
} from "./lib";
5 12
6 13
interface Env {
7 14
	ASSETS: Fetcher;
35 42
const subscribe = new Hono<{ Bindings: Env }>();
36 43
37 44
const COLLECTION = "site.standard.graph.subscription";
38 -
const REDIRECT_DELAY_SECONDS = 5;
39 45
40 46
// ============================================================================
41 47
// Helpers
42 48
// ============================================================================
43 49
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 -
/**
63 -
 * Scan the user's repo for an existing site.standard.graph.subscription
64 -
 * matching the given publication URI. Returns the record AT-URI if found.
65 -
 */
66 -
async function findExistingSubscription(
67 -
	agent: Agent,
68 -
	did: string,
69 -
	publicationUri: string,
70 -
): Promise<string | null> {
71 -
	let cursor: string | undefined;
72 -
73 -
	do {
74 -
		const result = await agent.com.atproto.repo.listRecords({
75 -
			repo: did,
76 -
			collection: COLLECTION,
77 -
			limit: 100,
78 -
			cursor,
79 -
		});
80 -
81 -
		for (const record of result.data.records) {
82 -
			const value = record.value as { publication?: string };
83 -
			if (value.publication === publicationUri) {
84 -
				return record.uri;
85 -
			}
86 -
		}
87 -
88 -
		cursor = result.data.cursor;
89 -
	} while (cursor);
90 -
91 -
	return null;
92 -
}
93 -
94 50
// ============================================================================
95 51
// POST /subscribe
96 52
//
127 83
		const session = await client.restore(did);
128 84
		const agent = new Agent(session);
129 85
130 -
		const existingUri = await findExistingSubscription(
86 +
		const existingUri = await findExistingRecord(
131 87
			agent,
132 88
			did,
89 +
			COLLECTION,
90 +
			"publication",
133 91
			publicationUri,
134 92
		);
135 93
		if (existingUri) {
196 154
	const did = getSessionDid(c);
197 155
	if (!did) {
198 156
		return c.html(
199 -
			renderHandleForm(publicationUri, styleHref, returnTo, undefined, action),
157 +
			renderHandleForm(
158 +
				{
159 +
					resourceUri: publicationUri,
160 +
					resourceField: "publicationUri",
161 +
					loginPath: "/subscribe/login",
162 +
					title: "Subscribe on Sequoia",
163 +
					description:
164 +
						"Enter your Bluesky handle to subscribe to this publication.",
165 +
					buttonLabel: "Continue on Bluesky",
166 +
					returnTo,
167 +
					action,
168 +
				},
169 +
				styleHref,
170 +
			),
200 171
		);
201 172
	}
202 173
206 177
		const agent = new Agent(session);
207 178
208 179
		if (action === "unsubscribe") {
209 -
			const existingUri = await findExistingSubscription(
180 +
			const existingUri = await findExistingRecord(
210 181
				agent,
211 182
				did,
183 +
				COLLECTION,
184 +
				"publication",
212 185
				publicationUri,
213 186
			);
214 187
			if (existingUri) {
234 207
235 208
			return c.html(
236 209
				renderSuccess(
237 -
					publicationUri,
238 -
					null,
239 -
					"Unsubscribed ✓",
240 -
					existingUri
241 -
						? "You've successfully unsubscribed!"
242 -
						: "You weren't subscribed to this publication.",
210 +
					{
211 +
						resourceUri: publicationUri,
212 +
						resourceLabel: "Publication",
213 +
						recordUri: null,
214 +
						heading: "Unsubscribed ✓",
215 +
						msg: existingUri
216 +
							? "You've successfully unsubscribed!"
217 +
							: "You weren't subscribed to this publication.",
218 +
						returnTo: withReturnToParam(
219 +
							cleanReturnTo,
220 +
							"sequoia_unsubscribed",
221 +
							"1",
222 +
						),
223 +
					},
243 224
					styleHref,
244 -
					withReturnToParam(cleanReturnTo, "sequoia_unsubscribed", "1"),
245 225
				),
246 226
			);
247 227
		}
248 228
249 -
		const existingUri = await findExistingSubscription(
229 +
		const existingUri = await findExistingRecord(
250 230
			agent,
251 231
			did,
232 +
			COLLECTION,
233 +
			"publication",
252 234
			publicationUri,
253 235
		);
254 236
		const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did);
256 238
		if (existingUri) {
257 239
			return c.html(
258 240
				renderSuccess(
259 -
					publicationUri,
260 -
					existingUri,
261 -
					"Subscribed ✓",
262 -
					"You're already subscribed to this publication.",
241 +
					{
242 +
						resourceUri: publicationUri,
243 +
						resourceLabel: "Publication",
244 +
						recordUri: existingUri,
245 +
						heading: "Subscribed ✓",
246 +
						msg: "You're already subscribed to this publication.",
247 +
						returnTo: returnToWithDid,
248 +
					},
263 249
					styleHref,
264 -
					returnToWithDid,
265 250
				),
266 251
			);
267 252
		}
277 262
278 263
		return c.html(
279 264
			renderSuccess(
280 -
				publicationUri,
281 -
				result.data.uri,
282 -
				"Subscribed ✓",
283 -
				"You've successfully subscribed!",
265 +
				{
266 +
					resourceUri: publicationUri,
267 +
					resourceLabel: "Publication",
268 +
					recordUri: result.data.uri,
269 +
					heading: "Subscribed ✓",
270 +
					msg: "You've successfully subscribed!",
271 +
					returnTo: returnToWithDid,
272 +
				},
284 273
				styleHref,
285 -
				returnToWithDid,
286 274
			),
287 275
		);
288 276
	} catch (error) {
290 278
		// Session expired - ask the user to sign in again
291 279
		return c.html(
292 280
			renderHandleForm(
293 -
				publicationUri,
281 +
				{
282 +
					resourceUri: publicationUri,
283 +
					resourceField: "publicationUri",
284 +
					loginPath: "/subscribe/login",
285 +
					title: "Subscribe on Sequoia",
286 +
					description:
287 +
						"Enter your Bluesky handle to subscribe to this publication.",
288 +
					buttonLabel: "Continue on Bluesky",
289 +
					returnTo,
290 +
					error: "Session expired. Please sign in again.",
291 +
					action,
292 +
				},
294 293
				styleHref,
295 -
				returnTo,
296 -
				"Session expired. Please sign in again.",
297 -
				action,
298 294
			),
299 295
		);
300 296
	}
330 326
		const client = createOAuthClient(c.env.SEQUOIA_SESSIONS, c.env.CLIENT_URL);
331 327
		const session = await client.restore(did);
332 328
		const agent = new Agent(session);
333 -
		const recordUri = await findExistingSubscription(
329 +
		const recordUri = await findExistingRecord(
334 330
			agent,
335 331
			did,
332 +
			COLLECTION,
333 +
			"publication",
336 334
			publicationUri,
337 335
		);
338 336
		return recordUri
375 373
		`${c.env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`,
376 374
	);
377 375
});
378 -
379 -
// ============================================================================
380 -
// HTML rendering
381 -
// ============================================================================
382 -
383 -
function renderHandleForm(
384 -
	publicationUri: string,
385 -
	styleHref: string,
386 -
	returnTo?: string,
387 -
	error?: string,
388 -
	action?: string,
389 -
): string {
390 -
	const errorHtml = error
391 -
		? `<p class="vocs_Paragraph error">${escapeHtml(error)}</p>`
392 -
		: "";
393 -
	const returnToInput = returnTo
394 -
		? `<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />`
395 -
		: "";
396 -
	const actionInput = action
397 -
		? `<input type="hidden" name="action" value="${escapeHtml(action)}" />`
398 -
		: "";
399 -
400 -
	return page(
401 -
		`
402 -
		<h1 class="vocs_H1 vocs_Heading">Subscribe on Bluesky</h1>
403 -
		<p class="vocs_Paragraph">Enter your Bluesky handle to subscribe to this publication.</p>
404 -
		${errorHtml}
405 -
		<form method="POST" action="/subscribe/login">
406 -
			<input type="hidden" name="publicationUri" value="${escapeHtml(publicationUri)}" />
407 -
			${returnToInput}
408 -
			${actionInput}
409 -
			<input
410 -
				type="text"
411 -
				name="handle"
412 -
				placeholder="you.bsky.social"
413 -
				autocomplete="username"
414 -
				required
415 -
				autofocus
416 -
			/>
417 -
			<button type="submit" class="vocs_Button_button vocs_Button_button_accent">Continue on Bluesky</button>
418 -
		</form>
419 -
	`,
420 -
		styleHref,
421 -
	);
422 -
}
423 -
424 -
function renderSuccess(
425 -
	publicationUri: string,
426 -
	recordUri: string | null,
427 -
	heading: string,
428 -
	msg: string,
429 -
	styleHref: string,
430 -
	returnTo?: string,
431 -
): string {
432 -
	const escapedPublicationUri = escapeHtml(publicationUri);
433 -
	const escapedReturnTo = returnTo ? escapeHtml(returnTo) : "";
434 -
435 -
	const redirectHtml = returnTo
436 -
		? `<p class="vocs_Paragraph" id="redirect-msg">Redirecting to <a class="vocs_Anchor" href="${escapedReturnTo}">${escapedReturnTo}</a> in <span id="countdown">${REDIRECT_DELAY_SECONDS}</span>\u00a0seconds\u2026</p>
437 -
		<script>
438 -
		(function(){
439 -
			var secs = ${REDIRECT_DELAY_SECONDS};
440 -
			var el = document.getElementById('countdown');
441 -
			var iv = setInterval(function(){
442 -
				secs--;
443 -
				if (el) el.textContent = String(secs);
444 -
				if (secs <= 0) { clearInterval(iv); location.href = ${JSON.stringify(returnTo)}; }
445 -
			}, 1000);
446 -
		})();
447 -
		</script>`
448 -
		: "";
449 -
	const headExtra = returnTo
450 -
		? `<meta http-equiv="refresh" content="${REDIRECT_DELAY_SECONDS};url=${escapedReturnTo}" />`
451 -
		: "";
452 -
453 -
	return page(
454 -
		`
455 -
		<h1 class="vocs_H1 vocs_Heading">${escapeHtml(heading)}</h1>
456 -
		<p class="vocs_Paragraph">${msg}</p>
457 -
		${redirectHtml}
458 -
		<table class="vocs_Table" style="display:table;table-layout:fixed;width:100%;overflow:hidden;">
459 -
			<colgroup><col style="width:7rem;"><col></colgroup>
460 -
			<tbody>
461 -
				<tr class="vocs_TableRow">
462 -
					<td class="vocs_TableCell">Publication</td>
463 -
					<td class="vocs_TableCell" style="overflow:hidden;">
464 -
						<div style="overflow-x:auto;white-space:nowrap;"><code class="vocs_Code"><a href="https://pds.ls/${escapedPublicationUri}">${escapedPublicationUri}</a></code></div>
465 -
					</td>
466 -
				</tr>
467 -
				${
468 -
					recordUri
469 -
						? `<tr class="vocs_TableRow">
470 -
					<td class="vocs_TableCell">Record</td>
471 -
					<td class="vocs_TableCell" style="overflow:hidden;">
472 -
						<div style="overflow-x:auto;white-space:nowrap;"><code class="vocs_Code"><a href="https://pds.ls/${escapeHtml(recordUri)}">${escapeHtml(recordUri)}</a></code></div>
473 -
					</td>
474 -
				</tr>`
475 -
						: ""
476 -
				}
477 -
			</tbody>
478 -
		</table>
479 -
	`,
480 -
		styleHref,
481 -
		headExtra,
482 -
	);
483 -
}
484 -
485 -
function renderError(message: string, styleHref: string): string {
486 -
	return page(
487 -
		`<h1 class="vocs_H1 vocs_Heading">Error</h1><p class="vocs_Paragraph error">${escapeHtml(message)}</p>`,
488 -
		styleHref,
489 -
	);
490 -
}
491 -
492 -
function page(body: string, styleHref: string, headExtra = ""): string {
493 -
	return `<!DOCTYPE html>
494 -
<html lang="en">
495 -
<head>
496 -
  <meta charset="UTF-8" />
497 -
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
498 -
  <title>Sequoia · Subscribe</title>
499 -
  <link rel="stylesheet" href="${styleHref}" />
500 -
  <script>if(window.matchMedia('(prefers-color-scheme: dark)').matches)document.documentElement.classList.add('dark')</script>
501 -
  ${headExtra}
502 -
  <style>
503 -
    .page-container {
504 -
      max-width: calc(var(--vocs-content_width, 480px) / 1.6);
505 -
      margin: 4rem auto;
506 -
      padding: 0 var(--vocs-space_20, 1.25rem);
507 -
    }
508 -
    .vocs_Heading { margin-bottom: var(--vocs-space_12, .75rem); }
509 -
    .vocs_Paragraph { margin-bottom: var(--vocs-space_16, 1rem); }
510 -
    input[type="text"] {
511 -
      padding: var(--vocs-space_8, .5rem) var(--vocs-space_12, .75rem);
512 -
      border: 1px solid var(--vocs-color_border, #D5D1C8);
513 -
      border-radius: var(--vocs-borderRadius_6, 6px);
514 -
      margin-bottom: var(--vocs-space_20, 1.25rem);
515 -
	  min-width: 30vh;
516 -
	  width: 100%;
517 -
      font-size: var(--vocs-fontSize_16, 1rem);
518 -
      font-family: inherit;
519 -
      background: var(--vocs-color_background, #F5F3EF);
520 -
      color: var(--vocs-color_text, #2C2C2C);
521 -
    }
522 -
    input[type="text"]:focus {
523 -
      border-color: var(--vocs-color_borderAccent, #3A5A40);
524 -
      outline: 2px solid var(--vocs-color_borderAccent, #3A5A40);
525 -
      outline-offset: 2px;
526 -
    }
527 -
    .error { color: var(--vocs-color_dangerText, #8B3A3A); }
528 -
  </style>
529 -
</head>
530 -
<body>
531 -
  <div class="page-container">
532 -
    ${body}
533 -
  </div>
534 -
</body>
535 -
</html>`;
536 -
}
537 -
538 -
function escapeHtml(text: string): string {
539 -
	return text
540 -
		.replace(/&/g, "&amp;")
541 -
		.replace(/</g, "&lt;")
542 -
		.replace(/>/g, "&gt;")
543 -
		.replace(/"/g, "&quot;");
544 -
}
545 376
546 377
export default subscribe;
docs/vocs.config.ts +1 −0
34 34
				{ text: "Setup", link: "/setup" },
35 35
				{ text: "Publishing", link: "/publishing" },
36 36
				{ text: "Comments", link: "/comments" },
37 +
				{ text: "Recommend", link: "/recommend" },
37 38
				{ text: "Subscribe", link: "/subscribe" },
38 39
				{ text: "Verifying", link: "/verifying" },
39 40
				{ text: "Workflows", link: "/workflows" },
docs/wrangler.toml +1 −1
8 8
binding = "ASSETS"
9 9
not_found_handling = "single-page-application"
10 10
html_handling = "auto-trailing-slash"
11 -
run_worker_first = ["/api/*", "/oauth/*", "/subscribe", "/subscribe/*"]
11 +
run_worker_first = ["/api/*", "/oauth/*", "/recommend", "/recommend/*", "/subscribe", "/subscribe/*"]
12 12
13 13
[[kv_namespaces]]
14 14
binding = "SEQUOIA_SESSIONS"
packages/cli/src/components/sequoia-subscribe.js +398 −103
1 1
/**
2 -
 * Sequoia Subscribe - An AT Protocol-powered subscribe component
3 -
 *
4 -
 * A self-contained Web Component that lets users subscribe to a publication
5 -
 * via the AT Protocol by creating a site.standard.graph.subscription record.
6 -
 *
7 -
 * Usage:
8 -
 *   <sequoia-subscribe></sequoia-subscribe>
2 +
 * Sequoia Web Components — AT Protocol-powered engagement components
9 3
 *
10 -
 * The component resolves the publication AT URI from the host site's
11 -
 * /.well-known/site.standard.publication endpoint.
4 +
 * Self-contained Web Components for subscribing to publications and
5 +
 * recommending documents via the AT Protocol.
12 6
 *
13 -
 * Attributes:
14 -
 *   - publication-uri: Override the publication AT URI (optional)
15 -
 *   - callback-uri: Redirect URI after OAuth authentication (default: "https://sequoia.pub/subscribe")
16 -
 *   - button-type: Branding style — "sequoia" (default), "bluesky", "blacksky", "atmosphere", or "plain"
17 -
 *   - label: Override the subscribe button label text
18 -
 *   - unsubscribe-label: Override the unsubscribe button label text
19 -
 *   - hide: Set to "auto" to hide if no publication URI is detected
7 +
 * Both components share:
8 +
 *   - OAuth redirect flow via a hosted callback endpoint
9 +
 *   - DID caching in a cookie (primary) and localStorage (fallback)
10 +
 *   - A common visual style driven by CSS custom properties
20 11
 *
21 -
 * CSS Custom Properties:
12 +
 * CSS Custom Properties (apply to both components):
22 13
 *   - --sequoia-fg-color: Text color (default: #1f2937)
23 14
 *   - --sequoia-bg-color: Background color (default: #ffffff)
24 15
 *   - --sequoia-border-color: Border color (default: #e5e7eb)
26 17
 *   - --sequoia-secondary-color: Secondary text color (default: #6b7280)
27 18
 *   - --sequoia-border-radius: Border radius (default: 8px)
28 19
 *   - --sequoia-icon-display: Icon display mode (default: inline-block) — set to "none" to hide
29 -
 *
30 -
 * Events:
31 -
 *   - sequoia-subscribed: Fired when the subscription is created successfully.
32 -
 *     detail: { publicationUri: string, recordUri: string }
33 -
 *   - sequoia-subscribe-error: Fired when the subscription fails.
34 -
 *     detail: { message: string }
35 20
 */
36 21
37 22
// ============================================================================
50 35
	box-sizing: border-box;
51 36
}
52 37
53 -
.sequoia-subscribe-button {
38 +
.sequoia-button {
54 39
	display: inline-flex;
55 40
	align-items: center;
56 41
	gap: 0.375rem;
67 52
	font-family: inherit;
68 53
}
69 54
70 -
.sequoia-subscribe-button:hover:not(:disabled) {
55 +
.sequoia-button:hover:not(:disabled) {
71 56
	background: color-mix(in srgb, var(--sequoia-accent-color, #2563eb) 85%, black);
72 57
}
73 58
74 -
.sequoia-subscribe-button:disabled {
59 +
.sequoia-button:disabled {
75 60
	opacity: 0.6;
76 61
	cursor: not-allowed;
77 62
}
78 63
79 -
.sequoia-subscribe-button svg {
64 +
.sequoia-button svg {
80 65
	display: var(--sequoia-icon-display, inline-block);
81 66
	width: 1rem;
82 67
	height: 1rem;
149 134
};
150 135
151 136
// ============================================================================
137 +
// Recommend Icon Configuration
138 +
// ============================================================================
139 +
140 +
const HEART_PATH =
141 +
	"M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z";
142 +
const HEART_ICON_OUTLINED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="${HEART_PATH}"/></svg>`;
143 +
const HEART_ICON_FILLED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="${HEART_PATH}"/></svg>`;
144 +
145 +
const STAR_PATH =
146 +
	"M3.612 15.443c-.386.198-.824-.149-.746-.592l.83-4.73L.173 6.765c-.329-.314-.158-.888.283-.95l4.898-.696L7.538.792c.197-.39.73-.39.927 0l2.184 4.327 4.898.696c.441.062.612.636.282.95l-3.522 3.356.83 4.73c.078.443-.36.79-.746.592L8 13.187l-4.389 2.256z";
147 +
const STAR_ICON_OUTLINED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"><path d="${STAR_PATH}"/></svg>`;
148 +
const STAR_ICON_FILLED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor"><path d="${STAR_PATH}"/></svg>`;
149 +
150 +
const THUMBS_UP_RECT_PATH = "M1 21h4V9H1v12z";
151 +
const THUMBS_UP_HAND_PATH =
152 +
	"M23 10c0-1.1-.9-2-2-2h-6.31l.95-4.57.03-.32c0-.41-.17-.79-.44-1.06L14.17 1 7.59 7.59C7.22 7.95 7 8.45 7 9v10c0 1.1.9 2 2 2h9c.83 0 1.54-.5 1.84-1.22l3.02-7.05c.09-.23.14-.47.14-.73v-2z";
153 +
const THUMBS_UP_ICON_OUTLINED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="${THUMBS_UP_RECT_PATH}" fill="currentColor"/><path d="${THUMBS_UP_HAND_PATH}" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></svg>`;
154 +
const THUMBS_UP_ICON_FILLED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="${THUMBS_UP_RECT_PATH}"/><path d="${THUMBS_UP_HAND_PATH}"/></svg>`;
155 +
156 +
const RECOMMEND_ICON_TYPES = {
157 +
	heart: {
158 +
		icon: HEART_ICON_OUTLINED,
159 +
		iconActioned: HEART_ICON_FILLED,
160 +
		action: "Recommend",
161 +
		unaction: "Unrecommend",
162 +
	},
163 +
	star: {
164 +
		icon: STAR_ICON_OUTLINED,
165 +
		iconActioned: STAR_ICON_FILLED,
166 +
		action: "Recommend",
167 +
		unaction: "Unrecommend",
168 +
	},
169 +
	"thumbs-up": {
170 +
		icon: THUMBS_UP_ICON_OUTLINED,
171 +
		iconActioned: THUMBS_UP_ICON_FILLED,
172 +
		action: "Recommend",
173 +
		unaction: "Unrecommend",
174 +
	},
175 +
};
176 +
177 +
// ============================================================================
152 178
// DID Storage
153 179
// ============================================================================
154 180
289 315
// SSR-safe base class - use HTMLElement in browser, empty class in Node.js
290 316
const BaseElement = typeof HTMLElement !== "undefined" ? HTMLElement : class {};
291 317
292 -
class SequoiaSubscribe extends BaseElement {
318 +
/**
319 +
 * Abstract base class shared by SequoiaSubscribe and SequoiaRecommend.
320 +
 * Handles shadow DOM setup, state management, the OAuth redirect flow,
321 +
 * DID storage, and button rendering. Subclasses implement template methods
322 +
 * to provide resource-specific behaviour.
323 +
 */
324 +
class SequoiaActionBase extends BaseElement {
293 325
	constructor() {
294 326
		super();
295 327
		const shadow = this.attachShadow({ mode: "open" });
303 335
		wrapper.part = "container";
304 336
305 337
		this.wrapper = wrapper;
306 -
		this.subscribed = false;
338 +
		this.actioned = false;
307 339
		this.state = { type: "idle" };
308 340
		this.abortController = null;
309 341
		this.render();
310 342
	}
311 343
312 -
	static get observedAttributes() {
313 -
		return [
314 -
			"publication-uri",
315 -
			"callback-uri",
316 -
			"label",
317 -
			"unsubscribe-label",
318 -
			"button-type",
319 -
			"hide",
320 -
		];
321 -
	}
322 -
323 -
	connectedCallback() {
324 -
		consumeReturnParams();
325 -
		this.checkPublication();
326 -
	}
327 -
328 344
	disconnectedCallback() {
329 345
		this.abortController?.abort();
330 346
	}
331 347
332 348
	attributeChangedCallback() {
333 -
		if (this.state.type === "error" || this.state.type === "no-publication") {
349 +
		if (this.state.type === "error" || this.state.type === "no-resource") {
334 350
			this.state = { type: "idle" };
335 351
		}
336 352
		this.render();
337 353
	}
338 354
339 -
	get publicationUri() {
340 -
		return this.getAttribute("publication-uri") ?? null;
355 +
	// ── Shared getters ───────────────────────────────────────────────────────
356 +
357 +
	get callbackUri() {
358 +
		return this.getAttribute("callback-uri") ?? this.defaultCallbackUri;
359 +
	}
360 +
361 +
	get hide() {
362 +
		return this.getAttribute("hide") === "auto";
363 +
	}
364 +
365 +
	// ── Template methods (override in subclasses) ────────────────────────────
366 +
367 +
	/** @returns {string} Default callback URI when the attribute is absent */
368 +
	get defaultCallbackUri() {
369 +
		return "";
370 +
	}
371 +
372 +
	/** @returns {string} Query-parameter name for the resource URI */
373 +
	get resourceParam() {
374 +
		return "resourceUri";
341 375
	}
342 376
343 -
	get callbackUri() {
344 -
		return this.getAttribute("callback-uri") ?? "https://sequoia.pub/subscribe";
377 +
	/**
378 +
	 * Value of the `action` query-parameter used in the unaction redirect.
379 +
	 * @returns {string}
380 +
	 */
381 +
	get unactionValue() {
382 +
		return "unaction";
345 383
	}
346 384
347 -
	get label() {
348 -
		return this.getAttribute("label") ?? null;
385 +
	/** @returns {string} Key in the /check response that signals the action was taken */
386 +
	get actionedKey() {
387 +
		return "actioned";
349 388
	}
350 389
351 -
	get unsubscribeLabel() {
352 -
		return this.getAttribute("unsubscribe-label") ?? null;
390 +
	/** @returns {string} CustomEvent name dispatched on success */
391 +
	get actionedEventName() {
392 +
		return "sequoia-actioned";
353 393
	}
354 394
355 -
	get buttonType() {
356 -
		const val = this.getAttribute("button-type");
357 -
		return val && val in BUTTON_TYPES ? val : "sequoia";
395 +
	/** @returns {string} CustomEvent name dispatched on error */
396 +
	get errorEventName() {
397 +
		return "sequoia-action-error";
398 +
	}
399 +
400 +
	/** @returns {string} Fallback error message when the thrown value has no message */
401 +
	get defaultErrorMessage() {
402 +
		return "Action failed";
358 403
	}
359 404
360 -
	get hide() {
361 -
		const hideAttr = this.getAttribute("hide");
362 -
		return hideAttr === "auto";
405 +
	/** @returns {string} SVG string for the button icon */
406 +
	getIcon() {
407 +
		return "";
363 408
	}
364 409
365 -
	async checkPublication() {
366 -
		this.abortController?.abort();
367 -
		this.abortController = new AbortController();
410 +
	/** @returns {string} Accessible label for the button (defaults to the visible label) */
411 +
	getAriaLabel() {
412 +
		return this.actioned
413 +
			? (this.getUnactionLabel?.() ?? this.getDefaultUnactionLabel?.() ?? "")
414 +
			: (this.label ?? this.getDefaultActionLabel?.() ?? "");
415 +
	}
368 416
369 -
		try {
370 -
			const uri = this.publicationUri ?? (await fetchPublicationUri());
371 -
			this.checkSubscription(uri);
372 -
		} catch {
373 -
			this.state = { type: "no-publication" };
374 -
			this.render();
375 -
		}
417 +
	/**
418 +
	 * Resolve the resource URI for this action. May perform async network calls.
419 +
	 * @returns {Promise<string>}
420 +
	 */
421 +
	async resolveResourceUri() {
422 +
		throw new Error("resolveResourceUri() must be implemented by subclass");
376 423
	}
377 424
378 -
	async checkSubscription(publicationUri) {
425 +
	// ── Shared logic ─────────────────────────────────────────────────────────
426 +
427 +
	/**
428 +
	 * Check whether the current user has already taken this action for the
429 +
	 * given resource URI. Updates this.actioned and re-renders on success.
430 +
	 * @param {string} resourceUri
431 +
	 */
432 +
	async checkStatusFor(resourceUri) {
379 433
		try {
380 434
			const checkUrl = new URL(`${this.callbackUri}/check`);
381 -
			checkUrl.searchParams.set("publicationUri", publicationUri);
435 +
			checkUrl.searchParams.set(this.resourceParam, resourceUri);
382 436
383 437
			// Pass the stored DID so the server can check without a session cookie
384 438
			const storedDid = getStoredSubscriberDid();
391 445
			});
392 446
			if (!res.ok) return;
393 447
			const data = await res.json();
394 -
			if (data.subscribed) {
395 -
				this.subscribed = true;
448 +
			if (data[this.actionedKey]) {
449 +
				this.actioned = true;
396 450
				this.render();
397 451
			}
398 452
		} catch {
399 -
			// Ignore errors — show default subscribe button
453 +
			// Ignore errors — show default action button
400 454
		}
401 455
	}
402 456
405 459
			return;
406 460
		}
407 461
408 -
		// Unsubscribe: redirect to full-page unsubscribe flow
409 -
		if (this.subscribed) {
410 -
			const publicationUri =
411 -
				this.publicationUri ?? (await fetchPublicationUri());
412 -
			window.location.href = `${this.callbackUri}?publicationUri=${encodeURIComponent(publicationUri)}&action=unsubscribe`;
462 +
		// Unaction: redirect to the full-page unaction flow
463 +
		if (this.actioned) {
464 +
			const resourceUri = await this.resolveResourceUri();
465 +
			window.location.href = `${this.callbackUri}?${this.resourceParam}=${encodeURIComponent(resourceUri)}&action=${this.unactionValue}`;
413 466
			return;
414 467
		}
415 468
417 470
		this.render();
418 471
419 472
		try {
420 -
			const publicationUri =
421 -
				this.publicationUri ?? (await fetchPublicationUri());
473 +
			const resourceUri = await this.resolveResourceUri();
422 474
423 475
			const response = await fetch(this.callbackUri, {
424 476
				method: "POST",
425 477
				headers: { "Content-Type": "application/json" },
426 478
				credentials: "include",
427 479
				referrerPolicy: "no-referrer-when-downgrade",
428 -
				body: JSON.stringify({ publicationUri }),
480 +
				body: JSON.stringify({ [this.resourceParam]: resourceUri }),
429 481
			});
430 482
431 483
			const data = await response.json();
432 484
433 485
			if (response.status === 401 && data.authenticated === false) {
434 -
				// Redirect to the hosted subscribe page to complete OAuth,
486 +
				// Redirect to the hosted action page to complete OAuth,
435 487
				// passing the current page URL (without credentials) as returnTo.
436 -
				const subscribeUrl = new URL(data.subscribeUrl);
488 +
				const actionUrl = new URL(data.subscribeUrl);
437 489
				const pageUrl = new URL(window.location.href);
438 490
				pageUrl.username = "";
439 491
				pageUrl.password = "";
440 -
				subscribeUrl.searchParams.set("returnTo", pageUrl.toString());
441 -
				window.location.href = subscribeUrl.toString();
492 +
				actionUrl.searchParams.set("returnTo", pageUrl.toString());
493 +
				window.location.href = actionUrl.toString();
442 494
				return;
443 495
			}
444 496
456 508
				}
457 509
			}
458 510
459 -
			this.subscribed = true;
511 +
			this.actioned = true;
460 512
			this.state = { type: "idle" };
461 513
			this.render();
462 514
463 515
			this.dispatchEvent(
464 -
				new CustomEvent("sequoia-subscribed", {
516 +
				new CustomEvent(this.actionedEventName, {
465 517
					bubbles: true,
466 518
					composed: true,
467 -
					detail: { publicationUri, recordUri },
519 +
					detail: { [this.resourceParam]: resourceUri, recordUri },
468 520
				}),
469 521
			);
470 522
		} catch (error) {
471 523
			if (this.state.type !== "loading") return;
472 524
473 525
			const message =
474 -
				error instanceof Error ? error.message : "Failed to subscribe";
526 +
				error instanceof Error ? error.message : this.defaultErrorMessage;
475 527
			this.state = { type: "error", message };
476 528
			this.render();
477 529
478 530
			this.dispatchEvent(
479 -
				new CustomEvent("sequoia-subscribe-error", {
531 +
				new CustomEvent(this.errorEventName, {
480 532
					bubbles: true,
481 533
					composed: true,
482 534
					detail: { message },
488 540
	render() {
489 541
		const { type } = this.state;
490 542
491 -
		if (type === "no-publication") {
543 +
		if (type === "no-resource") {
492 544
			if (this.hide) {
493 545
				this.wrapper.innerHTML = "";
494 546
				this.wrapper.style.display = "none";
497 549
		}
498 550
499 551
		const isLoading = type === "loading";
500 -
		const config = BUTTON_TYPES[this.buttonType] ?? BUTTON_TYPES.sequoia;
501 -
502 552
		const icon = isLoading
503 553
			? `<span class="sequoia-loading-spinner"></span>`
504 -
			: config.icon;
554 +
			: this.getIcon();
555 +
556 +
		const label = this.actioned
557 +
			? (this.getUnactionLabel?.() ?? this.getDefaultUnactionLabel?.() ?? "")
558 +
			: (this.label ?? this.getDefaultActionLabel?.() ?? "");
505 559
506 -
		const label = this.subscribed
507 -
			? (this.unsubscribeLabel ?? config.unsubscribe)
508 -
			: (this.label ?? config.subscribe);
560 +
		const ariaLabel = this.getAriaLabel();
509 561
510 562
		const errorHtml =
511 563
			type === "error"
514 566
515 567
		this.wrapper.innerHTML = `
516 568
			<button
517 -
				class="sequoia-subscribe-button"
569 +
				class="sequoia-button"
518 570
				type="button"
519 571
				part="button"
520 572
				${isLoading ? "disabled" : ""}
521 -
				aria-label="${label}"
573 +
				aria-label="${ariaLabel}"
522 574
			>
523 575
				${icon}
524 576
				${label}
531 583
	}
532 584
}
533 585
586 +
class SequoiaSubscribe extends SequoiaActionBase {
587 +
	static get observedAttributes() {
588 +
		return [
589 +
			"publication-uri",
590 +
			"callback-uri",
591 +
			"label",
592 +
			"unsubscribe-label",
593 +
			"button-type",
594 +
			"hide",
595 +
		];
596 +
	}
597 +
598 +
	connectedCallback() {
599 +
		consumeReturnParams();
600 +
		this.checkPublication();
601 +
	}
602 +
603 +
	get publicationUri() {
604 +
		return this.getAttribute("publication-uri") ?? null;
605 +
	}
606 +
607 +
	get label() {
608 +
		return this.getAttribute("label") ?? null;
609 +
	}
610 +
611 +
	get buttonType() {
612 +
		const val = this.getAttribute("button-type");
613 +
		return val && val in BUTTON_TYPES ? val : "sequoia";
614 +
	}
615 +
616 +
	get unsubscribeLabel() {
617 +
		return this.getAttribute("unsubscribe-label") ?? null;
618 +
	}
619 +
620 +
	// ── Template method overrides ────────────────────────────────────────────
621 +
622 +
	get defaultCallbackUri() {
623 +
		return "https://sequoia.pub/subscribe";
624 +
	}
625 +
	get resourceParam() {
626 +
		return "publicationUri";
627 +
	}
628 +
	get unactionValue() {
629 +
		return "unsubscribe";
630 +
	}
631 +
	get actionedKey() {
632 +
		return "subscribed";
633 +
	}
634 +
	get actionedEventName() {
635 +
		return "sequoia-subscribed";
636 +
	}
637 +
	get errorEventName() {
638 +
		return "sequoia-subscribe-error";
639 +
	}
640 +
	get defaultErrorMessage() {
641 +
		return "Failed to subscribe";
642 +
	}
643 +
644 +
	getDefaultActionLabel() {
645 +
		return (BUTTON_TYPES[this.buttonType] ?? BUTTON_TYPES.sequoia).subscribe;
646 +
	}
647 +
648 +
	getDefaultUnactionLabel() {
649 +
		return (BUTTON_TYPES[this.buttonType] ?? BUTTON_TYPES.sequoia).unsubscribe;
650 +
	}
651 +
652 +
	getUnactionLabel() {
653 +
		return this.unsubscribeLabel;
654 +
	}
655 +
656 +
	getIcon() {
657 +
		return (BUTTON_TYPES[this.buttonType] ?? BUTTON_TYPES.sequoia).icon;
658 +
	}
659 +
660 +
	async resolveResourceUri() {
661 +
		return this.publicationUri ?? (await fetchPublicationUri());
662 +
	}
663 +
664 +
	// ── SequoiaSubscribe-specific logic ──────────────────────────────────────
665 +
666 +
	/** @returns {boolean} Whether the user is currently subscribed. Alias for this.actioned. */
667 +
	get subscribed() {
668 +
		return this.actioned;
669 +
	}
670 +
671 +
	/**
672 +
	 * Check whether the current user is subscribed to the given publication URI.
673 +
	 * Forwards to the shared checkStatusFor() method.
674 +
	 * @param {string} publicationUri
675 +
	 */
676 +
	checkSubscription(publicationUri) {
677 +
		return this.checkStatusFor(publicationUri);
678 +
	}
679 +
680 +
	async checkPublication() {
681 +
		this.abortController?.abort();
682 +
		this.abortController = new AbortController();
683 +
684 +
		try {
685 +
			const uri = await this.resolveResourceUri();
686 +
			this.checkStatusFor(uri);
687 +
		} catch {
688 +
			this.state = { type: "no-resource" };
689 +
			this.render();
690 +
		}
691 +
	}
692 +
}
693 +
694 +
class SequoiaRecommend extends SequoiaActionBase {
695 +
	static get observedAttributes() {
696 +
		return ["document-uri", "callback-uri", "button-type", "hide"];
697 +
	}
698 +
699 +
	connectedCallback() {
700 +
		consumeReturnParams();
701 +
		this.checkDocument();
702 +
	}
703 +
704 +
	get documentUri() {
705 +
		const attrUri = this.getAttribute("document-uri");
706 +
		if (attrUri) return attrUri;
707 +
		const linkTag = document.querySelector(
708 +
			'link[rel="site.standard.document"]',
709 +
		);
710 +
		return linkTag?.href ?? null;
711 +
	}
712 +
713 +
	get buttonType() {
714 +
		const val = this.getAttribute("button-type");
715 +
		return val && val in RECOMMEND_ICON_TYPES ? val : "heart";
716 +
	}
717 +
718 +
	// ── Template method overrides ────────────────────────────────────────────
719 +
720 +
	get defaultCallbackUri() {
721 +
		return "https://sequoia.pub/recommend";
722 +
	}
723 +
	get resourceParam() {
724 +
		return "documentUri";
725 +
	}
726 +
	get unactionValue() {
727 +
		return "remove";
728 +
	}
729 +
	get actionedKey() {
730 +
		return "recommended";
731 +
	}
732 +
	get actionedEventName() {
733 +
		return "sequoia-recommended";
734 +
	}
735 +
	get errorEventName() {
736 +
		return "sequoia-recommend-error";
737 +
	}
738 +
	get defaultErrorMessage() {
739 +
		return "Failed to recommend";
740 +
	}
741 +
742 +
	getAriaLabel() {
743 +
		const config =
744 +
			RECOMMEND_ICON_TYPES[this.buttonType] ?? RECOMMEND_ICON_TYPES.heart;
745 +
		return this.actioned ? config.unaction : config.action;
746 +
	}
747 +
748 +
	getIcon() {
749 +
		const config =
750 +
			RECOMMEND_ICON_TYPES[this.buttonType] ?? RECOMMEND_ICON_TYPES.heart;
751 +
		return this.actioned ? config.iconActioned : config.icon;
752 +
	}
753 +
754 +
	async resolveResourceUri() {
755 +
		const uri = this.documentUri;
756 +
		if (!uri) throw new Error("No document URI found");
757 +
		return uri;
758 +
	}
759 +
760 +
	// ── SequoiaRecommend-specific logic ──────────────────────────────────────
761 +
762 +
	async checkDocument() {
763 +
		this.abortController?.abort();
764 +
		this.abortController = new AbortController();
765 +
766 +
		const uri = this.documentUri;
767 +
		if (!uri) {
768 +
			this.state = { type: "no-resource" };
769 +
			this.render();
770 +
			return;
771 +
		}
772 +
773 +
		this.checkStatusFor(uri);
774 +
	}
775 +
}
776 +
534 777
/**
535 778
 * Escape HTML special characters (no DOM dependency for SSR).
536 779
 * @param {string} text
544 787
		.replace(/"/g, "&quot;");
545 788
}
546 789
547 -
// Register the custom element
790 +
// Register the custom elements
548 791
if (typeof customElements !== "undefined") {
549 792
	customElements.define("sequoia-subscribe", SequoiaSubscribe);
793 +
	customElements.define("sequoia-recommend", SequoiaRecommend);
550 794
}
551 795
552 -
// Export for module usage
796 +
/**
797 +
 * Sequoia Subscribe - An AT Protocol-powered subscribe component
798 +
 *
799 +
 * A self-contained Web Component that lets users subscribe to a publication
800 +
 * via the AT Protocol by creating a site.standard.graph.subscription record.
801 +
 *
802 +
 * Usage:
803 +
 *   <sequoia-subscribe></sequoia-subscribe>
804 +
 *
805 +
 * The component resolves the publication AT URI from the host site's
806 +
 * /.well-known/site.standard.publication endpoint.
807 +
 *
808 +
 * Attributes:
809 +
 *   - publication-uri: Override the publication AT URI (optional)
810 +
 *   - callback-uri: Redirect URI after OAuth authentication (default: "https://sequoia.pub/subscribe")
811 +
 *   - button-type: Branding style — "sequoia" (default), "bluesky", "blacksky", "atmosphere", or "plain"
812 +
 *   - label: Override the subscribe button label text
813 +
 *   - unsubscribe-label: Override the unsubscribe button label text
814 +
 *   - hide: Set to "auto" to hide if no publication URI is detected
815 +
 *
816 +
 * Events:
817 +
 *   - sequoia-subscribed: Fired when the subscription is created successfully.
818 +
 *     detail: { publicationUri: string, recordUri: string }
819 +
 *   - sequoia-subscribe-error: Fired when the subscription fails.
820 +
 *     detail: { message: string }
821 +
 */
553 822
export { SequoiaSubscribe };
823 +
824 +
/**
825 +
 * Sequoia Recommend - An AT Protocol-powered recommend component
826 +
 *
827 +
 * A self-contained Web Component that lets users recommend a document
828 +
 * via the AT Protocol by creating a site.standard.graph.recommend record.
829 +
 *
830 +
 * Usage:
831 +
 *   <sequoia-recommend></sequoia-recommend>
832 +
 *
833 +
 * The component resolves the document AT URI from the `document-uri` attribute
834 +
 * or a <link rel="site.standard.document" href="at://..."> tag in the page head.
835 +
 *
836 +
 * Attributes:
837 +
 *   - document-uri: AT Protocol URI of the document to recommend (optional if link tag present)
838 +
 *   - callback-uri: Redirect URI after OAuth authentication (default: "https://sequoia.pub/recommend")
839 +
 *   - button-type: Icon style — "heart" (default), "star", or "thumbs-up"
840 +
 *   - hide: Set to "auto" to hide if no document URI is detected
841 +
 *
842 +
 * Events:
843 +
 *   - sequoia-recommended: Fired when the recommendation is created successfully.
844 +
 *     detail: { documentUri: string, recordUri: string }
845 +
 *   - sequoia-recommend-error: Fired when the recommendation fails.
846 +
 *     detail: { message: string }
847 +
 */
848 +
export { SequoiaRecommend };
packages/server/README.md +9 −5
1 1
# Sequoia Server
2 2
3 -
Self-hostable AT Protocol OAuth and subscription server. Handles Bluesky login and manages `site.standard.graph.subscription` records on behalf of users. Built with Bun, Hono, and Redis.
3 +
Self-hostable AT Protocol OAuth and subscription/recommendation server. Handles Bluesky login and manages `site.standard.graph.subscription` and `site.standard.graph.recommend` records on behalf of users. Built with Bun, Hono, and Redis.
4 4
5 5
## Quickstart
6 6
23 23
24 24
## How it works
25 25
26 -
1. A user visits `/subscribe?publicationUri=at://...` and enters their Bluesky handle
26 +
1. A user visits `/subscribe?publicationUri=at://...` or `/recommend?documentUri=at://...` and enters their Bluesky handle
27 27
2. The server initiates an AT Protocol OAuth flow — the user authorizes on Bluesky
28 -
3. After callback, the server creates a `site.standard.graph.subscription` record in the user's repo
29 -
4. The [sequoia-subscribe](https://github.com/standard-schema/sequoia) web component can point to this server for the full flow
28 +
3. After callback, the server creates a `site.standard.graph.subscription` or `site.standard.graph.recommend` record in the user's repo
29 +
4. The [sequoia-subscribe](https://github.com/standard-schema/sequoia) web components can point to this server for the full flow
30 30
31 31
### Routes
32 32
42 42
| `/subscribe` | POST | Subscribe via API (JSON) |
43 43
| `/subscribe/check` | GET | Check subscription status |
44 44
| `/subscribe/login` | POST | Handle form submission |
45 +
| `/recommend` | GET | Recommend page (HTML) |
46 +
| `/recommend` | POST | Recommend via API (JSON) |
47 +
| `/recommend/check` | GET | Check recommendation status |
48 +
| `/recommend/login` | POST | Handle form submission |
45 49
46 50
## Configuration
47 51
54 58
55 59
### Theming
56 60
57 -
The subscribe pages use CSS custom properties that can be overridden via environment variables:
61 +
The subscribe and recommend pages use CSS custom properties that can be overridden via environment variables:
58 62
59 63
| Variable | Default |
60 64
|----------|---------|
packages/server/src/index.ts +18 −0
5 5
import { openDatabase } from "./lib/db";
6 6
import auth from "./routes/auth";
7 7
import subscribe from "./routes/subscribe";
8 +
import recommend from "./routes/recommend";
8 9
9 10
const env = loadEnv();
10 11
43 44
	}),
44 45
);
45 46
app.route("/subscribe", subscribe);
47 +
48 +
// Recommend routes with CORS
49 +
app.use(
50 +
	"/recommend/*",
51 +
	cors({
52 +
		origin: (origin) => origin,
53 +
		credentials: true,
54 +
	}),
55 +
);
56 +
app.use(
57 +
	"/recommend",
58 +
	cors({
59 +
		origin: (origin) => origin,
60 +
		credentials: true,
61 +
	}),
62 +
);
63 +
app.route("/recommend", recommend);
46 64
47 65
console.log(`Sequoia server listening on port ${env.PORT}`);
48 66
packages/server/src/lib/oauth-client.ts +1 −1
5 5
import { createStateStore, createSessionStore } from "./stores";
6 6
7 7
export const OAUTH_SCOPE =
8 -
	"atproto repo:site.standard.graph.subscription?action=create&action=delete";
8 +
	"atproto repo:site.standard.graph.recommend?action=create&action=delete repo:site.standard.graph.subscription?action=create&action=delete";
9 9
10 10
export function createOAuthClient(
11 11
	db: Database,
packages/server/src/routes/lib.ts (added) +179 −0
1 +
import type { Agent } from "@atproto/api";
2 +
import { escapeHtml, page } from "../lib/theme";
3 +
4 +
export const REDIRECT_DELAY_SECONDS = 5;
5 +
6 +
// ============================================================================
7 +
// Helpers
8 +
// ============================================================================
9 +
10 +
export function withReturnToParam(
11 +
	returnTo: string | undefined,
12 +
	key: string,
13 +
	value: string,
14 +
): string | undefined {
15 +
	if (!returnTo) return undefined;
16 +
	try {
17 +
		const url = new URL(returnTo);
18 +
		url.searchParams.set(key, value);
19 +
		return url.toString();
20 +
	} catch {
21 +
		return returnTo;
22 +
	}
23 +
}
24 +
25 +
/**
26 +
 * Scan a repo for a record in `collection` where `record[field] === uri`.
27 +
 * Returns the record AT-URI, or null if not found.
28 +
 */
29 +
export async function findExistingRecord(
30 +
	agent: Agent,
31 +
	did: string,
32 +
	collection: string,
33 +
	field: string,
34 +
	uri: string,
35 +
): Promise<string | null> {
36 +
	let cursor: string | undefined;
37 +
38 +
	do {
39 +
		const result = await agent.com.atproto.repo.listRecords({
40 +
			repo: did,
41 +
			collection,
42 +
			limit: 100,
43 +
			cursor,
44 +
		});
45 +
46 +
		for (const record of result.data.records) {
47 +
			const value = record.value as Record<string, unknown>;
48 +
			if (value[field] === uri) {
49 +
				return record.uri;
50 +
			}
51 +
		}
52 +
53 +
		cursor = result.data.cursor;
54 +
	} while (cursor);
55 +
56 +
	return null;
57 +
}
58 +
59 +
// ============================================================================
60 +
// HTML rendering
61 +
// ============================================================================
62 +
63 +
export function renderHandleForm(params: {
64 +
	resourceUri: string;
65 +
	resourceField: string;
66 +
	loginPath: string;
67 +
	title: string;
68 +
	description: string;
69 +
	buttonLabel: string;
70 +
	returnTo?: string;
71 +
	error?: string;
72 +
	action?: string;
73 +
}): string {
74 +
	const {
75 +
		resourceUri,
76 +
		resourceField,
77 +
		loginPath,
78 +
		title,
79 +
		description,
80 +
		buttonLabel,
81 +
		returnTo,
82 +
		error,
83 +
		action,
84 +
	} = params;
85 +
86 +
	const errorHtml = error ? `<p class="error">${escapeHtml(error)}</p>` : "";
87 +
	const returnToInput = returnTo
88 +
		? `<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />`
89 +
		: "";
90 +
	const actionInput = action
91 +
		? `<input type="hidden" name="action" value="${escapeHtml(action)}" />`
92 +
		: "";
93 +
94 +
	return page(`
95 +
		<h1>${escapeHtml(title)}</h1>
96 +
		<p>${escapeHtml(description)}</p>
97 +
		${errorHtml}
98 +
		<form method="POST" action="${escapeHtml(loginPath)}">
99 +
			<input type="hidden" name="${escapeHtml(resourceField)}" value="${escapeHtml(resourceUri)}" />
100 +
			${returnToInput}
101 +
			${actionInput}
102 +
			<input
103 +
				type="text"
104 +
				name="handle"
105 +
				placeholder="you.bsky.social"
106 +
				autocomplete="username"
107 +
				required
108 +
				autofocus
109 +
			/>
110 +
			<button type="submit">${escapeHtml(buttonLabel)}</button>
111 +
		</form>
112 +
	`);
113 +
}
114 +
115 +
export function renderSuccess(params: {
116 +
	resourceUri: string;
117 +
	resourceLabel: string;
118 +
	recordUri: string | null;
119 +
	heading: string;
120 +
	msg: string;
121 +
	returnTo?: string;
122 +
}): string {
123 +
	const { resourceUri, resourceLabel, recordUri, heading, msg, returnTo } =
124 +
		params;
125 +
	const escapedResourceUri = escapeHtml(resourceUri);
126 +
	const escapedReturnTo = returnTo ? escapeHtml(returnTo) : "";
127 +
128 +
	const redirectHtml = returnTo
129 +
		? `<p id="redirect-msg">Redirecting to <a href="${escapedReturnTo}">${escapedReturnTo}</a> in <span id="countdown">${REDIRECT_DELAY_SECONDS}</span>\u00a0seconds\u2026</p>
130 +
		<script>
131 +
		(function(){
132 +
			var secs = ${REDIRECT_DELAY_SECONDS};
133 +
			var el = document.getElementById('countdown');
134 +
			var iv = setInterval(function(){
135 +
				secs--;
136 +
				if (el) el.textContent = String(secs);
137 +
				if (secs <= 0) { clearInterval(iv); location.href = ${JSON.stringify(returnTo)}; }
138 +
			}, 1000);
139 +
		})();
140 +
		</script>`
141 +
		: "";
142 +
	const headExtra = returnTo
143 +
		? `<meta http-equiv="refresh" content="${REDIRECT_DELAY_SECONDS};url=${escapedReturnTo}" />`
144 +
		: "";
145 +
146 +
	return page(
147 +
		`
148 +
		<h1>${escapeHtml(heading)}</h1>
149 +
		<p>${msg}</p>
150 +
		${redirectHtml}
151 +
		<table>
152 +
			<colgroup><col style="width:7rem;"><col></colgroup>
153 +
			<tbody>
154 +
				<tr>
155 +
					<td>${escapeHtml(resourceLabel)}</td>
156 +
					<td>
157 +
						<div><code><a href="https://pds.ls/${escapedResourceUri}">${escapedResourceUri}</a></code></div>
158 +
					</td>
159 +
				</tr>
160 +
				${
161 +
					recordUri
162 +
						? `<tr>
163 +
					<td>Record</td>
164 +
					<td>
165 +
						<div><code><a href="https://pds.ls/${escapeHtml(recordUri)}">${escapeHtml(recordUri)}</a></code></div>
166 +
					</td>
167 +
				</tr>`
168 +
						: ""
169 +
				}
170 +
			</tbody>
171 +
		</table>
172 +
	`,
173 +
		headExtra,
174 +
	);
175 +
}
176 +
177 +
export function renderError(message: string): string {
178 +
	return page(`<h1>Error</h1><p class="error">${escapeHtml(message)}</p>`);
179 +
}
packages/server/src/routes/recommend.ts (added) +291 −0
1 +
import { Agent } from "@atproto/api";
2 +
import { Hono } from "hono";
3 +
import type { Database } from "bun:sqlite";
4 +
import { createOAuthClient } from "../lib/oauth-client";
5 +
import { getSessionDid, setReturnToCookie } from "../lib/session";
6 +
import type { Env } from "../env";
7 +
import {
8 +
	findExistingRecord,
9 +
	renderError,
10 +
	renderHandleForm,
11 +
	renderSuccess,
12 +
	withReturnToParam,
13 +
} from "./lib";
14 +
15 +
type Variables = { env: Env; db: Database };
16 +
17 +
const recommend = new Hono<{ Variables: Variables }>();
18 +
19 +
const COLLECTION = "site.standard.graph.recommend";
20 +
21 +
// ============================================================================
22 +
// POST /recommend
23 +
// ============================================================================
24 +
25 +
recommend.post("/", async (c) => {
26 +
	const env = c.get("env");
27 +
	const db = c.get("db");
28 +
29 +
	let documentUri: string;
30 +
	try {
31 +
		const body = await c.req.json<{ documentUri?: string }>();
32 +
		documentUri = body.documentUri ?? "";
33 +
	} catch {
34 +
		return c.json({ error: "Invalid JSON body" }, 400);
35 +
	}
36 +
37 +
	if (!documentUri || !documentUri.startsWith("at://")) {
38 +
		return c.json({ error: "Missing or invalid documentUri" }, 400);
39 +
	}
40 +
41 +
	const did = getSessionDid(c);
42 +
	if (!did) {
43 +
		const subscribeUrl = `${env.CLIENT_URL}/recommend?documentUri=${encodeURIComponent(documentUri)}`;
44 +
		return c.json({ authenticated: false, subscribeUrl }, 401);
45 +
	}
46 +
47 +
	try {
48 +
		const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME);
49 +
		const session = await client.restore(did);
50 +
		const agent = new Agent(session);
51 +
52 +
		const existingUri = await findExistingRecord(
53 +
			agent,
54 +
			did,
55 +
			COLLECTION,
56 +
			"document",
57 +
			documentUri,
58 +
		);
59 +
		if (existingUri) {
60 +
			return c.json({
61 +
				recommended: true,
62 +
				existing: true,
63 +
				recordUri: existingUri,
64 +
			});
65 +
		}
66 +
67 +
		const result = await agent.com.atproto.repo.createRecord({
68 +
			repo: did,
69 +
			collection: COLLECTION,
70 +
			record: {
71 +
				$type: COLLECTION,
72 +
				document: documentUri,
73 +
				createdAt: new Date().toISOString(),
74 +
			},
75 +
		});
76 +
77 +
		return c.json({
78 +
			recommended: true,
79 +
			existing: false,
80 +
			recordUri: result.data.uri,
81 +
		});
82 +
	} catch (error) {
83 +
		console.error("Recommend POST error:", error);
84 +
		const subscribeUrl = `${env.CLIENT_URL}/recommend?documentUri=${encodeURIComponent(documentUri)}`;
85 +
		return c.json({ authenticated: false, subscribeUrl }, 401);
86 +
	}
87 +
});
88 +
89 +
// ============================================================================
90 +
// GET /recommend
91 +
// ============================================================================
92 +
93 +
recommend.get("/", async (c) => {
94 +
	const env = c.get("env");
95 +
	const db = c.get("db");
96 +
97 +
	const documentUri = c.req.query("documentUri");
98 +
	const action = c.req.query("action");
99 +
100 +
	if (action && action !== "remove") {
101 +
		return c.html(renderError(`Unsupported action: ${action}`), 400);
102 +
	}
103 +
104 +
	if (!documentUri || !documentUri.startsWith("at://")) {
105 +
		return c.html(renderError("Missing or invalid document URI."), 400);
106 +
	}
107 +
108 +
	const referer = c.req.header("referer");
109 +
	const returnTo =
110 +
		c.req.query("returnTo") ??
111 +
		(referer && !referer.includes("/recommend") ? referer : undefined);
112 +
113 +
	const did = getSessionDid(c);
114 +
	if (!did) {
115 +
		return c.html(
116 +
			renderHandleForm({
117 +
				resourceUri: documentUri,
118 +
				resourceField: "documentUri",
119 +
				loginPath: "/recommend/login",
120 +
				title: "Recommend on Sequoia",
121 +
				description: "Enter your Bluesky handle to recommend this document.",
122 +
				buttonLabel: "Continue on Bluesky",
123 +
				returnTo,
124 +
				action,
125 +
			}),
126 +
		);
127 +
	}
128 +
129 +
	try {
130 +
		const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME);
131 +
		const session = await client.restore(did);
132 +
		const agent = new Agent(session);
133 +
134 +
		if (action === "remove") {
135 +
			const existingUri = await findExistingRecord(
136 +
				agent,
137 +
				did,
138 +
				COLLECTION,
139 +
				"document",
140 +
				documentUri,
141 +
			);
142 +
			if (existingUri) {
143 +
				const rkey = existingUri.split("/").pop()!;
144 +
				await agent.com.atproto.repo.deleteRecord({
145 +
					repo: did,
146 +
					collection: COLLECTION,
147 +
					rkey,
148 +
				});
149 +
			}
150 +
151 +
			return c.html(
152 +
				renderSuccess({
153 +
					resourceUri: documentUri,
154 +
					resourceLabel: "Document",
155 +
					recordUri: null,
156 +
					heading: "Recommendation Removed",
157 +
					msg: existingUri
158 +
						? "You've successfully removed your recommendation."
159 +
						: "You hadn't recommended this document.",
160 +
					returnTo: withReturnToParam(returnTo, "sequoia_did", did),
161 +
				}),
162 +
			);
163 +
		}
164 +
165 +
		const existingUri = await findExistingRecord(
166 +
			agent,
167 +
			did,
168 +
			COLLECTION,
169 +
			"document",
170 +
			documentUri,
171 +
		);
172 +
		const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did);
173 +
174 +
		if (existingUri) {
175 +
			return c.html(
176 +
				renderSuccess({
177 +
					resourceUri: documentUri,
178 +
					resourceLabel: "Document",
179 +
					recordUri: existingUri,
180 +
					heading: "Recommended",
181 +
					msg: "You've already recommended this document.",
182 +
					returnTo: returnToWithDid,
183 +
				}),
184 +
			);
185 +
		}
186 +
187 +
		const result = await agent.com.atproto.repo.createRecord({
188 +
			repo: did,
189 +
			collection: COLLECTION,
190 +
			record: {
191 +
				$type: COLLECTION,
192 +
				document: documentUri,
193 +
				createdAt: new Date().toISOString(),
194 +
			},
195 +
		});
196 +
197 +
		return c.html(
198 +
			renderSuccess({
199 +
				resourceUri: documentUri,
200 +
				resourceLabel: "Document",
201 +
				recordUri: result.data.uri,
202 +
				heading: "Recommended",
203 +
				msg: "You've successfully recommended this document!",
204 +
				returnTo: returnToWithDid,
205 +
			}),
206 +
		);
207 +
	} catch (error) {
208 +
		console.error("Recommend GET error:", error);
209 +
		return c.html(
210 +
			renderHandleForm({
211 +
				resourceUri: documentUri,
212 +
				resourceField: "documentUri",
213 +
				loginPath: "/recommend/login",
214 +
				title: "Recommend on Sequoia",
215 +
				description: "Enter your Bluesky handle to recommend this document.",
216 +
				buttonLabel: "Continue on Bluesky",
217 +
				returnTo,
218 +
				error: "Session expired. Please sign in again.",
219 +
				action,
220 +
			}),
221 +
		);
222 +
	}
223 +
});
224 +
225 +
// ============================================================================
226 +
// GET /recommend/check
227 +
// ============================================================================
228 +
229 +
recommend.get("/check", async (c) => {
230 +
	const env = c.get("env");
231 +
	const db = c.get("db");
232 +
233 +
	const documentUri = c.req.query("documentUri");
234 +
235 +
	if (!documentUri || !documentUri.startsWith("at://")) {
236 +
		return c.json({ error: "Missing or invalid documentUri" }, 400);
237 +
	}
238 +
239 +
	const did = getSessionDid(c) ?? c.req.query("did") ?? null;
240 +
	if (!did || !did.startsWith("did:")) {
241 +
		return c.json({ authenticated: false }, 401);
242 +
	}
243 +
244 +
	try {
245 +
		const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME);
246 +
		const session = await client.restore(did);
247 +
		const agent = new Agent(session);
248 +
		const recordUri = await findExistingRecord(
249 +
			agent,
250 +
			did,
251 +
			COLLECTION,
252 +
			"document",
253 +
			documentUri,
254 +
		);
255 +
		return recordUri
256 +
			? c.json({ recommended: true, recordUri })
257 +
			: c.json({ recommended: false });
258 +
	} catch {
259 +
		return c.json({ authenticated: false }, 401);
260 +
	}
261 +
});
262 +
263 +
// ============================================================================
264 +
// POST /recommend/login
265 +
// ============================================================================
266 +
267 +
recommend.post("/login", async (c) => {
268 +
	const env = c.get("env");
269 +
270 +
	const body = await c.req.parseBody();
271 +
	const handle = (body.handle as string | undefined)?.trim();
272 +
	const documentUri = body.documentUri as string | undefined;
273 +
	const formReturnTo = (body.returnTo as string | undefined) || undefined;
274 +
	const formAction = (body.action as string | undefined) || undefined;
275 +
276 +
	if (!handle || !documentUri) {
277 +
		return c.html(renderError("Missing handle or document URI."), 400);
278 +
	}
279 +
280 +
	const returnTo =
281 +
		`${env.CLIENT_URL}/recommend?documentUri=${encodeURIComponent(documentUri)}` +
282 +
		(formAction ? `&action=${encodeURIComponent(formAction)}` : "") +
283 +
		(formReturnTo ? `&returnTo=${encodeURIComponent(formReturnTo)}` : "");
284 +
	setReturnToCookie(c, returnTo, env.CLIENT_URL);
285 +
286 +
	return c.redirect(
287 +
		`${env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`,
288 +
	);
289 +
});
290 +
291 +
export default recommend;
packages/server/src/routes/subscribe.ts +68 −181
3 3
import type { Database } from "bun:sqlite";
4 4
import { createOAuthClient } from "../lib/oauth-client";
5 5
import { getSessionDid, setReturnToCookie } from "../lib/session";
6 -
import { page, escapeHtml } from "../lib/theme";
7 6
import type { Env } from "../env";
7 +
import {
8 +
	findExistingRecord,
9 +
	renderError,
10 +
	renderHandleForm,
11 +
	renderSuccess,
12 +
	withReturnToParam,
13 +
} from "./lib";
8 14
9 15
type Variables = { env: Env; db: Database };
10 16
11 17
const subscribe = new Hono<{ Variables: Variables }>();
12 18
13 19
const COLLECTION = "site.standard.graph.subscription";
14 -
const REDIRECT_DELAY_SECONDS = 5;
15 -
16 -
// ============================================================================
17 -
// Helpers
18 -
// ============================================================================
19 -
20 -
function withReturnToParam(
21 -
	returnTo: string | undefined,
22 -
	key: string,
23 -
	value: string,
24 -
): string | undefined {
25 -
	if (!returnTo) return undefined;
26 -
	try {
27 -
		const url = new URL(returnTo);
28 -
		url.searchParams.set(key, value);
29 -
		return url.toString();
30 -
	} catch {
31 -
		return returnTo;
32 -
	}
33 -
}
34 -
35 -
async function findExistingSubscription(
36 -
	agent: Agent,
37 -
	did: string,
38 -
	publicationUri: string,
39 -
): Promise<string | null> {
40 -
	let cursor: string | undefined;
41 -
42 -
	do {
43 -
		const result = await agent.com.atproto.repo.listRecords({
44 -
			repo: did,
45 -
			collection: COLLECTION,
46 -
			limit: 100,
47 -
			cursor,
48 -
		});
49 -
50 -
		for (const record of result.data.records) {
51 -
			const value = record.value as { publication?: string };
52 -
			if (value.publication === publicationUri) {
53 -
				return record.uri;
54 -
			}
55 -
		}
56 -
57 -
		cursor = result.data.cursor;
58 -
	} while (cursor);
59 -
60 -
	return null;
61 -
}
62 20
63 21
// ============================================================================
64 22
// POST /subscribe
91 49
		const session = await client.restore(did);
92 50
		const agent = new Agent(session);
93 51
94 -
		const existingUri = await findExistingSubscription(
52 +
		const existingUri = await findExistingRecord(
95 53
			agent,
96 54
			did,
55 +
			COLLECTION,
56 +
			"publication",
97 57
			publicationUri,
98 58
		);
99 59
		if (existingUri) {
152 112
	const did = getSessionDid(c);
153 113
	if (!did) {
154 114
		return c.html(
155 -
			renderHandleForm(publicationUri, returnTo, undefined, action),
115 +
			renderHandleForm({
116 +
				resourceUri: publicationUri,
117 +
				resourceField: "publicationUri",
118 +
				loginPath: "/subscribe/login",
119 +
				title: "Subscribe on Sequoia",
120 +
				description:
121 +
					"Enter your Bluesky handle to subscribe to this publication.",
122 +
				buttonLabel: "Continue on Bluesky",
123 +
				returnTo,
124 +
				action,
125 +
			}),
156 126
		);
157 127
	}
158 128
162 132
		const agent = new Agent(session);
163 133
164 134
		if (action === "unsubscribe") {
165 -
			const existingUri = await findExistingSubscription(
135 +
			const existingUri = await findExistingRecord(
166 136
				agent,
167 137
				did,
138 +
				COLLECTION,
139 +
				"publication",
168 140
				publicationUri,
169 141
			);
170 142
			if (existingUri) {
188 160
			}
189 161
190 162
			return c.html(
191 -
				renderSuccess(
192 -
					publicationUri,
193 -
					null,
194 -
					"Unsubscribed",
195 -
					existingUri
163 +
				renderSuccess({
164 +
					resourceUri: publicationUri,
165 +
					resourceLabel: "Publication",
166 +
					recordUri: null,
167 +
					heading: "Unsubscribed",
168 +
					msg: existingUri
196 169
						? "You've successfully unsubscribed!"
197 170
						: "You weren't subscribed to this publication.",
198 -
					withReturnToParam(cleanReturnTo, "sequoia_unsubscribed", "1"),
199 -
				),
171 +
					returnTo: withReturnToParam(
172 +
						cleanReturnTo,
173 +
						"sequoia_unsubscribed",
174 +
						"1",
175 +
					),
176 +
				}),
200 177
			);
201 178
		}
202 179
203 -
		const existingUri = await findExistingSubscription(
180 +
		const existingUri = await findExistingRecord(
204 181
			agent,
205 182
			did,
183 +
			COLLECTION,
184 +
			"publication",
206 185
			publicationUri,
207 186
		);
208 187
		const returnToWithDid = withReturnToParam(returnTo, "sequoia_did", did);
209 188
210 189
		if (existingUri) {
211 190
			return c.html(
212 -
				renderSuccess(
213 -
					publicationUri,
214 -
					existingUri,
215 -
					"Subscribed",
216 -
					"You're already subscribed to this publication.",
217 -
					returnToWithDid,
218 -
				),
191 +
				renderSuccess({
192 +
					resourceUri: publicationUri,
193 +
					resourceLabel: "Publication",
194 +
					recordUri: existingUri,
195 +
					heading: "Subscribed",
196 +
					msg: "You're already subscribed to this publication.",
197 +
					returnTo: returnToWithDid,
198 +
				}),
219 199
			);
220 200
		}
221 201
229 209
		});
230 210
231 211
		return c.html(
232 -
			renderSuccess(
233 -
				publicationUri,
234 -
				result.data.uri,
235 -
				"Subscribed",
236 -
				"You've successfully subscribed!",
237 -
				returnToWithDid,
238 -
			),
212 +
			renderSuccess({
213 +
				resourceUri: publicationUri,
214 +
				resourceLabel: "Publication",
215 +
				recordUri: result.data.uri,
216 +
				heading: "Subscribed",
217 +
				msg: "You've successfully subscribed!",
218 +
				returnTo: returnToWithDid,
219 +
			}),
239 220
		);
240 221
	} catch (error) {
241 222
		console.error("Subscribe GET error:", error);
242 223
		return c.html(
243 -
			renderHandleForm(
244 -
				publicationUri,
224 +
			renderHandleForm({
225 +
				resourceUri: publicationUri,
226 +
				resourceField: "publicationUri",
227 +
				loginPath: "/subscribe/login",
228 +
				title: "Subscribe on Sequoia",
229 +
				description:
230 +
					"Enter your Bluesky handle to subscribe to this publication.",
231 +
				buttonLabel: "Continue on Bluesky",
245 232
				returnTo,
246 -
				"Session expired. Please sign in again.",
233 +
				error: "Session expired. Please sign in again.",
247 234
				action,
248 -
			),
235 +
			}),
249 236
		);
250 237
	}
251 238
});
273 260
		const client = createOAuthClient(db, env.CLIENT_URL, env.CLIENT_NAME);
274 261
		const session = await client.restore(did);
275 262
		const agent = new Agent(session);
276 -
		const recordUri = await findExistingSubscription(
263 +
		const recordUri = await findExistingRecord(
277 264
			agent,
278 265
			did,
266 +
			COLLECTION,
267 +
			"publication",
279 268
			publicationUri,
280 269
		);
281 270
		return recordUri
313 302
		`${env.CLIENT_URL}/oauth/login?handle=${encodeURIComponent(handle)}`,
314 303
	);
315 304
});
316 -
317 -
// ============================================================================
318 -
// HTML rendering
319 -
// ============================================================================
320 -
321 -
function renderHandleForm(
322 -
	publicationUri: string,
323 -
	returnTo?: string,
324 -
	error?: string,
325 -
	action?: string,
326 -
): string {
327 -
	const errorHtml = error ? `<p class="error">${escapeHtml(error)}</p>` : "";
328 -
	const returnToInput = returnTo
329 -
		? `<input type="hidden" name="returnTo" value="${escapeHtml(returnTo)}" />`
330 -
		: "";
331 -
	const actionInput = action
332 -
		? `<input type="hidden" name="action" value="${escapeHtml(action)}" />`
333 -
		: "";
334 -
335 -
	return page(`
336 -
		<h1>Subscribe on Bluesky</h1>
337 -
		<p>Enter your Bluesky handle to subscribe to this publication.</p>
338 -
		${errorHtml}
339 -
		<form method="POST" action="/subscribe/login">
340 -
			<input type="hidden" name="publicationUri" value="${escapeHtml(publicationUri)}" />
341 -
			${returnToInput}
342 -
			${actionInput}
343 -
			<input
344 -
				type="text"
345 -
				name="handle"
346 -
				placeholder="you.bsky.social"
347 -
				autocomplete="username"
348 -
				required
349 -
				autofocus
350 -
			/>
351 -
			<button type="submit">Continue on Bluesky</button>
352 -
		</form>
353 -
	`);
354 -
}
355 -
356 -
function renderSuccess(
357 -
	publicationUri: string,
358 -
	recordUri: string | null,
359 -
	heading: string,
360 -
	msg: string,
361 -
	returnTo?: string,
362 -
): string {
363 -
	const escapedPublicationUri = escapeHtml(publicationUri);
364 -
	const escapedReturnTo = returnTo ? escapeHtml(returnTo) : "";
365 -
366 -
	const redirectHtml = returnTo
367 -
		? `<p id="redirect-msg">Redirecting to <a href="${escapedReturnTo}">${escapedReturnTo}</a> in <span id="countdown">${REDIRECT_DELAY_SECONDS}</span>\u00a0seconds\u2026</p>
368 -
		<script>
369 -
		(function(){
370 -
			var secs = ${REDIRECT_DELAY_SECONDS};
371 -
			var el = document.getElementById('countdown');
372 -
			var iv = setInterval(function(){
373 -
				secs--;
374 -
				if (el) el.textContent = String(secs);
375 -
				if (secs <= 0) { clearInterval(iv); location.href = ${JSON.stringify(returnTo)}; }
376 -
			}, 1000);
377 -
		})();
378 -
		</script>`
379 -
		: "";
380 -
	const headExtra = returnTo
381 -
		? `<meta http-equiv="refresh" content="${REDIRECT_DELAY_SECONDS};url=${escapedReturnTo}" />`
382 -
		: "";
383 -
384 -
	return page(
385 -
		`
386 -
		<h1>${escapeHtml(heading)}</h1>
387 -
		<p>${msg}</p>
388 -
		${redirectHtml}
389 -
		<table>
390 -
			<colgroup><col style="width:7rem;"><col></colgroup>
391 -
			<tbody>
392 -
				<tr>
393 -
					<td>Publication</td>
394 -
					<td>
395 -
						<div><code><a href="https://pds.ls/${escapedPublicationUri}">${escapedPublicationUri}</a></code></div>
396 -
					</td>
397 -
				</tr>
398 -
				${
399 -
					recordUri
400 -
						? `<tr>
401 -
					<td>Record</td>
402 -
					<td>
403 -
						<div><code><a href="https://pds.ls/${escapeHtml(recordUri)}">${escapeHtml(recordUri)}</a></code></div>
404 -
					</td>
405 -
				</tr>`
406 -
						: ""
407 -
				}
408 -
			</tbody>
409 -
		</table>
410 -
	`,
411 -
		headExtra,
412 -
	);
413 -
}
414 -
415 -
function renderError(message: string): string {
416 -
	return page(`<h1>Error</h1><p class="error">${escapeHtml(message)}</p>`);
417 -
}
418 305
419 306
export default subscribe;