diff --git a/app/components/PostCard.tsx b/app/components/PostCard.tsx
index 1c4e278..523ff56 100644
--- a/app/components/PostCard.tsx
+++ b/app/components/PostCard.tsx
@@ -17,10 +17,11 @@ type Props = {
resolvedUrl?: string
path?: string
textContent?: string
+ description?: string
}
export default function PostCard({
- authorDid, rkey, pubRkey, title, publishedAt, resolvedHandle, publicationName, coverImageCid, resolvedUrl, path, textContent,
+ authorDid, rkey, pubRkey, title, publishedAt, resolvedHandle, publicationName, coverImageCid, resolvedUrl, path, textContent, description,
}: Props) {
const fullUrl = resolvedUrl && path ? resolvedUrl + path : null
const href = fullUrl && isExternalUrl(fullUrl) ? fullUrl : null
@@ -46,9 +47,9 @@ export default function PostCard({
{title}
- {textContent && (
+ {(textContent || description) && (
- {textContent}
+ {textContent || description}
)}
diff --git a/app/lib/api-client.ts b/app/lib/api-client.ts
index 914ac6e..1d85d0b 100644
--- a/app/lib/api-client.ts
+++ b/app/lib/api-client.ts
@@ -76,6 +76,7 @@ export type FeedItem = {
publishedAt: string
coverImageRef?: string
textContent?: string
+ description?: string
}
type FeedPage = {
@@ -100,6 +101,8 @@ export type TimelineItem = {
path?: string
resolvedHandle?: string
coverImageCid?: string
+ description?: string
+ textContent?: string
}
type TimelinePage = {
diff --git a/app/routes/following.tsx b/app/routes/following.tsx
index f3d6be4..39fd605 100644
--- a/app/routes/following.tsx
+++ b/app/routes/following.tsx
@@ -103,6 +103,8 @@ export default function Following() {
coverImageCid={item.coverImageCid}
resolvedUrl={item.resolvedUrl}
path={item.path}
+ textContent={item.textContent}
+ description={item.description}
/>
))}
diff --git a/app/routes/recent.tsx b/app/routes/recent.tsx
index f92d168..9935e66 100644
--- a/app/routes/recent.tsx
+++ b/app/routes/recent.tsx
@@ -94,6 +94,7 @@ export default function Recent() {
resolvedUrl={item.resolvedUrl}
path={item.path}
textContent={item.textContent}
+ description={item.description}
/>
)
})}
diff --git a/docs/architecture.md b/docs/architecture.md
index 2bf952a..a769f30 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -86,10 +86,10 @@ GSI = Global Secondary Index
| Entity | PK | SK | GSI1PK | GSI1SK | Notes |
|--------|----|----|--------|--------|-------|
| Publication | `u#
#pub` | `pub#` | — | — | Written on `site.standard.publication` create/update/delete. |
-| Post | `u##post` | `p#` | `blog-post#published` | `#` | Written on `site.standard.document` create/update/delete. Optional `bskyPostRef` attribute holds `{ uri, cid }` of the sidecar Bluesky post. |
+| Post | `u##post` | `p#` | `blog-post#published` | `#` | Written on `site.standard.document` create/update/delete. `GSI1SK` clamps `publishedAt` to index time to prevent future-dated records from dominating the feed. Optional attributes: `bskyPostRef` (`{ uri, cid }` of the sidecar Bluesky post), `description` (from the document's `description` field), `textContent` (plain-text excerpt, max 300 chars, derived from markdown content). |
| Subscription | `u##subscriptions` | `sub#` | — | — | Written when a reader subscribes. `authorDid` and `publicationAtUri` attributes identify the target publication. |
| Subscriber | `u##subscribers` | `u#` | — | — | Written by the indexer/backfill on subscribe; used to enumerate an author's subscribers for fan-out. |
-| Timeline item | `u##timeline` | `#` | — | — | Fan-in/fan-out items written when a subscription is created or a new post is published. Expires via `ttl` (30 days). |
+| Timeline item | `u##timeline` | `#` | — | — | Fan-in/fan-out items written when a subscription is created or a new post is published. Expires via `ttl` (30 days). Carries `description` and `textContent` (max 300 chars) from the source post so the following feed can render excerpts without a secondary lookup. |
| Subscriber count | `u#` | `count` | — | — | `subscribers` attribute incremented on each new subscription. Counted per DID, not per publication — if a reader subscribes to two publications by the same author, the count increments twice. Used by the API to identify celebrity authors (threshold: 1000) who bypass fan-out in favour of live post queries at read time. |
| User info | `u#` | `info` | — | — | Written on first sign-in via `POST /users`. Attributes: `entityType` (`user-info`), `acceptedTermsAt` (ISO timestamp, set once), `updatedAt`. |
| iframely cache | `iframely#cache` | SHA-256 hex of the embed URL | — | — | Expires via `ttl` attribute (DynamoDB TTL enabled) |
@@ -106,7 +106,7 @@ The `/following` page shows a reader their personalised timeline. There is no fa
When the indexer sees a new `site.standard.document` commit on the firehose it checks the author's subscriber count (`u#` / `count`):
-- **Below the celebrity threshold (1 000)** — the indexer queries `u##subscribers` to enumerate every reader, then writes a `timeline` item into each reader's `u##timeline` partition. Timeline items expire after 30 days.
+- **Below the celebrity threshold (1 000)** — the indexer queries `u##subscribers` to enumerate every reader, then writes a `timeline` item into each reader's `u##timeline` partition. Each item copies `description` and `textContent` (max 300 chars) from the post so the following feed can render excerpts without a secondary lookup. Timeline items expire after 30 days.
- **Above the threshold** — fan-out is skipped entirely; those readers receive the author's posts via the live celebrity query instead (see below).
### Fan-in (new subscription created)
@@ -141,6 +141,8 @@ The indexer (`infra/indexer/`) is a long-running Node.js process that subscribes
Jetstream delivers events with a `time_us` microsecond timestamp. The indexer writes the most recent `time_us` to a local file (`/var/lib/indexer/cursor`) on every event. On startup (or after a reconnect) the cursor is passed as a URL parameter so no events are missed or double-processed. The cursor file survives process restarts but not instance replacement.
+**Replay window limitation:** Jetstream retains events for approximately 72 hours. If the indexer is down for longer than that window, the cursor becomes stale and Jetstream resumes from the current time, permanently skipping any events (including deletes) that occurred during the gap. Delete events missed this way leave orphaned records in DynamoDB that must be removed manually.
+
### Reconnection
The WebSocket connection uses exponential backoff (1 s → 30 s) on disconnect. The `SIGINT` handler closes the socket cleanly before the process exits.
@@ -210,7 +212,7 @@ Public (no auth required) — the API key is kept server-side in AWS Secrets Man
The app uses [Standard.site](https://standard.site) lexicons for publishing and social graph:
- `site.standard.publication` — written to the user's PDS to represent their blog. Required before any documents can be created.
-- `site.standard.document` — written to the user's PDS for each post. The `site` field holds the publication's AT-URI; `path` is set to `/` so the document URL can be reconstructed from the publication's base URL.
+- `site.standard.document` — written to the user's PDS for each post. The `site` field holds the publication's AT-URI; `path` is set to `/` so the document URL can be reconstructed from the publication's base URL. Optional `description` field holds a short summary displayed in feed cards.
- `site.standard.graph.subscription` — written to the reader's PDS when they subscribe to a publication. The `publication` field holds the publication's AT-URI.
Publications and documents are written by the browser via `com.atproto.repo.putRecord` (upsert semantics). Subscriptions are written via `com.atproto.repo.createRecord`. All use the user's DPoP-bound session. The API Lambda only receives the resulting AT-URIs for indexing.
diff --git a/docs/security.md b/docs/security.md
index 9edb0b2..ebeab68 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -101,7 +101,7 @@ Public read endpoints (`GET /feed`, `GET /profile`, `GET /iframely`) require no
### Input validation
- Required fields (`atUri`, `site`, `rkey`, `title` for posts; `atUri`, `url`, `name`, `rkey` for publications) are checked and return 400 if missing or blank.
-- `bskyPostRef`, if present, must be a non-null object with string `uri` (must start with `at://`, max 512 chars) and string `cid` (max 256 chars). Invalid structure returns 400.
+- `bskyPostRef`, if present, must be a non-null object with string `uri` (must start with `at://`, max 512 chars) and string `cid` (max 256 chars). The DID embedded in the `uri` (`at:///…`) must match the authenticated user's DID — a user cannot point their document at another user's Bluesky post. Invalid structure or DID mismatch returns 400. The indexer applies the same ownership check when reading `bskyPostRef` from AT Protocol records.
- Feed cursor is base64url-decoded JSON; malformed values return 400.
- `visibility` is coerced to `'draft'` or `'published'` — arbitrary strings are normalised.
@@ -209,7 +209,7 @@ Covered in full in `docs/atproto-auth.md`. Key points:
| Issue | Impact | Rationale |
|-------|--------|-----------|
| `transition:generic` OAuth scope in local dev | Broad PDS write access during local development | Production uses enumerated `rpc:` scopes covering only the specific operations the app needs (see `docs/atproto-auth.md`). `transition:generic` is required for local dev because the PDS cannot reach `localhost` to validate a `did:web` service reference. |
-| `publishedAt` is user-controlled | Users can set a future or past date to influence global feed ordering | Intentional: the backfill flow (`backfillPosts`) must preserve the original publication timestamp from the PDS record. |
+| `publishedAt` is user-controlled | Users can set a past date to influence global feed ordering | Future dates are mitigated: `GSI1SK` is computed as `min(publishedAt, now)` at index time, so a record with a far-future `publishedAt` sorts no higher than the moment it was indexed. Past dates are intentional — the backfill flow must preserve original publication timestamps. |
| Handle resolution via `bsky.social` | Leaks user handles and IPs to Bluesky during sign-in | Documented in `docs/atproto-auth.md`. DNS-over-HTTPS (`cloudflare-dns.com`) is the recommended alternative. |
| No `Content-Security-Policy` header | Reduces defence-in-depth against XSS | CloudFront is in use but no Response Headers Policy or CloudFront Function is configured to inject the header. |
| Mermaid/Fountain code stored in `data-*` DOM attributes | The raw source code of all rich blocks is visible in the DOM | Intentional: the rehype plugin stores source so the client-side renderer can process it. The content is already in the post body and is public. |
diff --git a/infra/api/src/index.ts b/infra/api/src/index.ts
index 476bb8f..ef3b456 100644
--- a/infra/api/src/index.ts
+++ b/infra/api/src/index.ts
@@ -310,6 +310,7 @@ async function getFeed(event: APIGatewayProxyEventV2): Promise