···1+---
2+name: spec
3+description: Draft design documents and specs with research-informed questioning
4+disable-model-invocation: true
5+---
6+7+# Spec Writing
8+9+## When to Use
10+11+Use this skill when the user explicitly requests a spec, design document, or similar planning artifact. Do not auto-trigger for routine tasks.
12+13+## Process
14+15+### 1. Initial Scoping
16+17+When starting a spec:
18+- Ask the user for a brief description of what they want to build/change
19+- Reason about which question categories matter for this particular workโderive these from the problem, not a checklist
20+21+### 2. Interrogation Loop
22+23+Conduct open-ended questioning until the designer signals completion:
24+25+**Research-informed questions**: Before asking about an area, do just-in-time codebase research. Find relevant files, understand current patterns, identify constraints. Reference specific files and functions in questions when it adds clarity.
26+27+**Adaptive scope**: When the designer marks something as out of scope, update your mental model and don't revisit. However, flag when you believe there are gaps that could cause problems:
28+- "This approach assumes Xโis that intentional?"
29+- "The current implementation of Y in `src/foo.ts:42` handles Z differently. Should we align or diverge?"
30+31+**Question style**:
32+- Ask one focused question at a time, or a small related cluster
33+- Ground questions in what you've learned from the codebase
34+- Avoid hypotheticals that don't apply to this codebase
35+- When presenting options, describe tradeoffs without recommending unless asked
36+37+### 3. Spec Writing
38+39+When the designer indicates readiness, produce the spec document.
40+41+## Output
42+43+Save to: `/specs/YYYY-MM-DD-short-name.md`
44+45+### Required Elements
46+47+**Title and status** (draft | approved | implemented)
48+49+**Goal**: What this achieves and why. 1-3 sentences.
50+51+**Design**: The substance of what will be built/changed. For each significant component or concern:
52+- Describe the approach
53+- State key decisions with their rationale inline
54+- Reference specific files, functions, and types where relevant
55+- Structure subsections to fit the problemโno fixed format
56+57+**Implementation**: Ordered steps that an agent or developer can execute. Each step should:
58+- Be concrete and actionable
59+- Reference specific files/functions to modify or create
60+- Be sequenced correctly (dependencies before dependents)
61+62+### Optional
63+64+- Background context (only if necessary for understanding)
65+- Open questions (only if unresolved items remain)
66+67+Add other sections if the problem demands it.
68+69+## Writing Style
70+71+- No filler, hedging, or preamble
72+- No "This document describes..." or "In this spec we will..."
73+- Start sections with substance, not meta-commentary
74+- Use precise technical language
75+- Keep decisions and rationale tightโone sentence each when possible
76+- Code references use `file/path.ts:lineNumber` or `functionName` in backticks
77+- Prefer concrete over abstract; specific over general
78+79+## Status Lifecycle
80+81+- **draft**: Work in progress. Should not be merged to main.
82+- **approved**: Designer has signed off. Ready for implementation.
83+- **implemented**: Work is complete. Spec is now historical record.
84+85+Update status in the document as it progresses. Specs are point-in-time snapshotsโdo not update content after implementation begins except to change status.
···5 PubLeafletPublication,
6 SiteStandardPublication,
7} from "lexicons/api";
8+import {
9+ restoreOAuthSession,
10+ OAuthSessionError,
11+} from "src/atproto-oauth";
12import { getIdentityData } from "actions/getIdentityData";
13import { supabaseServerClient } from "supabase/serverClient";
14import { Json } from "supabase/database.types";
···7677 // Build record based on publication type
78 let record: SiteStandardPublication.Record | PubLeafletPublication.Record;
79+ let iconBlob: Awaited<ReturnType<typeof agent.com.atproto.repo.uploadBlob>>["data"]["blob"] | undefined;
00008081 // Upload the icon if provided
82 if (iconFile && iconFile.size > 0) {
···97 ...(iconBlob && { icon: iconBlob }),
98 basicTheme: {
99 $type: "site.standard.theme.basic",
100+ background: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.background },
101+ foreground: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.foreground },
102+ accent: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.accent },
103+ accentForeground: { $type: "site.standard.theme.color#rgb", ...PubThemeDefaultsRGB.accentForeground },
000000000000104 },
105 preferences: {
106 showInDiscover: preferences.showInDiscover,
107 showComments: preferences.showComments,
108 showMentions: preferences.showMentions,
109 showPrevNext: preferences.showPrevNext,
0110 },
111 } satisfies SiteStandardPublication.Record;
112 } else {
+1-30
app/lish/createPub/getPublicationURL.ts
···5import {
6 normalizePublicationRecord,
7 isLeafletPublication,
8- type NormalizedDocument,
9 type NormalizedPublication,
10} from "src/utils/normalizeRecords";
11···21 const normalized = normalizePublicationRecord(pub.record);
2223 // If we have a normalized record with a URL (site.standard format), use it
24- if (normalized?.url) {
25 return normalized.url;
26 }
27···45 const name = aturi.rkey || normalized?.name;
46 return `/lish/${aturi.host}/${encodeURIComponent(name || "")}`;
47}
48-49-/**
50- * Gets the full URL for a document.
51- * Always appends the document's path property.
52- */
53-export function getDocumentURL(
54- doc: NormalizedDocument,
55- docUri: string,
56- publication?: PublicationInput | NormalizedPublication | null,
57-): string {
58- let path = doc.path || "/" + new AtUri(docUri).rkey;
59- if (path[0] !== "/") path = "/" + path;
60-61- if (!publication) {
62- return doc.site + path;
63- }
64-65- // Already-normalized publications: use URL directly
66- if (
67- (publication as NormalizedPublication).$type ===
68- "site.standard.publication"
69- ) {
70- return ((publication as NormalizedPublication).url || doc.site) + path;
71- }
72-73- // Raw publication input: delegate to getPublicationURL for full resolution
74- return getPublicationURL(publication as PublicationInput) + path;
75-}
···5import {
6 normalizePublicationRecord,
7 isLeafletPublication,
08 type NormalizedPublication,
9} from "src/utils/normalizeRecords";
10···20 const normalized = normalizePublicationRecord(pub.record);
2122 // If we have a normalized record with a URL (site.standard format), use it
23+ if (normalized?.url && isProductionDomain()) {
24 return normalized.url;
25 }
26···44 const name = aturi.rkey || normalized?.name;
45 return `/lish/${aturi.host}/${encodeURIComponent(name || "")}`;
46}
0000000000000000000000000000
···3import { AtpBaseClient } from "lexicons/api";
4import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api";
5import { getIdentityData } from "actions/getIdentityData";
6-import { restoreOAuthSession, OAuthSessionError } from "src/atproto-oauth";
0007import { TID } from "@atproto/common";
8import { supabaseServerClient } from "supabase/serverClient";
9import { revalidatePath } from "next/cache";
···76 }
7778 let bsky = new BskyAgent(credentialSession);
79- let [profile, resolveDid] = await Promise.all([
080 bsky.app.bsky.actor.profile
81 .get({
82 repo: credentialSession.did!,
···92 handle: resolveDid?.alsoKnownAs?.[0]?.slice(5),
93 });
94 }
00095 revalidatePath("/lish/[did]/[publication]", "layout");
96 return {
97 success: true,
98- hasFeed: true,
99 };
100}
101···104 | { success: false; error: OAuthSessionError };
105106export async function unsubscribeToPublication(
107- publication: string,
108): Promise<UnsubscribeResult> {
109 let identity = await getIdentityData();
110 if (!identity || !identity.atp_did) {
···137 // Delete from both collections (old and new schema) - one or both may exist
138 let rkey = new AtUri(existingSubscription.uri).rkey;
139 await Promise.all([
140- agent.pub.leaflet.graph.subscription
141- .delete({ repo: credentialSession.did!, rkey })
142- .catch(() => {}),
143- agent.site.standard.graph.subscription
144- .delete({ repo: credentialSession.did!, rkey })
145- .catch(() => {}),
146 ]);
147148 await supabaseServerClient
···3import { AtpBaseClient } from "lexicons/api";
4import { AppBskyActorDefs, Agent as BskyAgent } from "@atproto/api";
5import { getIdentityData } from "actions/getIdentityData";
6+import {
7+ restoreOAuthSession,
8+ OAuthSessionError,
9+} from "src/atproto-oauth";
10import { TID } from "@atproto/common";
11import { supabaseServerClient } from "supabase/serverClient";
12import { revalidatePath } from "next/cache";
···79 }
8081 let bsky = new BskyAgent(credentialSession);
82+ let [prefs, profile, resolveDid] = await Promise.all([
83+ bsky.app.bsky.actor.getPreferences(),
84 bsky.app.bsky.actor.profile
85 .get({
86 repo: credentialSession.did!,
···96 handle: resolveDid?.alsoKnownAs?.[0]?.slice(5),
97 });
98 }
99+ let savedFeeds = prefs.data.preferences.find(
100+ (pref) => pref.$type === "app.bsky.actor.defs#savedFeedsPrefV2",
101+ ) as AppBskyActorDefs.SavedFeedsPrefV2;
102 revalidatePath("/lish/[did]/[publication]", "layout");
103 return {
104 success: true,
105+ hasFeed: !!savedFeeds.items.find((feed) => feed.value === leafletFeedURI),
106 };
107}
108···111 | { success: false; error: OAuthSessionError };
112113export async function unsubscribeToPublication(
114+ publication: string
115): Promise<UnsubscribeResult> {
116 let identity = await getIdentityData();
117 if (!identity || !identity.atp_did) {
···144 // Delete from both collections (old and new schema) - one or both may exist
145 let rkey = new AtUri(existingSubscription.uri).rkey;
146 await Promise.all([
147+ agent.pub.leaflet.graph.subscription.delete({ repo: credentialSession.did!, rkey }).catch(() => {}),
148+ agent.site.standard.graph.subscription.delete({ repo: credentialSession.did!, rkey }).catch(() => {}),
0000149 ]);
150151 await supabaseServerClient
+11
app/route.ts
···00000000000
···1+import { createNewLeaflet } from "actions/createNewLeaflet";
2+import { cookies } from "next/headers";
3+import { redirect } from "next/navigation";
4+5+export const preferredRegion = ["sfo1"];
6+export const dynamic = "force-dynamic";
7+export const fetchCache = "force-no-store";
8+9+export async function GET() {
10+ redirect("/home");
11+}
+1-18
appview/index.ts
···109 data: record.value as Json,
110 });
111 if (docResult.error) console.log(docResult.error);
112- await inngest.send({
113- name: "appview/sync-document-metadata",
114- data: {
115- document_uri: evt.uri.toString(),
116- bsky_post_uri: record.value.postRef?.uri,
117- },
118- });
119 if (record.value.publication) {
120 let publicationURI = new AtUri(record.value.publication);
121···276 data: record.value as Json,
277 });
278 if (docResult.error) console.log(docResult.error);
279- await inngest.send({
280- name: "appview/sync-document-metadata",
281- data: {
282- document_uri: evt.uri.toString(),
283- bsky_post_uri: record.value.bskyPostRef?.uri,
284- },
285- });
286287 // site.standard.document uses "site" field to reference the publication
288 // For documents in publications, site is an AT-URI (at://did:plc:xxx/site.standard.publication/rkey)
···392393 // Now validate the record since we know it contains our quote param
394 let record = AppBskyFeedPost.validateRecord(evt.record);
395- if (!record.success) {
396- console.log(record.error);
397- return;
398- }
399400 let embed: string | null = null;
401 if (
···109 data: record.value as Json,
110 });
111 if (docResult.error) console.log(docResult.error);
0000000112 if (record.value.publication) {
113 let publicationURI = new AtUri(record.value.publication);
114···269 data: record.value as Json,
270 });
271 if (docResult.error) console.log(docResult.error);
0000000272273 // site.standard.document uses "site" field to reference the publication
274 // For documents in publications, site is an AT-URI (at://did:plc:xxx/site.standard.publication/rkey)
···378379 // Now validate the record since we know it contains our quote param
380 let record = AppBskyFeedPost.validateRecord(evt.record);
381+ if (!record.success) return;
000382383 let embed: string | null = null;
384 if (
···8import type { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data";
9import { createContext, useContext, useMemo } from "react";
10import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData";
11-import { getPublicationURL, getDocumentURL } from "app/lish/createPub/getPublicationURL";
12import { AtUri } from "@atproto/syntax";
13import {
14 normalizeDocumentRecord,
···119 // Compute the full post URL for sharing
120 let postShareLink: string | undefined;
121 if (publishedInPublication?.publications && publishedInPublication.documents) {
122- const normalizedDoc = normalizeDocumentRecord(
123- publishedInPublication.documents.data,
124- publishedInPublication.documents.uri,
125- );
126- if (normalizedDoc) {
127- postShareLink = getDocumentURL(
128- normalizedDoc,
129- publishedInPublication.documents.uri,
130- publishedInPublication.publications,
131- );
132- }
133 } else if (publishedStandalone?.document) {
134- const normalizedDoc = publishedStandalone.documents
135- ? normalizeDocumentRecord(publishedStandalone.documents.data, publishedStandalone.document)
136- : null;
137- if (normalizedDoc) {
138- postShareLink = getDocumentURL(normalizedDoc, publishedStandalone.document);
139- } else {
140- const docUri = new AtUri(publishedStandalone.document);
141- postShareLink = `/p/${docUri.host}/${docUri.rkey}`;
142- }
143 }
144145 return {
···8import type { GetLeafletDataReturnType } from "app/api/rpc/[command]/get_leaflet_data";
9import { createContext, useContext, useMemo } from "react";
10import { getPublicationMetadataFromLeafletData } from "src/utils/getPublicationMetadataFromLeafletData";
11+import { getPublicationURL } from "app/lish/createPub/getPublicationURL";
12import { AtUri } from "@atproto/syntax";
13import {
14 normalizeDocumentRecord,
···119 // Compute the full post URL for sharing
120 let postShareLink: string | undefined;
121 if (publishedInPublication?.publications && publishedInPublication.documents) {
122+ // Published in a publication - use publication URL + document rkey
123+ const docUri = new AtUri(publishedInPublication.documents.uri);
124+ postShareLink = `${getPublicationURL(publishedInPublication.publications)}/${docUri.rkey}`;
00000000125 } else if (publishedStandalone?.document) {
126+ // Standalone published post - use /p/{did}/{rkey} format
127+ const docUri = new AtUri(publishedStandalone.document);
128+ postShareLink = `/p/${docUri.host}/${docUri.rkey}`;
000000129 }
130131 return {
···28export type NormalizedDocument = SiteStandardDocument.Record & {
29 // Keep the original theme for components that need leaflet-specific styling
30 theme?: PubLeafletPublication.Theme;
31- preferences?: SiteStandardPublication.Preferences;
32};
3334// Normalized publication type - uses the generated site.standard.publication type
···51 * Checks if the record is a pub.leaflet.document
52 */
53export function isLeafletDocument(
54- record: unknown,
55): record is PubLeafletDocument.Record {
56 if (!record || typeof record !== "object") return false;
57 const r = record as Record<string, unknown>;
···66 * Checks if the record is a site.standard.document
67 */
68export function isStandardDocument(
69- record: unknown,
70): record is SiteStandardDocument.Record {
71 if (!record || typeof record !== "object") return false;
72 const r = record as Record<string, unknown>;
···77 * Checks if the record is a pub.leaflet.publication
78 */
79export function isLeafletPublication(
80- record: unknown,
81): record is PubLeafletPublication.Record {
82 if (!record || typeof record !== "object") return false;
83 const r = record as Record<string, unknown>;
···92 * Checks if the record is a site.standard.publication
93 */
94export function isStandardPublication(
95- record: unknown,
96): record is SiteStandardPublication.Record {
97 if (!record || typeof record !== "object") return false;
98 const r = record as Record<string, unknown>;
···107 | $Typed<PubLeafletThemeColor.Rgba>
108 | $Typed<PubLeafletThemeColor.Rgb>
109 | { $type: string }
110- | undefined,
111): { r: number; g: number; b: number } | undefined {
112 if (!color || typeof color !== "object") return undefined;
113 const c = color as Record<string, unknown>;
···125 * Converts a pub.leaflet theme to a site.standard.theme.basic format
126 */
127export function leafletThemeToBasicTheme(
128- theme: PubLeafletPublication.Theme | undefined,
129): SiteStandardThemeBasic.Main | undefined {
130 if (!theme) return undefined;
131132 const background = extractRgb(theme.backgroundColor);
133- const accent =
134- extractRgb(theme.accentBackground) || extractRgb(theme.primary);
135 const accentForeground = extractRgb(theme.accentText);
136137 // If we don't have the required colors, return undefined
···162 * @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records
163 * @returns A normalized document in site.standard format, or null if invalid/unrecognized
164 */
165-export function normalizeDocument(
166- record: unknown,
167- uri?: string,
168-): NormalizedDocument | null {
169 if (!record || typeof record !== "object") return null;
170171 // Pass through site.standard records directly (theme is already in correct format if present)
172 if (isStandardDocument(record)) {
173- const preferences = record.preferences as
174- | SiteStandardPublication.Preferences
175- | undefined;
176 return {
177 ...record,
178 theme: record.theme,
179- preferences,
180 } as NormalizedDocument;
181 }
182···203 }
204 : undefined;
205206- // Extract preferences if present (available after lexicon rebuild)
207- const leafletPrefs = (record as Record<string, unknown>)
208- .preferences as SiteStandardPublication.Preferences | undefined;
209-210 return {
211 $type: "site.standard.document",
212 title: record.title,
···219 bskyPostRef: record.postRef,
220 content,
221 theme: record.theme,
222- preferences: leafletPrefs
223- ? { ...leafletPrefs, $type: "site.standard.publication#preferences" as const }
224- : undefined,
225 };
226 }
227···235 * @returns A normalized publication in site.standard format, or null if invalid/unrecognized
236 */
237export function normalizePublication(
238- record: unknown,
239): NormalizedPublication | null {
240 if (!record || typeof record !== "object") return null;
241···284 showComments: record.preferences.showComments,
285 showMentions: record.preferences.showMentions,
286 showPrevNext: record.preferences.showPrevNext,
287- showRecommends: record.preferences.showRecommends,
288 }
289 : undefined;
290···307 * Type guard to check if a normalized document has leaflet content
308 */
309export function hasLeafletContent(
310- doc: NormalizedDocument,
311): doc is NormalizedDocument & {
312 content: $Typed<PubLeafletContent.Main>;
313} {
···321 * Gets the pages array from a normalized document, handling both formats
322 */
323export function getDocumentPages(
324- doc: NormalizedDocument,
325): PubLeafletContent.Main["pages"] | undefined {
326 if (!doc.content) return undefined;
327
···28export type NormalizedDocument = SiteStandardDocument.Record & {
29 // Keep the original theme for components that need leaflet-specific styling
30 theme?: PubLeafletPublication.Theme;
031};
3233// Normalized publication type - uses the generated site.standard.publication type
···50 * Checks if the record is a pub.leaflet.document
51 */
52export function isLeafletDocument(
53+ record: unknown
54): record is PubLeafletDocument.Record {
55 if (!record || typeof record !== "object") return false;
56 const r = record as Record<string, unknown>;
···65 * Checks if the record is a site.standard.document
66 */
67export function isStandardDocument(
68+ record: unknown
69): record is SiteStandardDocument.Record {
70 if (!record || typeof record !== "object") return false;
71 const r = record as Record<string, unknown>;
···76 * Checks if the record is a pub.leaflet.publication
77 */
78export function isLeafletPublication(
79+ record: unknown
80): record is PubLeafletPublication.Record {
81 if (!record || typeof record !== "object") return false;
82 const r = record as Record<string, unknown>;
···91 * Checks if the record is a site.standard.publication
92 */
93export function isStandardPublication(
94+ record: unknown
95): record is SiteStandardPublication.Record {
96 if (!record || typeof record !== "object") return false;
97 const r = record as Record<string, unknown>;
···106 | $Typed<PubLeafletThemeColor.Rgba>
107 | $Typed<PubLeafletThemeColor.Rgb>
108 | { $type: string }
109+ | undefined
110): { r: number; g: number; b: number } | undefined {
111 if (!color || typeof color !== "object") return undefined;
112 const c = color as Record<string, unknown>;
···124 * Converts a pub.leaflet theme to a site.standard.theme.basic format
125 */
126export function leafletThemeToBasicTheme(
127+ theme: PubLeafletPublication.Theme | undefined
128): SiteStandardThemeBasic.Main | undefined {
129 if (!theme) return undefined;
130131 const background = extractRgb(theme.backgroundColor);
132+ const accent = extractRgb(theme.accentBackground) || extractRgb(theme.primary);
0133 const accentForeground = extractRgb(theme.accentText);
134135 // If we don't have the required colors, return undefined
···160 * @param uri - Optional document URI, used to extract the rkey for the path field when normalizing pub.leaflet records
161 * @returns A normalized document in site.standard format, or null if invalid/unrecognized
162 */
163+export function normalizeDocument(record: unknown, uri?: string): NormalizedDocument | null {
000164 if (!record || typeof record !== "object") return null;
165166 // Pass through site.standard records directly (theme is already in correct format if present)
167 if (isStandardDocument(record)) {
000168 return {
169 ...record,
170 theme: record.theme,
0171 } as NormalizedDocument;
172 }
173···194 }
195 : undefined;
1960000197 return {
198 $type: "site.standard.document",
199 title: record.title,
···206 bskyPostRef: record.postRef,
207 content,
208 theme: record.theme,
000209 };
210 }
211···219 * @returns A normalized publication in site.standard format, or null if invalid/unrecognized
220 */
221export function normalizePublication(
222+ record: unknown
223): NormalizedPublication | null {
224 if (!record || typeof record !== "object") return null;
225···268 showComments: record.preferences.showComments,
269 showMentions: record.preferences.showMentions,
270 showPrevNext: record.preferences.showPrevNext,
0271 }
272 : undefined;
273···290 * Type guard to check if a normalized document has leaflet content
291 */
292export function hasLeafletContent(
293+ doc: NormalizedDocument
294): doc is NormalizedDocument & {
295 content: $Typed<PubLeafletContent.Main>;
296} {
···304 * Gets the pages array from a normalized document, handling both formats
305 */
306export function getDocumentPages(
307+ doc: NormalizedDocument
308): PubLeafletContent.Main["pages"] | undefined {
309 if (!doc.content) return undefined;
310
···1+# Pro Tier Subscription System
2+3+**Status**: draft
4+5+## Goal
6+7+Add a paid Pro tier to Leaflet using Stripe for billing, with an entitlements-based architecture that decouples feature access from subscription state.
8+9+## Design
10+11+### Data Model
12+13+Two new tables separate Stripe subscription state from feature access:
14+15+**`user_subscriptions`** โ Stripe sync state
16+- `identity_id` (UUID, FK to identities, PK)
17+- `stripe_customer_id` (text, unique)
18+- `stripe_subscription_id` (text, unique, nullable)
19+- `plan` (text) โ Price ID from `stripe/products.ts`, e.g., `pro_monthly_v1_usd`
20+- `status` (text) โ mirrors Stripe: `trialing`, `active`, `past_due`, `canceled`, `unpaid`
21+- `current_period_end` (timestamp)
22+- `created_at`, `updated_at`
23+24+**`user_entitlements`** โ Feature access grants
25+- `identity_id` (UUID, FK to identities)
26+- `entitlement_key` (text) โ e.g., `analytics`
27+- `granted_at` (timestamp)
28+- `expires_at` (timestamp, nullable) โ null means permanent
29+- `source` (text) โ provenance, e.g., `stripe:sub_xxx`, `manual:admin`, `promo:launch2026`
30+- `metadata` (jsonb, nullable) โ for limits or additional config
31+- Primary key: `(identity_id, entitlement_key)` โ one entitlement per key per user
32+33+The unique constraint on `(identity_id, entitlement_key)` means writes are upserts. The most recent write wins; `source` tracks provenance.
34+35+### SKU โ Entitlements Mapping
36+37+Entitlements for each Stripe Product are stored in Stripe's product metadata, not locally. Example product metadata:
38+39+```json
40+{
41+ "entitlements": "{\"analytics\": true}"
42+}
43+```
44+45+This keeps Stripe as the source of truth for what each SKU grants.
46+47+### Stripe Product Sync
48+49+Products and prices are defined in code and synced to Stripe via a GitHub Action. This provides version control, reproducibility, and enforces immutability.
50+51+**Directory structure:**
52+```
53+stripe/
54+โโโ products.ts # Product/price definitions with entitlement metadata
55+โโโ sync.ts # Script that ensures Stripe matches definitions
56+```
57+58+**Product definition format** (`stripe/products.ts`):
59+```typescript
60+export const products = [
61+ {
62+ id: "pro_monthly_v1",
63+ name: "Leaflet Pro (Monthly)",
64+ prices: [
65+ {
66+ id: "pro_monthly_v1_usd",
67+ currency: "usd",
68+ unit_amount: 900, // $9.00
69+ recurring: { interval: "month" },
70+ },
71+ ],
72+ metadata: {
73+ entitlements: JSON.stringify({ publication_analytics: true }),
74+ },
75+ },
76+];
77+```
78+79+**Sync script behavior** (`stripe/sync.ts`):
80+1. Fetch existing products and prices by `id` (`stripe.products.retrieve(id)`, `stripe.prices.retrieve(id)`)
81+2. For each definition:
82+ - If missing in Stripe โ create it with the custom `id`
83+ - If exists and matches โ no-op
84+ - If exists but differs โ update it to match the definition
85+3. Never deletes products/prices
86+87+Both products and prices use custom `id` for idempotent matching (set at creation). Same definitions apply to both test and live modeโthe script targets whichever mode the API key belongs to.
88+89+**GitHub Action** (`.github/workflows/stripe-sync.yml`):
90+- Triggers on push to `stripe/` directory
91+- Runs sync against test mode automatically
92+- Live mode sync requires manual workflow dispatch with approval
93+94+**Versioning/grandfathering:**
95+To grandfather existing subscribers on different terms, create a new product with a new `id` (e.g., `pro_monthly_v2`). Existing subscribers stay on v1. New subscribers get v2. For additive changes (new entitlements, metadata updates), just update the existing product definition.
96+97+### Stripe Sync Strategy
98+99+**Webhook-driven** with **optimistic updates**:
100+101+1. **Optimistic**: After successful Checkout Session completion on the client, immediately call a server action to write `user_subscriptions` and `user_entitlements` based on the session data. User gets instant access.
102+103+2. **Durable**: Stripe webhooks confirm and reconcile state. Handles edge cases (payment failures, disputes, subscription updates from Stripe dashboard).
104+105+Webhooks to handle:
106+- `checkout.session.completed` โ initial subscription created
107+- `customer.subscription.created` โ backup for subscription creation
108+- `customer.subscription.updated` โ plan changes, renewals, status changes
109+- `customer.subscription.deleted` โ subscription ended
110+- `invoice.payment_failed` โ payment issues
111+- `customer.subscription.trial_will_end` โ trial ending reminder (optional, for notifications)
112+113+### Entitlement Lifecycle
114+115+**Grant flow** (subscription activates or trial starts):
116+1. Webhook receives event with subscription and product data
117+2. Parse `entitlements` from product metadata
118+3. Upsert `user_entitlements` rows with `expires_at` = `current_period_end`, `source` = `stripe:{subscription_id}`
119+120+**Renewal flow** (subscription renews):
121+1. `customer.subscription.updated` webhook fires
122+2. Update `expires_at` on all entitlements with matching `source`
123+124+**Cancellation flow** (user cancels but period remains):
125+1. `customer.subscription.updated` with `cancel_at_period_end: true`
126+2. Update `user_subscriptions.status`
127+3. Entitlements remain valid until `expires_at` (already set to period end)
128+129+**Expiration flow** (subscription ends):
130+1. `customer.subscription.deleted` webhook fires
131+2. No action needed on `user_entitlements` โ they naturally expire via `expires_at`
132+3. Update `user_subscriptions.status` to `canceled`
133+134+Soft expiration via `expires_at` preserves audit history and handles the canceled-but-paid-through-period case without additional webhooks.
135+136+### Trials
137+138+Stripe-managed trials:
139+1. Trial starts โ subscription created with `status: trialing`
140+2. Entitlements granted with `expires_at` = trial end date
141+3. Trial converts โ `expires_at` updated to subscription period end
142+4. Trial lapses โ entitlements expire naturally, no action needed
143+144+### Entitlement Checks
145+146+Extend `getIdentityData()` to join against `user_entitlements` and return active entitlements:
147+148+```typescript
149+type IdentityData = {
150+ id: string;
151+ email: string;
152+ atp_did: string;
153+ // ... existing fields
154+ entitlements: Record<string, {
155+ granted_at: string;
156+ expires_at: string | null;
157+ source: string;
158+ metadata: Record<string, unknown> | null;
159+ }>;
160+};
161+```
162+163+Query filters to `expires_at IS NULL OR expires_at > NOW()`. The `useIdentityData()` hook exposes the same shape client-side.
164+165+Feature gating in server actions:
166+167+```typescript
168+export async function getAnalyticsData() {
169+ const identity = await getIdentityData();
170+ if (!identity?.entitlements.analytics) {
171+ return err("Pro subscription required");
172+ }
173+ // ... fetch analytics
174+}
175+```
176+177+### Initial Pro Features
178+179+Single entitlement at launch:
180+- `publication_analytics` (boolean) โ access to analytics features for
181+ publications
182+183+### Webhook Endpoint
184+185+New API route at `app/api/webhooks/stripe/route.ts`:
186+- Verify Stripe signature using `STRIPE_WEBHOOK_SECRET`
187+- Dispatch to Inngest functions for processing (keeps webhook response fast, enables retries)
188+189+### Inngest Functions
190+191+New functions in `app/api/inngest/functions/`:
192+- `stripe/handle-checkout-completed` โ process successful checkout
193+- `stripe/handle-subscription-updated` โ sync subscription changes to entitlements
194+- `stripe/handle-subscription-deleted` โ update subscription status
195+196+Events to add to Inngest client:
197+```typescript
198+"stripe/checkout.session.completed": { data: { sessionId: string } }
199+"stripe/customer.subscription.updated": { data: { subscriptionId: string } }
200+"stripe/customer.subscription.deleted": { data: { subscriptionId: string } }
201+```
202+203+### Environment Variables
204+205+```
206+STRIPE_SECRET_KEY=sk_...
207+STRIPE_WEBHOOK_SECRET=whsec_...
208+```
209+210+Price IDs are defined in `stripe/products.ts` and imported directly โ no env var needed.
211+212+## Open Questions
213+214+- **UI**: Where upgrade prompts appear, pricing page design, checkout flow UX โ to be designed separately
215+- **Multiple plans**: Current design supports one subscription per user. If we add team plans or multiple concurrent subscriptions, `user_subscriptions` would need to become one-to-many
216+217+## Implementation
218+219+1. **Add Stripe packages**: Install `stripe` npm package
220+221+2. **Database migration**: Create `user_subscriptions` and `user_entitlements` tables with indexes on `identity_id` and `expires_at`
222+223+3. **Drizzle schema**: Add table definitions to `drizzle/schema.ts`
224+225+4. **Environment setup**: Add Stripe env vars to `.env.local` and deployment config
226+227+5. **Stripe webhook endpoint**: Create `app/api/webhooks/stripe/route.ts` with signature verification, dispatch events to Inngest
228+229+6. **Inngest events and functions**: Add event types to `app/api/inngest/client.ts`, create handler functions for checkout completed, subscription updated, subscription deleted
230+231+7. **Extend getIdentityData**: Join against `user_entitlements`, filter expired, return entitlements object
232+233+8. **Extend useIdentityData**: Ensure client-side hook receives entitlements from server
234+235+9. **Optimistic update action**: Create `actions/subscriptions/activateSubscription.ts` for immediate access after checkout
236+237+10. **Stripe sync infrastructure**:
238+ - Create `stripe/products.ts` with Pro product definitions and entitlement metadata
239+ - Create `stripe/sync.ts` script using Stripe Node SDK to reconcile definitions with Stripe
240+ - Add `.github/workflows/stripe-sync.yml` for automated test mode sync on push, manual live mode sync
241+242+11. **Initial product sync**: Run sync script against both test and live mode to create Pro products
243+244+12. **Gate analytics**: Add entitlement check to analytics server actions
···19 const uri = new AtUri(atUri);
2021 if (isPublicationCollection(uri.collection)) {
22- return `/lish/uri/${encodeURIComponent(atUri)}`;
023 } else if (isDocumentCollection(uri.collection)) {
0024 return `/lish/uri/${encodeURIComponent(atUri)}`;
25 }
26···39export function handleMentionClick(
40 e: MouseEvent | React.MouseEvent,
41 type: "did" | "at-uri",
42- value: string,
43) {
44 e.preventDefault();
45 e.stopPropagation();
···19 const uri = new AtUri(atUri);
2021 if (isPublicationCollection(uri.collection)) {
22+ // Publication URL: /lish/{did}/{rkey}
23+ return `/lish/${uri.host}/${uri.rkey}`;
24 } else if (isDocumentCollection(uri.collection)) {
25+ // Document URL - we need to resolve this via the API
26+ // For now, create a redirect route that will handle it
27 return `/lish/uri/${encodeURIComponent(atUri)}`;
28 }
29···42export function handleMentionClick(
43 e: MouseEvent | React.MouseEvent,
44 type: "did" | "at-uri",
45+ value: string
46) {
47 e.preventDefault();
48 e.stopPropagation();
···1-ALTER TABLE documents ADD COLUMN recommend_count integer NOT NULL DEFAULT 0;
2-3-UPDATE documents d
4-SET recommend_count = (
5- SELECT COUNT(*) FROM recommends_on_documents r WHERE r.document = d.uri
6-);
7-8-CREATE OR REPLACE FUNCTION update_recommend_count() RETURNS trigger AS $$
9-BEGIN
10- IF TG_OP = 'INSERT' THEN
11- UPDATE documents SET recommend_count = recommend_count + 1
12- WHERE uri = NEW.document;
13- ELSIF TG_OP = 'DELETE' THEN
14- UPDATE documents SET recommend_count = recommend_count - 1
15- WHERE uri = OLD.document;
16- END IF;
17- RETURN NULL;
18-END;
19-$$ LANGUAGE plpgsql;
20-21-CREATE TRIGGER trg_recommend_count
22-AFTER INSERT OR DELETE ON recommends_on_documents
23-FOR EACH ROW EXECUTE FUNCTION update_recommend_count();