Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,16 @@ INDEXER_URL=https://magic-indexer-prod.up.railway.app/graphql
# INDEXER_URL above and leave this unset on new deploys.
# NEXT_PUBLIC_INDEXER_URL=

# Home-feed transport cutover. Defaults to `indexer` while the hydrated
# Certified Feed Service completes staging/production observation. Set to
# `service` to send one direct, credentialless XRPC request per feed page.
# NEXT_PUBLIC_HOME_FEED_SOURCE=indexer
#
# Required when NEXT_PUBLIC_HOME_FEED_SOURCE=service. Configure the exact HTTPS
# origin only (no path/query/fragment); the app appends the getFeed XRPC path.
# Local development also permits an http://localhost/127.0.0.1/[::1] origin.
# NEXT_PUBLIC_CERTIFIED_FEED_SERVICE_URL=https://feed.example.com

# Optional indexer fast-path for /api/resolve-did (default false). When
# "true", resolve-did reads identity (handle + the Bluesky profile block)
# from the indexer's actorProfile(did) query instead of fanning out to
Expand Down
47 changes: 30 additions & 17 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ Source: `.env.local.example` and `src/lib/utils/config.ts`.
| Variable | Required | Purpose |
|---|---|---|
| `NEXT_PUBLIC_PDS_URL` | yes | PDS / handle resolver URL. Defaults to `https://certified.one`. |
| `NEXT_PUBLIC_HOME_FEED_SOURCE` | optional | Home-feed transport: `indexer` (default and rollback path) or `service` (direct hydrated XRPC). Invalid values fail with an actionable configuration error. |
| `NEXT_PUBLIC_CERTIFIED_FEED_SERVICE_URL` | service mode | Exact Certified Feed Service origin. Production/staging require HTTPS; non-production permits HTTP only for localhost/loopback. Do not include a path, query, fragment, or credentials. |
| `PUBLIC_URL` | recommended in production | Canonical app origin. Wins when deriving OAuth `client_id` and `redirect_uris`; exact same-origin CSRF requests from it are trusted. Falls back to `VERCEL_BRANCH_URL`, then `VERCEL_URL`, then `http://localhost:3000` outside production. **For local atproto OAuth sign-in to actually complete, set this to `http://127.0.0.1:3000`** — see [§22 Common Pitfalls](#22-common-pitfalls) #3. |
| `VERCEL_BRANCH_URL` | Vercel-provided fallback | Hostname-only stable branch URL. Becomes the canonical OAuth origin when `PUBLIC_URL` is absent and is accepted for same-origin CSRF requests. Do not add a scheme or `NEXT_PUBLIC_` alias. |
| `VERCEL_URL` | Vercel-provided fallback | Hostname-only commit deployment URL. Final canonical OAuth fallback and accepted for same-origin CSRF requests. Do not add a scheme or `NEXT_PUBLIC_` alias. |
Expand Down Expand Up @@ -176,6 +178,7 @@ Key principles:
2. **All XRPC calls go through `/api/xrpc/[...method]`.** Never call the PDS from the browser directly with credentials — there are none. Use `authFetch()` from `src/lib/auth/fetch.ts`. It detects 401 and triggers the global `onUnauthorized` handler registered by `AuthProvider`, which clears auth state and asks the user to sign in again.
3. **Group operations** use a parallel set of routes under `/api/groups/**` because they require the AtpAgent's `withProxy("certified_group", groupDid)` pattern + custom NSID lexicons (`app.certified.group.*`). They do not share the `/api/xrpc/[...method]` handler.
4. **DID resolution is direct.** `resolvePdsUrl` and `resolveHandle` (in `src/lib/atproto/did.ts`) hit `plc.directory` or the `did:web` host with a 5s timeout; results are not cached server-side.
5. **The home-feed service path is the sole credentialless direct XRPC exception.** When `NEXT_PUBLIC_HOME_FEED_SOURCE=service`, `src/lib/atproto/certified-feed.ts` calls the public read-only Certified Feed Service with `credentials: "omit"` and `cache: "no-store"`. It never sends the app session or uses `authFetch`. The build-time `indexer` source retains the old browser/indexer path for rollback until the observation-window cleanup is separately approved.

## 6. Provider Tree & Layout System

Expand Down Expand Up @@ -1052,15 +1055,18 @@ stop when the next pass would be nit-picking.

### Branching (project-specific override)

Work happens **directly on `staging`**, not on per-feature
branches. When `staging` is in good shape, open a Draft PR
from `staging` into `main`. The operator merges; agents never
merge.
Work happens on **task-specific feature branches**, not
directly on `staging`. Continue an existing task on its current
feature branch; create one before editing when the current
branch is `main`, `staging`, or another default/integration
branch. Keep planning, implementation, review, and commits on
the feature branch.

This overrides the global "feature-branch into staging"
default in `~/.claude/CLAUDE.md`. This repo's review cadence
is dense enough that `staging` is the natural integration
point.
After the feature branch passes review and verification, merge
it into `staging` with operator approval. When `staging` is in
good shape, open a Draft PR from `staging` into `main`. The
operator merges the `staging` → `main` PR; agents never merge
that PR.

### Order of operations

Expand Down Expand Up @@ -1095,9 +1101,10 @@ point.
item. Update the plan in place. Run further rounds only if
the previous round surfaced substantive items.

4. **Implement.** Commit directly to `staging`. Atomic commits
with a clear scope tag. Match the existing commit-message
convention (`Co-Authored-By:` trailer per Safety Rule 6).
4. **Implement.** Commit on the task's feature branch. Use
atomic commits with a clear scope tag. Match the existing
commit-message convention (`Co-Authored-By:` trailer per
Safety Rule 6).

5. **Local verification.** Run all four quality gates plus
anything that exercises the new surface:
Expand All @@ -1116,20 +1123,26 @@ point.
follow-up round only if round 1 surfaced enough
substantive items to justify one.

7. **Draft PR `staging → main`.** Body must link to the plan
7. **Integrate into `staging`.** After operator approval, merge
the verified feature branch into `staging`. Push only with
explicit approval.

8. **Draft PR `staging → main`.** Body must link to the plan
and review-decision docs, list breaking changes, state
out-of-scope items, and include a test plan checklist.

8. **Make CI green.** Fix root causes. Never `--no-verify`.
9. **Make CI green.** Fix root causes. Never `--no-verify`.
Never skip hooks. Loop until all checks pass.

9. **Stop.** The operator merges. Notify with the PR URL and
a short summary of what shipped.
10. **Stop.** The operator merges the `staging` → `main` PR.
Notify with the PR URL and a short summary of what shipped.

### Hard rules

- **Never merge.** Stopping at "PR Draft, CI green" is the
contract.
- **Never merge `staging` into `main` or merge the corresponding
PR.** Feature-branch integration into `staging` requires
operator approval. Stopping at "PR Draft, CI green" is the
release contract.
- **Never `--force` push to `main`.** Avoid history rewrites
on `staging` once you've pushed; it's the shared working
branch.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ Edit `.env.local` with your values:
| Variable | Required | Description |
|----------|----------|-------------|
| `NEXT_PUBLIC_PDS_URL` | Yes | PDS / handle resolver URL (default: `https://certified.one`) |
| `NEXT_PUBLIC_HOME_FEED_SOURCE` | No | Home feed transport: `indexer` (default/rollback) or `service` |
| `NEXT_PUBLIC_CERTIFIED_FEED_SERVICE_URL` | Service mode | Exact HTTPS origin of the Certified Feed Service; local loopback HTTP is allowed in development |
| `PUBLIC_URL` | Recommended in production | Canonical app origin used for OAuth metadata and callbacks |
| `VERCEL_BRANCH_URL` | Vercel-provided fallback | Stable branch hostname used when `PUBLIC_URL` is absent |
| `VERCEL_URL` | Vercel-provided fallback | Commit deployment hostname used when the first two values are absent |
Expand Down
41 changes: 41 additions & 0 deletions docs/feed-service-consumption/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# Feed service consumption update

## Goal

Make the Certified app consume the current `http/configurable-cors` feed-service contract without spreading service-specific wire types through the home-feed UI.

## Main API / ownership

```ts
fetchCertifiedFeed(input: HomeFeedRequest): Promise<CertifiedFeedPage>
// adapter emits { feedId, params: { $type, viewerDid, ... }, limit, cursor }
// adapter normalizes { feed, cursor? } into the app-owned page model
```

- `src/lib/atproto/certified-feed.ts` owns wire request construction, response validation, and normalization.
- `src/hooks/use-home-feed.ts` maps normalized items into the existing `HomeFeedEvent` model.
- Existing home-feed components remain unchanged unless nullable timestamps require a minimal rendering adjustment.

## Alternatives

- Keep the current UI model and translate at the boundary (chosen: smallest surface and preserves rollback hook).
- Propagate `feed/view/content` through UI components (drop: unnecessary coupling).
- Support both old and new wire contracts (drop: beta service has no required compatibility window).

## Acceptance criteria

- Requests include the feed ID and typed nested params; pagination remains top-level.
- Responses parse `feed` entries with URI-only subjects and nested Certified views.
- Current activity, collection, endorsement, evaluation, measurement, hyperboard, and update cards map correctly.
- Missing optional wire fields are safe; source CID/feed timestamp are not assumed.
- CORS remains credentialless and requires no app proxy/configuration.
- Adapter and hook regression tests fail before implementation and pass afterward.

## Out of scope / rollback

- No service-repository changes, CORS policy changes, activity-quality UI, or new feed UI.
- Roll back by reverting the app adapter/hook commit; `NEXT_PUBLIC_HOME_FEED_SOURCE=indexer` remains available.

## Open question

- If the service rebase adds `activityQuality`, model it as an optional top-level request field in a follow-up or include it only when the app exposes that filter.
13 changes: 13 additions & 0 deletions docs/feed-service-consumption/review-round-1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Review round 1

## Reviewer status

Two independent read-only reviewer runs were requested for contract correctness and test coverage. Both timed out after 120 seconds and produced no findings. No reviewer approval is claimed.

## Manual decisions

- **Keep boundary adapter:** the current home-feed UI model is already stable and the wire change is isolated to the transport adapter plus its mapper.
- **Update request and response together:** the old flat request and `items` response cannot interoperate with the current service contract.
- **Preserve nullable internal normalization:** the service omits optional fields and no longer exposes source feed timestamps/CIDs.
- **Do not add CORS configuration:** the committed service HEAD uses wildcard credentialless CORS and the app already uses `credentials: omit`.
- **Defer activity-quality support:** it is not part of committed `51594fb` and is not exposed by this app's current UI.
8 changes: 8 additions & 0 deletions src/components/dev/mock-fetch-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
} from "@/lib/dev/fixtures/profile"
import { resolveDidsResults } from "@/lib/dev/fixtures/authors"
import {
certifiedFeedPage,
followerEventsConnection,
hydrateFeedPageData,
activitiesConnection,
Expand All @@ -60,6 +61,7 @@ import {
groupsMembershipsResponse,
} from "@/lib/dev/fixtures/groups"
import { searchActorsResponse } from "@/lib/dev/fixtures/search"
import { CERTIFIED_FEED_PATH } from "@/lib/atproto/certified-feed"
import {
isManagedAuthorsRequest,
managedProjectsConnection,
Expand Down Expand Up @@ -343,6 +345,12 @@ function installMockFetch(

const path = url.pathname

// Direct cross-origin feed-service XRPC. Match the path rather than the
// origin so preview builds can use any explicitly configured service URL.
if (path === CERTIFIED_FEED_PATH) {
return json(empty ? { items: [] } : certifiedFeedPage())
}

// --- PLC directory (resolvePdsUrl / resolveHandle fetch it directly) ---
if (url.hostname === "plc.directory") {
// Managed scenario: serve a per-group DID document so each managed
Expand Down
30 changes: 11 additions & 19 deletions src/components/home/__tests__/cert-preview-location-icon.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from "vitest"
import { render, screen, cleanup, within } from "@testing-library/react"

import { CertPreview } from "../home-feed-rows"
import type { ActivityRecord } from "@/lib/atproto/activity-types"
import type { ActivityHomeFeedView } from "@/hooks/use-home-feed"

// bug-010: the feed PreviewCard's MapPin was gated on
// `i === 0 && withLocationIcon && i === meta.length - 1`, which is only
Expand All @@ -20,24 +20,16 @@ afterEach(() => {

describe("CertPreview location MapPin", () => {
it("renders a MapPin next to the locations entry even when a period is also present", () => {
const record = {
uri: URI,
cid: "bafycid",
value: {
title: "Reforestation effort",
shortDescription: "Planting trees",
createdAt: "2025-01-01T00:00:00.000Z",
startDate: "2025-01-15T00:00:00.000Z",
endDate: "2025-03-20T00:00:00.000Z",
locations: [
{ uri: "at://did:plc:l/app.certified.location/a", cid: "bafa" },
{ uri: "at://did:plc:l/app.certified.location/b", cid: "bafb" },
{ uri: "at://did:plc:l/app.certified.location/c", cid: "bafc" },
],
},
} as unknown as ActivityRecord

render(<CertPreview record={record} uri={URI} labels={[]} />)
const view = {
title: "Reforestation effort",
shortDescription: "Planting trees",
imageUrl: null,
startDate: "2025-01-15T00:00:00.000Z",
endDate: "2025-03-20T00:00:00.000Z",
locationCount: 3,
} satisfies ActivityHomeFeedView

render(<CertPreview view={view} uri={URI} />)

// The locations text still renders.
const locationsItem = screen.getByText(/3 locations/).closest("span")
Expand Down
29 changes: 18 additions & 11 deletions src/components/home/__tests__/endorsement-group-row.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { render, screen, cleanup, fireEvent } from "@testing-library/react"

import { EndorsementGroupRow } from "../home-feed-rows"
import type { EndorsementGroupItem } from "@/lib/utils/group-feed"
import type { FeedActor } from "@/lib/atproto/follower-events"
import type { HomeFeedActor } from "@/hooks/use-home-feed"

// useAuthorInfo goes through the batched DID resolver (network). Stub
// it so rows render DID-only bylines synchronously; the spy doubles as
Expand All @@ -17,13 +17,18 @@ vi.mock("@/hooks/use-author-info", () => ({
useAuthorInfo: (did: string | null) => useAuthorInfoMock(did),
}))

const ACTOR_PROFILE: FeedActor = {
did: "did:plc:actor",
handle: null,
displayName: null,
avatarCid: null,
function actor(did: string): HomeFeedActor {
return {
did,
handle: null,
displayName: null,
avatarUrl: null,
complete: false,
}
}

const ACTOR_PROFILE = actor("did:plc:actor")

function makeGroup(
count: number,
overrides?: Partial<EndorsementGroupItem>,
Expand All @@ -34,7 +39,9 @@ function makeGroup(
actor: "did:plc:actor",
actorProfile: ACTOR_PROFILE,
createdAt: "2026-07-01T00:00:00.000Z",
subjectDids: Array.from({ length: count }, (_, i) => `did:plc:subject${i}`),
subjects: Array.from({ length: count }, (_, i) =>
actor(`did:plc:subject${i}`),
),
...overrides,
}
}
Expand Down Expand Up @@ -90,15 +97,15 @@ describe("EndorsementGroupRow expanded-list windowing", () => {

describe("EndorsementGroupRow memo comparator", () => {
// groupConsecutiveEndorsements rebuilds group objects (and their
// subjectDids arrays) on every events change; the memo comparator
// must bail on a structurally-equal rebuild and re-render when the
// group absorbs another subject.
// subjects arrays) on every events change; the memo comparator must
// bail on a structurally-equal rebuild and re-render when the group
// absorbs another subject.
it("skips re-render for a rebuilt-but-equal group and re-renders on growth", () => {
const { rerender } = render(<EndorsementGroupRow group={makeGroup(5)} />)
const baseline = useAuthorInfoMock.mock.calls.length
expect(baseline).toBeGreaterThan(0)

// Fresh group + fresh subjectDids array, same values (actorProfile
// Fresh group + fresh subject summaries, same values (actorProfile
// ref is stable in production — it comes from the stable event).
rerender(<EndorsementGroupRow group={makeGroup(5)} />)
expect(useAuthorInfoMock.mock.calls.length).toBe(baseline)
Expand Down
57 changes: 57 additions & 0 deletions src/components/home/__tests__/home-feed-empty-pagination.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { HomeFeedBody } from "../home-feed"

const observerConstructed = vi.fn()

class FakeIntersectionObserver {
constructor() {
observerConstructed()
}
observe() {}
disconnect() {}
}

beforeEach(() => {
vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver)
observerConstructed.mockClear()
})

afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})

describe("empty home-feed pagination", () => {
it("keeps an accessible manual control after the 25-attempt auto budget", async () => {
const loadMore = vi.fn()
const props = {
events: [],
isLoading: false,
isLoadingMore: false,
hasMore: true,
cursor: "cursor-a",
error: null,
continuationError: null,
retryAt: null,
canAutoLoad: true,
requestKey: "request-a",
retryInitial: vi.fn(),
loadMore,
}
const { rerender } = render(<HomeFeedBody {...props} />)
await waitFor(() => expect(loadMore).toHaveBeenCalledTimes(1))

for (let attempt = 1; attempt < 25; attempt++) {
rerender(<HomeFeedBody {...props} isLoadingMore />)
rerender(<HomeFeedBody {...props} isLoadingMore={false} />)
await waitFor(() => expect(loadMore).toHaveBeenCalledTimes(attempt + 1))
}

const manualButton = screen.getByRole("button", { name: "Load more" })
expect(manualButton).toBeTruthy()
expect(observerConstructed).not.toHaveBeenCalled()
fireEvent.click(manualButton)
expect(loadMore).toHaveBeenCalledTimes(26)
})
})
Loading
Loading