Skip to content

Positioning redesign — overnight hardening pass (Critical leaflet XSS fix + 16 follow-ups) - #65

Merged
hb-agent merged 98 commits into
stagingfrom
feat/positioning-redesign
May 18, 2026
Merged

Positioning redesign — overnight hardening pass (Critical leaflet XSS fix + 16 follow-ups)#65
hb-agent merged 98 commits into
stagingfrom
feat/positioning-redesign

Conversation

@hb-agent

Copy link
Copy Markdown
Collaborator

Summary

Overnight autonomous deep-flow review and improvement pass on the positioning-redesign branch (79 commits ahead of staging). One CRITICAL fix landed (leaflet XSS class), plus 16 atomic follow-up commits closing 17 of the 22 MUST-FIX / high-medium items the diagnostic surfaced.

Branch ends the night with tsc clean, npm run lint at 0 errors / 38 warnings (was 6 errors / 39 warnings), and npm run build green.

The full deep-flow trail lives in docs/overnight-2026-05-18/:

The single most important thing to look at

Commit e43edbafix(leaflet): scheme-allowlist user-controlled URLs in renderer + editor

This closes a critical stored-XSS class. The leaflet renderer was emitting <a href={url}> for facet-link URIs and iframe-fallback URLs with no scheme allowlist. AGENTS.md §22 pitfall #11 calls this exact attack out by name. Any atproto-account holder could write a pub.leaflet.pages.linearDocument record with a javascript: URI in a link facet; every signed-in viewer of that profile / cert / long-description executes the script in certified.app's origin.

Five render sites + two (de)serializer sites now route through the pre-existing safeHttpUrl() helper (which was sitting in src/lib/utils/safe-url.ts exactly for this purpose, with a docstring literally saying "never render <a href={url}>, always safeHttpUrl(url) ?? '#'"). On rejection the surfaces degrade to plain text — content stays visible, attack closes.

Items completed (17 commits)

# Sev Commit What
1 b85d45f docs(env): declare missing INDEXER_URL, INDEXER_DID, NEXT_PUBLIC_INDEXER_URL, NEXT_PUBLIC_STADIA_API_KEY
2 High 65630f3 chore(lint): clear 6 baseline ESLint errors (useMemo replaces ref-during-render; drop broken memoization)
3 Critical e43edba fix(leaflet): scheme-allowlist user-controlled URLs in renderer + editor
4 Med 94ba191 fix(api): echo 4xx upstream messages and clamp status in extractRouteError
5 Med eee165d fix(api): drop duplicate console.error in three group routes
6 Med 89da494 fix(api/groups/activity): allowlist record fields on PUT (close mass-assignment)
7 High 048855b fix(api/geocode): require session, sanitize 5xx, tighten input parsing
8 High c404817 fix(api/indexer): reject mutation operations; warn on missing INDEXER_URL in production
9 High 24a8084 fix(leaflet/editor): preserve cursor when external value catches up to editor
10 Med ac72a8c fix(hooks/use-session): clear handle/email/error on sign-out
10b fc4d746 fix(locations): use authFetch for geocode calls (401 surfaces session expiry)
11 Med 1fd99c6 fix(activity-detail): revoke prior object URL on save + unmount
12 Med a0479be fix(api/groups/follow): preserve client-supplied createdAt
13 Med a2dc45e fix(leaflet): preserve ordered nested lists in linearDocument round-trip
14 Med 402fde2 fix(hooks/social-graph-sync): thread abort signal through importDids; isWriting in finally
15 Med 122965a fix(styles): use --color-error token; drop 100vw; merge duplicate cert-detail__image rule
16 Low 952a343 chore(deps): bump Next.js 16.2.3 → 16.2.6 (high-severity advisory chain)
17 Low 08e0691 chore(atproto/follow): use extractError to match sibling write helpers (Option B from F-11)

Items deferred and why

These were surfaced by the diagnostic but explicitly NOT fixed tonight:

  • writeToRepo shared helper for the five dual-path write sites (F-11 full) — Sitting down to do it, the body shapes diverge enough across cert/profile/location/follow/org-marker that a single helper would force a discriminated union on callers (more boilerplate than today) or have each caller pre-shape both branches (same as today). The architecture-lens reviewer flagged this as the "single highest-value architecture finding"; reading the call sites in front of me, I'm not sure the abstraction earns its cost. Landed the smaller normalization (Option B, commit 17). Full helper → operator decision.
  • /api/indexer full server-held queries + operation allowlist (F-6 full) — Tonight does the minimum (reject mutation keyword). The full restructure is a real refactor that would change feed semantics; defer to a focused PR.
  • listFollowing truncation surface (F-16) — Hook return shape change + 2-3 consumer UI changes. M-effort; surface change touches widely.
  • useFocusTrap on the new <dialog> modals (F-57) — Native <dialog>.showModal() provides focus containment; the AGENTS.md update saying so is the smallest change but is operator copy.
  • <AppDialog> primitive extracting the 8 modals' shared dialog skeleton (F-58) — Substantial; defer to focused PR.
  • Profile page / activity-detail inline-edit hook extraction (F-51, F-52) — Architecture agent recommended defer; not bug-shaped.
  • swapRecord concurrent-edit precondition on cert + location + marker writes (F-18, etc.) — PR Positioning redesign — implement docs/positioning/brief.md (14/16 tracks) #63 body already acknowledged the mergeProfile race as "acceptable for v1"; the same applies elsewhere. Operator decision.
  • Group BFF role enforcement at app tier (F-27) — Defensible architecture decision; documentation suffices.
  • postcss vulnerability chain (npm audit) — The 3 remaining moderates are all rooted in postcss <8.5.10 reachable via Next.js's nested dep. npm audit fix --force proposes downgrading Next to 9.x (obviously wrong). Needs either a Next.js drop of the constraint or a forced resolution; defer to dependency-hygiene work.
  • core 4.5 GB crash dump at repo root — April audit listed as F-026 low-priority cleanup; per the brief's safety rules, no destructive ops without operator approval.
  • Tests / test framework — Not in scope tonight (no test infra; adding it would blow scope ceiling).

The full WILL-NOT-FIX list with rationale lives in 03-implementation-plan.md.

Breaking changes

None. Every commit is additive / hardening / cosmetic. The geocode auth gate (commit 7) is a behavior change for any caller hitting /api/geocode anonymously — verified the only consumer is the location-picker UI on auth-gated edit screens (updated to authFetch in commit 10b).

The Next.js bump (commit 16) is a patch-within-minor; no API changes affecting this codebase.

Test plan

  • Sign in, edit a personal profile, change the long description in the leaflet editor — verify cursor stays at typing position with each keystroke (commit 9 — manual eye is the only way without test infra).
  • In the link dialog, type javascript:alert(1) and submit — verify the inline error appears and no link is inserted (commit 3 dialog-side guard).
  • Open a profile / cert with leaflet content; visit view-source mentally — verify any user-controlled URI that isn't http(s) / mailto: / tel: renders as plain text, not an <a> (commit 3 renderer guard).
  • Edit a cert image: pick A, save, edit again, pick B, save — verify the previously-saved image disappears from memory (commit 11; testable in DevTools Memory profiler).
  • Sign out from a long-lived tab (e.g. settings) — verify the email / handle in the page chrome clears immediately (commit 10).
  • Open the social-graph sync modal, start an import, close mid-flight — verify the loop stops writing follows to the repo (commit 14; testable by counting follow records before / after).
  • Edit a leaflet doc: create a nested ordered list, save, reopen — verify it stays ordered (commit 13).
  • Submit a group profile with an invalid handle — verify the actual upstream validation message surfaces instead of "Bad request" (commit 4).
  • Verify Vercel preview deploy ships green.
  • npx tsc --noEmit → 0 errors. npm run lint → 0 errors. npm run build → green. (Repo-wide gates.)

Operator decisions needed

Items the final-review pass flagged that I did NOT touch and want your call on:

  1. profile-endorsements.css:1073 and :1368 — the character-counter --warn classes use var(--color-error) (red) after commit 15's --danger--color-error sweep. The class name is --warn (which usually means amber) but the original code was a reddish #d44 fallback. Pre-existing semantic drift; commit 15 cemented the red. If you wanted amber here, switch these two to var(--color-warning) (#F5A623 / #fbbf24 in dark). Visual call.
  2. Cursor-preservation fix (commit 9) — load-bearing change to the leaflet editor's value-sync effect. tsc + lint pass and the logic reads correct, but there's no test infra to confirm under all browser quirks. Manual exercise recommended before promoting to main.
  3. createdAt plumbing on createFollow (commit 12) — accepts the param now, but the sole caller (useSocialGraphSync.importDids) doesn't pass one because useBlueskyFollows only surfaces Set<string> of DIDs. The plumbing is latent — useful when you decide to surface bluesky-follow timestamps too.

🤖 Generated with Claude Code

holkexyz and others added 30 commits May 16, 2026 09:56
Replaces the desktop left rail with a sticky two-row top bar. Row 1 carries
the brandmark, current page title, search, Apps + Settings icon links, and
the account switcher. Row 2 surfaces the profile tabs (Overview, Activities,
Endorsements, Groups) only on /profile/[handle]. Adds an Overview tab as
the default profile landing — banner, avatar, name, bio, stat chips, and
previews of groups and endorsements.

Mobile is unchanged: in-page ProfileHeader and tab strip remain, hidden on
desktop via CSS. Right rail keeps News + footer; search moves up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DesktopTopBar reads useSearchParams() to highlight the active profile tab,
which deopts statically-rendered pages (/dsa, /terms, /privacy, etc.) and
fails the production build with missing-suspense-with-csr-bailout. Wrap
the bar in a Suspense boundary in the root layout and add a placeholder
fallback that reserves row-1 height so first paint doesn't jump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewrites the Overview tab as a GitHub profile layout. Left sidebar
(~296px) carries the avatar, display name, handle, DID, bio, Edit
profile / Follow button, a followers placeholder, a link list
(joined date, website, Bluesky link), and a tile grid of groups.
Right pane carries the banner (Overview only), three stat cards,
and digest previews of recent activities and endorsements with
links into the dedicated tabs.

Drops the desktop right rail entirely and widens the profile-page
container to 1280px. Non-Overview tabs (Activities, Endorsements,
Groups) constrain to a 720px reading column via .profile-panel--reading
so list rows aren't stretched. Mobile keeps the existing in-page
ProfileHeader + tab strip; the new sidebar identity block is CSS-hidden
below 800px to avoid duplication.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The in-page .profile-tabs--in-page strip was meant to be desktop-hidden
via CSS, but profile.css loads after layout.css and the base
.profile-tabs { display: flex } overrode the hide rule (same
specificity, later source wins), so the strip showed alongside the
top bar's row 2 tabs.

Deleting the strip entirely is the cleaner fix: the desktop top bar's
row 2 is now the single source of profile tab navigation. Mobile loses
in-page tab nav for now — to be re-added if needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Profile tabs are now [Overview, Certs, Projects, Endorsements]:
Activities renamed to Certs, Groups dropped from the strip (still in
the sidebar grid), Projects added with a new fetcher that lists
`org.hypercerts.collection` records on the user's PDS and filters
to `value.type === "project"`.

Extracts the GitHub-style identity sidebar out of ProfileOverview
into a dedicated ProfileSidebar so it renders on every tab — avatar,
display name, handle, DID, bio, edit-profile / follow action,
followers placeholder, link list, and the groups grid persist as the
viewer switches tabs.

The 2-column grid lifts up to the profile page (.profile-page__layout)
so the sidebar stays put while only the right pane swaps between tabs.
The banner remains exclusive to the Overview right pane.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The fallback group-service URL and DID still pointed at the staging
Railway service (atproto-group-gate-staging…). Production Vercel env
left NEXT_PUBLIC_GROUP_SERVICE_URL / _DID empty, so the empty-string
fell through `||` to the staging fallback — silently sending prod
traffic to the staging CGS.

Bump the fallback to certified-group-service-production.up.railway.app
and update the AGENTS.md cross-references. A follow-up env update is
still needed on Vercel.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The certs-social sync commit (70b9669) overwrote oauth-client.ts and
removed the loopback-dev branch documented in AGENTS.md §22 #3a. With
PUBLIC_URL=http://127.0.0.1:3000 in .env.local, every login POST threw
ZodError 'URL must use the "https:" protocol' / 'ClientID hostname
must not be an IP address' because the spec only accepts an https://
client_id or the literal http://localhost loopback exception.

Detect loopback dev (NODE_ENV !== 'production' AND PUBLIC_URL is
missing or http://) and swap to buildAtprotoLoopbackClientMetadata,
which generates the virtual http://localhost?... client_id the spec
calls for. The redirect_uri is forced to http://127.0.0.1:<port>/
oauth/callback (the spec inverts client_id host vs redirect_uri host:
client_id must be localhost, redirect_uri must be 127.0.0.1 or [::1]).
Port is read from PUBLIC_URL, defaulting to 3000.

ATPROTO_PRIVATE_KEY is intentionally ignored in loopback dev — the
helper hard-codes token_endpoint_auth_method: 'none'. Production code
path is unchanged.

Verified end-to-end:
  GET /.well-known/oauth-client-metadata → 200, client_id matches spec
  POST /api/auth/login → 200 with real auth.certified.one redirect URL

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Certs tab

Top bar (DesktopTopBar + mobile Navbar):
  - Drop divider between row 1 (chrome) and row 2 (profile tabs).
  - Reduce row 1 height 64px → 52px.
  - Add a breadcrumb mode to NavbarContext: usePageTitleBreadcrumb({left, right})
    renders the title as two separate links joined by " / ", with a subtle
    pill-style background on hover (no link underline). Falls back to plain
    pageTitle when no breadcrumb is set. Title weight 600 → 450.
  - Cert detail page wires the breadcrumb to "@handle / cert-title".

Certs tab (new ProfileCerts component, replacing direct UserFeed render):
  - Sub-tab strip: "Created" | "Contributed to" with live count chips.
  - Right-aligned toolbar: search input + sort dropdown (newest/oldest/
    title A→Z/Z→A). Search filters title+shortDescription client-side;
    sort applies client-side to the loaded subset.
  - Combined data source: useUserIndexerActivities calls the new
    fetchUserIndexerActivities() with the magic-indexer's
    `where: { _or: [{did:{eq}}, {contributor:{eq}}] }` filter and splits
    results client-side by record DID. Pagination is shared across tabs.
  - Cards laid out as a 3-column grid at ≥1100px (2 at 800-1099, 1 below).
    Cards are uniform size: square 1:1 image, 2-line title clamp + reserved
    min-height, 2-line desc clamp + reserved min-height, meta row pinned
    to the bottom. Per-card hover background; empty/error states span all
    columns so EmptyState centers in the pane.
  - Missing-image fallback: Award icon on a subtle gradient placeholder,
    same dimensions as a real image so grid alignment is preserved.

Profile Overview:
  - Add a thumbnail to each "Recent certs" item (48×48 square, same image
    placeholder as the grid cards).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`authFetch`'s 401 interceptor fired on EVERY 401, which meant any
upstream-service failure on a route that just happens to use authFetch
would log the user out. After today's CGS switch this surfaced as: log
in → avatar appears → `/api/groups/memberships` 401s because the new
production CGS host doesn't yet have a `/.well-known/did.json` (so its
service-auth JWTs fail audience verification) → 401 interceptor fires
→ user is signed back out.

The intent of the interceptor is to catch atproto-OAuth-session expiry.
Limit it to that: only treat 401s from `/api/xrpc/*` (proxied through
the user's PDS via OAuth) and `/api/auth/*` (our cookie session) as
session-expiry signals. 401s from `/api/groups/*`, `/api/indexer`,
`/api/notifications`, etc. propagate to the caller as normal failed
responses without touching auth state.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d.app)

Production has been routing to https://groups.certified.app all along —
its build (May 11) baked the value in via Next.js's DefinePlugin from a
NEXT_PUBLIC_GROUP_SERVICE_URL env that has since been emptied on Vercel.
With the env now empty everywhere, code's fallback is the only source
of truth. The fallback pointed at atproto-group-gate-staging.up.railway
.app — a SEPARATE Railway service (different IP, different database)
that returns a different membership set for the same DID. So preview /
fresh dev hits the staging CGS and shows different groups than prod.

Switch the fallback to https://groups.certified.app /
did:web:groups.certified.app so any build (with or without explicit
envs) routes to the actual production CGS. Vercel env vars will also
be set explicitly in a follow-up step for belt-and-suspenders.

The earlier 7765799 / f371cb4 churn pointed at a third URL,
certified-group-service-production.up.railway.app, which has no
did:web doc and isn't the live prod CGS. This commit corrects that.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…\` form

Magic Indexer's GraphQL resolver returns \`Blob.ref\` as a Go
\`fmt.Sprintf("%v", <map>)\` rendering — literally the string
\`map[\$link:<cid>]\` instead of the bare CID. The GraphQL schema declares
\`ref: String!\`, so callers (us) receive that malformed string. Plugging
it into our blob proxy URL produces a 4xx and the image never loads,
even when the underlying record uses the lexicon-canonical
\`org.hypercerts.defs#smallImage\` shape.

Strip the \`map[\$link:…]\` wrapper inside \`getBlobRefLink\` so any code
path that reads a ref via this helper is unaffected by the indexer's
serialisation bug. Other shapes (\`{ \$link: string }\`, bare CID string,
CID instance) keep working unchanged.

Remove this branch once the indexer fixes its resolver.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ab, sub-page agents

Sidebar (left pane on profile pages):
  - Drop the "DID" prefix label; the DID string is self-identifying.
  - Add small copy-icon buttons after the handle and the DID.
  - Move the bio (`description`) out of the sidebar; it now lives in
    the Overview tab's About section.
  - Pull the website link up to first position in the details list.
  - Render org-only `additionalUrls` after the website when the
    profile carries `app.certified.actor.organization` (the page can
    pass `isOrg` and `additionalUrls` to the sidebar). No data wiring
    yet — props default to off so callers opt in once an org-marker
    hook lands.
  - Make the GROUPS section header a link to the Groups tab.
  - More breathing room between group tiles (5-column grid, 12px gap).

Overview tab:
  - New "About" section showing the description above the stat cards.
  - 4 stat cards instead of 3, in the order Endorsements / Certs /
    Projects / Groups. Endorsements splits received vs given. Certs
    splits created vs contributed (data via `useUserIndexerActivities`,
    same source as `<ProfileCerts>`). Projects + Given-endorsements
    show "—" placeholder until their hooks ship.

Groups tab:
  - New profile sub-route: <ProfileGroups>. Same toolbar+grid pattern
    as <ProfileCerts> (title + count, search input, sort dropdown,
    responsive card grid).
  - Wired into the profile page tab strip and into desktop-top-bar's
    PROFILE_TABS in the order Overview / Certs / Projects / Groups /
    Endorsements.

Certs grid:
  - Fixed-track 3-column grid: `repeat(3, minmax(0, calc((100% - 48px)
    / 3)))`. With fewer than 3 certs the cards stay capped at 1/3 of
    the container instead of stretching to 50% (or 100%).

Parallel agent contributions also bundled in this commit:
  - `cert-detail.css` + rewritten `<ActivityDetail>` (cert detail page
    redesigned to match the new aesthetic).
  - `profile-edit.css` + rewritten `<ProfileEditForm>` (edit-profile
    page redesigned, supports the new `website` + org `additionalUrls`).
  - `profile-endorsements.css` + rewritten `<ProfileEndorsements>`
    (sub-tabs Received / Given, search + sort, Received default).
  - `profile-projects.css`, `project-detail.css` + `<ProfileProjects>`
    redesign + new project detail route at
    `src/app/project/[did]/[rkey]/page.tsx` with `<ProjectDetail>`,
    `<ProjectCard>`, `useProject`, `useProjectItems`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Make the profile page title (`@handle`) clickable just like the
breadcrumb pattern on cert / project pages. To support pages that only
have one segment, the breadcrumb shape now allows `right` to be
omitted — when only `left` is set the navbar renders a single
clickable title (no separator, no second segment) instead of forcing a
two-part hierarchy.

Both the desktop top bar and the mobile titled-navbar render the
single-part variant gracefully. Cert + project pages keep their
existing two-part breadcrumbs untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the "—" placeholders with real counts from the existing hooks:
  - `useGivenEndorsements` (lives in `use-endorsements.ts`) for the
    Endorsements stat's "given" line.
  - `useUserProjects` for the Projects stat. Reads from the user's PDS,
    filters `org.hypercerts.collection` records by case-insensitive
    `type === "project"`.

No new hooks — both already existed from the earlier sub-agent passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…profile + cert-detail redesigns, org marker

Profile sidebar:
  - Drop the copy-icon button next to the handle; keep it next to the
    DID (where it's actually useful — handles are easy to type).
  - Drop the gear-icon "settings" button next to "Edit profile"; the
    group settings link now lives elsewhere (still passed through the
    `settingsHref` prop for the mobile <ProfileHeader>).

Profile overview:
  - Hide the banner block entirely when no banner is set (was showing
    an empty placeholder strip).

Joined date now reflects the real `createdAt` on the profile record:
  - `/api/resolve-did` route returns `createdAt` from the
    `app.certified.actor.profile` record.
  - `useUserProfile` consumes it instead of the bogus
    `new Date(0).toISOString()` fallback that produced "Joined Jan 1970".

Org-only sidebar fields wired:
  - New hook `useOrgMarker(did)` reads `app.certified.actor.organization`
    via the XRPC proxy, exposes `{ isOrg, additionalUrls, isLoading }`
    with module-level caching + in-flight dedupe.
  - Profile page consumes the hook and forwards `isOrg` /
    `additionalUrls` into <ProfileSidebar>. Hook returns `false` while
    loading so the sidebar stays in non-org mode without flicker.

Parallel agents bundled in this commit:
  - Edit-profile page rewrite (`<ProfileEditForm>` + `profile-edit.css`)
    — fits the 600px reading column, sticky Cancel/Save footer, back
    affordance, section headings, pronouns field restored.
  - Cert detail page goes wide: `:has(.cert-detail--wide)` opens the
    `.app-shell__content` cap up to ~960px for this page only, two-
    column layout under the hero (description left, metadata right),
    collapses to single column under ~720px.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gs tab, settings hub redesign

Account switcher (top bar):
  - Trigger now shows the active account's display name + handle next
    to the avatar (collapses back to avatar-only below 1100px to keep
    the bar compact on narrow desktops).

Smart links (new <SmartLink> component):
  - Detects 14+ services from a URL (X/Twitter, GitHub, Bluesky,
    Instagram, LinkedIn, YouTube, Mastodon, Telegram, Facebook,
    TikTok, Medium, Threads, Discord, Twitch) and renders a brand
    icon + a short display string (e.g. `@handle`, `github.com/owner/
    repo`, `linkedin.com/in/<slug>`). Uses Lucide where available;
    inline SVGs for brands Lucide doesn't ship.
  - Wired into <ProfileSidebar> — replaces the generic Link icon +
    bare URL for both `profile.website` and org `additionalUrls`.
  - Only http/https URLs are rendered as links; everything else
    degrades to plain text (rejects `javascript:` etc.).
  - Website also now flows through the data pipeline: resolve-did
    route surfaces `website` (and `pronouns`) from
    `app.certified.actor.profile`; `useUserProfile` consumes them.

Own-profile Settings tab:
  - New tab in the profile tab strip, appears only when the viewer's
    handle === the profile's handle. It's a top-bar shortcut: clicking
    routes to `/settings` (no in-profile panel).

Settings hub (/settings):
  - Full redesign to live in the new layout: 600px reading column,
    `@handle / Settings` breadcrumb, back-to-@handle affordance, vertical
    list of category rows (Edit profile linked out; Account inline
    items for Username/Email/Password; Appearance with ThemeToggle).
  - Org-active short-circuit (<OrgSettings>) preserved. TODOs marked
    in `page.tsx` for future sub-routes per category.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-pane cert layout

Sidebar groups (left pane on profile):
  - One group per row now (vertical list) instead of a 5-column avatar
    tile grid. Each row shows the avatar + display name + handle.
  - When viewing your own profile, the sidebar consumes the same
    `useOrg().groups` source the account switcher uses, so the two
    lists can no longer drift. Foreign profiles keep the
    `useUserGroups(did)` PDS path (CGS isn't available for them).
  - Sidebar accepts `groupsOverride` + `groupsLoadingOverride` props so
    the profile page can hand the resolved list down explicitly.

Overview stats order:
  - Reordered to match the tab strip (Certs, Projects, Groups,
    Endorsements). Overview is the current page, so Certs leads.

Cert detail page — restructured to mirror the profile page layout:
  - Two columns: 296px slim left pane + fluid main pane (same
    measurements as `.profile-page__layout`).
  - Left opens with a 1:1 SQUARE image (was 16:9 hero), then the
    author byline, then the small Created / Time period / Work scope /
    Rights meta block.
  - Main opens with the title + short description, then the
    Description section, Contributors, and Locations.
  - Reading column on the cert page widens to 1100px (from the prior
    960px) so the two-pane layout has breathing room.
  - Stacks to a single column under 800px.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lish

Account switcher chrome:
  - Trigger border-radius switched from a full pill (999px) to
    `var(--radius)` so it matches the surrounding icon buttons and
    search bar. The textual name + handle now sit inside a rectangle
    consistent with the rest of the top-bar chrome.
  - "Switch to a different account" icon (UserPlus) added to the left
    of the log-out icon in the account-switcher dropdown. Clicking it
    closes the menu and opens the sign-in flow so the user can land on
    a different individual atproto account (the new session replaces
    the current one). Wired in both `<AccountSwitcherList>` callers
    (desktop top bar + mobile navbar).

Inline profile editing:
  - "Edit profile" on the sidebar now toggles `isEditing` rather than
    routing to /settings/edit-profile. While editing, the same slots
    swap into inputs:
      • avatar  ─ <AvatarUpload> overlay on the existing circular slot
      • banner  ─ <BannerUpload> (renders an empty drop zone when not
                                  set, so the affordance is visible)
      • display name ─ inline <input> in the sidebar
      • about        ─ <textarea> in the Overview's About block
      • website      ─ inline <input> in the Overview
    Save / Cancel buttons replace the Edit-profile button. Save reuses
    the existing `putProfile`, `uploadAvatar`, `uploadBanner` helpers
    from `lib/atproto/profile.ts` and best-effort-evicts the resolve-did
    cache so the read-only render reflects the change immediately.
  - Pronouns + org additionalUrls are NOT inline-editable yet; both
    still live behind /settings/edit-profile (preserved on save).
  - Shared `ProfileDrafts` type extracted to
    `src/components/profile/profile-inline-edit-types.ts` to keep the
    page / sidebar / overview from forming an import cycle.

Settings two-pane:
  - /settings restructured into a 296px left menu + fluid right panel
    with hash-based selection (#username / #email / #password /
    #appearance), default = first item. Active item highlighted via
    a subtle pill background; ARIA tablist wiring.
  - "Edit profile" dropped from the menu (now lives inline on the
    profile page); /settings/edit-profile still resolves as a
    fallback. <OrgSettings> short-circuit preserved for active orgs.
  - Reading column for this page widens to 1100px via the same
    `:has()` scoping the cert detail page uses.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rts inline

Replace the flat project-card grid on the Projects tab with a
sectioned layout where each project is its own section: header (thumb +
title + count chip + short description + See all →) followed by a 3-up
grid of cert cards that belong to that project. Pattern is the GitHub
user "pinned repos" / Behance "collections" / Spotify "made for you"
shape — scannable top-to-bottom, every section a self-contained
surface with a clear deep-link into the project detail page.

  - Each section hydrates the project's `items[]` strong-refs via the
    existing `useProjectItems` hook (which already filters non-activity
    refs and surfaces resolutions in order), and renders the first
    three certs as <ActivityCard>s using the same `.feed-card`
    normalisation rules the Certs tab uses. The remaining certs live
    on the project detail page; the "See all →" link only appears
    when there's more than three.
  - Header thumb falls back to a FolderGit2 placeholder on the
    project's `banner` / legacy `image` field via the existing
    resolveActivityImageUrl helper.
  - Cert-count chip optimistic: while items are still resolving we
    show a best-effort count of activity-typed entries from
    `value.items` so the chip doesn't flash "0 certs".

The old <ProjectCard> is deleted (only ever rendered by this file).
profile-projects.css is rewritten end-to-end (`.profile-projects__*`
class set) — no `layout.css` touch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ings scroll-spy + tabs

Account switcher dropdown:
  - Switch-account icon swapped from `UserPlus` to `ArrowLeftRight` —
    a proper "switch" affordance, not "add new user".
  - Per-user log-out icon removed from the user row. Sign-out now
    lives in its own full-width row at the bottom of the dropdown,
    separated by a divider, with `LogOut` icon + "Sign out" label.

Profile edit mode — Cancel/Save banner:
  - When inline-editing, the Cancel/Save buttons no longer live in
    the sidebar's action slot. They render in a sticky banner above
    BOTH the avatar (left pane) and the banner image (main pane),
    spanning the full layout width. Sticky-positioned just under the
    top bar so the actions stay reachable while scrolling.
  - Save errors and the "Editing profile" label render inline in the
    banner.
  - Sidebar action row is hidden entirely while editing.

Settings page:
  - Restructured to a single long scrolling page: every section
    (Username, Email, Password, Appearance) renders at once, stacked.
    Left-rail menu becomes a scroll-spy nav — clicking an item smooth-
    scrolls to that section; the active item highlights as the section
    enters the viewport. Hash-deeplinks (`/settings#email`) still work,
    triggering an explicit `scrollIntoView` on mount.
  - Profile tab strip (row 2 of the desktop top bar) now also renders
    on `/settings`, with the "Settings" tab marked active. Other tabs
    (Overview / Certs / Projects / Groups / Endorsements) route into
    the signed-in user's own profile so the tab strip stays consistent
    across the own-profile context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d project detail

SmartLink:
  - Replaced the lucide-react brand icons + the hand-drawn approximate
    SVGs with the official Simple Icons (https://simpleicons.org) marks
    for every supported service. All paths are single-path 24×24
    currentColor SVGs inlined as small React components — no new
    dependency.
  - twitter.com and x.com both use the X mark now (the post-rebrand
    identity); display still uses `@handle` for both. Removed the
    `Twitter` lucide bird import entirely.
  - Simplified display strings: drop the host prefix on services we
    already recognise, since the icon already establishes which
    platform it is. GitHub renders `owner/repo` (no leading
    `github.com/`); LinkedIn renders just the `<slug>` after `/in/`,
    `/company/`, `/school/`; Facebook / Medium / YouTube short-URL
    forms all collapse to their slug/handle. Unrecognised hosts
    render just the bare hostname (no `/firstSegment`).

Project detail page:
  - Rebalanced toward the project (parallel-agent rewrite of
    `project-detail.tsx` + scoped CSS rewrite). Top of page is now a
    21:9 hero on desktop / 16:9 on mobile, large serif title, lead
    short-description, full description as prose (capped to 68ch).
    Meta lives in a tight bordered card below (Created / Time period /
    Location / Contributors).
  - Certs are clearly secondary: a single bordered list with one row
    per cert (44px square thumb + title + truncated short-desc +
    Award placeholder for missing images). No more full <ActivityCard>
    grid on this page — cards belonged on the profile's Projects tab,
    not on the project detail.
  - `.project-detail--wide` opt-in widens this page's reading column
    to 1100px via the same `:has()` scoping the cert detail uses; no
    `layout.css` touch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… cert detail back row

Inline profile edit:
  - Avatar stays at its read-only 240×240 position+size in edit mode; a
    floating "Change" pill anchored to the bottom-right of the avatar
    is always visible (no longer requires hover) so the affordance is
    obvious. AvatarUpload's small 64px render is no longer used.
  - Banner image now fills the entire banner slot edge-to-edge — the
    grey gradient on the outer wrapper is hidden in edit mode so the
    image doesn't appear to float on top of a grey strip.
  - Website is editable directly inside the sidebar's details list at
    the exact spot where it renders read-only as a SmartLink. The
    website input was removed from the Overview's About section.
  - "Edit profile" button slot is reserved as whitespace in edit mode
    (the row stays at its 36px height so the layout doesn't jump).

Sidebar:
  - Bluesky icon updated to the current official Simple Icons path
    (the earlier path drew a malformed butterfly).
  - Followers row gets margin top + bottom so it's clearly separated
    from the action row above and the social-links list below.
  - The "Joined" entry gets margin-top when it isn't the only item in
    the details list so the date reads as its own visual block apart
    from the social links.

Cert / project detail:
  - The desktop top-bar's row 2 now also renders on `/activity/[…]` and
    `/project/[…]` with a left-aligned "← Back" button (router.back()).
    Keeps the navigation rhythm consistent across the app.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When a viewer is owner / admin of the group whose profile is being
viewed, the same inline-edit affordances that work on the viewer's own
profile (avatar overlay, display-name input, about textarea, website
input, banner upload, top-of-page Save / Cancel banner) now light up
on the group's profile page too. Non-admin viewers are unchanged.

Wiring:
  - `canEditInline = isOwnProfile || isAdminOfThisGroup` on the profile
    page. The same `editing` gate flows down into <ProfileSidebar> and
    <ProfileOverview> unchanged.
  - New `editTargetDid` is set to the viewed group DID when an admin is
    editing a group; otherwise undefined. Threaded into the save and
    blob-upload helpers.
  - `putProfile`, `uploadAvatar`, `uploadBanner`, `uploadBlob` in
    `src/lib/atproto/profile.ts` gain an optional `{ targetDid }`
    parameter. When set and different from the session DID, the helper
    routes through the group BFF endpoints
    (`/api/groups/[did]/profile` for the record write,
    `/api/groups/[did]/upload-blob` for blob uploads) so blobs land in
    the group's repo, not the viewer's. Default path unchanged for
    every existing caller.
  - Sidebar's `sidebarEditHref` group-admin fallback is dropped —
    admins now inline-edit instead of routing out. `mobileEditHref`
    keeps the legacy `/groups/[did]/edit-profile` link since the
    compact mobile header isn't wired for inline edit yet. The eyebrow
    reads "Admin of this group" when applicable.

Deferred (still live on /groups/[did]/edit-profile):
  - Org-only additionalUrls + the org-marker setting
  - Org metadata (founded date, organization type, etc.) and the
    OrgUrls list editor

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Settings page:
  - Drop the duplicate "Settings" segment from the top-bar breadcrumb;
    the row-2 active "Settings" tab already names the page. The
    breadcrumb is now just `@handle`.
  - Left rail sticky offset now clears BOTH top-bar rows
    (`row1 + row2`) so the menu sits flush below the bar and doesn't
    slide off as the main pane scrolls.
  - Clicking a menu item now smooth-scrolls to its section AND adds a
    transient `.sx-section--flash` class (background pulse, 1.4s) so
    the eye lands on the right place.

Inline profile edit clarity:
  - Display-name + about-textarea inputs now use the SAME visual
    treatment as the website input — `1.5px` solid border in
    `--border-hover` over `--bg-elevated`. Old hard-coded `--color-*`
    tokens dropped in favour of the design-system vars.
  - About textarea auto-grows to fit content via a new
    `<AutoGrowTextarea>` wrapper so long bios are never clipped.
  - Banner edit slot shows a centred "Click to add a banner" label +
    a dashed border when no banner is set — uses `:has(img)` so the
    label vanishes the moment an image is uploaded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… flash polish

- Settings: section-flash now animates only the heading background (no border)
- Groups tab: full-width rows with avatar, description and joined date
- Groups tab: own-profile "Private" subtab lists CGS-only memberships;
  per-row Make public / Make private buttons toggle PDS visibility
- Projects tab: project becomes the focal point — shortDescription and
  publish date in the section header, certs collapse to compact rows
- desktop-left-rail: pass onSwitchAccount to AccountSwitcherList

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… cards

- Organization type: multi-select preset chips + free-text "Other" in
  edit mode; rendered as a row of tag pills below the About section.
- Location: free-text name + map pin picker in edit mode; read-only
  shows a Leaflet map to the right of the About block when coords are
  set, making the main pane a two-column layout for orgs with a pinned
  location. Backwards-compatible with the legacy plain-string shape.
- Founded date: replaces the generic "Joined ..." sidebar line when
  present (e.g. "Founded May 2026").
- Endorsement cards: redesigned card layout — avatar + body grid; name
  / handle and date sit on the same flex row so the date never overlaps
  the name; 3-line note clamp; owner-only kebab floats top-right with
  reserved padding so it doesn't crowd the date.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…le labels

- Endorsement cards: bumped padding-right when the owner kebab is
  present (28px → 40px) so the date no longer reads as overlapping
  the floating action menu; softer 12px-radius card surface, subtle
  shadow on hover for visual lift.
- Title: dropped the `@` sigil from every breadcrumb / page-title
  fallback (profile, settings, edit-profile, activity, project) and
  from the sidebar h1 fallback for users without a displayName.
- Edit profile (orgs): added a "Location" section heading above the
  pin picker; "Clear pin" button no longer wraps; org-type chip's
  active state now uses the canonical primary-button token pair
  (--btn-primary-bg/-fg) so the selected text stays readable instead
  of using the non-existent --bg-base.
- Long description heading renamed to "Description (optional)".
- Founded-date sidebar input gets a leading "Founded" label.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Settings: extracted the two-pane scroll-spy layout into a shared
  <SettingsPanel> component; rendered both at /settings (preserved as
  a deep-link target) and as a real ?tab=settings panel on the user's
  own profile. The top-bar Settings tab now uses the normal tab-href
  logic (no more /settings shortcut), so the URL becomes
  /profile/<handle>?tab=settings consistently.
- Banner upload: refactored to mirror the avatar's always-visible
  Camera button instead of hover-reveal — both affordances now share
  the same interaction model. Empty banner renders a grey gradient
  box with the same "Change banner" pill so the upload path is
  identical with or without an image.
- Sidebar edit: added "Main website" / "Additional links" sub-headers
  above the corresponding inputs so each block has a clear label.
- Removed the Groups stat from the profile-overview stat row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The viewer must be currently signed in *as* the entity being viewed —
being an admin of a group is no longer enough. For a group, the user
must switch into it via the account switcher; for a personal profile,
the active org must be null.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Holke Brammer and others added 23 commits May 17, 2026 22:21
Two small polish items:
  - The cert and profile edit banners use the same <EditBanner>
    component but landed at different vertical offsets: the
    `.cert-detail-page` wrapper has 24px top padding (for non-edit
    content's breathing room) while `.profile-page` has none, so
    the cert banner sat 24px lower. Added a `:has(.profile-edit-banner)`
    rule that drops the wrapper's top padding when the banner is
    present — the banner's own 16px top margin then provides the
    same gap on both surfaces.
  - On the Overview tab's Contributors section, the "See all"
    affordance moved out of the section header (where it shared
    space with the title and count) and into a centered link
    beneath the list. New copy reads "X contributors — see all"
    so the click target is unambiguous.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… apply

The certs tab split was previously exclusive: each record went
into either Created (author) OR Contributed (else). A cert where
the viewer is both author AND contributor only appeared under
Created — and asking the indexer with `_or` and splitting locally
made that hard to fix.

Refactored the data layer to fire two parallel indexer queries
(`where: { did: { eq } }` and `where: { contributor: { eq } }`)
instead of one OR'd query, and return two independent lists. A
cert matching both filters appears in both lists naturally.

Plumbing changes:
  - `fetchUserIndexerActivities` gains a `mode: "authored" |
    "contributed"` option. Default stays "authored" for source
    compat; the hook calls it twice.
  - `useUserIndexerActivities` now returns `{ created, contributed,
    dids, ...}` with independent pagination state per bucket. The
    combined `dids` map is still exposed for consumers that key on
    author DID per URI (the certs feed-layout still uses it).
  - profile-certs drops the local author-vs-contributor split and
    consumes the two lists directly.
  - profile-overview's stat counts and recent-certs digest
    re-derive from the two buckets (digest dedupes across them).

Same indexer limitation as before: strong-ref contributor
identities aren't matched by `contributor.eq` server-side — that's
the separate indexer fix tracked alongside hb-agent/magic-indexer#81.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…settings sync

Profile / social graph
- New Followers tab with Followers/Following sub-tabs (URL-driven)
- Sidebar follower/following counts deep-link into the matching sub-tab
- Follow / Unfollow button with optimistic updates + dedup
- Endorse button moved to sidebar; opens reason-capture modal
- Per-card × revoke on Given endorsements, Following cards, list items
- About tab hidden on individual profiles (org-only)
- Bluesky-import tag treatment on imported profiles (hides Joined date)

Endorsement lists
- New "Lists" section on the Endorsements tab (above Received/Given)
- Create / edit / delete a list (delete also removes linked awards)
- "+ Add people" reuses the multi-endorse modal bound to the list's badge
- List detail view: ←Back + title left-aligned, Edit/Delete on the right
- Compact "No lists yet" line on foreign profiles

Endorsements UX
- PersonCard layout: name / @handle / date / list-name (row 4) stacked
- Reason field on single-target + multi-target endorse modals
  (500-char cap, live counter); list awards skip the reason capture
- Owner-only filter dropdown on Received: hide rejected (default) /
  only rejected / show all; explanatory note about response visibility
- Hooks attach `listTitle` per award (Given + Received)

Indexer-side wins (already deployed)
- Received endorsements: 2 indexer calls instead of per-issuer PDS scan
- Magic-indexer issues filed for nested-where on badge.definition,
  array-element where on collection.items, and per-def awardCount

Projects tab
- Boxed sections with large hero image; "Certs" sub-section per box
- Cert rows show image + title + time period (start–end)
- Owner-only "Create new project" CTA → /project/new placeholder

Settings
- New "Sync social graph" section in personal + group settings; compares
  Certified vs Bluesky follow sets, "Import all" or paginated picker
- Group BFF route /api/groups/[did]/follow for group-scoped follow writes
- Identity-switch flow preserves the pre-signin path (rewrites
  /profile/<old-handle> → /profile/<new-did> for identity-scoped URLs)

Layout / chrome
- Top bar no longer sticky; whole page scrolls together
- Edit banner shared between profile and cert pages; padding/width unified
- Dark mode lightened (zinc-900 surface band)
- Cert image placeholder centered; "Activity" page-title flash removed
- Settings menu jitter fixed
- Modal radius convention codified in DESIGN.md §11:
  new `.app-modal` class returns sign-in chrome to 2px radius
  (sign-in modal stays the intentional 20px exception). Applied to
  every existing dialog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ession

Adds §15a (Social Graph + Endorsements) covering lexicons, write
helpers, hooks, card/modal patterns, indexer migration state, and the
optimistic-state reconciler.

Adds the modal radius rule to §11 (sign-in keeps the 20px exception;
everything else needs `.app-modal`).

Adds 7 new entries to §22 Common Pitfalls — forgotten `.app-modal`,
clearing optimistic state in `finally`, reverting the new vertical
PersonCard layout, `listTitle` privacy assumption, group follow
writes without `targetDid`, rejected-endorsement privacy, and
static-vs-dynamic route precedence at `/project/new`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
INDEXER_URL, INDEXER_DID, NEXT_PUBLIC_INDEXER_URL,
NEXT_PUBLIC_STADIA_API_KEY, and the group-service URL/DID pair are
all read by production code but were absent from the example. The
most consequential omission is INDEXER_URL: when unset the indexer
proxy falls back through NEXT_PUBLIC_INDEXER_URL to a hardcoded dev
URL (magic-indexer-dev.up.railway.app), so a production deploy that
follows the example would silently route every feed and notifications
query at the dev indexer.

The Stadia entry also documents that the key is bundle-public by
design and that Stadia's intended enforcement is per-domain Referer
allowlist on the Stadia dashboard.

Docs-only; no runtime change in this commit. The matching prod-warn
on missing INDEXER_URL lands in a follow-up indexer-route commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… memoization

Two pre-existing ESLint errors gated tonight's "no new lint errors"
contract. Both have small, semantically-identical fixes:

useMergedDidsMap (use-user-indexer-activities.ts): the helper
reinvented `useMemo` using `useRef` + `setMerged` during render to
get "stable reference unless inputs change." Under StrictMode the
ref mutation persists across discarded renders and the second pass
sees the cached pair and skips the setState, leaving stale state.
Replaced with `useMemo(() => mergeMaps(a, b), [a, b])`. Same
semantic, no setState-in-render, lint-clean. Closes 5 of 6 errors.

useSocialGraphSync.refetch: the prior useCallback depended on
`certified` (the whole object returned by useFollowing — a fresh
object literal each render), and the body had a dead
`bluesky ? Promise.resolve() : Promise.resolve()` ternary. The fresh
dep array defeated downstream memoization and the React Compiler
bailed out. Destructure useFollowing and useBlueskyFollows so the
closures close over individually-stable callbacks; drop the dead
ternary. Closes the 6th error.

Lint baseline goes from 45 problems (6 errors, 39 warnings) to
38 problems (0 errors, 38 warnings).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the XSS class from AGENTS.md §22 pitfall #11: user-controlled
URLs were rendered into `<a href={url}>` and `iframe src` without any
scheme allowlist, so a `pub.leaflet.pages.linearDocument` record on
any federated PDS could carry a `javascript:` URI that fires when a
viewer clicks the link in a cert / profile / long-description.

Five sites, one existing helper (`safeHttpUrl` from
`src/lib/utils/safe-url.ts`) applied:

- leaflet-document.tsx renderIframe — iframe fallback `<a>` when the
  host is not in the embed allowlist. `isAllowedEmbedHost` only
  inspects `hostname`, so `javascript:` (no host) fell through to
  the fallback anchor with the raw URI.
- leaflet-document.tsx applyFacets — facet `<a href={linkUri}>` for
  bold/italic/link inline runs. The same `javascript:` payload could
  reach here via a foreign linearDocument record.
- leaflet-iframe-node.tsx — TipTap node-view's unsupported-embed
  fallback `<a>` was the in-editor mirror of the same bug.
- leaflet-editor.tsx handleLinkConfirm — TipTap's Link extension only
  runs `isAllowedUri` on `setLink`/`toggleLink`; the no-selection path
  uses `insertContent` which bypasses validation. Scheme-allowlist
  both branches before write.
- link-dialog.tsx — reject non-http(s) URLs at submit with an inline
  error so the user gets immediate feedback instead of a silent drop.

Defense in depth on the (de)serializer boundaries closes the
re-publish path under the user's identity:

- from-tiptap.ts marksToFeatures — drop the FEATURE_LINK when the
  href fails the allowlist. Without this, a malicious foreign record
  hydrated into the editor and then saved would re-publish the URI
  under the user's DID.
- to-tiptap.ts featureToMark — drop the link mark on rejection so
  the editor surfaces the plain text and the in-memory JSON never
  carries the URI.

Render-side rejections degrade to plain text (`<span>`) rather than
silently dropping the content, so the user can still see the URL
without one-click execution.

CSS: new `.link-dialog__error` rule sized like `.link-dialog__hint`
and themed via `--color-error`.

tsc clean. Lint 38/38 (0 errors). Build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…Error

AGENTS.md §17 #7 and §24 #8 both state explicitly that "4xx errors
*can* echo upstream messages — those are usually validation a user
can act on." The helper, however, was returning generic strings
("Bad request" / "Forbidden" / etc.) for every status, so every
route using it surfaced opaque errors that masked actionable upstream
detail. A user submitting an invalid group handle, for example, got
"Bad request" instead of the upstream's "Handle must be at least 3
characters". The XRPC proxy already did the right thing (xrpc/route
echoes for 4xx, generic for 5xx); this brings the shared helper in
line with the documented policy and the XRPC proxy precedent.

Also: clamp the upstream-supplied status to the valid HTTP range
(200..599). The function previously trusted any integer on
`err.status` / `err.statusCode`, which would pass through to
NextResponse — caches and browsers handle non-standard codes
inconsistently. Anything outside the valid range now collapses to
500.

5xx behavior is unchanged: still generic message, still logged.
Echoed 4xx messages pass through a redactSecrets pass that strips
Bearer tokens, DPoP material, and bare JWTs that the atproto SDK
occasionally embeds in error messages.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… extractRouteError logSafe

extractRouteError already logs via logSafe (which strips
JWT/DPoP/Authorization material that the atproto SDK occasionally
embeds in err.cause). The three routes also called
`console.error(label, err)` before invoking the helper, which both
duplicated the log line and bypassed the redactSecrets pass —
defeating the very mitigation logSafe exists for.

Replace with a single extractRouteError call carrying a
route-tagged prefix (AGENTS.md §24 #9 convention) so the resulting
log line is greppable.

Affected: /api/groups/[groupDid]/{profile,metadata,upload-blob}

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s-assignment

The activity PUT was the one group BFF write route that didn't
field-allowlist its `record` body. Sibling routes (/profile,
/metadata, /location) all narrow via pickAllowedFields() or a
hand-rolled set; this brings activity into line.

ALLOWED_ACTIVITY_FIELDS mirrors the lexicon's ClaimActivity in
src/lib/atproto/activity-types.ts: title, shortDescription,
createdAt, shortDescriptionFacets, description, image, contributors,
workScope, startDate, endDate, locations, rights. Unknown / future
/ accidental keys on the caller's body are now dropped at the BFF.

CGS may also validate upstream but defense-in-depth at the BFF
matches the pattern established in AUDIT_REPORT.md CS-005 for
/profile and /metadata. Updating the lexicon-aware allowlist becomes
the contract for adding any future activity field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes four issues on the geocode route:

- Auth: GET handler now requires a session DID. Geocode UI is only
  mounted on edit screens (cert / profile / group), so gating is
  invisible to legitimate users and closes the open-internet abuse
  surface (anonymous traffic burning Nominatim quota and rate-
  limiting our egress IP for everyone else).
- 5xx hygiene: the non-2xx and catch branches no longer echo the
  upstream status string. The bodies return generic strings; the
  upstream status moves into a logSafe context so operators can still
  diagnose Nominatim health. Mirrors AGENTS.md §17 #7.
- Input parsing: limit is now Number()+isInteger rather than
  parseInt — rejects "3.7" / "3abc" / leading-whitespace tricks
  consistently instead of silently truncating.
- Server logging: the catch was using bare console.error; switched
  to logSafe with the existing [geocode] prefix.

Also: getAuthenticatedAgent silently swallowed client.restore
failures (deleting the session and returning null) with no log. Every
group BFF route's session-expiry event was invisible in Vercel logs.
Add logSafe matching the XRPC proxy's existing pattern at
src/app/api/xrpc/[...method]/route.ts:152.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…_URL in production

Two operability gaps on the indexer proxy:

Mutation gate: the proxy forwarded the entire request body verbatim
to the upstream /graphql endpoint with no operation-type check.
CSRF is enforced (same-origin only) and there's no auth gate by
design (feed is publicly readable), but any same-origin context —
including an XSS payload — could call arbitrary GraphQL operations
through the proxy. The notifications route does this right
(server-held queries + operation allowlist + variable scrubbing);
this commit adds the bare-minimum for the indexer route: parse the
body as JSON, reject any request whose `query` (after leading
whitespace + GraphQL `#` line-comments) starts with `mutation`.
The full operation-allowlist restructure is the right answer but
out of scope for tonight; this closes the obvious abuse path.

Production warn: when both INDEXER_URL and NEXT_PUBLIC_INDEXER_URL
are unset, the route falls back to a hardcoded dev URL
(magic-indexer-dev.up.railway.app). A production deploy that misses
the env var would silently route every feed query at the dev
indexer with no operator-visible signal. Add a module-load
`console.warn` mirroring the existing notifications/route.ts
pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…o editor

The value-sync effect compared the new external `value` against
`lastExternalRef.current` — the *prior* external doc. On a controlled
form (the cert-edit and profile-edit inline-edit paths) every
keystroke fires the editor's onUpdate, which sets parent state, which
re-renders this component with a fresh `value` whose
`toInitialDoc(value)` doesn't shallow-match the still-stale
`lastExternalRef`. setContent fires, ProseMirror runs a
`tr.replaceWith(0, doc.content.size, …)`, and the selection /
cursor is destroyed even though `emitUpdate:false` suppresses the
change event.

Compare against `editor.getJSON()` instead — that's the actual
source of truth for "what's currently on screen". When the new
`value` is the round-tripped echo of what the editor just emitted,
the shallow-equal hits and we skip the setContent. External resets
(parent setting a totally different `value`) still flow through.

`lastExternalRef` is removed; the comment block above the effect
explains the previous wrong shape and the new comparison.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When isAuthenticated flipped to false, the effect cleared the
module-level cache but left local state on each consumer untouched.
Components that mounted while a user was signed in kept returning
the prior identity's handle/email until they unmounted and
remounted — visibly stale data in the settings panel, account
switcher, and any consumer of useSession that survives a sign-out.

The initial-state expressions at the top of the hook only gate on
isAuthenticated for *fresh* mounts; existing mounts re-run the
effect but not the initial-state expressions. Add setHandle(null),
setEmail(null), setError(null) to the sign-out branch so existing
mounts observe the change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…on expiry

Follow-on from the geocode-auth commit: the geocode helper still
used raw fetch, so a 401 from the now-gated /api/geocode would be
silently swallowed (catch-and-return-null), and the user would see
"no suggestions" instead of being told their session expired.

Swap to authFetch per AGENTS.md §22 pitfall #2 — raw fetch on
auth-bearing routes silently fails; only authFetch's 401
interceptor surfaces the session-expiry UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two leaks in the cert inline-edit image lifecycle:

On save, the pending preview URL was *moved* into localImageUrl
(setLocalImageUrl(pendingImagePreviewUrl) + setPendingImagePreviewUrl(null))
without revoking the prior localImageUrl. Every edit-then-save cycle
left the previous mirror's blob URL referenced by nothing,
unrevoked, leaking for the page's lifetime. Now wrap the promotion
in a functional setter that revokes the prior value first.

On unmount, neither URL was revoked. A user who navigates away
mid-edit leaks the pending preview until tab close. Add an unmount
cleanup that revokes both — using refs (not effect deps) so the
cleanup only fires on unmount, not on every state transition. A
deps-based cleanup would revoke the URL we just promoted on the
save path (where pending → null and localImageUrl ← pending in the
same batch), killing the image we just saved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The group-follow PUT hardcoded createdAt to the server's clock,
silently dropping any timestamp the client sent. For social-graph
sync that's wrong: the user's intent for an imported Bluesky
follow is to preserve the original timestamp ("I followed X in
2023"), not to stamp every imported follow with the import time.

Accept an optional createdAt on the body, validate it as a
parseable ISO-8601 string (junk → fall back to server time), and
pass it through to the record. The personal-repo path
(createFollow → /api/xrpc/createRecord) already takes whatever the
client puts in the record body; this brings the BFF route into
parity.

Thread an optional createdAt through createFollow in
src/lib/atproto/follow.ts so callers (use-social-graph-sync's
import flow next) can opt into the original timestamp.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The from-tiptap writer wrote any nested list — bullet OR ordered —
into the ListItem's `children` field. The to-tiptap reader hydrates
`children` as a bulletList and only emits an orderedList when
`orderedListChildren.children` is present. Round-trip: user creates
a nested ordered list → reopens it as a nested bullet list. Silent
data-loss visible on the next edit.

Split the writer to route nested bulletList → `children` and nested
orderedList → `orderedListChildren.children`, mirroring the
reader's existing asymmetric handling. The ListItem schema already
has both fields (see src/lib/leaflet/types.ts:65-69).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… isWriting in finally

Two fixes on the social-graph-sync write path:

importDids had no abort path. The serial for-loop awaited
createFollow per DID without checking caller cancellation, so a
user who closed the import modal mid-loop kept writing follows to
their repo and the loop kept calling optimistic addFollow on the
unmounted modal's parent — populating the module-level cache with
rows the user thought they cancelled. Accept opts?: { signal? },
check signal.aborted between iterations. SyncModal now scopes an
AbortController to its lifetime and aborts on unmount.

isWriting could leak true on refetch failure. The previous shape
called setIsWriting(false) outside any try/finally, after a
post-loop `await certified.refetch()`. If refetch threw, the modal
stayed stuck on "Importing…" forever. Wrap the whole import in
try { … } finally { setIsWriting(false) }.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…t-detail__image rule

Three small CSS hygiene fixes:

--danger fallback: 16 occurrences of `var(--danger, #d44)` across
feed.css, social-graph-sync.css, and profile-endorsements.css. The
`--danger` variable is not declared anywhere in tokens.css or
globals.css, so the rule always resolved to `#d44` — a fixed color
that ignores the light/dark theme split (tokens.css already declares
`--color-error: #ba1a1a` for light and `#f87171` for dark). AGENTS.md
§11 rule 3: reuse the CSS variables. Replace all 16 with
`var(--color-error)`.

100vw in leaflet.css:473: `.long-description-modal { max-width:
min(720px, calc(100vw - 32px)); }` reintroduces the AGENTS.md
pitfall #13 pattern — `100vw` includes the scrollbar width and pushes
the dialog past the viewport edge when a vertical scrollbar is
present. Use `100%` (resolves to the dialog's containing block, the
viewport when showModal()'d) so the math doesn't break.

cert-detail.css duplicate: `.cert-detail__image {}` was defined
twice, with the second occurrence just adding `position: relative;`
needed by the inline-edit absolute-positioned chrome. Cascade merges
them today, but the split is confusing and would silently break if
anyone reordered the file. Move `position: relative;` into the
first declaration; drop the second.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ain)

Patch-within-minor bump. Closes the npm audit "high" advisory on
Next 16.2.3 covering the App Router CSP-nonce XSS chain,
cache-poisoning, middleware bypass, and a handful of DoS vectors.
None of the specific exploits apply to this app today (no
middleware, no CSP nonces, no Image Optimization disk cache use),
but staying on a CVE'd minor is friction we don't need.

eslint-config-next bumped in lockstep to match (16.2.3 → 16.2.6).

Verified: tsc clean, lint baseline unchanged at 0 errors / 38
warnings, next build green. npm audit drops from "1 high + 1
moderate" to "0 high + 3 moderate" (the remaining moderates are
all rooted in postcss <8.5.10 reachable via next's nested
dependency; addressing requires either a postcss bump that depends
on Next dropping the constraint, or a forced resolution. Deferred
as separate dep-hygiene work — `npm audit fix --force` proposes
downgrading next to 9.x which is obviously wrong.)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
follow.ts had two inline `data.error || ${fallback}: ${res.status}` blocks
that diverged from cert.ts / location.ts / org-marker.ts / profile.ts
which all use extractError(res, fallback). The string-format difference
("Failed to create follow on group: 503" vs. just "Failed to create
follow on group") leaked into the UI on error.

Normalize on extractError. Also split the !res.ok and missing-uri/cid
branches into distinct throws so the failure modes are
distinguishable in logs ("upstream returned no record reference" is a
shape problem, not a transport error).

This is the minimal slice of the architecture-lens "dual-path write
helper" recommendation (F-11). The full helper extraction was
considered and deferred (see 04-mini-review-3.md) — the body shapes
across the five call sites diverge too much to fit one helper
without each caller still pre-shaping both branches, so the
abstraction would burn complexity for shallow savings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…nal review

Full deep-flow trail for the 17 fix commits between ad6668c and
this commit:

- 00-orientation.md         Phase 0 — repo shape, stack, baseline gates
- 01-review-plan.md         Phase 1 — lenses, time budget, stopping rule
- 02-findings.md            Phase 2 — consolidated findings (6 lenses)
- 02-findings-lens-6-perf-a11y.md   My own sequential pass
- 03-implementation-plan.md Phase 3 — triage + deep-flow plans per fix
- 04-mini-review-{1,2,3}.md Phase 4 checkpoints after commits 5, 10, 17
- 05-final-review.md        Phase 6 — fresh-eyes pass over the full diff

Per the overnight brief, the operator should be able to reconstruct
my reasoning from these alone without asking. Per-lens raw transcripts
(Security, Correctness, Architecture, Reuse, API/Ops) are summarised
in 02-findings.md rather than committed as standalone files — they
live in 02-findings.md's "Cross-lens consensus" and finding bodies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented May 18, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
certified-app Ready Ready Preview, Comment May 18, 2026 2:53am

Request Review

@coderabbitai

coderabbitai Bot commented May 18, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 831452a2-2a01-407a-87cb-507b40a16288

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/positioning-redesign

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@hb-agent
hb-agent marked this pull request as ready for review May 18, 2026 08:09

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@hb-agent
hb-agent merged commit ccb4f83 into staging May 18, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants