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
DESIGN.md is the design system. Read §0 Rules at a glance first, then the section for what you're touching (color, type, layout, interaction, motion, icons, components, states). tokens.css is the only source of numeric values.
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 / hsl / named colors outside
src/app/styles/tokens.cssandsrc/app/styles/landing.css(stylelint-enforced). 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. Elevation shadows are
var(--shadow-sm)/var(--shadow-md)/var(--shadow-lg), plusvar(--shadow-modal)for dialogs only; rings and hairlines use the--shadow-ring-*/--shadow-inset-*/--shadow-outline-*/--shadow-navbar-hairlinetokens. 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-feedback-trigger,--z-portal-sheet,--z-modal,--z-skip-nav,--z-feedback,--z-tooltip, …). - Reach for the canonical UI primitive before writing a new BEM class. The components in
src/components/ui/are:<Button>(4 variants × 5 sizes includingsize="icon"andsize="icon-sm"),<Input>(4 sizes incl.bare× 3 variants),<Textarea>,<Badge>(11 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, variable), Noto Serif (headline; 300 / 400 / 600 / 700, normal + italic) — 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. |
CREATED_VIA_SIGNING_KEY |
optional | Hex secp256k1 private key for #createdVia record signatures (§16a). Sensitive. Set only for the staging branch and Production — never for all of Preview, never locally. Not the OAuth key. |
CREATED_VIA_DID |
optional | Must equal did:web:<PUBLIC_URL host> (e.g. did:web:certified.app). Deliberately redundant with PUBLIC_URL: a drifted PUBLIC_URL stops signing instead of switching DIDs. |
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. |
/.well-known/did.json |
server | public | did:web document for the record-signing key (§16a). 200 cached public, max-age=300; 404 (signing off) / 500 (bad key) are no-store. |
/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>. There is no in-memory fallback:getRedis()throws withoutUPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKEN, so local sign-in needs a real Upstash database. - Request lock —
src/lib/auth/request-lock.tsis the client'srequestLock. The client takes it around every session read for a DID, and a read may refresh the tokens. Refresh tokens rotate, so two instances refreshing at once replay one and the authorization server revokes the session. Calls queue in-process first, then holdoauth:lock:<name>in Redis (SET NX PX 15000, owner token, owner-only release). The holder renews the TTL every 5s while it works, because the refresh request has no timeout of its own and, with a lock configured, the client no longer recovers from a concurrent refresh. Waiters give up after 45s withRequestLockTimeoutError. Never remove it; without it the client logs "No lock mechanism provided. Credentials might get revoked." - Response size cap —
safeFetch(the client'sfetch) limits every response to 128 KiB except authenticated XRPC calls to the PDS (/xrpc/and anAuthorizationheader;src/lib/auth/response-limit.ts). The path alone isn't enough: a hostile authorization server can publish its token/PAR endpoint under/xrpc/, and those requests carry a DPoP proof but noAuthorization. The client validates metadata, DID documents and PAR/token responses with zod 3, which has an unfixed array memory-exhaustion bug (GHSA-mrhx-c7xc-m3w5, TECH-804), and an anonymous visitor can point a sign-in at a server they control. Remove the cap only once@atproto/*is on zod ≥ 4.6.6. - SSRF guard — server code that fetches a URL taken from a DID document, a handle or OAuth metadata goes through
guardedFetch(src/lib/security/ssrf.ts, TECH-811): the OAuth client (viasafeFetch, which also covers everyAgentbuilt on an OAuth session), did:web / handle.well-knownlookups, foreign-PDS reads in the xrpc proxy,get-record-server,resolve-core, app-password and group-account sessions, and the group PDS routes. An undici dispatcher refuses any connection to a non-public address: hostnames are resolved once and every address must be public unicast (IPv6: global unicast2000::/3), literal IPs are checked before connecting, and every redirect hop is checked again. The dispatcher travels oninit— never wrap the URL in aRequest, because Next's patched fetch rebuilds Request inputs without it.did.tsis also bundled for the browser, so it can't import the guard; it reads it from a process-wide slot thatsrc/instrumentation.tsfills at server start, and on the server an empty slot fails closed.ssrf.tsdeep-imports undici'sAgent/buildConnector: the package index would replace the process-wide fetch dispatcher for the whole app. Production (Node 24) bundles undici 7, matching our dependency; re-verify on a Node major bump.ALLOW_PRIVATE_NETWORK_FETCH=trueturns it off in local development only.ssrf-contract.test.tsfails on a barefetch(of a PDS / DID-derived URL (also via a variable, in files that handle PDS URLs) and on an atprotoAgentbuilt on a service URL. - Restore failures — every
client.restore(did)caller decides withisSessionGone(err, did)(src/lib/auth/restore-error.ts) whether to delete the app session. Only a session that is really gone (revoked/invalid/unrefreshable tokens, 401/403, or a stored session that is verifiably absent) deletes it and answers 401. Anything else (auth-server metadata fetch, Upstash, the request lock) keeps it and answers 503 viasessionUnavailable(), becauseauthFetchtreats a 401 as signed out. The client reports a failed session-store read as "The session was deleted by another process", so that one error is checked against Redis before deleting. - 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). |
/.well-known/did.json |
GET | n/a | none | App DID document with the #created-via signing key (only when signing is configured, §16a). |
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:
screensreplaced withmd800 /lg1100 /xl1300 ·future.hoverOnlyWhenSupported·colors.{success, warning, error}·fontFamily.{sans, headline, mono}· the fullfontSizetype ramp (display,h1-h4,body,body-sm,ui,caption,label,micro) ·letterSpacing.label·transitionDuration.{instant, fast, base, slow}+transitionTimingFunction.{out, emphasized, in-out}(defaults: fast +--ease-out) ·height/minHeightcontrol-{compact, sm, md, lg}·maxWidth.measure·boxShadow.{sm, md, lg, modal}aliasing the CSS vars ·borderRadius.DEFAULT: 2px· thepulse/skeletonanimations. 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(a tint, not an elevation step) ·--bg-elevated(cards, inputs, popovers, sheets, drawers) ·--bg-overlay(= canvas; AppDialog surface). - Foreground:
--fg-primary(headings, primary text) ·--fg-secondary(body) ·--fg-muted(placeholders, captions). - Borders (exactly four):
--border-subtle(row dividers) ·--border-default(cards, text fields, dialogs, popovers) ·--border-hover(hover / emphasis) ·--border-control(checkbox box and switch-off track; ≥ 3:1). The old--border-light/--border-medium/--border-hover-softare removed. - Buttons:
--btn-primary-bg/--btn-primary-fg(these invert in dark) ·--btn-primary-bg-hover·--opacity-disabled(0.5). - Overlays:
--overlay-weak(hover fill) ·--overlay-medium(selected fill) ·--scrim(0.4 light / 0.6 dark; behind AppDialog, Drawer, BottomSheet, feedback modal;--modal-backdropis removed) ·--scrim-pill*(invariant pills over user images). - Chrome:
--navbar-bg(canvas at 80%) /--sheet-bg(elevated at 95%), bothcolor-mix(), no dark override ·--navbar-border. - Shadows: elevation
--shadow-sm/--shadow-md/--shadow-lg·--shadow-modal(dialogs only; Tailwindshadow-modal) · rings and hairlines--shadow-ring-{weak,medium,focus,error,elevated},--shadow-outline-subtle,--shadow-inset-{subtle,hover,indicator},--shadow-navbar-hairline. - Invariant utilities:
--mask-opaque,--mix-shade,--confetti-0..--confetti-9. - 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: durations
--duration-instant(100 ms) ·--duration-fast(150) ·--duration-base(200) ·--duration-slow(300); easings--ease-out(default UI) ·--ease-emphasized(dialogs, sheets, drawers; no overshoot) ·--ease-in-out.--transition-fast/--transition-baseare aliases (--duration-fast|base+--ease-out);--transition-slowis removed. Under reduced motion, spinners keep turning and dialog / toast / tab-panel / popover keep 150 ms fades (DESIGN.md §7.4). - Type:
--text-display…--text-micromirror the Tailwind ramp ·--tracking-label(0.06em, uppercase only) ·--measure(65ch). - Control heights:
--control-h-compact(32) /-sm(36) /-md(44) /-lg(56) → Tailwindh-control-*. - Geometry:
--radius(2 px) ·--radius-pill(999 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-local-raise(1) ·--z-rail-sticky(10) ·--z-rail(30) ·--z-popover(40) ·--z-navbar/--z-bottom-nav(50) ·--z-feedback-trigger(55; floating Feedback button) ·--z-portal-sheet(60) ·--z-modal(100) ·--z-skip-nav(9999) ·--z-feedback(10000) ·--z-feedback-above(10001) ·--z-tour-backdrop(10015) ·--z-tour-card(10016) ·--z-tooltip(10020).
- 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. Elevation is
var(--shadow-sm|md|lg), orvar(--shadow-modal)for dialogs; rings and hairlines arevar(--shadow-ring-*|inset-*|outline-*)/var(--shadow-navbar-hairline). Never a literalbox-shadow. - 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. - Run
npm run lint:css. Stylelint (stylelint.config.mjs, CI step "Lint CSS") enforces rules 1-5 onsrc/**/*.css: no hex /rgb()/rgba()/hsl()/ named colors;box-shadowonlyvar(--shadow-*)(or a comma list of them) /none;z-indexonlyvar(--z-*),calc(var(--z-*) ± n),auto,0(local stacking uses--z-local-raise); radius onlyvar(--radius)/var(--radius-pill)/999px/50%/0/inherit; mediamin-width800/1100/1300 andmax-width799/1099/1299 only.tokens.cssandlanding.cssare exempt from the color and shadow rules only. - 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" | "icon-sm". sm / md / lg are 32 / 36 / 44 px on desktop from the control-height scale (min-h-control-*), and every size is at least 44 px below 800 px. size="icon" is 36×36 on desktop, 44×44 below 800 px; size="icon-sm" is 32×32 visual with a 44×44 hit area (use it for dismiss / close controls). Both icon sizes require aria-label (enforced via TypeScript discriminated union) and show it as a tooltip. Also loading (spinner + aria-busy), pressed (toggle, aria-pressed), tooltip, href (renders a next/link). |
<Input> |
size: "sm" | "md" | "lg" | "bare" (36 / 44 / 56 px via h-control-*; bare has no fixed height) · 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> |
11 variants: verified / pending / unverified (status, with icon) · tag / role / count / count-bare (neutral chips and counts) · 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}. Fill --overlay-weak; the pulse stops under 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: "xs" | "sm" | "md" | "lg" | "xl" | "2xl" (20 / 32 / 48 / 64 / 96 / 240 px). Pass fallbackInitials={getInitials(name)} from src/lib/utils/initials.ts. |
<AppDialog> + <AppDialogHeader> + <AppDialogBody> |
The modal primitive: --bg-overlay surface, --shadow-modal, --scrim backdrop. <AppDialogHeader title onClose? disabled?> renders a left-aligned serif text-h4 title and an icon-sm close button; the old center prop is removed. See §11 Modals. |
<ConfirmDialog>, <DeleteRecordDialog> |
Specializations of AppDialog for destructive / type-to-confirm flows. |
<EmptyState> |
variant: "rich" | "inline" (compact = alias of inline). Rich: centered 40 px icon (stroke 1.25) + serif title + description + actions; headingLevel sets the title element. |
<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 the ladder in DESIGN.md §8: 12 px (stroke 2) meta / error glyphs · 14 px (1.75) inline actions, menus · 16 px (1.75) button, toast, banner and close icons · 20 px (1.5) chrome · 24 px bottom nav (1.5, 2.5 active) · 40 px empty state (1.25). Smaller icons get heavier strokes; snap 11 / 13 / 15 px to the nearest step.
- 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).
Every hypercerts-lexicon record the app writes (app.certified.*, org.hypercerts.*, org.hyperboards.*) carries an app.certified.signature.defs#createdVia entry in its signatures array (hypercerts-lexicon 1.6.0). app.bsky.* and org.impactindexer.* are left unsigned — a scope choice.
What it means: an authenticated user wrote this version of the record through certified.app's server. It does not mean certified.app validated or endorses the content, and it says nothing about the other entries in signatures (they aren't covered by it). Any UI that surfaces it must say so.
Where it happens (signCreatedVia in src/lib/atproto/created-via.ts, a server-only module):
- Own-repo writes: the xrpc proxy's
createRecord/putRecord, after the repo / allowlist / rate-limit checks. - Group writes:
groupServiceFetchinsrc/lib/groups/proxy-agent.ts, after each route's field allowlist. Never POST records to CGS with a rawfetch—src/app/api/__tests__/created-via-contract.test.tsfails if you do. - First sign-in: the
app.certified.actor.profileseed in the OAuth callback. applyWritesisn't proxied. If it ever is, sign each create/update op'svalue.
Rules:
- Signing never blocks a write. On any failure (bad input, floats, signer error) the record goes out without a createdVia entry and
[created-via] sign failedis logged. - An edit always replaces the old createdVia (any change outside
signaturesinvalidates it). Other well-formed entries are kept on own-repo writes; group route allowlists dropsignaturesentirely, so group edits reset third-party signatures (accepted for now). - A client-sent
$sigis never stored. A record whose$typenames a different collection is passed through untouched, so the PDS still rejects it. - Every collection the app writes must be in exactly one of
SIGNED_COLLECTIONS/UNSIGNED_COLLECTIONS;created-via.test.tsenforces it against the xrpc allowlist and the group routes. - Same-session
getRecord/listRecordsresponses go throughlexToJson, so$bytessurvives an edit round trip instead of serializing as{"0":..}. Blob refs keep the shapeBlobRef.toJSONalways produced; a legacy{cid, mimeType}blob reads back assize: -1, which strict encoding rejects, so a record re-saved with one goes out unsigned (the app never writes legacy blobs). - Encode with
@atproto/lex-json+@atproto/lex-cbor, never@atproto/lexicon'sjsonToLex(itsBlobRefinstances change the CID).
Keys and DIDs: signing runs only when CREATED_VIA_SIGNING_KEY, CREATED_VIA_DID and an explicit https PUBLIC_URL are set and the DID equals did:web:<PUBLIC_URL host>; loopback and *.vercel.app hosts never sign. When it's off, each instance logs [created-via] record signing disabled: <reason> once and did.json returns 404 — check that line first when debugging a rollout. A malformed key logs [created-via] invalid signing key and did.json returns 500. Each environment has its own key (did:web:staging.certified.app, did:web:certified.app).
/.well-known/did.json must be reachable without authentication (check Vercel Deployment Protection for the staging domain and add an exception if needed), and it — with every key that ever signed — must stay served for as long as signed records exist.
| Environment | DID | Public did:key |
Set on |
|---|---|---|---|
| staging | did:web:staging.certified.app |
did:key:zQ3shkw7ytTFdJmn3epcWaqTXED3tZFu8SbsCwZBfGAWFA868 |
2026-10-27 |
| production | did:web:certified.app |
did:key:zQ3shirLmopLSHcwUNnfBaoPMNCrtLWxLZtup2UpiX7oEV56P |
2026-10-27 |
Generate a key:
node -e "import('@atproto/crypto').then(async ({ Secp256k1Keypair }) => { const k = await Secp256k1Keypair.create({ exportable: true }); console.log(Buffer.from(await k.export()).toString('hex')); console.log(k.did()) })"First line is CREATED_VIA_SIGNING_KEY; second is the public did:key to record. Check a stored record with node scripts/verify-created-via.mjs <at-uri>.
Rotation is future work: add #created-via-2 and keep the old key in did.json. If a key leaks: remove the env var and redeploy. Signing stops and did.json 404s, so every past signature stops verifying — accept that loss, then publish a new key under #created-via-2. Never keep a leaked key published.
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. - Region: functions run in
fra1(Frankfurt), next to the Upstash databases (production and staging, both eu-central-1) and the rest of our infrastructure (certified.one, the group service, the indexer).vercel.jsonsets it per deployment and overrides the project's dashboard default, which is alsofra1; keep the two in step. Redis is on the hot path (session cookie lookup, the OAuth request lock, session store, rate limits), and from Vercel's defaultiad1every call crossed the Atlantic (~85 ms each). Keep new Redis-backed services in eu-central-1; don't add apreferredRegionthat moves a route away from Redis.
certified-app/
├── AGENTS.md # Short pointer to README + this draft
├── README.md # Public README
├── next.config.ts # Headers, redirects, image remotePatterns, serverExternalPackages
├── vercel.json # Function region (fra1, next to Upstash)
├── 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)
│ └── did.json/route.ts # did:web doc for #createdVia signing key
│
├── 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
<Skeleton>shaped like the content (see DESIGN.md loading/empty/error matrix).LoadingSpinneris for full-page or section loading only. - 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 use
<AppDialog>(native<dialog>), which traps focus and restores it on close. Don't calluseFocusTrapdirectly for modals. - 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.
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in node_modules/next/dist/docs/ (resolved from this file's directory; in monorepos the next package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by next dev — verify at node_modules/next/dist/server/lib/generate-agent-files.js. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.