Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
98 commits
Select commit Hold shift + click to select a range
066fcd4
feat(layout): desktop top bar + profile Overview tab
holkexyz May 16, 2026
14ff385
fix(layout): wrap DesktopTopBar in Suspense for static prerender
holkexyz May 16, 2026
4e19e45
feat(profile): GitHub-style two-column Overview tab
holkexyz May 16, 2026
0fde337
fix(profile): drop in-page tab strip; top bar is the sole tab nav
holkexyz May 16, 2026
5d8409f
feat(profile): persistent sidebar + Certs/Projects tabs
holkexyz May 16, 2026
7765799
chore(groups): point default CGS at production Railway deployment
May 16, 2026
46a22a2
fix(auth): support atproto OAuth on localhost via loopback metadata
May 16, 2026
ab1acf2
feat(profile): redesigned top bar, GitHub-style cert breadcrumb, new …
May 16, 2026
e7c0e0c
fix(auth): scope 401 sign-out trigger to session-bearing routes only
May 16, 2026
f371cb4
Revert "chore(groups): point default CGS at production Railway deploy…
May 16, 2026
29c9f82
fix(groups): default CGS to the real production host (groups.certifie…
May 16, 2026
9b17eb4
fix(blob-ref): unwrap magic-indexer's stringified \`map[\$link:<cid>]…
May 16, 2026
1afa9c4
feat(profile): big redesign — sidebar, overview, certs grid, Groups t…
May 16, 2026
9f73677
feat(navbar): single-part breadcrumb for profile pages
May 16, 2026
81677f6
feat(overview): wire Given endorsements + Projects stats to live hooks
May 17, 2026
57f049f
feat(profile): banner-hide, real joined date, sidebar cleanups, edit-…
May 17, 2026
1999fc5
feat: expanded account switcher, smart link icons, own-profile Settin…
May 17, 2026
abb9dc3
feat(profile + cert detail): vertical groups list, stats reorder, two…
May 17, 2026
6ed0841
feat: in-place profile editing, settings two-pane layout, switcher po…
May 17, 2026
b747fe2
feat(profile/projects): sectioned layout — every project shows its ce…
May 17, 2026
370a3b7
feat(profile + settings): edit banner, switch icon, signout row, sett…
May 17, 2026
57e7484
feat: official Simple Icons in SmartLink, simpler displays, rebalance…
May 17, 2026
4bb73ea
fix: edit-profile UX clarity, official Bluesky icon, sidebar spacing,…
May 17, 2026
6cbb01e
feat(profile): inline edit parity for group profiles
May 17, 2026
51c937b
feat: settings polish, edit-profile clarity, in-flight agent groundwork
May 17, 2026
64ee33d
feat(profile): private groups subtab, compact project certs, settings…
May 17, 2026
af206ee
feat(profile): org type tags, location map, founded date, endorsement…
May 17, 2026
bcb8a7d
fix(profile): endorsement card spacing, org-type contrast, edit-profi…
May 17, 2026
170dd69
feat(profile): Settings as a tab, unified banner upload, sidebar headers
May 17, 2026
7378bc3
fix(profile): gate Edit profile button on exact active identity
May 17, 2026
5f12084
fix(profile): expand stat tiles from 4-col to 3-col after Groups removal
May 17, 2026
437f5e1
fix(profile): show new avatar/banner immediately after picking + afte…
May 17, 2026
f57d47f
feat(profile): swap profile sidebar for settings menu on settings tab
May 17, 2026
df22307
fix(profile): match avatar change button to the banner change pill
May 17, 2026
7e631c3
feat(profile): warn on leaving inline-edit with unsaved changes
May 17, 2026
75be4cf
fix(profile): route group-profile saves through the BFF, not the XRPC…
May 17, 2026
7fdd281
feat(profile): remove banner without replacing
May 17, 2026
c74a27a
feat(leaflet): TipTap editor + neutral renderer for linearDocument
May 17, 2026
24e1d0d
fix(leaflet): empty lines, H1, height, list markers, simpler chrome
May 17, 2026
e3c365a
fix(leaflet): heading rhythm + persistent empty-line in editor
May 17, 2026
d953a8b
feat(leaflet): site-styled link dialog replaces window.prompt
May 17, 2026
d425a98
feat(profile): "more" link → long-description modal
May 17, 2026
d4f2d92
fix(leaflet): single-scroll modal so rounded corners stay clean
May 17, 2026
8ee2674
feat(leaflet): image upload + YouTube/Vimeo embed blocks
May 17, 2026
9ceb7de
fix(leaflet): YouTube embed showing "This content is blocked"
May 17, 2026
85bb7f5
fix(csp): allowlist YouTube + Vimeo in frame-src for embedded videos
May 17, 2026
e03051f
feat(profile): About tab + inline image preview from local file
May 17, 2026
72f2652
chore(profile): move About tab to after Endorsements, before Settings
May 17, 2026
8d5577b
chore(sidebar): hide Groups section when the profile has none
May 17, 2026
a9da053
fix(profile): hide Groups tab on foreign profiles with no memberships
May 17, 2026
7abe081
feat(profile): move longDescription editor to the About tab
May 17, 2026
307fa9f
fix(profile): always show Groups + About tabs when viewing your own
May 17, 2026
a3a1985
feat(profile): two-way location bind + app.certified.location record
May 17, 2026
f51903b
feat(profile): autocomplete dropdown for location input
May 17, 2026
25c1cdd
feat(top-bar): brandmark links to the active identity's profile
May 17, 2026
39010b3
feat(profile): org-type chips in the About column + EPSG:4326 SRS
May 17, 2026
fca1912
feat(map): allow zoom on the location read column
May 17, 2026
89feed7
feat(profile): empty-state prompt on the About tab for own profile
May 17, 2026
4d7a6df
feat(groups): two-pane settings layout matching personal accounts
May 17, 2026
b0dd12a
fix(sidebar): split joined-vs-founded date by entity type
May 17, 2026
182ab41
fix(sidebar): consistent URL spacing + no duplicate Founded row in edit
May 17, 2026
a95426e
feat(cert-detail): Overview / Description / Contributors tab strip
May 17, 2026
d077054
feat(cert-detail): project section in main pane, "more" link, Edit btn
May 17, 2026
42ece6d
fix(cert-detail): drop Created aside row, Back skips tab history
May 17, 2026
d759222
feat(profile-edit): reorder URLs with up/down arrows
May 17, 2026
9a70db3
fix(settings): unify cog destination + group-settings tab render
May 17, 2026
47c2f81
fix(cert-detail): show Edit for acting-as-group viewers too
May 17, 2026
df5aa8a
fix(cert-detail): gate group Edit on owner/admin role, not membership
May 17, 2026
6589119
fix(cert-detail): square Edit button + Created label in byline
May 17, 2026
1acc05a
feat(map): render geojson-polygon locations on the cert map
May 17, 2026
41e40f0
feat(cert-detail): inline edit (title, short desc, image, description)
May 17, 2026
d0492b6
feat(layout): minimal GitHub-style footer
May 17, 2026
322e1a3
fix(cert-edit): banner spans full width above the 2-column layout
May 17, 2026
8198937
feat(cert-detail): cap Overview contributors at 5 with "See all"
May 17, 2026
7091618
chore(layout): shared EditBanner, content alignment, sticky stable, f…
May 17, 2026
f921794
fix(cert-detail): banner alignment + centered "See all" footer
May 17, 2026
d3ea780
feat(certs-tab): show certs in both Created and Contributed when both…
May 17, 2026
1f822f1
feat(profile): social graph + endorsements lists, projects redesign, …
May 18, 2026
ad6668c
docs(agents): codify modal radius + social-graph patterns from this s…
May 18, 2026
b85d45f
docs(env): declare missing env vars in .env.local.example
holkexyz May 18, 2026
65630f3
chore(lint): clear baseline by replacing ref-during-render and broken…
holkexyz May 18, 2026
e43edba
fix(leaflet): scheme-allowlist user-controlled URLs in renderer + editor
holkexyz May 18, 2026
94ba191
fix(api): echo 4xx upstream messages and clamp status in extractRoute…
holkexyz May 18, 2026
eee165d
fix(api): drop duplicate console.error in three group routes; rely on…
holkexyz May 18, 2026
89da494
fix(api/groups/activity): allowlist record fields on PUT to close mas…
holkexyz May 18, 2026
048855b
fix(api/geocode): require session, sanitize 5xx, tighten input parsing
holkexyz May 18, 2026
c404817
fix(api/indexer): reject mutation operations; warn on missing INDEXER…
holkexyz May 18, 2026
24a8084
fix(leaflet/editor): preserve cursor when external value catches up t…
holkexyz May 18, 2026
ac72a8c
fix(hooks/use-session): clear handle/email/error on sign-out
holkexyz May 18, 2026
fc4d746
fix(locations): use authFetch for geocode calls so 401 surfaces sessi…
holkexyz May 18, 2026
1fd99c6
fix(activity-detail): revoke prior object URL on save + unmount
holkexyz May 18, 2026
a0479be
fix(api/groups/follow): preserve client-supplied createdAt
holkexyz May 18, 2026
a2dc45e
fix(leaflet): preserve ordered nested lists in linearDocument round-trip
holkexyz May 18, 2026
402fde2
fix(hooks/social-graph-sync): thread abort signal through importDids;…
holkexyz May 18, 2026
122965a
fix(styles): use --color-error token; drop 100vw; merge duplicate cer…
holkexyz May 18, 2026
952a343
chore(deps): bump Next.js 16.2.3 -> 16.2.6 (high-severity advisory ch…
holkexyz May 18, 2026
08e0691
chore(atproto/follow): use extractError to match sibling write helpers
holkexyz May 18, 2026
6d1e265
docs(overnight): orientation, review plan, findings, mini reviews, fi…
holkexyz May 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,36 @@ COOKIE_SECRET=dev-secret-change-in-production
UPSTASH_REDIS_REST_URL=
UPSTASH_REDIS_REST_TOKEN=

# Required in production: Magic Indexer GraphQL endpoint and DID.
#
# INDEXER_URL is consumed by the server-side /api/indexer and /api/notifications
# proxies. If unset, the code falls back to NEXT_PUBLIC_INDEXER_URL, and then to
# a hardcoded dev URL (magic-indexer-dev.up.railway.app). A production deploy
# with INDEXER_URL unset will silently route every feed/notifications query at
# the dev indexer — set this explicitly in prod.
#
# INDEXER_DID is required for the notifications JWT `aud` claim. Without it,
# /api/notifications returns 503 and logs a module-load warning.
INDEXER_URL=https://magic-indexer-dev.up.railway.app/graphql
INDEXER_DID=

# Deprecated alias for INDEXER_URL; still read for backwards-compat. Prefer
# INDEXER_URL above and leave this unset on new deploys.
# NEXT_PUBLIC_INDEXER_URL=

# Optional: Group service URL and DID (CGS). Defaults to the production CGS.
# NEXT_PUBLIC_GROUP_SERVICE_URL=https://groups.certified.app
# NEXT_PUBLIC_GROUP_SERVICE_DID=did:web:groups.certified.app

# Optional: Stadia Maps API key for map tiles. When unset, the map falls back
# to Carto tiles (also free for basic use).
#
# Note: This is a NEXT_PUBLIC_ var, so the key is inlined into the client
# bundle — it is NOT secret. Stadia's intended enforcement is per-domain
# Referer allowlist configured on the Stadia dashboard. Set the allowlist
# there to your production domain(s) before relying on the key in prod.
# NEXT_PUBLIC_STADIA_API_KEY=

# Optional: Set to enable confidential client (private_key_jwt) authentication
# in production. Ignored in loopback dev mode (the spec mandates
# token_endpoint_auth_method: none for loopback clients).
Expand Down
70 changes: 67 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Certified is a passwordless identity platform built on **AT Protocol** (atproto)
- `app.certified.actor.membership` — user-side record of group memberships.
- `app.bsky.actor.profile` — fallback profile (for Bluesky discoverability).
- `org.impactindexer.link.attestation` — EIP-712 wallet attestation linking an EVM address to a DID.
- **Group service** — a separate atproto service (currently `atproto-group-gate-staging.up.railway.app`) that manages multi-user organizations. The app proxies all group operations through the user's PDS using a custom `certified_group` proxy pattern with custom NSIDs (`app.certified.group.*`).
- **Group service** — a separate atproto service (currently `groups.certified.app`) that manages multi-user organizations. The app proxies all group operations through the user's PDS using a custom `certified_group` proxy pattern with custom NSIDs (`app.certified.group.*`).

## 2. Tech Stack

Expand Down Expand Up @@ -105,8 +105,8 @@ Source: `.env.local.example` and `src/lib/utils/config.ts`.
| `RESEND_API_KEY` | optional | Resend key for `/api/feedback`. |
| `RESEND_FROM_EMAIL` | optional | Override "from" header. Defaults to `Certified <no-reply@certified.one>`. |
| `NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID` | optional | Adds WalletConnect connector to the wagmi config when set. |
| `NEXT_PUBLIC_GROUP_SERVICE_URL` | optional | Group service base URL. Defaults to the staging Railway deployment. |
| `NEXT_PUBLIC_GROUP_SERVICE_DID` | optional | Group service DID (for `getServiceAuth` `aud`). Defaults to `did:web:atproto-group-gate-staging.up.railway.app`. |
| `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.

Expand Down Expand Up @@ -380,6 +380,15 @@ If you need to write a new collection, **add it to `ALLOWED_WRITE_COLLECTIONS`**
3. **Reuse the CSS variables** above; don't hard-code colors or transitions in new rules.
4. **Skip-nav styles** are at the top of `globals.css`. Don't duplicate.

### Modals — the radius rule

Two-tier convention codified in DESIGN.md §11:

- **Sign-in modal** (`.signin-modal` only): 20px radius, hero padding — the **only** intentional exception to the 2px system. Reserved for the sign-in surface.
- **Every other dialog**: `<dialog className="signin-modal app-modal …">`. The `.app-modal` class (in `components.css`) inherits the sign-in chrome (backdrop, animation, close X, focus styling) but overrides `border-radius: var(--radius)` and trims padding.

If you ship a new modal that extends `.signin-modal` without `.app-modal`, the dialog comes out comically rounded and breaks the visual system. Search for `className="signin-modal"` before merging.

## 12. Component Conventions

- **Internal links:** `next/link`. Don't use `<a href>` for in-app routes.
Expand Down Expand Up @@ -456,6 +465,54 @@ Defined in `src/lib/groups/proxy-agent.ts`:

`MAX_SELF_CREATED_ORGS = 5`. Enforced both server-side (in `/api/groups/register`) and client-side (in `useOrgCreationLimit()`). A group is "self-created" when the user's member entry has `addedBy === ownerDid`. The server-side check fetches all memberships and member lists for those groups, then counts.

## 15a. Social Graph + Endorsements

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

### Write helpers
- `createFollow(ownDid, subjectDid, { targetDid? })` — XRPC for personal, BFF (`/api/groups/[did]/follow`) when `targetDid` set. Mirror this `targetDid` opt-in for any new group-aware write.
- `createEndorsementAward(ownDid, subjectDid, note?)` — default endorsement; lazy-ensures the default definition.
- `createListAward(ownDid, subjectDid, badge: StrongRef)` — award under a specific list. Skip ensure-def; caller passes the list's strong ref.
- `createListDefinition` / `updateListDefinition` / `deleteListAndAwards` — list CRUD; delete walks every linked award first so the def-delete never orphans records.
- `BADGE_AWARD_NOTE_MAX = 500` enforced in `writeBadgeAward` and again in every UI surface that captures a note. The UI also clamps via `maxLength` + `slice` (belt-and-suspenders).

### Hooks (own + foreign profiles)
- `useFollowing(did)` — PDS listRecords; exposes `addFollow` / `removeFollow` for optimistic updates.
- `useFollowers(did)` — indexer `appCertifiedGraphFollow(where: { subject })`; dedupes by follower DID; exposes `addFollower` / `removeFollower`.
- `useGivenEndorsements(did)` / `useReceivedEndorsements(did, { includeRejected? })` — both attach `listTitle` per award (`undefined` for default endorsements). `includeRejected` defaults to false; pass true on the owner view so the response filter dropdown can switch between Hide rejected / Only rejected / Show all.
- `useEndorsementLists(did)` — definitions + awards on one repo, grouped by def URI. Exposes `createList` / `updateList` / `deleteList`, all optimistic. `listAwards` here is paginated only by the PDS' default page (no full walk yet).
- `useSocialGraphSync(did, { ownDid, targetDid })` — composes `useFollowing` + `useBlueskyFollows`; returns `inBoth` / `onlyCertified` / `onlyBluesky` sets plus an `importDids(dids)` batch writer.

### Card / modal patterns
- `<PersonCard>` in `profile-endorsements.tsx` (and a parallel one in `profile-followers.tsx`) is the shared row used by Received/Given/Followers/Following. Layout: name → @handle → date → optional `listTitle` pill → optional note. Top-right `menu` slot is reserved for the × revoke / kebab / etc. Don't restore the right-aligned date.
- `<EndorsePeopleModal>` is callback-driven via `onEndorse(did, note?)`. It serves three flows: regular endorse (with `requireReason`), list `+ Add people` (skip reason — list is the reason), future awards (just supply a different `onEndorse`).
- `<EndorseReasonModal>` is the single-target reason capture used by the sidebar Endorse button. Pops up *before* the write, never after.
- All new dialogs use `<dialog className="signin-modal app-modal …">`. See §11 modal radius rule.

### Indexer queries
All four social-graph / endorsement hooks already target the post-#87 / #88 / #89 magic-indexer schema:
- `appCertifiedBadgeAward.badge.{badgeType, …}` nested-where is live; **`useReceivedEndorsements` still uses the 2-call workaround** (one indexer for awards, one for endorsement-typed definition URIs). Migrating to the nested-where is a self-contained client change.
- `appCertifiedHypercertsCollection.items.itemIdentifier.uri` array-element where is live; `useCertProjects` could swap from PDS-scan-(same-DID-only) to a single cross-DID indexer query.
- `AppCertifiedBadgeDefinition.awardCount` is live; `useEndorsementLists` could drop its `listAwards` round-trip and read counts directly.

If you touch one of these hooks, prefer the nested-where shape — search the file's comments for "round-trip" to find the migration notes inline.

### Optimistic state — the pattern
Every follow / endorse / unfollow button uses the same shape:

```ts
const [optimistic, setOptimistic] = useState<boolean | null>(null)
const effective = optimistic ?? parentValue
useEffect(() => {
if (optimistic !== null && parentValue === optimistic) setOptimistic(null)
}, [parentValue, optimistic])
```

Don't clear `optimistic` in `finally` — the parent's refetch may lag the PDS write, and clearing too early snaps the button back to a stale value for a frame. The `useEffect` reconciler clears the override only when the parent confirms.

## 16. Identity-Link / Wallet Attestation

**Goal:** prove a DID controls an EVM address (and vice versa) by signing an EIP-712 message with the wallet and storing the attestation in the user's PDS.
Expand Down Expand Up @@ -798,6 +855,13 @@ certified-app/
13. **`100vw` in CSS** — causes horizontal scroll when a vertical scrollbar is present. Use `100%`.
14. **Treating `next.config.ts`'s `serverExternalPackages: ["@atproto/oauth-client-node"]` as optional** — it's not. Without it, the OAuth client fails to bundle correctly for serverless.
15. **`ATPROTO_PRIVATE_KEY` / JWKS coupling** — if you set `ATPROTO_PRIVATE_KEY`, the OAuth client switches to confidential auth and the published `oauth-client-metadata` includes a `jwks_uri`. Removing the var without updating the registered metadata can desync clients.
16. **Forgetting `.app-modal` on a new dialog** — every modal except the sign-in surface needs `<dialog className="signin-modal app-modal …">`. See §11 modal radius rule. The 20px chunky-modal regression is the symptom.
17. **Clearing optimistic state in `finally`** — see §15a "Optimistic state — the pattern". The parent's refetch lags the PDS write; clear via the parent-value-caught-up `useEffect` instead.
18. **Reverting the PersonCard layout to right-aligned date** — Received/Given/Followers/Following cards intentionally stack name → @handle → date → listTitle. The previous "name on left, date on right" layout breaks the new `listTitle` row 4.
19. **`listTitle` privacy leak** — `useReceivedEndorsements` returns `listTitle` to ALL viewers (the def title is public on the issuer's repo). That's fine for endorsements. Don't accidentally apply the same logic to private metadata.
20. **Group follow writes via the personal XRPC proxy** — `createFollow(ownDid, subjectDid)` without `targetDid` writes to the PERSONAL repo, even when acting-as-group. Pass `{ targetDid: groupDid }` to route through `/api/groups/[did]/follow`.
21. **Hiding rejected endorsements from non-owners** — `useReceivedEndorsements` default keeps the privacy contract (foreign viewers never see rejected). Only pass `{ includeRejected: true }` on owner-side surfaces, and filter client-side from there.
22. **Static segments under dynamic routes** — `/project/new` lives at `src/app/project/new/page.tsx` alongside `[did]/[rkey]`. Static wins (and `[did]/[rkey]` is two segments so `/project/new` wouldn't match it anyway), but if you change the dynamic pattern to single-segment make sure `new` still wins.

## 23. Adding a New Feature — Checklist

Expand Down
9 changes: 8 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,14 @@ All icons from **Lucide React**. Conventions:

### Modals

**Standard modal** (sign-in, domain): Centered on desktop, full-width on mobile. `bg-elevated`, 1px `border-default`, `shadow-lg`. Entry animation: `modalFadeIn` (backdrop 200ms) + `modalSlideUp` (content 300ms, spring easing). Sign-in modal uses 20px radius — the only intentional exception to the 2px system.
**Standard sign-in modal** (`.signin-modal` only): Centered on desktop, full-width on mobile. `bg-elevated`, 1px `border-default`, `shadow-lg`. Entry animation: `modalFadeIn` (backdrop 200ms) + `modalSlideUp` (content 300ms, spring easing). 20px radius + 40px hero padding. This shape is **reserved for the sign-in surface** — it's an intentional exception to the 2px system because sign-in is a once-per-session, branded surface.

**App modals** (every other in-app dialog — endorse-people, create-list, sync-social-graph, future): use `<dialog className="signin-modal app-modal …">`. The `.app-modal` modifier inherits the sign-in chrome (backdrop / animation / close button / focus styling) but overrides:

- `border-radius: var(--radius)` (2px — matches cards, dropdowns, inputs).
- Padding trimmed to `16px 20px 12px` header / `0 20px 20px` body — denser than the sign-in surface, which is right for form-style and list-style modals.

If you're building a new modal that isn't the sign-in flow, **always** add `app-modal` alongside `signin-modal`. Forgetting it makes the dialog read as a chunky sign-in surface and breaks the 2px system everywhere else.

**Bottom sheet** (mobile account switcher, mobile feedback): Fixed to bottom, draggable handle, swipe-down-to-dismiss. `bg-elevated`, top border-radius. Expandable via swipe-up.

Expand Down
Loading