diff --git a/.env.local.example b/.env.local.example index 4c7f87ab..6372fd9b 100644 --- a/.env.local.example +++ b/.env.local.example @@ -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). @@ -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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26bbc581..20385efa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,3 +33,6 @@ jobs: - name: Test run: npm test + + - name: Build + run: COOKIE_SECRET="$(openssl rand -hex 32)" npm run build diff --git a/AGENTS.md b/AGENTS.md index 8c8aa79c..b54e2f32 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. | @@ -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 @@ -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:` / `oauth:session:`. **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>.`. - 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:`). - 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) @@ -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 `.`. 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. @@ -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 @@ -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) @@ -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 @@ -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:/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:/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`. diff --git a/README.md b/README.md index af8d3471..9bc2cb68 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/full-review-2026-07/findings.md b/docs/full-review-2026-07/findings.md new file mode 100644 index 00000000..9b3b9b1c --- /dev/null +++ b/docs/full-review-2026-07/findings.md @@ -0,0 +1,370 @@ +# Full review findings — certified-app (2026-07-02) + +Generated by the 10-dimension review workflow defined in [`prompt.md`](./prompt.md). Each finding was adversarially verified: critical/high by two skeptics (reachability + impact lenses), medium/low by one. A finding is **confirmed** only if a skeptic confirmed it and none refuted it. + +**Totals:** 62 findings — **36 confirmed**, 0 uncertain, 26 refuted. + +Provenance: raw verified data in the workflow journal (`subagents/workflows/wf_8beca369-113/journal.jsonl`). + +> **Caveat on the testing dimension.** The verification lens ("is there a defect at this file:line?") does not fit *missing-test* findings — an absent test is not a code defect to refute — so all Dimension-9 findings landed in the refuted bucket. They are nonetheless valid coverage gaps. The high-value ones (untested cross-account password-reset, clone-blob SSRF guards, upload-blob limits, members anti-escalation, feedback abuse config) are **actioned** via the test-only track in [`plan.md`](./plan.md), not dropped. + +--- + +## Confirmed findings (36) + +### 1. Global X-Frame-Options: DENY + CSP frame-ancestors 'none' break the shipped /embed/board third-party embed feature + +- **id / dimension:** `next-embed-frame-headers-block-embeds` · nextjs +- **severity / effort / regression-risk:** High / S / low +- **location:** `next.config.ts:27` +- **evidence:** headers() applies to `source: "/(.*)"` and sets `{ key: "X-Frame-Options", value: "DENY" }` (line 27) plus CSP `... frame-ancestors 'none'; ...` (lines 61-62), for BOTH prod and dev. There is no embed-specific override (grep of next.config.ts finds no /embed header rule; there is no middleware.ts). Yet `src/app/embed/board/[...slug]/page.tsx` is documented as 'Public embed route ... Renders bare ... so it drops cleanly into a third-party iframe', and `src/components/contributor-board/share-embed-dialog.tsx:20` hands users `` - const [copied, setCopied] = useState(null) - const copy = async (text: string, key: string) => { - try { - await navigator.clipboard.writeText(text) - setCopied(key) - window.setTimeout(() => setCopied((c) => (c === key ? null : c)), 1500) - } catch { - /* clipboard unavailable — no-op */ - } - } + // One shared-hook instance per copy target so each button shows its + // own check mark; the hook auto-resets after 1500ms. + const { copied: linkCopied, copy: copyLink } = useCopyToClipboard() + const { copied: embedCopied, copy: copyEmbed } = useCopyToClipboard() return ( @@ -48,9 +42,9 @@ export function ShareEmbedDialog({ did, rkey, onClose }: ShareEmbedDialogProps) size="icon" variant="secondary" aria-label="Copy link" - onClick={() => copy(shareUrl, "link")} + onClick={() => void copyLink(shareUrl)} > - {copied === "link" ? : } + {linkCopied ? : } @@ -70,9 +64,9 @@ export function ShareEmbedDialog({ did, rkey, onClose }: ShareEmbedDialogProps) size="icon" variant="secondary" aria-label="Copy embed code" - onClick={() => copy(embedCode, "embed")} + onClick={() => void copyEmbed(embedCode)} > - {copied === "embed" ? : } + {embedCopied ? : } diff --git a/src/components/create/contributor-identity-card.tsx b/src/components/create/contributor-identity-card.tsx index 1b8ea1ba..f8dd64bb 100644 --- a/src/components/create/contributor-identity-card.tsx +++ b/src/components/create/contributor-identity-card.tsx @@ -5,7 +5,8 @@ import Avatar from "@/components/ui/avatar" import LoadingSpinner from "@/components/ui/loading-spinner" import Tooltip from "@/components/ui/tooltip" import { useContributorInfo } from "@/hooks/use-contributor-info" -import { getInitials } from "@/lib/utils/initials" +import { deriveIdentity } from "@/lib/utils/identity" +import { isDid } from "@/lib/utils/did" interface ContributorIdentityCardProps { /** Normalised identity string (DID or handle without leading `@`). */ @@ -35,10 +36,14 @@ export function ContributorIdentityCard({ }: ContributorIdentityCardProps) { const { info, isLoading } = useContributorInfo(identity) - const displayName = info?.displayName || info?.handle || identity - const handle = - info?.handle && info.handle !== info.did ? info.handle : null - const initials = getInitials(info?.displayName ?? null, info?.did ?? identity) + // `identity` is a handle-or-DID string: a handle stays the display + // fallback while resolution is pending / failed; a DID falls through + // to deriveIdentity's canonical truncated-DID fallback. + const { displayName, handle, initials } = deriveIdentity( + info, + info?.did ?? identity, + { fallbackLabel: isDid(identity) ? undefined : identity }, + ) return (
(null) const [selectedExistingUri, setSelectedExistingUri] = useState("") - useEffect(() => { - const controller = new AbortController() + // Adjust state during render when the source repo changes (first mount + // already starts loading via the initializers), so the effect holds + // only the listRecords lifecycle. + const [prevOwnDid, setPrevOwnDid] = useState(ownDid) + if (prevOwnDid !== ownDid) { + setPrevOwnDid(ownDid) setMyLocationsLoading(true) setMyLocationsError(null) + } + + useEffect(() => { + const controller = new AbortController() const params = new URLSearchParams({ repo: ownDid, collection: "app.certified.location", @@ -132,7 +141,7 @@ export default function LocationPickerDialog({ const display = split?.name || rawName || - rec.uri.split("/").pop() || + rkeyFromUri(rec.uri) || "(unnamed location)" const lt = typeof rec.value?.locationType === "string" @@ -177,6 +186,7 @@ export default function LocationPickerDialog({ if (mode !== "new" || fieldMode !== "search") return const trimmed = name.trim() if (trimmed.length < 2) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- debounced geocode keyed on the typed name: this clears now-stale suggestions when input drops below 2 chars and bails out when already empty; onChange/pick/map handlers don't cover every write path setSuggestions([]) return } diff --git a/src/components/dashboard/custom-domain-modal.tsx b/src/components/dashboard/custom-domain-modal.tsx index 970ffe76..d8ab5649 100644 --- a/src/components/dashboard/custom-domain-modal.tsx +++ b/src/components/dashboard/custom-domain-modal.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, useRef, useCallback } from "react"; import { Globe, Copy, Check, AlertCircle, CheckCircle2 } from "lucide-react"; import { authFetch } from "@/lib/auth/fetch"; +import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; import { clearSessionCache } from "@/hooks/use-session"; import Button from "@/components/ui/button"; import AppDialog, { AppDialogHeader } from "@/components/ui/app-dialog"; @@ -30,7 +31,10 @@ export default function CustomDomainModal({ isOpen, onClose, did }: CustomDomain const [step, setStep] = useState("enter-domain"); const [domain, setDomain] = useState(""); - const [copied, setCopied] = useState<"host" | "value" | null>(null); + // One shared-hook instance per copy target so each DNS field shows its + // own check mark; the hook auto-resets after 2000ms. + const { copied: hostCopied, copy: copyHost } = useCopyToClipboard(2000); + const { copied: valueCopied, copy: copyValue } = useCopyToClipboard(2000); const [isVerifying, setIsVerifying] = useState(false); const [verifyError, setVerifyError] = useState(null); const [isSuccess, setIsSuccess] = useState(false); @@ -42,7 +46,6 @@ export default function CustomDomainModal({ isOpen, onClose, did }: CustomDomain if (isOpen) { setStep("enter-domain"); setDomain(""); - setCopied(null); setIsVerifying(false); setVerifyError(null); setIsSuccess(false); @@ -66,16 +69,6 @@ export default function CustomDomainModal({ isOpen, onClose, did }: CustomDomain setStep("dns-setup"); }; - const handleCopy = async (text: string, which: "host" | "value") => { - try { - await navigator.clipboard.writeText(text); - setCopied(which); - setTimeout(() => setCopied(null), 2000); - } catch { - // Fallback: select text - } - }; - const handleVerify = async () => { setIsVerifying(true); setVerifyError(null); @@ -220,11 +213,11 @@ export default function CustomDomainModal({ isOpen, onClose, did }: CustomDomain
@@ -236,11 +229,11 @@ export default function CustomDomainModal({ isOpen, onClose, did }: CustomDomain diff --git a/src/components/dev/mock-fetch-provider.tsx b/src/components/dev/mock-fetch-provider.tsx index 6d78cef5..1ebb1629 100644 --- a/src/components/dev/mock-fetch-provider.tsx +++ b/src/components/dev/mock-fetch-provider.tsx @@ -12,6 +12,7 @@ * Routing: * - `/api/auth/session` → fixture session `{ did }` * - `/api/indexer` (POST) → dispatched by `operationName` + * - `/api/indexer?op=…` (GET) → same dispatch, op from the query string * - `/api/resolve-did` (GET) → single resolved profile * - `/api/resolve-dids` (POST) → batched resolved profiles * - `/api/xrpc/...` → getSession / getRecord / listRecords @@ -380,6 +381,14 @@ function installMockFetch( return json({ ok: true }) } if (path === "/api/indexer") { + // GET variant (`/api/indexer?op=` — the edge-cacheable + // counts): the operation rides in the query string and there + // is no body. Dispatch it through the same op switch as POST; + // the response body is identical by contract. + const opParam = url.searchParams.get("op") + if (opParam) { + return indexerResponse({ operationName: opParam }, { empty, managed }) + } let parsed: IndexerBody = {} try { const text = diff --git a/src/components/endorsements/__tests__/endorsement-subject-row.test.tsx b/src/components/endorsements/__tests__/endorsement-subject-row.test.tsx new file mode 100644 index 00000000..f77747c1 --- /dev/null +++ b/src/components/endorsements/__tests__/endorsement-subject-row.test.tsx @@ -0,0 +1,208 @@ +import { describe, it, expect, afterEach, vi } from "vitest" +import { render, cleanup, fireEvent } from "@testing-library/react" + +import EndorsementSubjectRow, { + type EndorsementSubjectRowClasses, +} from "../endorsement-subject-row" +import type { AuthorInfo } from "@/hooks/use-author-info" + +// The shared subject row replaced three hand-rolled copies +// (endorsement-row, endorsement-lists' ListItemRow, and +// profile-endorsements' EndorsementRowBody) whose loading states, +// identity fallbacks, and