Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
267588f
docs(review): full-codebase review findings + refactor plan
holkexyz Jul 2, 2026
129346f
fix(security): harden SSRF filter and PDS proxy redirects/limits
holkexyz Jul 2, 2026
f548fd4
fix(security): add per-target unlock throttle and stop feedback email…
holkexyz Jul 2, 2026
ff145cd
fix(next): unblock /embed framing, server-redirect root, cache OG met…
holkexyz Jul 2, 2026
4e1e7ca
perf(bundle): lazy-load TipTap editor and contributor board; memoize …
holkexyz Jul 2, 2026
71c7242
perf(render): memoize explore/endorsement rows; fix nested main landm…
holkexyz Jul 2, 2026
dd99a94
perf(navbar): split context into values/setters; mark page title as h1
holkexyz Jul 2, 2026
d602bae
fix(hooks): abort stale loadMore, dedupe paginated activities, retry …
holkexyz Jul 2, 2026
694fd5b
perf(hooks): batch project-item fetch via indexer; guard breakpoint r…
holkexyz Jul 2, 2026
a84f80c
style(tokens): replace raw rgba with theme-aware tokens; add invarian…
holkexyz Jul 2, 2026
1c42a4b
chore(cleanup): remove dead exports; dedupe truncateDid in workspace
holkexyz Jul 2, 2026
6dd73dd
test(security): cover password-reset, clone-blob SSRF, members escala…
holkexyz Jul 2, 2026
9aaada4
docs(quality): perf/code-quality pass 2026-07-12 — prompt, verified f…
holkexyz Jul 12, 2026
89c656a
docs(quality): round-1 plan review decisions; corrected ownership + p…
holkexyz Jul 12, 2026
d6d4b1c
feat(lib): add postIndexer and deriveIdentity shared helpers
holkexyz Jul 12, 2026
13f98d9
perf(home): memoize feed rows, bound group expansion; extract row layer
holkexyz Jul 13, 2026
be9fbc2
perf(graph): pause redraw when idle, stop reheat on resize, parallel …
holkexyz Jul 13, 2026
63ad0cc
perf(next): lazy profile tabs + settings, server-render landing stats…
holkexyz Jul 13, 2026
76b9bab
fix(types): validate activity records, org-context storage, auth erro…
holkexyz Jul 13, 2026
69e9494
perf(hooks): single-flight caches for endorsement/follow hooks, batch…
holkexyz Jul 13, 2026
8c1074a
perf(detail): cache context updates, derive route rkey; extract trail…
holkexyz Jul 13, 2026
6e2baf4
refactor(profile): shared endorsement subject row, single hook call, …
holkexyz Jul 13, 2026
4b50dbd
perf(explore): isolate search keystrokes, right-size All view; split …
holkexyz Jul 13, 2026
77f97de
perf(api): cacheable GET variant for public indexer ops; split route …
holkexyz Jul 13, 2026
23733a4
perf(api): lazy OAuth restore, blob cache headers, parallel Upstash r…
holkexyz Jul 13, 2026
7539798
fix(api): close org-limit fail-open, add CGS timeouts and profile cac…
holkexyz Jul 13, 2026
c749825
fix(lint): resolve the react-hooks warning backlog across 36 files
holkexyz Jul 13, 2026
01690c2
chore(cleanup): delete dead hooks, exports, and their orphaned tests
holkexyz Jul 13, 2026
346cef9
fix(onboarding,tour): key-remount modal seeding; clamp tour step on v…
holkexyz Jul 13, 2026
1e9e37c
style(css): tokenize z-index/shadows/status colors, remove dead CSS, …
holkexyz Jul 13, 2026
df926a4
perf(explore): batch Ma Earth project fetch via indexer with PDS fall…
holkexyz Jul 13, 2026
b72e4d4
refactor(indexer): postIndexer across all call sites, domain-split li…
holkexyz Jul 13, 2026
c8fffbf
refactor(shared): deriveIdentity/rkeyFromUri/formatTimePeriod/project…
holkexyz Jul 13, 2026
04d3d9d
fix(review): harden op allowlists, break barrel cycle, fix debounce s…
holkexyz Jul 13, 2026
641de7f
docs(quality): implementation review round-2 decisions
holkexyz Jul 13, 2026
f74ded5
perf(projects): select avatar in all collection ops; probe dissolves …
holkexyz Jul 13, 2026
24e7aa9
Merge pull request #237 from hypercerts-org/perf/quality-pass-2026-07-12
holkexyz Jul 13, 2026
bff9613
docs(env): record staging-branch indexer convention, blocked on magic…
holkexyz Jul 13, 2026
e1d0668
auth: support Vercel preview origins
Kzoeps Jul 30, 2026
2848864
ci: run production build in pull requests
Kzoeps Jul 30, 2026
2760a15
auth: share loopback host validation
Kzoeps Jul 30, 2026
2adc79f
Merge pull request #239 from hypercerts-org/auth/vercel-preview-origins
Kzoeps Jul 30, 2026
205a0eb
ci: generate ephemeral cookie secret for build
Kzoeps Jul 30, 2026
282f3a2
Merge pull request #240 from hypercerts-org/ci/run-production-build
Kzoeps Jul 30, 2026
6c9b282
fix(xrpc): allowlist context.attachment so posting an update works
holkexyz Aug 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 14 additions & 2 deletions .env.local.example
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
# Required: The PDS / handle resolver URL
NEXT_PUBLIC_PDS_URL=https://certified.one

# Public URL of this app — used for OAuth client_id, redirect_uris, and the
# CSRF Origin allowlist.
# Canonical URL of this app — used for OAuth client_id and redirect_uris.
# CSRF also accepts same-origin requests on Vercel's exact branch and deployment
# URLs when VERCEL_BRANCH_URL / VERCEL_URL are present at runtime.
#
# Resolution order: PUBLIC_URL, then VERCEL_BRANCH_URL, then VERCEL_URL.
# Vercel supplies the latter two as hostname-only server variables; do not add
# NEXT_PUBLIC_ variants or include a scheme. PUBLIC_URL remains recommended for
# a stable custom production/staging domain. The selected OAuth metadata URL
# (and JWKS URL for confidential clients) must be publicly reachable.
#
# Production: set to the deployed origin, e.g. https://certified.app
# Local dev: use http://127.0.0.1:3000 (NOT http://localhost:3000).
Expand Down Expand Up @@ -30,6 +37,11 @@ UPSTASH_REDIS_REST_TOKEN=
# code falls back to NEXT_PUBLIC_INDEXER_URL, and then to a hardcoded fallback
# (magic-indexer-prod.up.railway.app). For local dev against the dev indexer,
# set this to magic-indexer-dev.up.railway.app.
#
# Deployment convention: the staging branch (staging.certified.app) points at
# magic-indexer-staging.up.railway.app via a branch-scoped Vercel preview env
# var — blocked on magic-indexer#273 (staging indexer has no lexicon schema
# yet); until that lands, staging deliberately stays on the prod indexer.
INDEXER_URL=https://magic-indexer-prod.up.railway.app/graphql

# Deprecated alias for INDEXER_URL; still read for backwards-compat. Prefer
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,6 @@ jobs:

- name: Test
run: npm test

- name: Build
run: COOKIE_SECRET="$(openssl rand -hex 32)" npm run build
22 changes: 12 additions & 10 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@ 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` | production | Public URL of this app. Used to derive OAuth `client_id`, `redirect_uris`, and the CSRF Origin allowlist. Falls back to `http://localhost:3000` in dev. **For local atproto OAuth sign-in to actually complete, set this to `http://127.0.0.1:3000`** — see [§22 Common Pitfalls](#22-common-pitfalls) #3. |
| `PUBLIC_URL` | recommended in production | Canonical app origin. Wins when deriving OAuth `client_id` and `redirect_uris`; exact same-origin CSRF requests from it are trusted. Falls back to `VERCEL_BRANCH_URL`, then `VERCEL_URL`, then `http://localhost:3000` outside production. **For local atproto OAuth sign-in to actually complete, set this to `http://127.0.0.1:3000`** — see [§22 Common Pitfalls](#22-common-pitfalls) #3. |
| `VERCEL_BRANCH_URL` | Vercel-provided fallback | Hostname-only stable branch URL. Becomes the canonical OAuth origin when `PUBLIC_URL` is absent and is accepted for same-origin CSRF requests. Do not add a scheme or `NEXT_PUBLIC_` alias. |
| `VERCEL_URL` | Vercel-provided fallback | Hostname-only commit deployment URL. Final canonical OAuth fallback and accepted for same-origin CSRF requests. Do not add a scheme or `NEXT_PUBLIC_` alias. |
| `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. |
Expand All @@ -141,7 +143,7 @@ Source: `.env.local.example` and `src/lib/utils/config.ts`.
| `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`. |

`PUBLIC_URL` is the most consequential variable — it is checked against the `Origin` header on every CSRF-protected route, baked into the OAuth client metadata, and used to build the `redirect_uris` array. If it does not match the deployed domain, sign-in and every POST will fail.
The canonical OAuth URL is resolved in this order: `PUBLIC_URL` → `VERCEL_BRANCH_URL` → `VERCEL_URL`. It is baked into OAuth client metadata and `redirect_uris`, so a login started on another accepted deployment origin finishes on that canonical host and receives its session cookie there. CSRF accepts the exact configured public, branch, and deployment origins only when the source also equals the request destination. No wildcard Vercel host matching is allowed. The selected metadata endpoint—and JWKS endpoint for confidential clients—must be publicly reachable by the authorization server; Vercel Deployment Protection can otherwise block sign-in.

## 5. Architecture & Data Flow

Expand Down Expand Up @@ -254,14 +256,14 @@ Permanent redirects (in `next.config.ts`):

### Components

- **OAuth client** — `src/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. In **loopback dev mode** (`NODE_ENV !== "production"` AND `PUBLIC_URL` is missing or `http://`) it skips the normal `${PUBLIC_URL}/.well-known/oauth-client-metadata` `client_id` and uses `buildAtprotoLoopbackClientMetadata` instead, because the spec only allows `https://` or the literal `http://localhost` (no port) as a `client_id`.
- **OAuth client** — `src/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_URL` → `VERCEL_BRANCH_URL` → `VERCEL_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`.
- **Stores** — `src/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 session** — `src/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.
- **CSRF** — `src/lib/auth/csrf.ts` checks `Origin` header against `new URL(PUBLIC_URL).origin`. If `Origin` is absent (some same-origin no-CORS posts) the request is allowed; if present it must match exactly. Wraps URL parsing in try/catch — any malformed origin returns 403.
- **CSRF** — `src/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.
- **authFetch** — `src/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)
Expand Down Expand Up @@ -641,7 +643,7 @@ 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 check compares the `Origin` header against `PUBLIC_URL`. URL parsing is wrapped in try/catch; malformed origins return 403.
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.
Expand Down Expand Up @@ -694,7 +696,7 @@ When adding a new public page: set `metadata.title`, `description`, `alternates.
- `staging` → preview (`staging.certified.app`)
- **Workflow:** push to `staging`, open a PR to `main`. Vercel deploys both branches automatically.
- **Quality gate:** `npm run build` must succeed before pushing. There is no test suite to run; `tsc --noEmit` is implicit in `next build`.
- **`PUBLIC_URL`** must match the deployed domain on each environment, since `client_id`, `redirect_uris`, and the CSRF allowlist all derive from it.
- **OAuth URL precedence:** `PUBLIC_URL` → `VERCEL_BRANCH_URL` → `VERCEL_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
Expand Down Expand Up @@ -857,7 +859,7 @@ certified-app/
└── lib/
├── auth/
│ ├── auth-context.tsx # AuthProvider, useAuth, sign-in modal, postMessage listeners
│ ├── csrf.ts # checkCsrf — Origin === PUBLIC_URL check
│ ├── 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)
Expand Down Expand Up @@ -885,7 +887,7 @@ certified-app/
│ └── api.ts # Shared response types (SessionResponse, ListRecordsResponse, PutRecordResponse)
├── utils/
│ ├── api.ts # extractError(res, fallback)
│ ├── config.ts # PUBLIC_URL + PUBLIC_URL_STRICT
│ ├── config.ts # OAuth URL precedence + allowed request origins
│ ├── constants.ts # LIMIT_MIN/MAX/DEFAULT, debounce timings
│ ├── initials.ts # getInitials()
│ └── sanitize.ts # stripInvisible/sanitizeEmail/sanitizeHandle
Expand All @@ -910,9 +912,9 @@ certified-app/

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 in dev** — if you set `PUBLIC_URL=https://certified.app` in `.env.local` and run `npm run dev` on localhost, every POST will 403. Match `PUBLIC_URL` to whatever host your browser actually hits (use `http://127.0.0.1:3000` if you want sign-in to work — see next pitfall).
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 detect dev mode (`NODE_ENV !== "production"` AND `PUBLIC_URL` missing or `http://`) and swap to `buildAtprotoLoopbackClientMetadata({ scope, redirect_uris: ["http://127.0.0.1:<port>/oauth/callback"] })`. Notes:
3a. **atproto OAuth in dev requires the loopback metadata helper, not just `PUBLIC_URL`.** The spec only accepts a `client_id` that is either a real `https://` URL or the literal `http://localhost` origin (no port, no path). Pointing `client_id` at `http://localhost:3000/...` or `http://127.0.0.1:3000/...` makes `NodeOAuthClient` throw `URL must use the "https:" protocol` (Zod). The fix — already wired into `src/lib/auth/oauth-client.ts` — is to resolve `PUBLIC_URL` → `VERCEL_BRANCH_URL` → `VERCEL_URL`, then use `buildAtprotoLoopbackClientMetadata({ scope, redirect_uris: ["http://127.0.0.1:<port>/oauth/callback"] })` only when that canonical URL is `http://` outside production. Notes:
- The `client_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 `localhost` ↔ `127.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`.
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,17 @@ Edit `.env.local` with your values:
| Variable | Required | Description |
|----------|----------|-------------|
| `NEXT_PUBLIC_PDS_URL` | Yes | PDS / handle resolver URL (default: `https://certified.one`) |
| `PUBLIC_URL` | Production | Public URL of the app (used for OAuth client_id and redirect URIs) |
| `PUBLIC_URL` | Recommended in production | Canonical app origin used for OAuth metadata and callbacks |
| `VERCEL_BRANCH_URL` | Vercel-provided fallback | Stable branch hostname used when `PUBLIC_URL` is absent |
| `VERCEL_URL` | Vercel-provided fallback | Commit deployment hostname used when the first two values are absent |
| `COOKIE_SECRET` | Production | Secret for signing session cookies (`openssl rand -hex 32`) |
| `UPSTASH_REDIS_REST_URL` | Yes | Upstash Redis REST URL |
| `UPSTASH_REDIS_REST_TOKEN` | Yes | Upstash Redis REST token |
| `ATPROTO_PRIVATE_KEY` | No | EC private key for confidential client auth |
| `RESEND_API_KEY` | No | Resend API key for feedback emails |

OAuth URL precedence is `PUBLIC_URL` → `VERCEL_BRANCH_URL` → `VERCEL_URL`. The Vercel variables are server-side hostname-only system values and do not need `NEXT_PUBLIC_` aliases. The selected metadata endpoint—and JWKS endpoint when confidential auth is enabled—must be publicly reachable by the authorization server, so Vercel Deployment Protection can prevent preview login. Deployments participating in one callback flow must share compatible Redis configuration; distinct canonical OAuth origins should use separate Redis databases because saved OAuth sessions are not namespaced by `client_id`.

### Development

```bash
Expand Down
Loading
Loading