This document is the canonical reference for coding agents working in this repository. It supersedes the shorter AGENTS.md and complements README.md. Read it end-to-end on a fresh clone; treat the file map and security rules as authoritative.
- Design Context
- Project Overview
- Tech Stack
- Quick Reference
- Environment Variables
- Architecture & Data Flow
- Provider Tree & Layout System
- Routing Map
- Authentication Flow
- API Routes Catalog
- XRPC Proxy
- CSS Conventions
- Component Conventions
- Hooks Catalog
- State Management
- Groups Feature
- Identity-Link / Wallet Attestation
- Security Rules
- SEO / GEO
- Git & Deployment
- File Map
- Known Limitations
- Common Pitfalls
- Adding a New Feature — Checklist
- Adding a New API Route — Checklist
- Conventions: Errors, Loading, A11y
PRODUCT.md at the repo root is the strategic design brief: register, primary user, brand personality (confident, principled, plain), anti-references (anchored on "visibly not-a-wallet"), and design principles. Read it before any UI/UX work. The /impeccable skill loads it automatically; humans should open it for any design decision that goes beyond a one-line copy or token tweak.
DESIGN.md is the visual companion. Read §14 — Design consolidation pass (2026-05-28) first: it locks in the post-consolidation rules and supersedes earlier sections where they contradict. Then read §1–§13 for the underlying system (Notary's Ledger North Star, civic palette, typography, three-step shadows).
The token refactor and component canonicalization that earlier drafts of this file described as future work have landed (PR #108, merged 2026-05-28 into
feat/positioning-redesign). The audit + visual divergence sheet that drove the work live atdocs/design-audit/component-audit.mdanddocs/design-audit/visual-divergence.md. The implementation plan + decision log isdocs/design-consolidation/plan.md.
These are the rules most often violated by drift. If your change touches CSS, JSX, or design tokens, hold yourself to them.
border-radiusisvar(--radius)(2 px) everywhere — except pills (999px) and circles (50%). No4px,6px,8px,12px,16px,20px. Sign-in modal is not an exception anymore.- No raw hex / rgb colors outside
src/app/styles/tokens.css. Use semantic tokens (--fg-primary,--bg-elevated,--border-default, …) or, on landing, the theme-aware landing tokens (--color-navy,--color-off-white,--color-light-gray,--color-mid-gray,--color-surface). The two invariant primitives--color-primaryand--color-whiteare reserved for systems that must not flip (skip-nav, brand SVG); don't reach for them on app surfaces. - No new breakpoints. The only canonical breakpoints are 800 / 1100 / 1300 (tokens
--bp-gt-mobile,--bp-gt-narrow-desktop,--bp-gt-desktop). For "below desktop" usemax-width: 799px. Don't introduce 768 / 760 / 640. - No ad-hoc shadows. Use
var(--shadow-sm)/var(--shadow-md)/var(--shadow-lg). Rawbox-shadow: 0 8px 24px rgba(...)is a smell. - No ad-hoc z-index. Use the tokens in
tokens.css§"Z-index map" (--z-rail,--z-popover,--z-navbar,--z-modal,--z-skip-nav,--z-feedback). - Reach for the canonical UI primitive before writing a new BEM class. The components in
src/components/ui/are:<Button>(4 variants × 4 sizes includingsize="icon"),<Input>(3 sizes × 3 variants),<Textarea>,<Badge>(10 variants),<Card>(row/elevated/inset),<Tabs>/<Tab>/<TabPanel>,<Skeleton>,<Popover>,<Avatar>,<AppDialog>,<ConfirmDialog>,<EmptyState>,<ErrorMessage>,<LoadingSpinner>,<EditBanner>. If a divergent style already exists for the job (e.g. a.foo__btnclass), do not add another — migrate the existing one toward the primitive or, if too risky, leave a TODO that references this file. - Headings use the
text-display/text-h1/text-h2/text-h3/text-h4scale andfont-headline(Noto Serif). Body usestext-body/text-body-sm/text-caption+ Inter. Do not use Tailwind'stext-xl/text-lg/text-2xlfor app headings. - All modals use
<AppDialog>(or<ConfirmDialog>/<DeleteRecordDialog>which wrap it). Don't hand-roll backdrop / Esc / focus-trap / scroll-lock — the<dialog>+showModal()pattern is centralized and has bug history. Seeapp-dialog.tsx:118for the bug class it prevents. - Dark mode must work. Toggle
data-theme="dark"on<html>and verify all text is readable. Don't add new CSS that pins colors in a way that breaks the flip. Landing is now fully dark-mode-aware via the landing tokens; don't reintroduce hardcodedvar(--color-primary)on landing surfaces.
# 1. Does a canonical primitive already do this job?
ls src/components/ui/
# 2. Does an existing BEM class do this job?
grep -rn "button\b\|card\b\|modal\b\|menu\b" src/app/styles/ | grep <your-pattern>
# 3. Are you sure?If you decide the existing options don't fit, document why in the new component / CSS rule's leading comment so the next agent doesn't unwind your decision.
Certified is a passwordless identity platform built on AT Protocol (atproto), operated by the Hypercerts Foundation. It lets a user create one identity that travels across partner applications with full data portability and no vendor lock-in.
- Primary user — anyone signing in to a partner app via Certified, plus admins managing groups (organizations).
- Two domains —
certified.app(this app, the BFF + UI) andcertified.one(the ePDS / extended Personal Data Server that hosts user data). When a user signs up they get an atproto identity rooted atcertified.one; they can also sign in with any external atproto handle. - AT Protocol context — atproto identities are DIDs (
did:plc:...ordid:web:...). Each DID resolves to a DID document that points to a PDS service endpoint, where records are stored under collections (NSIDs) likeapp.bsky.actor.profileor the Certified-specificapp.certified.actor.profile. This app does not run a PDS itself — it is a thin OAuth client + BFF that proxies XRPC calls. - Custom collections the app reads/writes:
app.certified.actor.profile— Certified profile (display name, avatar, banner, etc.).app.certified.actor.organization— group metadata (org type, urls, founded date).app.certified.actor.membership— user-side record of group memberships.app.bsky.actor.profile— fallback profile (for Bluesky discoverability).org.impactindexer.link.attestation— EIP-712 wallet attestation linking an EVM address to a DID.
- Group service — a separate atproto service (currently
groups.certified.app) that manages multi-user organizations. The app proxies all group operations through the user's PDS using a customcertified_groupproxy pattern with custom NSIDs (app.certified.group.*).
| Concern | Choice |
|---|---|
| Framework | Next.js 16.x (App Router, React Server Components) |
| React | 19.x |
| Language | TypeScript 5 (strict, paths: { "@/*": ["./src/*"] }) |
| Styling | Tailwind CSS 3.4 (utilities only) + custom CSS in globals.css (BEM-like) |
| Theming | next-themes 0.4 (light/dark via data-theme on <html>) |
| Atproto SDK | @atproto/api 0.13, @atproto/oauth-client-node 0.3, @atproto/jwk-jose 0.1 (@atproto/oauth-client 0.6 pulled in transitively) |
| Rich text | @tiptap/react 3.x (+ starter-kit, extension-link, extension-placeholder, pm) |
| Maps | leaflet 1.9 + react-leaflet 5.x |
| Session/State store | Upstash Redis (@upstash/redis) — REST-based, serverless-safe |
| Server actions | None — all server work is in route handlers (src/app/api/**) |
| Wallets | wagmi 2.x + viem 2.x + @tanstack/react-query (mounted only on /settings/wallet) |
resend 6.x (feedback only; OTP emails are sent by the PDS) |
|
| Analytics | @vercel/analytics |
| Icons | lucide-react |
| Fonts | Inter (sans), Noto Serif (headline), Instrument Serif (alt) — via next/font/google |
| Hosting | Vercel |
| Lint | ESLint flat config extending next/core-web-vitals and next/typescript |
| Test runner | Vitest (jsdom), ~1235 tests in src/**/__tests__/. Playwright for E2E in e2e/. See §27. |
Note: Next.js 16 renamed
middleware.tstoproxy.ts. This app ships no edge proxy/middleware — the/redirect is client-side (HomeClient).
npm run dev # next dev — http://localhost:3000
npm run build # next build — production build (quality gate)
npm start # next start — run production build locally
npm run lint # eslint src/ --ext .ts,.tsx
npx tsc --noEmit # type check onlyWhen the user asks for a dev server, run npm run dev from the repo root. When verifying changes before reporting done, run npm run build — it is the only automated quality signal in the repo.
Don't visually verify by default. The automated gates (npx tsc --noEmit, npm run lint, npm run build) are the default signal. Do NOT spin up a browser, take screenshots, or drive Chrome DevTools to visually confirm a change unless the user explicitly asks you to verify the results. Make the code change, run the automated gates, and report plainly what they show plus anything left unverified — let the user do the visual check unless they ask you to.
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. |
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 #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. |
COOKIE_SECRET |
production | HMAC secret for the certified_session cookie. Generate with openssl rand -hex 32. In dev a fallback string is used. |
UPSTASH_REDIS_REST_URL |
yes | Upstash REST URL. |
UPSTASH_REDIS_REST_TOKEN |
yes | Upstash REST token. |
ATPROTO_PRIVATE_KEY |
optional | EC P-256 private key. If set, the OAuth client switches to confidential (private_key_jwt with ES256) and exposes a JWKS at /.well-known/jwks.json. |
RESEND_API_KEY |
optional | Resend key for /api/feedback. |
RESEND_FROM_EMAIL |
optional | Override "from" header. Defaults to Certified <no-reply@certified.one>. |
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID |
optional | Adds WalletConnect connector to the wagmi config when set. |
NEXT_PUBLIC_GROUP_SERVICE_URL |
optional | Group service base URL. Defaults to https://groups.certified.app. |
NEXT_PUBLIC_GROUP_SERVICE_DID |
optional | Group service DID (for getServiceAuth aud). Defaults to did:web:groups.certified.app. |
The canonical OAuth URL is resolved in this order: PUBLIC_URL → VERCEL_BRANCH_URL → VERCEL_URL. It is baked into OAuth client metadata and redirect_uris, so a login started on another accepted deployment origin finishes on that canonical host and receives its session cookie there. CSRF accepts the exact configured public, branch, and deployment origins only when the source also equals the request destination. No wildcard Vercel host matching is allowed. The selected metadata endpoint—and JWKS endpoint for confidential clients—must be publicly reachable by the authorization server; Vercel Deployment Protection can otherwise block sign-in.
┌─────────────────┐ authFetch ┌──────────────────────┐
│ Client (React) │ ───────────────────▶ │ /api/xrpc/[...method]│
│ - useProfile │ /api/auth/session │ (BFF / proxy) │
│ - useOrg │ ◀─────────────────── │ │
│ - useSession │ │ uses session DID │
└─────────────────┘ │ restores OAuth │
│ session via Redis │
└──────────┬───────────┘
│
│ DPoP-bound
│ atproto agent
▼
┌─────────────────────────────┐
│ User's PDS (e.g. certified.one)│
└──────────────┬──────────────┘
│
┌──────────────┴──────────────┐
│ DID document → service ep │
│ plc.directory or did:web │
└─────────────────────────────┘
Key principles:
- Browser never holds tokens. The OAuth tokens / DPoP keys live in Upstash Redis under
oauth:session:<did>(30-day TTL). The browser only has thecertified_sessioncookie, which is an HMAC-signed random session id mapping to a DID viasession:did:<sid>in Redis (30-day TTL). - All XRPC calls go through
/api/xrpc/[...method]. Never call the PDS from the browser directly with credentials — there are none. UseauthFetch()fromsrc/lib/auth/fetch.ts. It detects 401 and triggers the globalonUnauthorizedhandler registered byAuthProvider, which clears auth state and asks the user to sign in again. - Group operations use a parallel set of routes under
/api/groups/**because they require the AtpAgent'swithProxy("certified_group", groupDid)pattern + custom NSID lexicons (app.certified.group.*). They do not share the/api/xrpc/[...method]handler. - DID resolution is direct.
resolvePdsUrlandresolveHandle(insrc/lib/atproto/did.ts) hitplc.directoryor thedid:webhost with a 5s timeout; results are not cached server-side.
src/app/layout.tsx mounts the global tree:
<html>
<head>… JSON-LD: Organization + WebSite …</head>
<body>
<Providers> // src/lib/providers.tsx (currently a passthrough)
<AuthProvider> // OAuth state, modal, redirect overlay
<OrgProvider> // Active group + memberships, persisted to localStorage
<NavbarProvider> // "default" | "transparent" navbar variant
<a class="skip-nav"> // Skip-to-main link
<Navbar />
<main id="main-content">
<AppShell>{children}</AppShell> // .app-shell wrapper, skipped on /welcome
</main>
<Footer /> // Single global footer on every page
<FeedbackModal /> // Floating feedback button + modal
</NavbarProvider>
</OrgProvider>
</AuthProvider>
</Providers>
<Analytics />
</body>
</html>
Scoped providers (mounted only where used):
WagmiProvider+QueryClientProvider— only insrc/app/settings/wallet/layout.tsx. Do not lift these to the root; wagmi is heavy and only the wallet linking flow needs it.AuthGuard— insettings/layout.tsx,connected-apps/layout.tsx, andgroups/layout.tsx. Redirects to/welcomewhen unauthenticated; renders a centered loading spinner while auth is initializing.WelcomeLayout(src/app/welcome/layout.tsx) — sets navbar variant to"transparent"while the user is on/welcome, and resets to"default"on unmount.
| Route | Type | Auth | Notes |
|---|---|---|---|
/ |
client redirector (HomeClient) |
mixed | Sends unauth → /welcome; sends auth → /{handle} (or the active group's handle/DID). The redirect is client-side only. The canonical landing for crawlers is /welcome (priority-1 in sitemap.ts, allowed in robots.ts). |
/welcome |
server | public | Landing page. Sets transparent navbar variant. JSON-LD: SoftwareApplication + FAQPage. |
/about |
server | public | About page. |
/terms |
server | public | Terms of Service. |
/privacy |
server | public | Privacy Policy. |
/dsa |
server | public | DSA compliance. |
/[actor] |
client | open | Canonical profile URL (handle-forward). actor is a handle (canonical/displayed) or a DID (durable). A DID-addressed URL canonicalizes to the handle form on load. Handles personal and group identities. |
/[actor]/[type]/[rkey] |
client | open | Canonical record URL. type ∈ activity | project (friendly segment ↔ collection NSID, mapped in src/lib/urls.ts). Resolves actor→DID, fetches the record, canonicalizes a DID-addressed URL to the handle form. |
/[actor]/[type]/[rkey]/edit |
client | gated (owner) | Record editor. Dispatches to the activity/project editor by type; same actor→DID resolution + canonicalization as the read route. |
/settings |
client | gated (AuthGuard) | If activeOrg, renders OrgSettings; otherwise account settings (handle, email, password, app-passwords placeholder, 2FA placeholder). |
/settings/edit-profile |
client | gated | Edit personal profile. |
/settings/my-data |
client | gated | Data export / view. |
/settings/wallet |
client | gated + Wagmi | EIP-712 wallet linking. The only route that loads wagmi/viem. |
/connected-apps |
client | gated | Lists CONNECTED_APPS from src/lib/constants/apps.ts. |
/groups |
client | gated | List groups, accept/leave/remove public membership. |
/groups/create |
client | gated | Register a new group. Enforces MAX_SELF_CREATED_ORGS = 5. |
/groups/[groupDid] |
client | gated | Group profile view. |
/groups/[groupDid]/edit-profile |
client | gated | Edit group profile + metadata. |
/groups/[groupDid]/apps |
client | gated | Apps view scoped to a group. |
/groups/[groupDid]/settings |
client | gated | Member management + audit log. |
/oauth/callback |
client | — | Receives the OAuth redirect. POSTs query string to /api/auth/callback-handler, then either postMessages the parent window (iframe flow) or window.location.replace("/"). |
/.well-known/oauth-client-metadata |
server | public | Generated from getOAuthClient().clientMetadata + extras (brand_color, tos_uri, etc.). Cached public, max-age=600. |
/.well-known/jwks.json |
server | public | Generated from getOAuthClient().jwks. Cached public, max-age=600. |
/sitemap.xml, /robots.txt, /manifest.webmanifest |
server | public | See src/app/sitemap.ts, robots.ts, manifest.ts. |
Permanent redirects (in next.config.ts):
/settings/security→/settings/settings/account→/settings/search→/explore;/connected-apps→/apps- URL migration (handle-forward scheme):
/profile/:handle→/:handle;/activity/:did/:rkey→/:did/activity/:rkey;/project/:did/:rkey→/:did/project/:rkey. The:didsegment may be a handle or a DID — either resolves on the new route. Record detail and edit both moved to the root scheme (/[actor]/[type]/[rkey]and/[actor]/[type]/[rkey]/edit). The old*/editpaths are NOT redirected (owner-only, never shared); they render a graceful not-found.
URL builders — src/lib/urls.ts is the single source of truth. Never hand-build /profile//activity//project paths; use profileUrl(actor) / recordUrl(actor, type, rkey) (and shareProfileUrl/shareRecordUrl for the absolute DID form used when sharing). Identifiers are NOT percent-encoded (handles are domains, DIDs use path-legal :, rkeys are TIDs) — that's what keeps the URLs clean.
Root-level [actor] invariant. Because the profile route lives at the root, the [actor] segment must be disambiguated from real app routes. AT Protocol handles always contain a dot; DIDs start with did:; every top-level app route is a dotless bare word (home, explore, settings, …). This is enforced by RESERVED_ROUTES + parseActor in src/lib/urls.ts. Any new top-level route MUST be a dotless word or it will collide with the handle namespace.
Edge proxy — src/proxy.ts (Next 16's renamed middleware). Added for pdsls.dev interop only: a pasted at-uri path (/at://did/collection/rkey, /at:/…, or the host-safe /at/…) is parsed and 308-redirected into the handle-forward scheme. Every other request passes straight through (single startsWith("/at") check). The / route redirect still runs client-side via HomeClient; auth-gated pages still rely on AuthGuard. SEO crawlers are pointed at /welcome (priority-1 in sitemap.ts, allowed in robots.ts).
- OAuth client —
src/lib/auth/oauth-client.tsbuilds aNodeOAuthClient(singleton). It registers Redis-backed state and session stores, leaveshandleResolverat the SDK default (AtprotoHandleResolverNode, which does DNS-TXT + HTTPS.well-known/atproto-didresolution and works for any atproto handle, not just Certified-rooted ones), and conditionally enablesprivate_key_jwtwhenATPROTO_PRIVATE_KEYis set. The canonical origin resolves asPUBLIC_URL→VERCEL_BRANCH_URL→VERCEL_URL. In loopback dev mode (NODE_ENV !== "production"and that resolved URL ishttp://) it skips the normal metadataclient_idand usesbuildAtprotoLoopbackClientMetadatainstead, because the spec only allowshttps://or the literalhttp://localhost(no port) as aclient_id. - Stores —
src/lib/auth/stores.tswraps Upstash Redis.RedisStateStore(10 min TTL) is for the short-lived OAuth flow.RedisSessionStore(30 day TTL) holds long-lived atproto sessions (tokens + DPoP key). Both key byoauth:state:<key>/oauth:session:<key>. Dev fallback: when Upstash creds are missing ANDNODE_ENV !== "production", the module switches to a process-localInMemoryRedisso a fresh clone can sign in locally without provisioning an Upstash database. State doesn't survive a server restart and isn't shared across workers — acceptable for dev only. A console warning fires on first use. - App session —
src/lib/auth/session.tsissues thecertified_sessioncookie:- Cookie value =
<32-byte hex sessionId>.<HMAC-SHA256 signature>. - Cookie attributes:
httpOnly,securein production,sameSite=lax,path=/,maxAge=30 days. - Server side, the session id maps to a DID in Redis (
session:did:<sid>). - HMAC verification uses
crypto.timingSafeEqualto avoid timing attacks.
- Cookie value =
- CSRF —
src/lib/auth/csrf.tsrejects requests missing bothOriginandReferer, then requires the parsed source origin to be in the exact configured set (PUBLIC_URL,VERCEL_BRANCH_URL,VERCEL_URL) and equal the request destination origin. This supports Vercel aliases without making deployments cross-origin peers.null, malformed, wildcard, and lookalike origins return 403; localhost/127.0.0.1 equivalence exists only outside production. - authFetch —
src/lib/auth/fetch.tswrapsfetchand calls a registeredonUnauthorized()listener on 401.AuthProviderregisters this listener to clearisAuthenticated/did/pdsUrland surface "Your session has expired."
- UI submits to
POST /api/auth/loginwith{ input, mode: "email" | "handle", prompt? }. - Server CSRF-checks, sanitizes input (
sanitizeEmail/sanitizeHandle), then callsclient.authorize(...). Formode: "email"it points atPDS_URLand addslogin_hint. Formode: "handle"it callsclient.authorize(input, …)and falls back tohttps://+ input if the bare input fails. - Returns
{ url }. The clientsafeRedirect()s — onlyhttps:URLs are allowed (andhttp:in dev). This intentionally allows cross-origin since OAuth bounces to external authorization servers. - The PDS UI may post a
switch-providermessage back to the modal (used when an existing user enters a handle on the wrong PDS).AuthProviderlistens for it and re-runs the handle login flow.
- The PDS redirects to
/oauth/callback?code=…&state=…. The page is rendered in the modal iframe; if not in an iframe it falls through towindow.location.replace("/"). - The page client-fetches
GET /api/auth/callback-handler?<query>, which:- calls
client.callback(params)to complete the OAuth exchange, - invalidates the existing
certified_sessionbefore creating a new one (defense against session fixation), - calls
createSession(did)which writes to Redis and sets the cookie, - best-effort seeds
app.certified.actor.profileandapp.bsky.actor.profilewith emptyselfrecords so other apps see the user immediately, - returns
{ did }.
- calls
- If the page is in an iframe, it
postMessages{ type: "oauth-callback-complete", sub: did }to the parent.AuthProviderlistens, validatesevent.origin, and callsrefreshSession().
GET /api/auth/session— reads cookie, looks up DID in Redis, callsclient.restore(did)to confirm the upstream session is still valid; ifrestorethrows, deletes both the local session and returns{ did: null }. The browser uses this on app load.POST /api/auth/logout— CSRF-checked. CallsoauthSession.signOut()upstream (best-effort), deletes the local session, returns{ success: true }. The client also callsclearSessionCache()foruseSessionand clears Auth state immediately (optimistic logout).
Validates returned URLs to prevent protocol-injection (e.g. javascript:). Allows only https: (and http: in dev). Cross-origin is permitted on purpose — the OAuth flow lands on external authorization servers.
| Route | Method | CSRF | Auth | Description |
|---|---|---|---|---|
/api/auth/login |
POST | yes | none | Build authorization URL for email or handle login. Sanitizes input. Returns { url }. |
/api/auth/callback-handler |
GET | n/a | none | Server-side OAuth code exchange. Invalidates old session, creates new one, seeds profile records. |
/api/auth/session |
GET | n/a | cookie | Returns { did } or { did: null }. Calls client.restore(did) to detect upstream invalidation. |
/api/auth/logout |
POST | yes | cookie | Calls upstream signOut, deletes Redis session and cookie. |
| Route | Method | CSRF | Auth | Description |
|---|---|---|---|---|
/api/xrpc/[...method] |
GET | n/a | cookie | Whitelisted query methods (see §10). |
/api/xrpc/[...method] |
POST | yes | cookie | Whitelisted procedure methods. Enforces collection allowlist + repo ownership. |
| Route | Method | CSRF | Auth | Description |
|---|---|---|---|---|
/api/groups/register |
POST | yes | cookie | Direct call to group service app.certified.group.register with service-auth JWT (getServiceAuth lxm: "app.certified.group.register"). Enforces MAX_SELF_CREATED_ORGS = 5 by counting groups where the caller's member entry has addedBy === ownerDid. Sanitizes 5xx errors. |
/api/groups/memberships |
GET | n/a | cookie | Lists remote memberships from the group service. Server-side service-auth using lxm: "app.certified.groups.membership.list". |
/api/groups/[groupDid]/profile |
GET | n/a | none | Reads app.certified.actor.profile from the group's PDS (resolved via DID document). Reads are public. |
/api/groups/[groupDid]/profile |
PUT | yes | cookie | Writes the org profile via createGroupAgent(...).call("app.certified.group.repo.putRecord", …). |
/api/groups/[groupDid]/metadata |
GET / PUT | PUT yes | open / cookie | Same pattern for app.certified.actor.organization. |
/api/groups/[groupDid]/bsky-profile |
POST | yes | cookie | Creates an empty app.bsky.actor.profile record for discoverability. |
/api/groups/[groupDid]/handle |
PUT | yes | cookie | Calls groupAgent.com.atproto.identity.updateHandle({ handle }) — proxied through PDS to group service. |
/api/groups/[groupDid]/members |
GET / POST / DELETE | POST/DELETE yes | cookie | List, add, remove members. |
/api/groups/[groupDid]/role |
PUT | yes | cookie | Set member role. Validates role is one of member, admin, owner. |
/api/groups/[groupDid]/audit |
GET | n/a | cookie | Query audit log. Filters: actorDid, action, collection, limit, cursor. |
/api/groups/[groupDid]/upload-blob |
POST | yes | cookie | 5MB cap, allowed types `image/jpeg |
| Route | Method | CSRF | Auth | Description |
|---|---|---|---|---|
/api/resolve-handle |
GET | n/a | cookie | Calls com.atproto.identity.resolveHandle. Returns { did, handle }. |
/api/resolve-did |
GET | n/a | cookie | Calls resolveHandle(did) (DID doc) + app.bsky.actor.getProfile for display name. Returns { did, handle, displayName }. |
/api/search-actors |
GET | n/a | cookie | Calls app.bsky.actor.searchActors. limit clamped to 25. |
| Route | Method | CSRF | Auth | Description |
|---|---|---|---|---|
/api/feedback |
POST | yes | none | Resend email to support@hypercerts.org. Strips invisible Unicode from message and email. Validates email format. Sends a confirmation email to the user if they provided one. |
/.well-known/oauth-client-metadata |
GET | n/a | none | OAuth client metadata. Cache-Control: public, max-age=600. |
/.well-known/jwks.json |
GET | n/a | none | JWKS (only meaningful when ATPROTO_PRIVATE_KEY is set). |
src/app/api/xrpc/[...method]/route.ts is the central proxy from the client to the user's PDS.
com.atproto.repo.getRecordcom.atproto.repo.listRecords(limit clamped to[LIMIT_MIN=1, LIMIT_MAX=100])com.atproto.server.getSessioncom.atproto.sync.getBlob— returns binary; setsContent-Typefrom upstream
Anything else returns 400 Unknown method.
com.atproto.repo.createRecordcom.atproto.repo.putRecordcom.atproto.repo.deleteRecordcom.atproto.repo.uploadBlobcom.atproto.identity.updateHandlecom.atproto.server.requestPasswordResetcom.atproto.server.resetPasswordcom.atproto.server.requestEmailUpdatecom.atproto.server.updateEmail
For createRecord / putRecord / deleteRecord:
body.repomust equal the session DID — cross-repo writes are 403.body.collectionmust be one of:org.impactindexer.link.attestationapp.certified.actor.profileapp.certified.actor.membershipapp.certified.actor.organization
If you need to write a new collection, add it to ALLOWED_WRITE_COLLECTIONS in src/app/api/xrpc/[...method]/route.ts. The proxy will silently 403 otherwise.
MAX_BLOB_SIZE = 4 * 1024 * 1024(4 MB) — Vercel serverless has a ~4.5 MB request body cap.ALLOWED_BLOB_CONTENT_TYPES = ["image/jpeg", "image/png", "image/webp", "image/gif", "image/svg+xml"].- Both
Content-Length(when present) and the actualarrayBuffer().byteLengthare checked. - The group blob route (
/api/groups/[groupDid]/upload-blob) uses 5 MB and disallows GIF/SVG (only JPEG/PNG/WEBP). - Client-side,
src/lib/atproto/profile.tsenforces 4 MB on avatar/banner uploads; 4 MB matches the proxy cap.
xrpcError(err) extracts status (or statusCode) and message. For status ≥ 500, message is replaced with "Internal server error" to avoid leaking PDS internals. Stick to this pattern in any new BFF routes.
src/app/styles/— a tree of feature-scoped CSS files, all imported bysrc/app/globals.css. The split is intentional; do not collapse it back to one file.tokens.css— design tokens (CSS custom properties + dark theme overrides). The only place raw hex / rgba values live.components.css— primitive component chrome (modals, app-card label, dash-card, error/empty states).layout.css— app shell, navbar, top-bar, bottom-nav, drawers, rails.- Per-feature:
feed.css,profile-*.css,cert-detail.css,project-detail.css,explore.css,home.css,settings-page.css,workspace.css,notifications.css,landing.css,leaflet.css, etc.
- Tailwind utilities are used freely inside JSX for one-off layout (
flex,mt-4,max-w-3xl, …). - Tailwind theme is small:
colors.{success, warning, error}+fontFamily.{sans, headline, mono}+ thefontSizeheading scale +boxShadow.{sm, md, lg}aliasing the CSS vars +borderRadius.DEFAULT: 2px. Seetailwind.config.ts.
Token catalog is the source of truth at src/app/styles/tokens.css. The most-used ones:
- Surfaces:
--bg-canvas(page) ·--bg-sunken(recessed) ·--bg-raised(slightly elevated) ·--bg-elevated(cards, modals). - Foreground:
--fg-primary(headings, primary text) ·--fg-secondary(body) ·--fg-muted(placeholders, captions). - Borders:
--border-subtle(whisper, list dividers) ·--border-default(input/card borders) ·--border-hover(hover state) · plus--border-light/--border-medium/--border-hover-softfor in-between cases. - Buttons:
--btn-primary-bg/--btn-primary-fg(these invert in dark). - Badges:
--badge-success-*,--badge-warning-*,--badge-neutral-*,--badge-count-*paired bg/fg/border. - Status:
--color-error,--color-success,--color-warning+ their-text/-bg/-bordercompanions for full-surface styling. - Landing palette (theme-aware):
--color-navy,--color-off-white,--color-gray-100,--color-light-gray,--color-mid-gray,--color-dark-gray,--color-surface,--color-surface-container-low. These flip in[data-theme="dark"]. Use on landing surfaces only. - Invariant primitives (don't use on app surfaces):
--color-primary(#111 always),--color-white(#fff always). Reserved for skip-nav, brand SVG, anything that must not flip. - Motion:
--transition-fast(150 ms ease-out) ·--transition-base(250 ms ease-out) ·--transition-slow(400 ms spring). - Geometry:
--radius(2 px) ·--navbar-height(64 px) ·--top-bar-row1/--top-bar-row2/--top-bar-totalfor desktop top-bar heights ·--bp-gt-mobile(800) /--bp-gt-narrow-desktop(1100) /--bp-gt-desktop(1300) — informational; CSS@mediaqueries hardcode the numbers because vanilla CSS can'tvar()inside@media. - Z-index:
--z-rail-sticky(10) ·--z-rail(30) ·--z-popover(40) ·--z-navbar/--z-bottom-nav(50) ·--z-portal-sheet(60) ·--z-modal(100) ·--z-skip-nav(9999) ·--z-feedback(10000) ·--z-feedback-above(10001).
- All
border-radiusisvar(--radius)(2 px). Pills use999px, circles use50%. No4/6/8/12/16/20px. To prevent regression:grep -rEn "border-radius:\s+(4|6|8|12|16|20)px" src/app/styles/should always return zero hits. - No raw hex / rgb / rgba colors outside
tokens.cssandlanding.css(landing has the invariant brand palette + theme-flippable landing tokens — that's where they belong). Use tokens. - No new breakpoints. Canonical: 800 / 1100 / 1300. For "below desktop" use
max-width: 799px(matches the existing convention in feed.css / settings-page.css / etc.). - No ad-hoc shadows. Always
var(--shadow-sm|md|lg). - No ad-hoc z-index. Always a
--z-*token (add to tokens.css if a genuinely new layer is needed). - No
100vw. It triggers horizontal overflow when a scrollbar is present. Use100%. - Skip-nav styles are at the top of
tokens.css. Don't duplicate. - Prefer a UI primitive over a new BEM class — see §12. New BEM classes are appropriate when the visual is genuinely unique (e.g., the leaflet editor toolbar's toggle state). Cosmetic styling that matches an existing primitive is not.
Every modal renders via <AppDialog> (src/components/ui/app-dialog.tsx) or one of its specializations (<ConfirmDialog>, <DeleteRecordDialog>). The sign-in modal uses the same <dialog> chrome at the same 2 px radius — there is no longer a "hero exception."
<AppDialog> handles: native <dialog> + showModal(), backdrop click (gateable via disableBackdropClose), Esc, focus save / restore, prevention of the InvalidStateError documented at app-dialog.tsx:118. If you find yourself hand-rolling backdrop + focus-trap + body-scroll-lock for a new modal, you're reintroducing the class of bug that motivated <AppDialog>. Stop and refactor to use it.
landing.css consumes the theme-aware landing tokens (not --color-primary / --color-white). The "Built for trust" section is intentionally inverted from the surrounding sections — it stays inverted in dark mode (becomes a light band on dark page). That's the brand contrast, not a bug.
Use these. Don't reimplement.
| Primitive | API surface |
|---|---|
<Button> |
variant: "primary" | "secondary" | "ghost" | "destructive" · size: "sm" | "md" | "lg" | "icon". size="icon" is a 40×40 square and requires aria-label (enforced via TypeScript discriminated union — you'll get a type error if you forget). loading prop renders a spinner. |
<Input> |
size: "sm" | "md" | "lg" (36 / 44 / 56 px) · variant: "default" | "underline" | "inline-edit". Use lg for hero inputs (sign-in modal). Use underline for inline typeahead. Use inline-edit for "currently editing" affordance (1.5 px hover-color border). Auto-wires aria-describedby / aria-invalid / id / useId() label associations. |
<Textarea> |
Mirror of <Input> for multi-line. |
<Badge> |
10 variants: verified / pending / unverified (status, with icon) · tag / role / count (neutral chips) · high-quality / standard / draft / test (activity quality pills). compact prop forces the tighter 11 px chip shape on any variant. |
<Card> |
variant: "row" | "elevated" | "inset" · hoverable · unpadded · as: "div" | "article" | "li" | "section". row for list-divider entries (transparent bg, bottom border only). elevated for object cards. inset for recessed sub-cards inside an elevated parent. |
<Tabs> + <TabList> + <Tab> + <TabPanel> |
Controlled (value / onChange). Proper ARIA tablist + arrow-key navigation. <TabPanel keepMounted> if child state should survive switches. |
<Skeleton> |
variant: "line" | "box" | "circle" | "text". width / height accept any CSS dimension. text accepts lines={n}. Honors prefers-reduced-motion. |
<Popover> + <PopoverTrigger> + <PopoverContent> + <PopoverItem> |
Floating menu. Handles click-outside, Esc, focus restore, ARIA. align: "start" | "center" | "end". Controlled (open / onOpenChange) or uncontrolled. |
<Avatar> |
size: "sm" | "md" | "lg" | "xl". Pass fallbackInitials={getInitials(name)} from src/lib/utils/initials.ts. |
<AppDialog> + <AppDialogHeader> |
The modal primitive. See §11 Modals. |
<ConfirmDialog>, <DeleteRecordDialog> |
Specializations of AppDialog for destructive / type-to-confirm flows. |
<EmptyState> |
Centered icon + title + description + actions. |
<ErrorMessage> |
Title + message + retry button in an error-tinted card. |
<LoadingSpinner> |
Pulsing Brandmark — used for full-page / section loading. For inline loading inside a Button, use the Button's loading prop instead. |
<EditBanner> |
Sticky edit-mode chrome (Cancel + Save buttons) used by profile / project / cert edit pages. |
<FeedbackModal> |
Dual-mode (desktop dialog ↔ mobile bottom-sheet). |
<SignInModal> |
Auth flow. Don't reach for it directly; use useAuth().openSignIn(). |
<Brandmark>, <CertIcon>, <FeedLabelPill>, <SmartLink> |
Brand / domain-specific primitives. |
- Search
src/components/ui/before writing a new component. If something almost matches, extend it (add a variant / size / prop) rather than forking. Use the variant-discriminated-union pattern fromButtonif a new variant has different required props (e.g.,size="icon"requiringaria-label). - Search the BEM classes before writing a new CSS rule.
grep -rn '<your-pattern>' src/app/styles/. Twelve button vocabularies and eight card families used to coexist undocumented — the consolidation merged them; don't restart that drift. - Internal links:
next/link. Don't use<a href>for in-app routes. - SVG icons / button graphics: raw
<img>(notnext/image) for SVG assets.next/imageis reserved for raster assets where the optimizer adds value (seepartner-apps.tsxfor an example usingImage). - Icons:
lucide-react. The only@tabler/icons-reactimport in the codebase is the cert icon, wrapped bysrc/components/ui/cert-icon.tsxto expose a lucide-compatiblestrokeWidthprop. Don't import tabler directly elsewhere. - Icon sizing follows context: 14 px inline with text · 18–22 px nav chrome · 20 px Plus / Settings · 24 px bottom nav · 40 px empty-state. Stroke 1.5 (default) / 1.75 (active, cert-icon) / 2 (emphasis). The
strokeWidth={1.25}+size={11}pattern appears in form dialogs only — don't use it as a general default. - Dropdown triggers: if it's a menu, use
<Popover>. Roll-your-own should be a last resort; if you do, the trigger needsaria-haspopup+aria-expanded(navbar.tsxhas the canonical pattern from before<Popover>existed). - Popover (menu) vs. panel — two sanctioned variants, no third. A menu (single-select list of actions/options) uses the canonical
<Popover>/<PopoverTrigger>/<PopoverContent>/<PopoverItem>(e.g. the Explore Sort + Sub-category dropdowns) — it carriesrole="menu"/role="menuitem", click-outside, Esc-to-close, focus restore, and ARIA wiring for free. A panel (a popover holding form controls like checkbox filters, where the open surface is arole="dialog"rather than a menu) usesuseClickOutsideCloseon a wrapper ref (e.g. the home-feedQualityFilter/EvaluatorFilterand the Explore quality-filter popover). Pick one of these two; never hand-roll a fourth ad-hoc popover variant. - Skip-nav: already wired in root layout (
<a href="#main-content" class="skip-nav">).<main id="main-content">exists. Don't reintroduce. - External user-controlled URLs (e.g.
profile.website): validate the scheme before using as anhref. Only allowhttp:,https:,mailto:,tel:; reject anything else (includingjavascript:,data:,vbscript:, malformed).
Located in src/hooks/:
| Hook | Purpose |
|---|---|
useSession() |
Cached session data (handle, email) from com.atproto.server.getSession. Module-level promise + result cache shared across all instances. clearSessionCache() exported for sign-out. Returns { handle, email, isLoading, error }. |
useProfile() |
Loads the user's app.certified.actor.profile; if empty, falls back to app.bsky.actor.profile. Sets isFallback: true when using Bluesky. Returns profile, isLoading, error, refetch, avatarUrl, bannerUrl, isFallback. Uses AbortController per fetch. |
useOrgProfile() |
Loads the active group's profile + metadata via useOrg().activeOrg. Returns orgProfile, orgMetadata, orgAvatarUrl, orgBannerUrl, isLoading, refetch. |
useIdentityLinks(did) |
Fetches all attestations from the user's PDS, EIP-712-verifies EOA signatures with viem.verifyTypedData, marks ERC-1271 / ERC-6492 as verified: false ("On-chain verification not yet supported"). |
useAttestationSigning(did) |
Wagmi-based signer. Builds the EIP-712 message, calls signTypedDataAsync, then storeAttestation to persist. Returns signAndStore, isSigning, isStoring, error, reset. Only valid inside /settings/wallet because it depends on the WagmiProvider. |
useFocusTrap<T>(active) |
Focus trap for modals (see §12). |
Group-creation limit hook is at src/lib/groups/use-org-limit.ts (useOrgCreationLimit()).
- Auth state:
AuthProviderinsrc/lib/auth/auth-context.tsx. HoldsisLoading,isAuthenticated,did,pdsUrl,error,isModalOpen,isRedirectingToProvider, plus actionsopenSignIn,closeModal,submitEmail,submitHandle,signOut. Also owns theSignInModalandProviderRedirectOverlaycomponents. - Active group:
OrgProviderinsrc/lib/groups/org-context.tsx. Persists the active org tolocalStorageunder keycertified_active_org. Initializes synchronously on first render (getInitialOrg) so the navbar avatar doesn't flicker. Refetches groups on auth change. When the auth state turns to "logged out", the active org is cleared. - Navbar variant:
NavbarProvider(src/lib/navbar-context.tsx). Two variants:default(opaque) andtransparent(used on/welcome). - Wagmi: scoped to
/settings/wallet/layout.tsx. MountsWagmiProviderwithconfigfromsrc/lib/wagmi.ts(chains: mainnet, base, optimism, arbitrum; connectors: injected, coinbaseWallet smart-wallet-only, walletConnect whenNEXT_PUBLIC_WALLETCONNECT_PROJECT_IDis set;ssr: true). - No Redux / Zustand / Jotai. Local component state + the three contexts above is the entire state model.
A "group" = an atproto identity (its own DID + PDS) operated through the group service at GROUP_SERVICE (default Railway staging). Membership has three roles: owner, admin, member.
The source of truth for "who is a member of what" is the group service (queried via app.certified.groups.membership.list). The user-side acceptance bit is a record in the user's own PDS at app.certified.actor.membership. A user can be a member without an accepted record (private/pending) or be an accepted member (public).
- UI:
src/app/groups/**andsrc/components/groups/**. - API:
src/app/api/groups/**(see §9). - Library:
src/lib/groups/:constants.ts—GROUP_SERVICE,GROUP_SERVICE_DID, NSIDs,MAX_SELF_CREATED_ORGS = 5.types.ts—Group,OrgRole,OrgMember,OrgProfile,GroupMetadata,OrgUrlItem,MembershipRecord,AuditEntry,RemoteMembership,CreateOrgParams,VerifiedAttestation.api.ts— client-side functions (listMemberships,putMembership,deleteMembership,uploadOrgBlob,createBskyProfile,registerGroup,getOrgProfile,putOrgProfile,getOrgMetadata,putOrgMetadata,listOrgMembers,addOrgMember,removeOrgMember,setOrgMemberRole,queryOrgAuditLog,fetchRemoteMemberships,getSelfCreatedOrgCount,resolveGroups).proxy-agent.ts— server-side. DefinesGROUP_LEXICONS(10 custom NSIDs underapp.certified.group.*), exportsgetAuthenticatedAgent(),createGroupAgent(agent, groupDid)(usesagent.withProxy("certified_group", groupDid)and registers the lexicons), andgetServiceAuthToken(agent, lxm)for the rare direct-call case (registration only).org-context.tsx— provider/context.use-org-limit.ts— group-creation limit hook.
Defined in src/lib/groups/proxy-agent.ts:
| NSID | Type | Purpose |
|---|---|---|
app.certified.group.register |
procedure | Register a new group (direct call). |
app.certified.group.repo.createRecord |
procedure | Proxied write. |
app.certified.group.repo.putRecord |
procedure | Proxied write. |
app.certified.group.repo.deleteRecord |
procedure | Proxied write. |
app.certified.group.repo.uploadBlob |
procedure | Proxied blob upload. |
app.certified.group.member.add |
procedure | Add member. |
app.certified.group.member.remove |
procedure | Remove member. |
app.certified.group.member.list |
query | List members. |
app.certified.group.role.set |
procedure | Set member role. |
app.certified.group.audit.query |
query | Query audit log. |
MAX_SELF_CREATED_ORGS = 5. Enforced both server-side (in /api/groups/register) and client-side (in useOrgCreationLimit()). A group is "self-created" when the user's member entry has addedBy === ownerDid. The server-side check fetches all memberships and member lists for those groups, then counts.
app.certified.graph.follow—{subject: did, createdAt, via?}. Viewer's PDS holds their follows; "followers of X" is reconstructed via the indexer (appCertifiedGraphFollowwithsubject.eqfilter).app.certified.badge.{definition, award, response}— endorsements + lists. A list is abadge.definitionwithbadgeType: "endorsement"andtitle !== "Endorsement". The reserved"Endorsement"title backs the regular endorse flow.- Allowlist any new collection in
ALLOWED_WRITE_COLLECTIONSinsrc/app/api/xrpc/[...method]/route.ts— silent 403 otherwise.
createFollow(ownDid, subjectDid, { targetDid? })— XRPC for personal, BFF (/api/groups/[did]/follow) whentargetDidset. Mirror thistargetDidopt-in for any new group-aware write.createEndorsementAward(ownDid, subjectDid, note?)— default endorsement; lazy-ensures the default definition.createListAward(ownDid, subjectDid, badge: StrongRef)— award under a specific list. Skip ensure-def; caller passes the list's strong ref.createListDefinition/updateListDefinition/deleteListAndAwards— list CRUD; delete walks every linked award first so the def-delete never orphans records.BADGE_AWARD_NOTE_MAX = 500enforced inwriteBadgeAwardand again in every UI surface that captures a note. The UI also clamps viamaxLength+slice(belt-and-suspenders).
useFollowing(did)— PDS listRecords; exposesaddFollow/removeFollowfor optimistic updates.useFollowers(did)— indexerappCertifiedGraphFollow(where: { subject }); dedupes by follower DID; exposesaddFollower/removeFollower.useGivenEndorsements(did)/useReceivedEndorsements(did, { includeRejected? })— both attachlistTitleper award (undefinedfor default endorsements).includeRejecteddefaults to false; pass true on the owner view so the response filter dropdown can switch between Hide rejected / Only rejected / Show all.useEndorsementLists(did)— definitions + awards on one repo, grouped by def URI. ExposescreateList/updateList/deleteList, all optimistic.listAwardshere is paginated only by the PDS' default page (no full walk yet).useSocialGraphSync(did, { ownDid, targetDid })— composesuseFollowing+useBlueskyFollows; returnsinBoth/onlyCertified/onlyBlueskysets plus animportDids(dids)batch writer.
<PersonCard>inprofile-endorsements.tsx(and a parallel one inprofile-followers.tsx) is the shared row used by Received/Given/Followers/Following. Layout: name → @handle → date → optionallistTitlepill → optional note. Top-rightmenuslot is reserved for the × revoke / kebab / etc. Don't restore the right-aligned date.<EndorsePeopleModal>is callback-driven viaonEndorse(did, note?). It serves three flows: regular endorse (withrequireReason), list+ Add people(skip reason — list is the reason), future awards (just supply a differentonEndorse).<EndorseReasonModal>is the single-target reason capture used by the sidebar Endorse button. Pops up before the write, never after.- All new dialogs use
<dialog className="signin-modal app-modal …">. See §11 modal radius rule.
All four social-graph / endorsement hooks already target the post-#87 / #88 / #89 magic-indexer schema:
appCertifiedBadgeAward.badge.{badgeType, …}nested-where is live;useReceivedEndorsementsstill uses the 2-call workaround (one indexer for awards, one for endorsement-typed definition URIs). Migrating to the nested-where is a self-contained client change.appCertifiedHypercertsCollection.items.itemIdentifier.uriarray-element where is live;useCertProjectscould swap from PDS-scan-(same-DID-only) to a single cross-DID indexer query.AppCertifiedBadgeDefinition.awardCountis live;useEndorsementListscould drop itslistAwardsround-trip and read counts directly.
If you touch one of these hooks, prefer the nested-where shape — search the file's comments for "round-trip" to find the migration notes inline.
Every follow / endorse / unfollow button uses the same shape:
const [optimistic, setOptimistic] = useState<boolean | null>(null)
const effective = optimistic ?? parentValue
useEffect(() => {
if (optimistic !== null && parentValue === optimistic) setOptimistic(null)
}, [parentValue, optimistic])Don't clear optimistic in finally — the parent's refetch may lag the PDS write, and clearing too early snaps the button back to a stale value for a frame. The useEffect reconciler clears the override only when the parent confirms.
Goal: prove a DID controls an EVM address (and vice versa) by signing an EIP-712 message with the wallet and storing the attestation in the user's PDS.
Defined in src/lib/identity-link/attestation.ts:
ATTESTATION_DOMAIN = { name: "ATProto EVM Attestation", version: "1" }
ATTESTATION_TYPES = {
Attestation: [
{ name: "did", type: "string" },
{ name: "evmAddress", type: "address" },
{ name: "chainId", type: "uint256" },
{ name: "timestamp", type: "uint256" },
{ name: "nonce", type: "uint256" },
],
}buildAttestationMessage(did, address, chainId) returns both the typed-form (with bigints, for signTypedData) and the stored-form (with strings, for JSON serialization in the PDS record).
- Collection:
org.impactindexer.link.attestation(allowlisted in the XRPC proxy). - rkey:
${address.toLowerCase()}-${chainId}so the same wallet on the same chain overwrites itself. - Record shape (
Attestationtype insrc/lib/identity-link/types.ts):{ $type, address, chainId, signature, message, signatureType: "eoa" | "erc1271" | "erc6492", createdAt }.
useIdentityLinks(did):
- Lists all attestations from the PDS.
- For
signatureType === "eoa", verifies the signature client-side withviem.verifyTypedData. The recovered address must matchaddressin the record. - For
erc1271/erc6492, returnsverified: falsewithverificationError: "On-chain verification not yet supported". This is a known limitation. The signing path can produce these signature types (smart contract wallets), but the verifier can't validate them yet — it would need a JSON-RPC eth_call to the contract'sisValidSignature(bytes32 hash, bytes signature)per ERC-1271, plus ERC-6492 unwrapping for not-yet-deployed accounts.
useAttestationSigning(did):
- Checks
isAuthenticatedand walletisConnected. - Builds the message.
signTypedDataAsync(domain, types, message).storeAttestation(did, address, chainId, signature, storedMessage, "eoa").
This hook depends on WagmiProvider, so it only works under /settings/wallet/. If you want wallet linking elsewhere, lift the provider — but think hard before doing so (wagmi + viem are heavy).
These rules are mandatory. Treat any deviation as a regression.
- CSRF on every POST/PUT/DELETE — call
checkCsrf(request)at the top of any state-changing route handler. The source must be an exact configured origin (PUBLIC_URL,VERCEL_BRANCH_URL, orVERCEL_URL) and equal the request destination. Missing,null, malformed, wildcard, and cross-deployment origins return 403. - Cookie verification uses
timingSafeEqual(src/lib/auth/session.ts). Don't replace it with===. - HMAC every session id. The cookie value is
<sessionId>.<HMAC>. Truncating to "just sessionId" would let attackers forge any session. - Invalidate the existing session before creating a new one in
callback-handler/route.ts. This prevents session fixation if the user reuses a tab where another session was active. - Wrap Redis ops in try/catch. Both
session.tsandstores.tsdo this; new BFF code must too. A Redis blip should not 500 the request unless absolutely necessary. - Sanitize input twice — client AND server (defense in depth). Use
stripInvisible,sanitizeEmail,sanitizeHandlefromsrc/lib/utils/sanitize.ts. The regex is/[- - -͏]/g. - Sanitize 5xx errors. Never echo
err.messagefrom upstream PDS errors when status ≥ 500 — return"Internal server error"(or a route-specific generic). The XRPC proxy and/api/groups/registerboth do this; copy the pattern. (4xx errors can echo upstream messages — those are usually validation errors a user can act on.) - Repo ownership on writes — for
createRecord/putRecord/deleteRecord,body.repomust equal the session DID. Cross-repo writes are 403. - Collection allowlist — only the eleven
ALLOWED_WRITE_COLLECTIONScan be written through the XRPC proxy. Add to that array consciously, not implicitly. - Blob limits — 4 MB cap (image MIME types only) on
/api/xrpc/[...method]foruploadBlob; 5 MB on the group blob route. Both checkContent-Lengthand the actual buffer size. Vercel has a hard ~4.5 MB body cap that constrains the XRPC proxy. - Service-auth tokens are short-lived and per-LXM —
getServiceAuthToken(agent, lxm)issues a token bound to a single method. Don't cache or reuse.
safeRedirect()inauth-context.tsx— onlyhttps:(andhttp:in dev). Never callwindow.location.href = serverProvidedUrldirectly.- Validate
event.originonpostMessage. Both message listeners inauth-context.tsxdo this. Copy the pattern. authFetch()for every authenticated XRPC call, not rawfetch. The 401 interceptor is what surfaces session expiry.
next.config.ts sets these on every response:
X-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originX-Frame-Options: DENY(the OAuth callback iframe is same-origin, so this is fine)Permissions-Policy: camera=(), microphone=(), geolocation=()Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
Don't override these per-route unless you have a specific reason.
- JSON-LD:
Organization(Hypercerts Foundation) andWebSiteinsrc/app/layout.tsx. Includes legal address, contact point,sameAsfor social profiles.SoftwareApplicationandFAQPageinsrc/app/welcome/page.tsx. The FAQPage entries are derived fromFAQ_ITEMSinsrc/components/landing/sections/faq-content.tsx.
- Title template:
default: "Certified",template: "%s — Certified". Pages export their owntitleviametadata. - OG / Twitter: root defaults in
layout.tsx(/assets/certified-hero-1200x630.png,@hypercerts). Pages override per-route.metadataBaseishttps://certified.app. - Canonical URLs: every public page exports
alternates: { canonical: "https://certified.app/<path>" }. Authenticated pages setrobots: { index: false, follow: false }and don't bother with canonicals. robots.ts— allows/,/welcome,/apps,/about,/terms,/privacy,/dsa,/imprint; disallows the app-only surfaces (/home,/explore,/search,/activity/*,/settings/*,/groups/*,/oauth/*,/api/*, …). Sitemap pointer athttps://certified.app/sitemap.xml.sitemap.ts— public URLs withlastModifieddates./welcomeis the priority-1 canonical landing (bare/is not listed);/appsis included as a public page.manifest.ts— PWA manifest.start_url: "/welcome",theme_color: "#f9f9f6", brandmark icons at 192/512.public/llms.txt— Markdown index for AI crawlers (similar to robots/sitemap but in prose)./.well-known/oauth-client-metadata— also an SEO-adjacent contract: changingclient_idorredirect_urisinvalidates existing OAuth sessions.
When adding a new public page: set metadata.title, description, alternates.canonical, and OG url + images. Add it to sitemap.ts and robots.ts (allow). Consider whether it deserves a JSON-LD entry.
- Branches:
main→ production (certified.app)staging→ preview (staging.certified.app)
- Workflow: push to
staging, open a PR tomain. Vercel deploys both branches automatically. - Quality gate:
npm run lint && npx tsc --noEmit && npm test && npm run buildmust all pass before pushing — CI runs exactly that on every PR (.github/workflows/ci.yml). See §27 for the test layout. - OAuth URL precedence:
PUBLIC_URL→VERCEL_BRANCH_URL→VERCEL_URL. Prefer an explicitPUBLIC_URLfor stable production/staging callbacks. Generated Vercel origins are also accepted for same-origin CSRF requests. The selected metadata/JWKS endpoints must be public, and deployments that initiate and complete one OAuth flow must share compatible Redis state/session configuration. Distinct canonical OAuth origins should use separate Redis databases because saved OAuth session keys are DID-based and are not namespaced byclient_id. - Don't commit secrets (
.env.localis gitignored).COOKIE_SECRET,UPSTASH_*,ATPROTO_PRIVATE_KEY,RESEND_API_KEYlive in Vercel envs.
certified-app/
├── AGENTS.md # Short pointer to README + this draft
├── README.md # Public README
├── next.config.ts # Headers, redirects, image remotePatterns, serverExternalPackages
├── tsconfig.json # strict, paths { "@/*": ["./src/*"] }, ES2017, react-jsx
├── tailwind.config.ts # Theme: navy/accent/sky/deep colors, h1-h4, elevation shadows
├── eslint.config.mjs # next/core-web-vitals + next/typescript
├── postcss.config.mjs # tailwindcss only — autoprefixer intentionally omitted (no browserslist); vendor prefixes are hand-maintained
├── .env.local.example # Env var template
├── e2e/ # Playwright specs — see §27
│ ├── smoke.spec.ts # /dev/preview surfaces, no credentials
│ ├── public-routes.spec.ts # signed-out route walk
│ └── auth/ # authenticated flows, skipped without E2E_TEST_DID
├── playwright.config.ts # baseURL pinned to PUBLIC_URL's exact origin + port
├── public/
│ ├── llms.txt # AI crawler description
│ ├── assets/
│ │ ├── partners/ # Partner app logos (maearth, gainforest, simocracy, hyperboards)
│ │ ├── certified_brandmark_black.{svg,png}
│ │ ├── certified_brandmark_black_{192,512}.png
│ │ ├── certified_wordmark_black.{svg,png}
│ │ ├── certified_signin_black.{svg,png}
│ │ ├── certified_signinwith_black.{svg,png}
│ │ ├── certified_poweredby_*.{svg,png}
│ │ ├── certified-hero-1200x630.png # OG image
│ │ └── guilloche_02.svg # Decorative background, SVGO-optimized
│ ├── brand/ # Partner-facing brand assets (brandmark, poweredby, signin, wordmark variants)
│ └── email/
│ └── otp-email-template.html # Branded OTP email (referenced from oauth-client-metadata)
└── src/
├── app/
│ ├── layout.tsx # Root layout: providers, JSON-LD, fonts, skip-nav, navbar/main/footer/feedback
│ ├── globals.css # ALL custom CSS (~4.7k lines, BEM-like)
│ ├── icon.png # Favicon
│ ├── apple-icon.png # Apple touch icon
│ ├── manifest.ts # PWA manifest
│ ├── robots.ts # robots.txt
│ ├── sitemap.ts # sitemap.xml
│ ├── error.tsx # Global error boundary
│ ├── not-found.tsx # 404 page
│ ├── page.tsx # `/` — renders <HomeClient /> (redirector)
│ ├── welcome/
│ │ ├── layout.tsx # Sets navbar variant to "transparent"
│ │ └── page.tsx # Landing + JSON-LD (SoftwareApplication, FAQPage)
│ ├── about/page.tsx
│ ├── terms/page.tsx
│ ├── privacy/page.tsx
│ ├── dsa/page.tsx
│ ├── profile/[did]/page.tsx # Canonical profile URL — renders <ProfileClient />
│ ├── settings/
│ │ ├── layout.tsx # AuthGuard
│ │ ├── page.tsx # If activeOrg → OrgSettings; else handle/email/password/2FA placeholder
│ │ ├── edit-profile/page.tsx # Edit personal profile
│ │ ├── my-data/page.tsx # Data export view
│ │ └── wallet/
│ │ ├── layout.tsx # WagmiProvider + QueryClientProvider (scoped here ONLY)
│ │ └── page.tsx # Wallet linking UI
│ ├── connected-apps/
│ │ ├── layout.tsx # AuthGuard
│ │ └── page.tsx
│ ├── groups/
│ │ ├── layout.tsx # AuthGuard
│ │ ├── page.tsx # List groups (accept/leave/remove public)
│ │ ├── create/page.tsx # Register new group
│ │ └── [groupDid]/
│ │ ├── page.tsx # Group profile view
│ │ ├── edit-profile/page.tsx
│ │ ├── apps/page.tsx
│ │ └── settings/page.tsx # Members + audit log
│ ├── oauth/callback/page.tsx # OAuth redirect target — postMessages parent or window.replace("/")
│ ├── api/
│ │ ├── auth/
│ │ │ ├── login/route.ts # POST {input,mode,prompt} → {url}
│ │ │ ├── callback-handler/route.ts # GET — completes OAuth, creates session, seeds profile records
│ │ │ ├── session/route.ts # GET — returns {did} or {did:null}
│ │ │ └── logout/route.ts # POST — signOut + delete session
│ │ ├── xrpc/[...method]/route.ts # The XRPC proxy
│ │ ├── feedback/route.ts # POST → Resend
│ │ ├── resolve-handle/route.ts # GET ?handle=
│ │ ├── resolve-did/route.ts # GET ?did=
│ │ ├── search-actors/route.ts # GET ?q=
│ │ └── groups/
│ │ ├── register/route.ts # POST — register new group (enforces MAX_SELF_CREATED_ORGS)
│ │ ├── memberships/route.ts # GET — list user's group memberships
│ │ └── [groupDid]/
│ │ ├── audit/route.ts # GET — audit log
│ │ ├── bsky-profile/route.ts # POST — create empty bsky profile
│ │ ├── handle/route.ts # PUT — update handle
│ │ ├── members/route.ts # GET/POST/DELETE
│ │ ├── metadata/route.ts # GET/PUT — app.certified.actor.organization
│ │ ├── profile/route.ts # GET/PUT — app.certified.actor.profile
│ │ ├── role/route.ts # PUT — set member role
│ │ └── upload-blob/route.ts # POST — 5MB image upload
│ └── .well-known/
│ ├── oauth-client-metadata/route.ts # OAuth client metadata
│ └── jwks.json/route.ts # JWKS (when ATPROTO_PRIVATE_KEY set)
│
├── components/
│ ├── landing/
│ │ ├── landing-page.tsx # Server-rendered landing assembly
│ │ ├── home-client.tsx # `/` redirector (auth → /profile/{did}, unauth → /welcome)
│ │ ├── hero-signin-button.tsx # Client island for hero CTA
│ │ ├── orbiting-logos.tsx # Animated logo orbit (IntersectionObserver-gated)
│ │ └── sections/
│ │ ├── built-for-trust.tsx
│ │ ├── faq-accordion.tsx
│ │ ├── faq-content.tsx # FAQ_ITEMS array — also used by FAQPage JSON-LD
│ │ ├── how-it-works.tsx
│ │ ├── partner-apps.tsx
│ │ ├── ready-cta-button.tsx
│ │ ├── ready-cta-content.tsx
│ │ └── what-you-get.tsx
│ ├── layout/
│ │ ├── app-shell.tsx # .app-shell wrapper, skipped on /welcome
│ │ ├── auth-guard.tsx # Auth redirect with loading spinner
│ │ ├── footer.tsx # Global footer (single instance, in root layout)
│ │ └── navbar.tsx # Top nav with account switcher, mobile bottom sheet
│ ├── dashboard/
│ │ ├── custom-domain-modal.tsx
│ │ └── username-card.tsx
│ ├── groups/
│ │ ├── add-org-modal.tsx
│ │ ├── handle-search.tsx
│ │ ├── membership-sync-modal.tsx
│ │ └── org-settings.tsx
│ ├── profile/
│ │ ├── avatar-upload.tsx
│ │ ├── banner-upload.tsx
│ │ ├── profile-client.tsx
│ │ └── profile-edit-form.tsx
│ ├── identity-link/
│ │ ├── identity-link-card.tsx
│ │ └── link-wallet-flow.tsx
│ ├── account/
│ │ ├── email-section.tsx
│ │ └── password-section.tsx
│ └── ui/
│ ├── avatar.tsx
│ ├── badge.tsx
│ ├── button.tsx
│ ├── card.tsx
│ ├── error-message.tsx
│ ├── feedback-modal.tsx # Floating feedback button + modal (avoids footer overlap via scroll listener on .landing-footer)
│ ├── input.tsx # Canonical aria-describedby/aria-invalid pattern
│ ├── loading-spinner.tsx
│ ├── provider-redirect-overlay.tsx
│ ├── sign-in-modal.tsx
│ └── textarea.tsx
│
├── hooks/
│ ├── use-attestation-signing.ts # Wagmi-only — must be inside /settings/wallet
│ ├── use-focus-trap.ts # Generic Tab/Shift+Tab trap + focus restore
│ ├── use-identity-links.ts # Lists + verifies wallet attestations
│ ├── use-org-profile.ts # Active org's profile + metadata
│ ├── use-profile.ts # User's profile with bsky fallback (AbortController)
│ └── use-session.ts # Cached handle+email (module-level promise cache)
│
└── lib/
├── auth/
│ ├── auth-context.tsx # AuthProvider, useAuth, sign-in modal, postMessage listeners
│ ├── csrf.ts # checkCsrf — configured exact origin + same destination
│ ├── fetch.ts # authFetch — 401 interceptor
│ ├── oauth-client.ts # NodeOAuthClient singleton, PDS_URL constant
│ ├── session.ts # createSession/getSessionDid/deleteSession (HMAC + Redis)
│ ├── stores.ts # RedisStateStore (10min) + RedisSessionStore (30day) + getRedis()
│ └── types.ts # AuthState interface
├── atproto/
│ ├── did.ts # resolveHandle + resolvePdsUrl (5s timeout, plc.directory + did:web)
│ ├── profile.ts # getProfile/putProfile, uploadAvatar/uploadBanner, getAvatarUrl/getBannerUrl
│ └── types.ts # CertifiedProfile, BlueskyProfile, hypercerts defs
├── groups/
│ ├── api.ts # All client-side group API calls
│ ├── constants.ts # GROUP_SERVICE, GROUP_SERVICE_DID, MAX_SELF_CREATED_ORGS
│ ├── index.ts # Re-exports
│ ├── org-context.tsx # OrgProvider, useOrg, localStorage persistence
│ ├── proxy-agent.ts # GROUP_LEXICONS, getAuthenticatedAgent, createGroupAgent
│ ├── types.ts # Group, OrgRole, OrgMember, etc.
│ └── use-org-limit.ts # useOrgCreationLimit hook
├── identity-link/
│ ├── attestation.ts # ATTESTATION_DOMAIN/TYPES, buildAttestationMessage, buildRecordKey
│ ├── pds.ts # listAttestations/storeAttestation/deleteAttestation (client-side)
│ └── types.ts # Attestation, AttestationRecord, asHex helper
├── constants/
│ └── apps.ts # CONNECTED_APPS — single source of truth for partner list
├── types/
│ └── api.ts # Shared response types (SessionResponse, ListRecordsResponse, PutRecordResponse)
├── utils/
│ ├── api.ts # extractError(res, fallback)
│ ├── config.ts # OAuth URL precedence + allowed request origins
│ ├── constants.ts # LIMIT_MIN/MAX/DEFAULT, debounce timings
│ ├── initials.ts # getInitials()
│ └── sanitize.ts # stripInvisible/sanitizeEmail/sanitizeHandle
├── navbar-context.tsx # NavbarProvider — "default" | "transparent" variant
├── providers.tsx # Currently a passthrough — placeholder for cross-cutting providers
└── wagmi.ts # wagmi config (mainnet/base/optimism/arbitrum) + SUPPORTED_CHAINS
- Thin coverage above the utility layer. The vitest suite is dense on
src/lib/utils(~85% of modules) but thin on components (~10% rendered) and pages (~12%). Most tests are single-layer: the client/server seam is always cut withvi.mockor a stubbedfetch, so a mismatch between what the client sends and what a route accepts is invisible to them — that is how theorg.hypercerts.context.attachmentallowlist gap shipped. Contract tests (§27) exist to close that specific class. - 2FA / TOTP — not implemented on the ePDS. The
/settingspage shows a "This will be available soon" placeholder. - App passwords — same status: placeholder card on
/settings. - ERC-1271 / ERC-6492 verification —
useIdentityLinksreturnsverified: falsefor smart-contract-wallet signatures withverificationError: "On-chain verification not yet supported". The signing path can produce these (thesignatureTypefield exists), but the verifier doesn't make on-chainisValidSignaturecalls yet. - TypeScript
ascasts — widespread on API request/response bodies (e.g. the XRPC proxy and most route handlers cast throughas). Improving this requires pulling in the official atproto SDK input/output types per method; tracked separately. - Group service is staging-only — default
GROUP_SERVICEpoints at a Railway staging deployment. Don't rely on group data for production-critical flows. - No avatar / banner CDN — image URLs are direct PDS
getBlobcalls. Heavy traffic would put load on the PDS. - No rate limiting in the BFF beyond what Vercel and Upstash provide.
- No structured logging —
console.error("[Auth] …", err)is the convention. Logs end up in Vercel's serverless logs.
useAttestationSigningoutside/settings/wallet— it depends onWagmiProviderwhich is mounted only insrc/app/settings/wallet/layout.tsx. Calling it elsewhere will throw "useConfig must be used within WagmiConfig".- Using
fetchinstead ofauthFetch— the 401 interceptor is the only thing surfacing session expiry to the user. Rawfetchwill silently fail. - Origin check failures — CSRF requires the source to be one of the exact configured origins and to equal the request destination. Locally, match
PUBLIC_URLto the browser host (usehttp://127.0.0.1:3000for sign-in). On Vercel, ensure the server runtime exposesVERCEL_BRANCH_URL/VERCEL_URL; noNEXT_PUBLIC_aliases are used. Generated preview login can still fail when Deployment Protection prevents the authorization server from fetching metadata/JWKS.
3a. atproto OAuth in dev requires the loopback metadata helper, not just PUBLIC_URL. The spec only accepts a client_id that is either a real https:// URL or the literal http://localhost origin (no port, no path). Pointing client_id at http://localhost:3000/... or http://127.0.0.1:3000/... makes NodeOAuthClient throw URL must use the "https:" protocol (Zod). The fix — already wired into src/lib/auth/oauth-client.ts — is to resolve PUBLIC_URL → VERCEL_BRANCH_URL → VERCEL_URL, then use buildAtprotoLoopbackClientMetadata({ scope, redirect_uris: ["http://127.0.0.1:<port>/oauth/callback"] }) only when that canonical URL is http:// outside production. Notes:
- The
client_idbecomes a virtualhttp://localhost?redirect_uri=...&scope=..., which is what the AS expects for loopback dev. - The
redirect_urihost must be127.0.0.1(or[::1]);localhostis NOT allowed there even though it IS the only allowedclient_idhost. Yes, this is inverted from intuition; it's the spec. - Cookies don't cross
localhost↔127.0.0.1. Pick one host for the whole flow. Since the redirect comes back on127.0.0.1, navigate tohttp://127.0.0.1:3000/welcome. - The PDS will show "atproto loopback client" on the consent screen instead of Certified branding. To get real branding in dev, run a tunnel (e.g.
cloudflared,ngrok) and setPUBLIC_URL=https://<tunnel>.example.comso the production code path runs. ATPROTO_PRIVATE_KEYis ignored in loopback dev mode — the helper hard-codestoken_endpoint_auth_method: "none".
- Wrong cookie name — it's
certified_session. Anything else (session,sid) is wrong. - Forgetting to add a new write collection to
ALLOWED_WRITE_COLLECTIONS—createRecord/putRecord/deleteRecordwill silently 403 withCollection not allowed. - Cross-repo writes —
body.repomust equal session DID. If you need to write to another repo (e.g. a group's repo), use thecreateGroupAgentproxy pattern, not the XRPC proxy. - OAuth callback in iframe vs. top window — the page detects
window.parent !== window. If you change the modal/iframe architecture, update both branches ofhandleCallback. postMessagewithout origin check — both listeners inauth-context.tsxvalidateevent.origin === window.location.origin. Don't drop this check.- Lifting wagmi to root — every page would pull in viem and wagmi (~hundreds of KB). Keep it scoped.
- Caching
getServiceAuthtokens — they're scoped per-LXM and short-lived. Always re-issue. - Rendering user-controlled URLs as
hrefwithout scheme validation —javascript:alert(1)becomes a one-click XSS. Always allowlist schemes (http:,https:,mailto:,tel:) before assigning user-controlled values tohref. sitemap.ts/robots.tsdrift — when you add a new public page, both must be updated. There is no automation.100vwin CSS — causes horizontal scroll when a vertical scrollbar is present. Use100%.- Treating
next.config.ts'sserverExternalPackages: ["@atproto/oauth-client-node"]as optional — it's not. Without it, the OAuth client fails to bundle correctly for serverless. ATPROTO_PRIVATE_KEY/ JWKS coupling — if you setATPROTO_PRIVATE_KEY, the OAuth client switches to confidential auth and the publishedoauth-client-metadataincludes ajwks_uri. Removing the var without updating the registered metadata can desync clients.- Hand-rolling a modal instead of using
<AppDialog>. The pattern atAddOrgModal/MembershipSyncModalbefore the consolidation — manual<div className="signin-modal__backdrop">+useFocusTrap+onKeyDown={e => e.key==="Escape" && onClose()}— has produced anInvalidStateErrorbug (documented atapp-dialog.tsx:118) and bypasses focus restore. Use<AppDialog>. The 2 px radius is now universal so the old "forgot.app-modal" 20 px regression no longer applies. 16a. Reintroducingborder-radius: 6px(or 4 / 8 / 12 / 16 / 20). The consolidation pass replaced 116+ instances;var(--radius)is now universal. Before merging CSS, rungrep -rEn "border-radius:\s+(4|6|8|12|16|20)px" src/app/styles/and confirm zero hits. 16b. Usingtext-xl/text-lgfor app headings. The canonical scale istext-display/text-h1/text-h2/text-h3/text-h4fromtailwind.config.ts, paired withfont-headline(Noto Serif). Legal pages,/about, and/privacywere migrated; new pages should follow. 16c. Hardcodingvar(--color-primary)on landing surfaces. Use--color-navy/--color-off-white(theme-aware landing tokens).--color-primaryis invariant and will leave a near-black blob on a dark canvas in dark mode. - Clearing optimistic state in
finally— see §15a "Optimistic state — the pattern". The parent's refetch lags the PDS write; clear via the parent-value-caught-upuseEffectinstead. - Reverting the PersonCard layout to right-aligned date — Received/Given/Followers/Following cards intentionally stack name → @handle → date → listTitle. The previous "name on left, date on right" layout breaks the new
listTitlerow 4. listTitleprivacy leak —useReceivedEndorsementsreturnslistTitleto ALL viewers (the def title is public on the issuer's repo). That's fine for endorsements. Don't accidentally apply the same logic to private metadata.- Group follow writes via the personal XRPC proxy —
createFollow(ownDid, subjectDid)withouttargetDidwrites to the PERSONAL repo, even when acting-as-group. Pass{ targetDid: groupDid }to route through/api/groups/[did]/follow. - Hiding rejected endorsements from non-owners —
useReceivedEndorsementsdefault keeps the privacy contract (foreign viewers never see rejected). Only pass{ includeRejected: true }on owner-side surfaces, and filter client-side from there. - Static segments under dynamic routes —
/project/newlives atsrc/app/project/new/page.tsxalongside[did]/[rkey]. Static wins (and[did]/[rkey]is two segments so/project/newwouldn't match it anyway), but if you change the dynamic pattern to single-segment make surenewstill wins.
When adding any non-trivial feature:
- Decide the route. Public or gated? Personal or group context (most pages must handle both — see how
/settingschecksactiveOrg)? - Layout & providers. Does it need a layout? AuthGuard? Any new provider? If a new provider would only be used by one route, scope it locally (see
settings/wallet/layout.tsx). - API surface. Will it call XRPC methods already supported by the proxy? If not, add them — and add their collection to
ALLOWED_WRITE_COLLECTIONSif writing. - Security:
- CSRF on POST/PUT/DELETE (
checkCsrf(req)). - Sanitize user input client + server (
sanitizeEmail,sanitizeHandle,stripInvisible). - Sanitize 5xx errors before returning.
- Allowlist URL schemes (
http:,https:,mailto:,tel:) before rendering any user-controlled URL ashref. safeRedirect()for any redirect target returned from the server.
- CSRF on POST/PUT/DELETE (
- A11y:
- Form inputs: use
<Input>/<Textarea>(they wire uparia-describedby/aria-invalidfor you). - Modals: use
<AppDialog>(NOT a hand-rolled backdrop +useFocusTrap). - Tabs: use
<Tabs>/<Tab>/<TabPanel>for proper tablist ARIA + arrow-key nav. - Dropdowns: use
<Popover>. If hand-rolled,aria-haspopup+aria-expandedare required. - Icon-only buttons:
<Button size="icon" aria-label="…">(TypeScript enforces the label). - Skip-nav already present.
- Form inputs: use
- Design system:
- Reach for an existing
src/components/ui/primitive before writing a new component. - Reuse CSS tokens (see §11). No raw hex/rgb outside
tokens.cssandlanding.css. No new breakpoints. Allborder-radiusisvar(--radius). - Verify dark mode: toggle
data-theme="dark"on<html>and confirm readable text + visible borders + primary button inverts. The landing page must also flip cleanly. - Headings:
text-h1/h2/h3/h4+font-headline. Body:text-body/body-sm/caption+ Inter. - Pre-merge sanity:
grep -rEn "border-radius:\s+(4|6|8|12|16|20)px" src/app/styles/should return zero hits.
- Reach for an existing
- SEO (public pages only):
metadata.title,description,alternates.canonical, OGurl+images.- Add to
src/app/sitemap.ts. - Update
src/app/robots.tsallow-list. - Consider JSON-LD if it semantically fits (Article, BreadcrumbList, etc.).
- Authenticated pages: set
robots: { index: false, follow: false }.
- Observability: if the feature can fail, log with
console.error("[Feature] …", err)so it shows up in Vercel logs. - Quality gate:
npm run buildmust pass. Manual smoke-test in the browser (sign in, exercise the feature, sign out — re-test in incognito for a clean session). - Update this file if the feature changes architecture, conventions, or security posture.
Use this for any new src/app/api/** route handler:
- Method-appropriate handler. GET for reads, POST/PUT/DELETE for writes.
- CSRF check first for mutating methods:
const csrfError = checkCsrf(request); if (csrfError) return csrfError;
- Auth check second:
For routes that need the atproto agent, prefer
const did = await getSessionDid(); if (!did) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
getAuthenticatedAgent()fromsrc/lib/groups/proxy-agent.ts— it handlesclient.restorefailures by deleting the session and returningnull. - Validate body shape — typeof checks, allowlists for enums (see
role/route.tsvalidating role ∈{member, admin, owner}). - Sanitize input at the boundary even if the client also sanitized.
- Respect allowlists — collection allowlist for XRPC, scheme allowlist for URLs, MIME-type allowlist for blobs.
- Try/catch the body — handle malformed JSON explicitly:
try { body = await req.json() } catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }) }
- Sanitize errors:
- 4xx errors can echo upstream messages (they're usually validation-shaped).
- 5xx errors must return a generic message — never echo upstream
err.message.
- Log on the server side with a route-tagged prefix:
console.error("[Route] …", err). - Return shape consistency — successful operations return
{ success: true }for void operations, the upstreamdataobject for queries, or domain-shaped JSON. Errors always return{ error: string }. - Update
AGENTS.md/ this draft if the route adds an endpoint to the public surface.
- Server side — return
NextResponse.json({ error: "..." }, { status }). UseextractError(res, fallback)fromsrc/lib/utils/api.tsto pull error messages out of nested upstream responses on the client. - Client side — store error in local component state, render via
<ErrorMessage>component (src/components/ui/error-message.tsx). Don'talert(). - Auth errors — surfaced through
AuthProvider.errorand the sign-in modal. Don't write a parallel auth-error system. - AbortController — every long-running fetch in a hook should accept an
AbortSignaland checksignal.abortedbefore setting state. SeeuseProfile,useOrgProfile,OrgProviderfor the canonical pattern. - Catch and ignore is fine for best-effort operations (avatar fetch, profile seeding). Catch and re-throw with a useful message for things the user must know about (sign-in failure, save failure).
- Initial auth load —
AuthProviderexposesisLoading. Pages gated byAuthGuardshow a centered spinner while it's true. - Per-card loading — use the
LoadingSpinnercomponent (src/components/ui/loading-spinner.tsx). - Don't gate the navbar on
isLoading— render placeholders / skeletons so the layout is stable. - Optimistic state on sign-out —
signOutclears local state immediately, then fires the server cleanup in the background (best-effort).
<a class="skip-nav">and<main id="main-content">are wired up in the root layout.- Form inputs use
Input/Textareawhich wire uparia-describedby+aria-invalid+ label associations viauseId. - Modals trap focus with
useFocusTrapand restore focus to the previously focused element on close. - Dropdowns have
aria-haspopup+aria-expanded. - Buttons that purely contain icons need
aria-label(ortitle) — see the small action buttons ingroups/page.tsx(title="Leave group",title="Accept membership publicly"). - Decorative images have
alt=""+aria-hidden="true"— seehome-client.tsxfor the loading screen logo. - Color is never the sole signal — error states pair red with text, success pairs green with an icon.
Reference: when the operator says "do this with the deep flow"
(or "deep-flow this issue", "use the deep flow"), apply the
process below. It is the default for any change beyond a
one-line fix, typo, dep bump, copy/string edit, or doc tweak.
For those mechanical changes, skip this and commit directly to
staging.
The operator sets a high bar on security, code quality, and performance. The number of reviewers, the kinds of lenses, and the number of rounds are your judgement — calibrated to that bar, not to a formula. Diminishing returns set in fast; stop when the next pass would be nit-picking.
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.
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.
-
Evaluate the request. Does it actually make sense? Is the proposed shape the right one? Read the issue, the surrounding code, and the larger goal it serves. Explicitly consider alternative implementations — do not run with the first proposal in the issue. Enumerate the plausible approaches, then pick the one that best serves the larger goal, not the one quickest to ship. Record the alternatives and the rationale in the plan. If the request doesn't make sense, push back instead of building it.
-
Plan. Write
docs/<feature-or-issue>/plan.mdcapturing:- the larger goal this serves
- scope and file ownership
- alternatives considered, with rationale for the chosen path
- acceptance criteria
- explicit out-of-scope items
- rollback plan
- any open questions for the operator
-
Plan review. Spawn multiple reviewer agents in parallel with different lenses (e.g. security, performance, GraphQL schema correctness, ATProto semantics, ops impact, API-consumer ergonomics, test coverage). Pick the count and mix yourself based on surface area and risk. Record decisions in
docs/<feature>/review-round-N.md— accepted / rejected with a one-line rationale for each item. Update the plan in place. Run further rounds only if the previous round surfaced substantive items. -
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). -
Local verification. Run all four quality gates plus anything that exercises the new surface:
go build ./... go vet ./... go test -race ./... golangci-lint run ./...Capture the pre-existing lint/test baseline so "no new errors" is a meaningful claim.
-
Implementation review. Same shape as plan review — parallel reviewers, different lenses, your call on count and mix. Apply accepted feedback in a follow-up commit. A follow-up round only if round 1 surfaced enough substantive items to justify one.
-
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. -
Make CI green. Fix root causes. Never
--no-verify. Never skip hooks. Loop until all checks pass. -
Stop. The operator merges. Notify with the PR URL and a short summary of what shipped.
- Never merge. Stopping at "PR Draft, CI green" is the contract.
- Never
--forcepush tomain. Avoid history rewrites onstagingonce you've pushed; it's the shared working branch. - Decisions belong in writing. If a reviewer raises an
item and you reject it, record the rationale in
review-round-N.md. Future-you will not remember why. - No emojis in code, commits, or PR bodies unless the
operator asks. Keep the standard
Co-Authored-By:trailer; nothing else.
The Go commands above are illustrative (the doc travels across repos). For certified-app the equivalent gate is:
npx tsc --noEmit
npx eslint src/ --ext .ts,.tsx
npx next buildPlus a smoke test of the changed surface in next dev when
the change is user-facing.
| Kind | Location | Runner | Needs credentials? |
|---|---|---|---|
| Unit / route-handler / component | src/**/__tests__/*.{test,spec}.{ts,tsx} |
Vitest (jsdom) | no |
| Contract ("tier 1") | same, colocated with the allowlist they guard | Vitest | no |
| Smoke E2E ("tier 2") | e2e/*.spec.ts |
Playwright | no |
| Authenticated E2E ("tier 3") | e2e/auth/*.spec.ts |
Playwright | yes — skipped without E2E_TEST_DID |
npm test # vitest run
npm run test:watch # vitest
npm run test:coverage # vitest run --coverage (no thresholds set yet)
npm run typecheck:test # tsc against tsconfig.test.json
npx playwright test # tiers 2 + 3src/test-setup.ts is the only setupFiles entry. It registers the jest-dom
matchers, runs cleanup() after every test, stubs window.matchMedia, and
forces an in-memory Storage onto both window and globalThis (Node 21+
ships a native localStorage global with no backing store that otherwise
shadows jsdom's working one).
Most of this app's surfaces talk to themselves through an allowlist: the XRPC
proxy's ALLOWED_WRITE_COLLECTIONS, its PUBLIC_READ_METHODS, the indexer's
operation map, the group BFF's op list. A client that sends something absent
from the matching allowlist gets a silent 403, and no single-layer test can
see it — the client is correct, the server is correct, only the pair is
wrong. That is precisely how own-repo activity updates shipped broken.
The fix is a test that asserts one side's set against the other's.
src/app/api/xrpc/[...method]/__tests__/allowed-collections.test.ts is the
template: it enumerates every collection client code writes and drives each
through the real route handler. When you add a write surface, add its NSID
there — the test fails until the allowlist agrees.
/dev/preview/{surface} mounts the real production components against
fixture data, wrapped in MockFetchProvider, which intercepts every network
egress (same-origin API routes plus plc.directory and public.api.bsky.app).
Surfaces: profile, profile-org, feed, settings, workspace, create,
profile-edit, activity-edit; ?fixture=empty and ?managed=1 switch
scenarios. This is how the smoke suite covers auth-gated screens with no
account at all.
These routes notFound() when NODE_ENV === "production", so they work under
npm run dev only — never against a deployed environment.
There is no password grant. Sign-in is atproto OAuth against the ePDS with an
emailed OTP, so it cannot be driven headlessly. Instead the OAuth session is
stored in Redis keyed by DID for 30 days, and the certified_session cookie is
just ${sessionId}.${hmac(sessionId, COOKIE_SECRET)} over a Redis DID lookup.
So: sign a dedicated test account in once, then e2e/auth/global-setup.ts
mints fresh cookies for that DID for the next 30 days.
Tier 3 writes real, federated records. Use a throwaway account, and clean up what you create.
checkCsrf requires the request origin to equal the destination origin and
be allowlisted; the loopback exemption matches on protocol and port. A dev
server that falls back to :3001, or mixing localhost with 127.0.0.1
(cookies don't cross the two), produces 403s that look like auth bugs. Pin the
port and use one spelling everywhere — playwright.config.ts does this.