Skip to content

Latest commit

 

History

History
1233 lines (1020 loc) · 99.6 KB

File metadata and controls

1233 lines (1020 loc) · 99.6 KB

Agent Instructions — Certified

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.

Table of Contents

  1. Design Context
  2. Project Overview
  3. Tech Stack
  4. Quick Reference
  5. Environment Variables
  6. Architecture & Data Flow
  7. Provider Tree & Layout System
  8. Routing Map
  9. Authentication Flow
  10. API Routes Catalog
  11. XRPC Proxy
  12. CSS Conventions
  13. Component Conventions
  14. Hooks Catalog
  15. State Management
  16. Groups Feature
  17. Identity-Link / Wallet Attestation
  18. Security Rules
  19. SEO / GEO
  20. Git & Deployment
  21. File Map
  22. Known Limitations
  23. Common Pitfalls
  24. Adding a New Feature — Checklist
  25. Adding a New API Route — Checklist
  26. Conventions: Errors, Loading, A11y

0. Design Context

PRODUCT.md at the repo root is the strategic design brief: register, primary user, brand personality (confident, principled, plain), anti-references (anchored on "visibly not-a-wallet"), and design principles. Read it before any UI/UX work. The /impeccable skill loads it automatically; humans should open it for any design decision that goes beyond a one-line copy or token tweak.

DESIGN.md is the visual companion. Read §14 — Design consolidation pass (2026-05-28) first: it locks in the post-consolidation rules and supersedes earlier sections where they contradict. Then read §1–§13 for the underlying system (Notary's Ledger North Star, civic palette, typography, three-step shadows).

The token refactor and component canonicalization that earlier drafts of this file described as future work have landed (PR #108, merged 2026-05-28 into feat/positioning-redesign). The audit + visual divergence sheet that drove the work live at docs/design-audit/component-audit.md and docs/design-audit/visual-divergence.md. The implementation plan + decision log is docs/design-consolidation/plan.md.

Rules at a glance — read before touching any UI

These are the rules most often violated by drift. If your change touches CSS, JSX, or design tokens, hold yourself to them.

  1. border-radius is var(--radius) (2 px) everywhere — except pills (999px) and circles (50%). No 4px, 6px, 8px, 12px, 16px, 20px. Sign-in modal is not an exception anymore.
  2. No raw hex / rgb colors outside src/app/styles/tokens.css. Use semantic tokens (--fg-primary, --bg-elevated, --border-default, …) or, on landing, the theme-aware landing tokens (--color-navy, --color-off-white, --color-light-gray, --color-mid-gray, --color-surface). The two invariant primitives --color-primary and --color-white are reserved for systems that must not flip (skip-nav, brand SVG); don't reach for them on app surfaces.
  3. 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" use max-width: 799px. Don't introduce 768 / 760 / 640.
  4. No ad-hoc shadows. Use var(--shadow-sm) / var(--shadow-md) / var(--shadow-lg). Raw box-shadow: 0 8px 24px rgba(...) is a smell.
  5. No ad-hoc z-index. Use the tokens in tokens.css §"Z-index map" (--z-rail, --z-popover, --z-navbar, --z-modal, --z-skip-nav, --z-feedback).
  6. Reach for the canonical UI primitive before writing a new BEM class. The components in src/components/ui/ are: <Button> (4 variants × 4 sizes including size="icon"), <Input> (3 sizes × 3 variants), <Textarea>, <Badge> (10 variants), <Card> (row / elevated / inset), <Tabs>/<Tab>/<TabPanel>, <Skeleton>, <Popover>, <Avatar>, <AppDialog>, <ConfirmDialog>, <EmptyState>, <ErrorMessage>, <LoadingSpinner>, <EditBanner>. If a divergent style already exists for the job (e.g. a .foo__btn class), do not add another — migrate the existing one toward the primitive or, if too risky, leave a TODO that references this file.
  7. Headings use the text-display / text-h1 / text-h2 / text-h3 / text-h4 scale and font-headline (Noto Serif). Body uses text-body / text-body-sm / text-caption + Inter. Do not use Tailwind's text-xl / text-lg / text-2xl for app headings.
  8. 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. See app-dialog.tsx:118 for the bug class it prevents.
  9. 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 hardcoded var(--color-primary) on landing surfaces.

Before adding a new component / CSS rule

# 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.

1. Project Overview

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 domainscertified.app (this app, the BFF + UI) and certified.one (the ePDS / extended Personal Data Server that hosts user data). When a user signs up they get an atproto identity rooted at certified.one; they can also sign in with any external atproto handle.
  • AT Protocol context — atproto identities are DIDs (did:plc:... or did:web:...). Each DID resolves to a DID document that points to a PDS service endpoint, where records are stored under collections (NSIDs) like app.bsky.actor.profile or the Certified-specific app.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 custom certified_group proxy pattern with custom NSIDs (app.certified.group.*).

2. Tech Stack

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)
Email resend 6.x (feedback only; OTP emails are sent by the PDS)
Analytics @vercel/analytics
Icons lucide-react
Fonts Inter (sans), Noto Serif (headline), Instrument Serif (alt) — via next/font/google
Hosting Vercel
Lint ESLint flat config extending next/core-web-vitals and next/typescript
Test runner Vitest (jsdom), ~1235 tests in src/**/__tests__/. Playwright for E2E in e2e/. See §27.

Note: Next.js 16 renamed middleware.ts to proxy.ts. This app ships no edge proxy/middleware — the / redirect is client-side (HomeClient).

3. Quick Reference

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 only

When 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.

4. Environment Variables

Source: .env.local.example and src/lib/utils/config.ts.

Variable Required Purpose
NEXT_PUBLIC_PDS_URL yes PDS / handle resolver URL. Defaults to https://certified.one.
PUBLIC_URL recommended in production Canonical app origin. Wins when deriving OAuth client_id and redirect_uris; exact same-origin CSRF requests from it are trusted. Falls back to VERCEL_BRANCH_URL, then VERCEL_URL, then http://localhost:3000 outside production. For local atproto OAuth sign-in to actually complete, set this to http://127.0.0.1:3000 — see §22 Common Pitfalls #3.
VERCEL_BRANCH_URL Vercel-provided fallback Hostname-only stable branch URL. Becomes the canonical OAuth origin when PUBLIC_URL is absent and is accepted for same-origin CSRF requests. Do not add a scheme or NEXT_PUBLIC_ alias.
VERCEL_URL Vercel-provided fallback Hostname-only commit deployment URL. Final canonical OAuth fallback and accepted for same-origin CSRF requests. Do not add a scheme or NEXT_PUBLIC_ alias.
COOKIE_SECRET production HMAC secret for the certified_session cookie. Generate with openssl rand -hex 32. In dev a fallback string is used.
UPSTASH_REDIS_REST_URL yes Upstash REST URL.
UPSTASH_REDIS_REST_TOKEN yes Upstash REST token.
ATPROTO_PRIVATE_KEY optional EC P-256 private key. If set, the OAuth client switches to confidential (private_key_jwt with ES256) and exposes a JWKS at /.well-known/jwks.json.
RESEND_API_KEY optional Resend key for /api/feedback.
RESEND_FROM_EMAIL optional Override "from" header. Defaults to Certified <no-reply@certified.one>.
NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID optional Adds WalletConnect connector to the wagmi config when set.
NEXT_PUBLIC_GROUP_SERVICE_URL optional Group service base URL. Defaults to https://groups.certified.app.
NEXT_PUBLIC_GROUP_SERVICE_DID optional Group service DID (for getServiceAuth aud). Defaults to did:web:groups.certified.app.

The canonical OAuth URL is resolved in this order: PUBLIC_URLVERCEL_BRANCH_URLVERCEL_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.

5. Architecture & Data Flow

┌─────────────────┐     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:

  1. Browser never holds tokens. The OAuth tokens / DPoP keys live in Upstash Redis under oauth:session:<did> (30-day TTL). The browser only has the certified_session cookie, which is an HMAC-signed random session id mapping to a DID via session:did:<sid> in Redis (30-day TTL).
  2. All XRPC calls go through /api/xrpc/[...method]. Never call the PDS from the browser directly with credentials — there are none. Use authFetch() from src/lib/auth/fetch.ts. It detects 401 and triggers the global onUnauthorized handler registered by AuthProvider, which clears auth state and asks the user to sign in again.
  3. Group operations use a parallel set of routes under /api/groups/** because they require the AtpAgent's withProxy("certified_group", groupDid) pattern + custom NSID lexicons (app.certified.group.*). They do not share the /api/xrpc/[...method] handler.
  4. DID resolution is direct. resolvePdsUrl and resolveHandle (in src/lib/atproto/did.ts) hit plc.directory or the did:web host with a 5s timeout; results are not cached server-side.

6. Provider Tree & Layout System

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 in src/app/settings/wallet/layout.tsx. Do not lift these to the root; wagmi is heavy and only the wallet linking flow needs it.
  • AuthGuard — in settings/layout.tsx, connected-apps/layout.tsx, and groups/layout.tsx. Redirects to /welcome when 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.

7. Routing Map

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. typeactivity | project (friendly segment ↔ collection NSID, mapped in src/lib/urls.ts). Resolves actor→DID, fetches the record, canonicalizes a DID-addressed URL to the handle form.
/[actor]/[type]/[rkey]/edit client gated (owner) Record editor. Dispatches to the activity/project editor by type; same actor→DID resolution + canonicalization as the read route.
/settings client gated (AuthGuard) If activeOrg, renders OrgSettings; otherwise account settings (handle, email, password, app-passwords placeholder, 2FA placeholder).
/settings/edit-profile client gated Edit personal profile.
/settings/my-data client gated Data export / view.
/settings/wallet client gated + Wagmi EIP-712 wallet linking. The only route that loads wagmi/viem.
/connected-apps client gated Lists CONNECTED_APPS from src/lib/constants/apps.ts.
/groups client gated List groups, accept/leave/remove public membership.
/groups/create client gated Register a new group. Enforces MAX_SELF_CREATED_ORGS = 5.
/groups/[groupDid] client gated Group profile view.
/groups/[groupDid]/edit-profile client gated Edit group profile + metadata.
/groups/[groupDid]/apps client gated Apps view scoped to a group.
/groups/[groupDid]/settings client gated Member management + audit log.
/oauth/callback client Receives the OAuth redirect. POSTs query string to /api/auth/callback-handler, then either postMessages the parent window (iframe flow) or window.location.replace("/").
/.well-known/oauth-client-metadata server public Generated from getOAuthClient().clientMetadata + extras (brand_color, tos_uri, etc.). Cached public, max-age=600.
/.well-known/jwks.json server public Generated from getOAuthClient().jwks. Cached public, max-age=600.
/sitemap.xml, /robots.txt, /manifest.webmanifest server public See src/app/sitemap.ts, robots.ts, manifest.ts.

Permanent redirects (in next.config.ts):

  • /settings/security/settings
  • /settings/account/settings
  • /search/explore; /connected-apps/apps
  • URL migration (handle-forward scheme): /profile/:handle/:handle; /activity/:did/:rkey/:did/activity/:rkey; /project/:did/:rkey/:did/project/:rkey. The :did segment 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 */edit paths 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).

8. Authentication Flow

Components

  • OAuth clientsrc/lib/auth/oauth-client.ts builds a NodeOAuthClient (singleton). It registers Redis-backed state and session stores, leaves handleResolver at the SDK default (AtprotoHandleResolverNode, which does DNS-TXT + HTTPS .well-known/atproto-did resolution and works for any atproto handle, not just Certified-rooted ones), and conditionally enables private_key_jwt when ATPROTO_PRIVATE_KEY is set. The canonical origin resolves as PUBLIC_URLVERCEL_BRANCH_URLVERCEL_URL. In loopback dev mode (NODE_ENV !== "production" and that resolved URL is http://) it skips the normal metadata client_id and uses buildAtprotoLoopbackClientMetadata instead, because the spec only allows https:// or the literal http://localhost (no port) as a client_id.
  • Storessrc/lib/auth/stores.ts wraps 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 by oauth:state:<key> / oauth:session:<key>. Dev fallback: when Upstash creds are missing AND NODE_ENV !== "production", the module switches to a process-local InMemoryRedis so a fresh clone can sign in locally without provisioning an Upstash database. State doesn't survive a server restart and isn't shared across workers — acceptable for dev only. A console warning fires on first use.
  • App sessionsrc/lib/auth/session.ts issues the certified_session cookie:
    • Cookie value = <32-byte hex sessionId>.<HMAC-SHA256 signature>.
    • Cookie attributes: httpOnly, secure in 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.timingSafeEqual to avoid timing attacks.
  • CSRFsrc/lib/auth/csrf.ts rejects requests missing both Origin and Referer, 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.
  • authFetchsrc/lib/auth/fetch.ts wraps fetch and calls a registered onUnauthorized() listener on 401. AuthProvider registers this listener to clear isAuthenticated/did/pdsUrl and surface "Your session has expired."

Sign-in (email or handle)

  1. UI submits to POST /api/auth/login with { input, mode: "email" | "handle", prompt? }.
  2. Server CSRF-checks, sanitizes input (sanitizeEmail / sanitizeHandle), then calls client.authorize(...). For mode: "email" it points at PDS_URL and adds login_hint. For mode: "handle" it calls client.authorize(input, …) and falls back to https:// + input if the bare input fails.
  3. Returns { url }. The client safeRedirect()s — only https: URLs are allowed (and http: in dev). This intentionally allows cross-origin since OAuth bounces to external authorization servers.
  4. The PDS UI may post a switch-provider message back to the modal (used when an existing user enters a handle on the wrong PDS). AuthProvider listens for it and re-runs the handle login flow.

Callback

  1. The PDS redirects to /oauth/callback?code=…&state=…. The page is rendered in the modal iframe; if not in an iframe it falls through to window.location.replace("/").
  2. The page client-fetches GET /api/auth/callback-handler?<query>, which:
    • calls client.callback(params) to complete the OAuth exchange,
    • invalidates the existing certified_session before 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.profile and app.bsky.actor.profile with empty self records so other apps see the user immediately,
    • returns { did }.
  3. If the page is in an iframe, it postMessages { type: "oauth-callback-complete", sub: did } to the parent. AuthProvider listens, validates event.origin, and calls refreshSession().

Session lookup & sign-out

  • GET /api/auth/session — reads cookie, looks up DID in Redis, calls client.restore(did) to confirm the upstream session is still valid; if restore throws, deletes both the local session and returns { did: null }. The browser uses this on app load.
  • POST /api/auth/logout — CSRF-checked. Calls oauthSession.signOut() upstream (best-effort), deletes the local session, returns { success: true }. The client also calls clearSessionCache() for useSession and clears Auth state immediately (optimistic logout).

safeRedirect in auth-context.tsx

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.

9. API Routes Catalog

Auth

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.

XRPC proxy

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.

Groups

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

Discovery

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.

Other

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).

10. XRPC Proxy

src/app/api/xrpc/[...method]/route.ts is the central proxy from the client to the user's PDS.

Allowed GET methods

  • com.atproto.repo.getRecord
  • com.atproto.repo.listRecords (limit clamped to [LIMIT_MIN=1, LIMIT_MAX=100])
  • com.atproto.server.getSession
  • com.atproto.sync.getBlob — returns binary; sets Content-Type from upstream

Anything else returns 400 Unknown method.

Allowed POST methods

  • com.atproto.repo.createRecord
  • com.atproto.repo.putRecord
  • com.atproto.repo.deleteRecord
  • com.atproto.repo.uploadBlob
  • com.atproto.identity.updateHandle
  • com.atproto.server.requestPasswordReset
  • com.atproto.server.resetPassword
  • com.atproto.server.requestEmailUpdate
  • com.atproto.server.updateEmail

For createRecord / putRecord / deleteRecord:

  • body.repo must equal the session DID — cross-repo writes are 403.
  • body.collection must be one of:
    • org.impactindexer.link.attestation
    • app.certified.actor.profile
    • app.certified.actor.membership
    • app.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.

Blob uploads

  • 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 actual arrayBuffer().byteLength are 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.ts enforces 4 MB on avatar/banner uploads; 4 MB matches the proxy cap.

Error sanitization

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.

11. CSS Conventions

Where styles live

  • src/app/styles/ — a tree of feature-scoped CSS files, all imported by src/app/globals.css. The split is intentional; do not collapse it back to one file.
    • tokens.css — design tokens (CSS custom properties + dark theme overrides). The only place raw hex / rgba values live.
    • components.css — primitive component chrome (modals, app-card label, dash-card, error/empty states).
    • layout.css — app shell, navbar, top-bar, bottom-nav, drawers, rails.
    • Per-feature: feed.css, profile-*.css, cert-detail.css, project-detail.css, explore.css, home.css, settings-page.css, workspace.css, notifications.css, landing.css, leaflet.css, etc.
  • Tailwind utilities are used freely inside JSX for one-off layout (flex, mt-4, max-w-3xl, …).
  • Tailwind theme is small: colors.{success, warning, error} + fontFamily.{sans, headline, mono} + the fontSize heading scale + boxShadow.{sm, md, lg} aliasing the CSS vars + borderRadius.DEFAULT: 2px. See tailwind.config.ts.

CSS variables — what to use

Token catalog is the source of truth at src/app/styles/tokens.css. The most-used ones:

  • Surfaces: --bg-canvas (page) · --bg-sunken (recessed) · --bg-raised (slightly elevated) · --bg-elevated (cards, modals).
  • Foreground: --fg-primary (headings, primary text) · --fg-secondary (body) · --fg-muted (placeholders, captions).
  • Borders: --border-subtle (whisper, list dividers) · --border-default (input/card borders) · --border-hover (hover state) · plus --border-light / --border-medium / --border-hover-soft for in-between cases.
  • Buttons: --btn-primary-bg / --btn-primary-fg (these invert in dark).
  • Badges: --badge-success-*, --badge-warning-*, --badge-neutral-*, --badge-count-* paired bg/fg/border.
  • Status: --color-error, --color-success, --color-warning + their -text / -bg / -border companions for full-surface styling.
  • Landing palette (theme-aware): --color-navy, --color-off-white, --color-gray-100, --color-light-gray, --color-mid-gray, --color-dark-gray, --color-surface, --color-surface-container-low. These flip in [data-theme="dark"]. Use on landing surfaces only.
  • Invariant primitives (don't use on app surfaces): --color-primary (#111 always), --color-white (#fff always). Reserved for skip-nav, brand SVG, anything that must not flip.
  • Motion: --transition-fast (150 ms ease-out) · --transition-base (250 ms ease-out) · --transition-slow (400 ms spring).
  • Geometry: --radius (2 px) · --navbar-height (64 px) · --top-bar-row1 / --top-bar-row2 / --top-bar-total for desktop top-bar heights · --bp-gt-mobile (800) / --bp-gt-narrow-desktop (1100) / --bp-gt-desktop (1300) — informational; CSS @media queries hardcode the numbers because vanilla CSS can't var() inside @media.
  • Z-index: --z-rail-sticky (10) · --z-rail (30) · --z-popover (40) · --z-navbar / --z-bottom-nav (50) · --z-portal-sheet (60) · --z-modal (100) · --z-skip-nav (9999) · --z-feedback (10000) · --z-feedback-above (10001).

Rules

  1. All border-radius is var(--radius) (2 px). Pills use 999px, circles use 50%. No 4/6/8/12/16/20 px. To prevent regression: grep -rEn "border-radius:\s+(4|6|8|12|16|20)px" src/app/styles/ should always return zero hits.
  2. No raw hex / rgb / rgba colors outside tokens.css and landing.css (landing has the invariant brand palette + theme-flippable landing tokens — that's where they belong). Use tokens.
  3. 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.).
  4. No ad-hoc shadows. Always var(--shadow-sm|md|lg).
  5. No ad-hoc z-index. Always a --z-* token (add to tokens.css if a genuinely new layer is needed).
  6. No 100vw. It triggers horizontal overflow when a scrollbar is present. Use 100%.
  7. Skip-nav styles are at the top of tokens.css. Don't duplicate.
  8. 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.

Modals

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 in dark mode

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.

12. Component Conventions

Canonical UI primitives (src/components/ui/)

Use these. Don't reimplement.

Primitive API surface
<Button> variant: "primary" | "secondary" | "ghost" | "destructive" · size: "sm" | "md" | "lg" | "icon". size="icon" is a 40×40 square and requires aria-label (enforced via TypeScript discriminated union — you'll get a type error if you forget). loading prop renders a spinner.
<Input> size: "sm" | "md" | "lg" (36 / 44 / 56 px) · variant: "default" | "underline" | "inline-edit". Use lg for hero inputs (sign-in modal). Use underline for inline typeahead. Use inline-edit for "currently editing" affordance (1.5 px hover-color border). Auto-wires aria-describedby / aria-invalid / id / useId() label associations.
<Textarea> Mirror of <Input> for multi-line.
<Badge> 10 variants: verified / pending / unverified (status, with icon) · tag / role / count (neutral chips) · high-quality / standard / draft / test (activity quality pills). compact prop forces the tighter 11 px chip shape on any variant.
<Card> variant: "row" | "elevated" | "inset" · hoverable · unpadded · as: "div" | "article" | "li" | "section". row for list-divider entries (transparent bg, bottom border only). elevated for object cards. inset for recessed sub-cards inside an elevated parent.
<Tabs> + <TabList> + <Tab> + <TabPanel> Controlled (value / onChange). Proper ARIA tablist + arrow-key navigation. <TabPanel keepMounted> if child state should survive switches.
<Skeleton> variant: "line" | "box" | "circle" | "text". width / height accept any CSS dimension. text accepts lines={n}. Honors prefers-reduced-motion.
<Popover> + <PopoverTrigger> + <PopoverContent> + <PopoverItem> Floating menu. Handles click-outside, Esc, focus restore, ARIA. align: "start" | "center" | "end". Controlled (open / onOpenChange) or uncontrolled.
<Avatar> size: "sm" | "md" | "lg" | "xl". Pass fallbackInitials={getInitials(name)} from src/lib/utils/initials.ts.
<AppDialog> + <AppDialogHeader> The modal primitive. See §11 Modals.
<ConfirmDialog>, <DeleteRecordDialog> Specializations of AppDialog for destructive / type-to-confirm flows.
<EmptyState> Centered icon + title + description + actions.
<ErrorMessage> Title + message + retry button in an error-tinted card.
<LoadingSpinner> Pulsing Brandmark — used for full-page / section loading. For inline loading inside a Button, use the Button's loading prop instead.
<EditBanner> Sticky edit-mode chrome (Cancel + Save buttons) used by profile / project / cert edit pages.
<FeedbackModal> Dual-mode (desktop dialog ↔ mobile bottom-sheet).
<SignInModal> Auth flow. Don't reach for it directly; use useAuth().openSignIn().
<Brandmark>, <CertIcon>, <FeedLabelPill>, <SmartLink> Brand / domain-specific primitives.

General rules

  • 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 from Button if a new variant has different required props (e.g., size="icon" requiring aria-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> (not next/image) for SVG assets. next/image is reserved for raster assets where the optimizer adds value (see partner-apps.tsx for an example using Image).
  • Icons: lucide-react. The only @tabler/icons-react import in the codebase is the cert icon, wrapped by src/components/ui/cert-icon.tsx to expose a lucide-compatible strokeWidth prop. Don't import tabler directly elsewhere.
  • Icon sizing follows context: 14 px inline with text · 18–22 px nav chrome · 20 px Plus / Settings · 24 px bottom nav · 40 px empty-state. Stroke 1.5 (default) / 1.75 (active, cert-icon) / 2 (emphasis). The strokeWidth={1.25} + size={11} pattern appears in form dialogs only — don't use it as a general default.
  • Dropdown triggers: if it's a menu, use <Popover>. Roll-your-own should be a last resort; if you do, the trigger needs aria-haspopup + aria-expanded (navbar.tsx has 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 carries role="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 a role="dialog" rather than a menu) uses useClickOutsideClose on a wrapper ref (e.g. the home-feed QualityFilter/EvaluatorFilter and 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 an href. Only allow http:, https:, mailto:, tel:; reject anything else (including javascript:, data:, vbscript:, malformed).

13. Hooks Catalog

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()).

14. State Management

  • Auth state: AuthProvider in src/lib/auth/auth-context.tsx. Holds isLoading, isAuthenticated, did, pdsUrl, error, isModalOpen, isRedirectingToProvider, plus actions openSignIn, closeModal, submitEmail, submitHandle, signOut. Also owns the SignInModal and ProviderRedirectOverlay components.
  • Active group: OrgProvider in src/lib/groups/org-context.tsx. Persists the active org to localStorage under key certified_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) and transparent (used on /welcome).
  • Wagmi: scoped to /settings/wallet/layout.tsx. Mounts WagmiProvider with config from src/lib/wagmi.ts (chains: mainnet, base, optimism, arbitrum; connectors: injected, coinbaseWallet smart-wallet-only, walletConnect when NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID is set; ssr: true).
  • No Redux / Zustand / Jotai. Local component state + the three contexts above is the entire state model.

15. Groups Feature

Mental 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).

Routes & files

  • UI: src/app/groups/** and src/components/groups/**.
  • API: src/app/api/groups/** (see §9).
  • Library: src/lib/groups/:
    • constants.tsGROUP_SERVICE, GROUP_SERVICE_DID, NSIDs, MAX_SELF_CREATED_ORGS = 5.
    • types.tsGroup, 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. Defines GROUP_LEXICONS (10 custom NSIDs under app.certified.group.*), exports getAuthenticatedAgent(), createGroupAgent(agent, groupDid) (uses agent.withProxy("certified_group", groupDid) and registers the lexicons), and getServiceAuthToken(agent, lxm) for the rare direct-call case (registration only).
    • org-context.tsx — provider/context.
    • use-org-limit.ts — group-creation limit hook.

Custom NSIDs (lexicons)

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.

Group creation limit

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.

15a. Social Graph + Endorsements

Lexicons in play

  • app.certified.graph.follow{subject: did, createdAt, via?}. Viewer's PDS holds their follows; "followers of X" is reconstructed via the indexer (appCertifiedGraphFollow with subject.eq filter).
  • app.certified.badge.{definition, award, response} — endorsements + lists. A list is a badge.definition with badgeType: "endorsement" and title !== "Endorsement". The reserved "Endorsement" title backs the regular endorse flow.
  • Allowlist any new collection in ALLOWED_WRITE_COLLECTIONS in src/app/api/xrpc/[...method]/route.ts — silent 403 otherwise.

Write helpers

  • createFollow(ownDid, subjectDid, { targetDid? }) — XRPC for personal, BFF (/api/groups/[did]/follow) when targetDid set. Mirror this targetDid opt-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 = 500 enforced in writeBadgeAward and again in every UI surface that captures a note. The UI also clamps via maxLength + slice (belt-and-suspenders).

Hooks (own + foreign profiles)

  • useFollowing(did) — PDS listRecords; exposes addFollow / removeFollow for optimistic updates.
  • useFollowers(did) — indexer appCertifiedGraphFollow(where: { subject }); dedupes by follower DID; exposes addFollower / removeFollower.
  • useGivenEndorsements(did) / useReceivedEndorsements(did, { includeRejected? }) — both attach listTitle per award (undefined for default endorsements). includeRejected defaults 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. Exposes createList / updateList / deleteList, all optimistic. listAwards here is paginated only by the PDS' default page (no full walk yet).
  • useSocialGraphSync(did, { ownDid, targetDid }) — composes useFollowing + useBlueskyFollows; returns inBoth / onlyCertified / onlyBluesky sets plus an importDids(dids) batch writer.

Card / modal patterns

  • <PersonCard> in profile-endorsements.tsx (and a parallel one in profile-followers.tsx) is the shared row used by Received/Given/Followers/Following. Layout: name → @handle → date → optional listTitle pill → optional note. Top-right menu slot is reserved for the × revoke / kebab / etc. Don't restore the right-aligned date.
  • <EndorsePeopleModal> is callback-driven via onEndorse(did, note?). It serves three flows: regular endorse (with requireReason), list + Add people (skip reason — list is the reason), future awards (just supply a different onEndorse).
  • <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.

Indexer queries

All four social-graph / endorsement hooks already target the post-#87 / #88 / #89 magic-indexer schema:

  • appCertifiedBadgeAward.badge.{badgeType, …} nested-where is live; useReceivedEndorsements still 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.uri array-element where is live; useCertProjects could swap from PDS-scan-(same-DID-only) to a single cross-DID indexer query.
  • AppCertifiedBadgeDefinition.awardCount is live; useEndorsementLists could drop its listAwards round-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.

Optimistic state — the pattern

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.

16. Identity-Link / Wallet Attestation

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.

EIP-712 schema

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).

Storage

  • 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 (Attestation type in src/lib/identity-link/types.ts): { $type, address, chainId, signature, message, signatureType: "eoa" | "erc1271" | "erc6492", createdAt }.

Verification

useIdentityLinks(did):

  • Lists all attestations from the PDS.
  • For signatureType === "eoa", verifies the signature client-side with viem.verifyTypedData. The recovered address must match address in the record.
  • For erc1271 / erc6492, returns verified: false with verificationError: "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's isValidSignature(bytes32 hash, bytes signature) per ERC-1271, plus ERC-6492 unwrapping for not-yet-deployed accounts.

Wallet flow

useAttestationSigning(did):

  1. Checks isAuthenticated and wallet isConnected.
  2. Builds the message.
  3. signTypedDataAsync(domain, types, message).
  4. 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).

17. Security Rules

These rules are mandatory. Treat any deviation as a regression.

Server-side

  1. 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, or VERCEL_URL) and equal the request destination. Missing, null, malformed, wildcard, and cross-deployment origins return 403.
  2. Cookie verification uses timingSafeEqual (src/lib/auth/session.ts). Don't replace it with ===.
  3. HMAC every session id. The cookie value is <sessionId>.<HMAC>. Truncating to "just sessionId" would let attackers forge any session.
  4. 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.
  5. Wrap Redis ops in try/catch. Both session.ts and stores.ts do this; new BFF code must too. A Redis blip should not 500 the request unless absolutely necessary.
  6. Sanitize input twice — client AND server (defense in depth). Use stripInvisible, sanitizeEmail, sanitizeHandle from src/lib/utils/sanitize.ts. The regex is /[​-‏
- ⁠-­͏؜᠎]/g.
  7. Sanitize 5xx errors. Never echo err.message from upstream PDS errors when status ≥ 500 — return "Internal server error" (or a route-specific generic). The XRPC proxy and /api/groups/register both do this; copy the pattern. (4xx errors can echo upstream messages — those are usually validation errors a user can act on.)
  8. Repo ownership on writes — for createRecord/putRecord/deleteRecord, body.repo must equal the session DID. Cross-repo writes are 403.
  9. Collection allowlist — only the eleven ALLOWED_WRITE_COLLECTIONS can be written through the XRPC proxy. Add to that array consciously, not implicitly.
  10. Blob limits — 4 MB cap (image MIME types only) on /api/xrpc/[...method] for uploadBlob; 5 MB on the group blob route. Both check Content-Length and the actual buffer size. Vercel has a hard ~4.5 MB body cap that constrains the XRPC proxy.
  11. Service-auth tokens are short-lived and per-LXMgetServiceAuthToken(agent, lxm) issues a token bound to a single method. Don't cache or reuse.

Client-side

  1. safeRedirect() in auth-context.tsx — only https: (and http: in dev). Never call window.location.href = serverProvidedUrl directly.
  2. Validate event.origin on postMessage. Both message listeners in auth-context.tsx do this. Copy the pattern.
  3. authFetch() for every authenticated XRPC call, not raw fetch. The 401 interceptor is what surfaces session expiry.

HTTP headers

next.config.ts sets these on every response:

  • X-Content-Type-Options: nosniff
  • Referrer-Policy: strict-origin-when-cross-origin
  • X-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.

18. SEO / GEO

  • JSON-LD:
    • Organization (Hypercerts Foundation) and WebSite in src/app/layout.tsx. Includes legal address, contact point, sameAs for social profiles.
    • SoftwareApplication and FAQPage in src/app/welcome/page.tsx. The FAQPage entries are derived from FAQ_ITEMS in src/components/landing/sections/faq-content.tsx.
  • Title template: default: "Certified", template: "%s — Certified". Pages export their own title via metadata.
  • OG / Twitter: root defaults in layout.tsx (/assets/certified-hero-1200x630.png, @hypercerts). Pages override per-route. metadataBase is https://certified.app.
  • Canonical URLs: every public page exports alternates: { canonical: "https://certified.app/<path>" }. Authenticated pages set robots: { 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 at https://certified.app/sitemap.xml.
  • sitemap.ts — public URLs with lastModified dates. /welcome is the priority-1 canonical landing (bare / is not listed); /apps is 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: changing client_id or redirect_uris invalidates 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.

19. Git & Deployment

  • Branches:
    • main → production (certified.app)
    • staging → preview (staging.certified.app)
  • Workflow: push to staging, open a PR to main. Vercel deploys both branches automatically.
  • Quality gate: npm run lint && npx tsc --noEmit && npm test && npm run build must 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_URLVERCEL_BRANCH_URLVERCEL_URL. Prefer an explicit PUBLIC_URL for 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 by client_id.
  • Don't commit secrets (.env.local is gitignored). COOKIE_SECRET, UPSTASH_*, ATPROTO_PRIVATE_KEY, RESEND_API_KEY live in Vercel envs.

20. File Map

certified-app/
├── AGENTS.md                           # Short pointer to README + this draft
├── README.md                           # Public README
├── next.config.ts                      # Headers, redirects, image remotePatterns, serverExternalPackages
├── tsconfig.json                       # strict, paths { "@/*": ["./src/*"] }, ES2017, react-jsx
├── tailwind.config.ts                  # Theme: navy/accent/sky/deep colors, h1-h4, elevation shadows
├── eslint.config.mjs                   # next/core-web-vitals + next/typescript
├── postcss.config.mjs                  # tailwindcss only — autoprefixer intentionally omitted (no browserslist); vendor prefixes are hand-maintained
├── .env.local.example                  # Env var template
├── e2e/                                # Playwright specs — see §27
│   ├── smoke.spec.ts                   # /dev/preview surfaces, no credentials
│   ├── public-routes.spec.ts           # signed-out route walk
│   └── auth/                           # authenticated flows, skipped without E2E_TEST_DID
├── playwright.config.ts                # baseURL pinned to PUBLIC_URL's exact origin + port
├── public/
│   ├── llms.txt                        # AI crawler description
│   ├── assets/
│   │   ├── partners/                   # Partner app logos (maearth, gainforest, simocracy, hyperboards)
│   │   ├── certified_brandmark_black.{svg,png}
│   │   ├── certified_brandmark_black_{192,512}.png
│   │   ├── certified_wordmark_black.{svg,png}
│   │   ├── certified_signin_black.{svg,png}
│   │   ├── certified_signinwith_black.{svg,png}
│   │   ├── certified_poweredby_*.{svg,png}
│   │   ├── certified-hero-1200x630.png # OG image
│   │   └── guilloche_02.svg            # Decorative background, SVGO-optimized
│   ├── brand/                          # Partner-facing brand assets (brandmark, poweredby, signin, wordmark variants)
│   └── email/
│       └── otp-email-template.html     # Branded OTP email (referenced from oauth-client-metadata)
└── src/
    ├── app/
    │   ├── layout.tsx                  # Root layout: providers, JSON-LD, fonts, skip-nav, navbar/main/footer/feedback
    │   ├── globals.css                 # ALL custom CSS (~4.7k lines, BEM-like)
    │   ├── icon.png                    # Favicon
    │   ├── apple-icon.png              # Apple touch icon
    │   ├── manifest.ts                 # PWA manifest
    │   ├── robots.ts                   # robots.txt
    │   ├── sitemap.ts                  # sitemap.xml
    │   ├── error.tsx                   # Global error boundary
    │   ├── not-found.tsx               # 404 page
    │   ├── page.tsx                    # `/` — renders <HomeClient /> (redirector)
    │   ├── welcome/
    │   │   ├── layout.tsx              # Sets navbar variant to "transparent"
    │   │   └── page.tsx                # Landing + JSON-LD (SoftwareApplication, FAQPage)
    │   ├── about/page.tsx
    │   ├── terms/page.tsx
    │   ├── privacy/page.tsx
    │   ├── dsa/page.tsx
    │   ├── profile/[did]/page.tsx      # Canonical profile URL — renders <ProfileClient />
    │   ├── settings/
    │   │   ├── layout.tsx              # AuthGuard
    │   │   ├── page.tsx                # If activeOrg → OrgSettings; else handle/email/password/2FA placeholder
    │   │   ├── edit-profile/page.tsx   # Edit personal profile
    │   │   ├── my-data/page.tsx        # Data export view
    │   │   └── wallet/
    │   │       ├── layout.tsx          # WagmiProvider + QueryClientProvider (scoped here ONLY)
    │   │       └── page.tsx            # Wallet linking UI
    │   ├── connected-apps/
    │   │   ├── layout.tsx              # AuthGuard
    │   │   └── page.tsx
    │   ├── groups/
    │   │   ├── layout.tsx              # AuthGuard
    │   │   ├── page.tsx                # List groups (accept/leave/remove public)
    │   │   ├── create/page.tsx         # Register new group
    │   │   └── [groupDid]/
    │   │       ├── page.tsx            # Group profile view
    │   │       ├── edit-profile/page.tsx
    │   │       ├── apps/page.tsx
    │   │       └── settings/page.tsx   # Members + audit log
    │   ├── oauth/callback/page.tsx     # OAuth redirect target — postMessages parent or window.replace("/")
    │   ├── api/
    │   │   ├── auth/
    │   │   │   ├── login/route.ts             # POST {input,mode,prompt} → {url}
    │   │   │   ├── callback-handler/route.ts  # GET — completes OAuth, creates session, seeds profile records
    │   │   │   ├── session/route.ts           # GET — returns {did} or {did:null}
    │   │   │   └── logout/route.ts            # POST — signOut + delete session
    │   │   ├── xrpc/[...method]/route.ts      # The XRPC proxy
    │   │   ├── feedback/route.ts              # POST → Resend
    │   │   ├── resolve-handle/route.ts        # GET ?handle=
    │   │   ├── resolve-did/route.ts           # GET ?did=
    │   │   ├── search-actors/route.ts         # GET ?q=
    │   │   └── groups/
    │   │       ├── register/route.ts          # POST — register new group (enforces MAX_SELF_CREATED_ORGS)
    │   │       ├── memberships/route.ts       # GET — list user's group memberships
    │   │       └── [groupDid]/
    │   │           ├── audit/route.ts         # GET — audit log
    │   │           ├── bsky-profile/route.ts  # POST — create empty bsky profile
    │   │           ├── handle/route.ts        # PUT — update handle
    │   │           ├── members/route.ts       # GET/POST/DELETE
    │   │           ├── metadata/route.ts      # GET/PUT — app.certified.actor.organization
    │   │           ├── profile/route.ts       # GET/PUT — app.certified.actor.profile
    │   │           ├── role/route.ts          # PUT — set member role
    │   │           └── upload-blob/route.ts   # POST — 5MB image upload
    │   └── .well-known/
    │       ├── oauth-client-metadata/route.ts # OAuth client metadata
    │       └── jwks.json/route.ts             # JWKS (when ATPROTO_PRIVATE_KEY set)
    │
    ├── components/
    │   ├── landing/
    │   │   ├── landing-page.tsx           # Server-rendered landing assembly
    │   │   ├── home-client.tsx            # `/` redirector (auth → /profile/{did}, unauth → /welcome)
    │   │   ├── hero-signin-button.tsx     # Client island for hero CTA
    │   │   ├── orbiting-logos.tsx         # Animated logo orbit (IntersectionObserver-gated)
    │   │   └── sections/
    │   │       ├── built-for-trust.tsx
    │   │       ├── faq-accordion.tsx
    │   │       ├── faq-content.tsx        # FAQ_ITEMS array — also used by FAQPage JSON-LD
    │   │       ├── how-it-works.tsx
    │   │       ├── partner-apps.tsx
    │   │       ├── ready-cta-button.tsx
    │   │       ├── ready-cta-content.tsx
    │   │       └── what-you-get.tsx
    │   ├── layout/
    │   │   ├── app-shell.tsx              # .app-shell wrapper, skipped on /welcome
    │   │   ├── auth-guard.tsx             # Auth redirect with loading spinner
    │   │   ├── footer.tsx                 # Global footer (single instance, in root layout)
    │   │   └── navbar.tsx                 # Top nav with account switcher, mobile bottom sheet
    │   ├── dashboard/
    │   │   ├── custom-domain-modal.tsx
    │   │   └── username-card.tsx
    │   ├── groups/
    │   │   ├── add-org-modal.tsx
    │   │   ├── handle-search.tsx
    │   │   ├── membership-sync-modal.tsx
    │   │   └── org-settings.tsx
    │   ├── profile/
    │   │   ├── avatar-upload.tsx
    │   │   ├── banner-upload.tsx
    │   │   ├── profile-client.tsx
    │   │   └── profile-edit-form.tsx
    │   ├── identity-link/
    │   │   ├── identity-link-card.tsx
    │   │   └── link-wallet-flow.tsx
    │   ├── account/
    │   │   ├── email-section.tsx
    │   │   └── password-section.tsx
    │   └── ui/
    │       ├── avatar.tsx
    │       ├── badge.tsx
    │       ├── button.tsx
    │       ├── card.tsx
    │       ├── error-message.tsx
    │       ├── feedback-modal.tsx         # Floating feedback button + modal (avoids footer overlap via scroll listener on .landing-footer)
    │       ├── input.tsx                  # Canonical aria-describedby/aria-invalid pattern
    │       ├── loading-spinner.tsx
    │       ├── provider-redirect-overlay.tsx
    │       ├── sign-in-modal.tsx
    │       └── textarea.tsx
    │
    ├── hooks/
    │   ├── use-attestation-signing.ts     # Wagmi-only — must be inside /settings/wallet
    │   ├── use-focus-trap.ts              # Generic Tab/Shift+Tab trap + focus restore
    │   ├── use-identity-links.ts          # Lists + verifies wallet attestations
    │   ├── use-org-profile.ts             # Active org's profile + metadata
    │   ├── use-profile.ts                 # User's profile with bsky fallback (AbortController)
    │   └── use-session.ts                 # Cached handle+email (module-level promise cache)
    │
    └── lib/
        ├── auth/
        │   ├── auth-context.tsx           # AuthProvider, useAuth, sign-in modal, postMessage listeners
        │   ├── csrf.ts                    # checkCsrf — configured exact origin + same destination
        │   ├── fetch.ts                   # authFetch — 401 interceptor
        │   ├── oauth-client.ts            # NodeOAuthClient singleton, PDS_URL constant
        │   ├── session.ts                 # createSession/getSessionDid/deleteSession (HMAC + Redis)
        │   ├── stores.ts                  # RedisStateStore (10min) + RedisSessionStore (30day) + getRedis()
        │   └── types.ts                   # AuthState interface
        ├── atproto/
        │   ├── did.ts                     # resolveHandle + resolvePdsUrl (5s timeout, plc.directory + did:web)
        │   ├── profile.ts                 # getProfile/putProfile, uploadAvatar/uploadBanner, getAvatarUrl/getBannerUrl
        │   └── types.ts                   # CertifiedProfile, BlueskyProfile, hypercerts defs
        ├── groups/
        │   ├── api.ts                     # All client-side group API calls
        │   ├── constants.ts               # GROUP_SERVICE, GROUP_SERVICE_DID, MAX_SELF_CREATED_ORGS
        │   ├── index.ts                   # Re-exports
        │   ├── org-context.tsx            # OrgProvider, useOrg, localStorage persistence
        │   ├── proxy-agent.ts             # GROUP_LEXICONS, getAuthenticatedAgent, createGroupAgent
        │   ├── types.ts                   # Group, OrgRole, OrgMember, etc.
        │   └── use-org-limit.ts           # useOrgCreationLimit hook
        ├── identity-link/
        │   ├── attestation.ts             # ATTESTATION_DOMAIN/TYPES, buildAttestationMessage, buildRecordKey
        │   ├── pds.ts                     # listAttestations/storeAttestation/deleteAttestation (client-side)
        │   └── types.ts                   # Attestation, AttestationRecord, asHex helper
        ├── constants/
        │   └── apps.ts                    # CONNECTED_APPS — single source of truth for partner list
        ├── types/
        │   └── api.ts                     # Shared response types (SessionResponse, ListRecordsResponse, PutRecordResponse)
        ├── utils/
        │   ├── api.ts                     # extractError(res, fallback)
        │   ├── config.ts                  # OAuth URL precedence + allowed request origins
        │   ├── constants.ts               # LIMIT_MIN/MAX/DEFAULT, debounce timings
        │   ├── initials.ts                # getInitials()
        │   └── sanitize.ts                # stripInvisible/sanitizeEmail/sanitizeHandle
        ├── navbar-context.tsx             # NavbarProvider — "default" | "transparent" variant
        ├── providers.tsx                  # Currently a passthrough — placeholder for cross-cutting providers
        └── wagmi.ts                       # wagmi config (mainnet/base/optimism/arbitrum) + SUPPORTED_CHAINS

21. Known Limitations

  • 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 with vi.mock or a stubbed fetch, so a mismatch between what the client sends and what a route accepts is invisible to them — that is how the org.hypercerts.context.attachment allowlist gap shipped. Contract tests (§27) exist to close that specific class.
  • 2FA / TOTP — not implemented on the ePDS. The /settings page shows a "This will be available soon" placeholder.
  • App passwords — same status: placeholder card on /settings.
  • ERC-1271 / ERC-6492 verificationuseIdentityLinks returns verified: false for smart-contract-wallet signatures with verificationError: "On-chain verification not yet supported". The signing path can produce these (the signatureType field exists), but the verifier doesn't make on-chain isValidSignature calls yet.
  • TypeScript as casts — widespread on API request/response bodies (e.g. the XRPC proxy and most route handlers cast through as). Improving this requires pulling in the official atproto SDK input/output types per method; tracked separately.
  • Group service is staging-only — default GROUP_SERVICE points at a Railway staging deployment. Don't rely on group data for production-critical flows.
  • No avatar / banner CDN — image URLs are direct PDS getBlob calls. Heavy traffic would put load on the PDS.
  • No rate limiting in the BFF beyond what Vercel and Upstash provide.
  • No structured loggingconsole.error("[Auth] …", err) is the convention. Logs end up in Vercel's serverless logs.

22. Common Pitfalls

  1. useAttestationSigning outside /settings/wallet — it depends on WagmiProvider which is mounted only in src/app/settings/wallet/layout.tsx. Calling it elsewhere will throw "useConfig must be used within WagmiConfig".
  2. Using fetch instead of authFetch — the 401 interceptor is the only thing surfacing session expiry to the user. Raw fetch will silently fail.
  3. Origin check failures — CSRF requires the source to be one of the exact configured origins and to equal the request destination. Locally, match PUBLIC_URL to the browser host (use http://127.0.0.1:3000 for sign-in). On Vercel, ensure the server runtime exposes VERCEL_BRANCH_URL / VERCEL_URL; no NEXT_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_URLVERCEL_BRANCH_URLVERCEL_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_id becomes a virtual http://localhost?redirect_uri=...&scope=..., which is what the AS expects for loopback dev.
  • The redirect_uri host must be 127.0.0.1 (or [::1]); localhost is NOT allowed there even though it IS the only allowed client_id host. Yes, this is inverted from intuition; it's the spec.
  • Cookies don't cross localhost127.0.0.1. Pick one host for the whole flow. Since the redirect comes back on 127.0.0.1, navigate to http://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 set PUBLIC_URL=https://<tunnel>.example.com so the production code path runs.
  • ATPROTO_PRIVATE_KEY is ignored in loopback dev mode — the helper hard-codes token_endpoint_auth_method: "none".
  1. Wrong cookie name — it's certified_session. Anything else (session, sid) is wrong.
  2. Forgetting to add a new write collection to ALLOWED_WRITE_COLLECTIONScreateRecord/putRecord/deleteRecord will silently 403 with Collection not allowed.
  3. Cross-repo writesbody.repo must equal session DID. If you need to write to another repo (e.g. a group's repo), use the createGroupAgent proxy pattern, not the XRPC proxy.
  4. OAuth callback in iframe vs. top window — the page detects window.parent !== window. If you change the modal/iframe architecture, update both branches of handleCallback.
  5. postMessage without origin check — both listeners in auth-context.tsx validate event.origin === window.location.origin. Don't drop this check.
  6. Lifting wagmi to root — every page would pull in viem and wagmi (~hundreds of KB). Keep it scoped.
  7. Caching getServiceAuth tokens — they're scoped per-LXM and short-lived. Always re-issue.
  8. Rendering user-controlled URLs as href without scheme validationjavascript:alert(1) becomes a one-click XSS. Always allowlist schemes (http:, https:, mailto:, tel:) before assigning user-controlled values to href.
  9. sitemap.ts / robots.ts drift — when you add a new public page, both must be updated. There is no automation.
  10. 100vw in CSS — causes horizontal scroll when a vertical scrollbar is present. Use 100%.
  11. Treating next.config.ts's serverExternalPackages: ["@atproto/oauth-client-node"] as optional — it's not. Without it, the OAuth client fails to bundle correctly for serverless.
  12. ATPROTO_PRIVATE_KEY / JWKS coupling — if you set ATPROTO_PRIVATE_KEY, the OAuth client switches to confidential auth and the published oauth-client-metadata includes a jwks_uri. Removing the var without updating the registered metadata can desync clients.
  13. Hand-rolling a modal instead of using <AppDialog>. The pattern at AddOrgModal / MembershipSyncModal before the consolidation — manual <div className="signin-modal__backdrop"> + useFocusTrap + onKeyDown={e => e.key==="Escape" && onClose()} — has produced an InvalidStateError bug (documented at app-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. Reintroducing border-radius: 6px (or 4 / 8 / 12 / 16 / 20). The consolidation pass replaced 116+ instances; var(--radius) is now universal. Before merging CSS, run grep -rEn "border-radius:\s+(4|6|8|12|16|20)px" src/app/styles/ and confirm zero hits. 16b. Using text-xl / text-lg for app headings. The canonical scale is text-display / text-h1 / text-h2 / text-h3 / text-h4 from tailwind.config.ts, paired with font-headline (Noto Serif). Legal pages, /about, and /privacy were migrated; new pages should follow. 16c. Hardcoding var(--color-primary) on landing surfaces. Use --color-navy / --color-off-white (theme-aware landing tokens). --color-primary is invariant and will leave a near-black blob on a dark canvas in dark mode.
  14. 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-up useEffect instead.
  15. 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 listTitle row 4.
  16. listTitle privacy leakuseReceivedEndorsements returns listTitle to 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.
  17. Group follow writes via the personal XRPC proxycreateFollow(ownDid, subjectDid) without targetDid writes to the PERSONAL repo, even when acting-as-group. Pass { targetDid: groupDid } to route through /api/groups/[did]/follow.
  18. Hiding rejected endorsements from non-ownersuseReceivedEndorsements default keeps the privacy contract (foreign viewers never see rejected). Only pass { includeRejected: true } on owner-side surfaces, and filter client-side from there.
  19. Static segments under dynamic routes/project/new lives at src/app/project/new/page.tsx alongside [did]/[rkey]. Static wins (and [did]/[rkey] is two segments so /project/new wouldn't match it anyway), but if you change the dynamic pattern to single-segment make sure new still wins.

23. Adding a New Feature — Checklist

When adding any non-trivial feature:

  1. Decide the route. Public or gated? Personal or group context (most pages must handle both — see how /settings checks activeOrg)?
  2. 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).
  3. API surface. Will it call XRPC methods already supported by the proxy? If not, add them — and add their collection to ALLOWED_WRITE_COLLECTIONS if writing.
  4. 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 as href.
    • safeRedirect() for any redirect target returned from the server.
  5. A11y:
    • Form inputs: use <Input> / <Textarea> (they wire up aria-describedby/aria-invalid for 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-expanded are required.
    • Icon-only buttons: <Button size="icon" aria-label="…"> (TypeScript enforces the label).
    • Skip-nav already present.
  6. 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.css and landing.css. No new breakpoints. All border-radius is var(--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.
  7. SEO (public pages only):
    • metadata.title, description, alternates.canonical, OG url+images.
    • Add to src/app/sitemap.ts.
    • Update src/app/robots.ts allow-list.
    • Consider JSON-LD if it semantically fits (Article, BreadcrumbList, etc.).
    • Authenticated pages: set robots: { index: false, follow: false }.
  8. Observability: if the feature can fail, log with console.error("[Feature] …", err) so it shows up in Vercel logs.
  9. Quality gate: npm run build must pass. Manual smoke-test in the browser (sign in, exercise the feature, sign out — re-test in incognito for a clean session).
  10. Update this file if the feature changes architecture, conventions, or security posture.

24. Adding a New API Route — Checklist

Use this for any new src/app/api/** route handler:

  1. Method-appropriate handler. GET for reads, POST/PUT/DELETE for writes.
  2. CSRF check first for mutating methods:
    const csrfError = checkCsrf(request); if (csrfError) return csrfError;
  3. Auth check second:
    const did = await getSessionDid();
    if (!did) return NextResponse.json({ error: "Not authenticated" }, { status: 401 });
    For routes that need the atproto agent, prefer getAuthenticatedAgent() from src/lib/groups/proxy-agent.ts — it handles client.restore failures by deleting the session and returning null.
  4. Validate body shape — typeof checks, allowlists for enums (see role/route.ts validating role ∈ {member, admin, owner}).
  5. Sanitize input at the boundary even if the client also sanitized.
  6. Respect allowlists — collection allowlist for XRPC, scheme allowlist for URLs, MIME-type allowlist for blobs.
  7. Try/catch the body — handle malformed JSON explicitly:
    try { body = await req.json() } catch { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }) }
  8. 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.
  9. Log on the server side with a route-tagged prefix: console.error("[Route] …", err).
  10. Return shape consistency — successful operations return { success: true } for void operations, the upstream data object for queries, or domain-shaped JSON. Errors always return { error: string }.
  11. Update AGENTS.md / this draft if the route adds an endpoint to the public surface.

25. Conventions: Errors, Loading, A11y

Error handling

  • Server side — return NextResponse.json({ error: "..." }, { status }). Use extractError(res, fallback) from src/lib/utils/api.ts to 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't alert().
  • Auth errors — surfaced through AuthProvider.error and the sign-in modal. Don't write a parallel auth-error system.
  • AbortController — every long-running fetch in a hook should accept an AbortSignal and check signal.aborted before setting state. See useProfile, useOrgProfile, OrgProvider for 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).

Loading states

  • Initial auth loadAuthProvider exposes isLoading. Pages gated by AuthGuard show a centered spinner while it's true.
  • Per-card loading — use the LoadingSpinner component (src/components/ui/loading-spinner.tsx).
  • Don't gate the navbar on isLoading — render placeholders / skeletons so the layout is stable.
  • Optimistic state on sign-outsignOut clears local state immediately, then fires the server cleanup in the background (best-effort).

Accessibility

  • <a class="skip-nav"> and <main id="main-content"> are wired up in the root layout.
  • Form inputs use Input / Textarea which wire up aria-describedby + aria-invalid + label associations via useId.
  • Modals trap focus with useFocusTrap and restore focus to the previously focused element on close.
  • Dropdowns have aria-haspopup + aria-expanded.
  • Buttons that purely contain icons need aria-label (or title) — see the small action buttons in groups/page.tsx (title="Leave group", title="Accept membership publicly").
  • Decorative images have alt="" + aria-hidden="true" — see home-client.tsx for the loading screen logo.
  • Color is never the sole signal — error states pair red with text, success pairs green with an icon.

26. Deep flow — process for substantial features

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.

Branching (project-specific override)

Work happens directly on staging, not on per-feature branches. When staging is in good shape, open a Draft PR from staging into main. The operator merges; agents never merge.

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.

Order of operations

  1. 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.

  2. Plan. Write docs/<feature-or-issue>/plan.md capturing:

    • 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
  3. 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.

  4. Implement. Commit directly to staging. Atomic commits with a clear scope tag. Match the existing commit-message convention (Co-Authored-By: trailer per Safety Rule 6).

  5. 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.

  6. 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.

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

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

  9. Stop. The operator merges. Notify with the PR URL and a short summary of what shipped.

Hard rules

  • Never merge. Stopping at "PR Draft, CI green" is the contract.
  • Never --force push to main. Avoid history rewrites on staging once 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.

Verification commands for this repo

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 build

Plus a smoke test of the changed surface in next dev when the change is user-facing.


27. Testing

Layout

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 + 3

src/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).

Contract tests — the class of bug that hurts here

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.

E2E without credentials

/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.

Authenticated E2E

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.

Origin discipline

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.