Skip to content

release: bsky-PDS write fix + groups list overhaul + UX/observability fixes - #54

Merged
hb-agent merged 12 commits into
mainfrom
staging
May 9, 2026
Merged

release: bsky-PDS write fix + groups list overhaul + UX/observability fixes#54
hb-agent merged 12 commits into
mainfrom
staging

Conversation

@holkexyz

@holkexyz holkexyz commented May 9, 2026

Copy link
Copy Markdown
Member

Summary

Brings 11 commits from staging into main. Three themes:

  • Auth — bsky-PDS write fix. Saves to repos hosted on *.host.bsky.network (i.e. any Bluesky-hosted handle) were 500ing with expected non-null body source. Root cause is vercel/next.js#90826 (Node ≥ 24.14): Next.js's patched fetch re-reads a Request body for tracing on error responses, but undici has tightened stream locking. The atproto OAuth DPoP wrapper passes a Request to fetch, and bsky's PDS reliably returns 401 + DPoP-Nonce on the first hit, so every authenticated write to a bsky-side PDS triggered the bug. Fixed by wrapping the fetch passed to NodeOAuthClient so any incoming Request is deconstructed to (url, init) before reaching Next.js's wrapper. Also bumped @atproto/api 0.13 → 0.19 and @atproto/oauth-client to 0.6.1 (and dropped the now-deprecated rkeyEnd/rkeyStart from the listRecords proxy).
  • Groups — list overhaul. Unified group list with join dates, a sort control (collapses to an icon), and removal of the beta test-server warning banner. Came in via merged PR feat(groups): unify list, show join date, add sort control #53.
  • Profile + observability. Hide the banner area on profiles with no banner image; log upstream xrpc errors server-side before masking them as Internal server error (this is what made the bsky-PDS bug above debuggable in the first place).

A sibling-repo issue tracking the same bsky-PDS bug for certs-social is filed at hypercerts-org/certs-social#86 — the recommended fix there is the identical safeFetch wrapper validated here.

Test plan

  • Sign in with a Bluesky-hosted handle (holke.xyz, DID resolves to amanita.us-east.host.bsky.network) on staging.certified.app; edit profile and save → POST /api/xrpc/com/atproto/repo/putRecord returns 200, redirect to /profile/[did], saved values render. Verified end-to-end before this PR was opened.
  • Sign in with a Certified-hosted handle (email-OTP flow); profile save still works as before. Verified.
  • Staging deploy builds clean on Node 24.x with the bumped @atproto packages. TypeScript passes (npx tsc --noEmit).
  • Smoke-test groups list page: sort control, join dates, no banner-warning banner.
  • Smoke-test a profile with no banner image: no empty banner area shows.
  • After merge: ensure production env on main has all the same env vars staging has (COOKIE_SECRET, PUBLIC_URL, UPSTASH_*, etc.) — production is on a separate Vercel environment and won't inherit from staging.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added sorting options for groups (by joined date or name, ascending/descending).
    • Groups now display when you joined each one.
  • Bug Fixes

    • Fixed profile banner rendering logic.
    • Improved error handling and logging in API requests.
    • Enhanced OAuth session restoration flow.
  • Chores

    • Updated dependencies.
    • Added review documentation.

Review Change Stack

holkexyz and others added 11 commits May 9, 2026 14:31
- Drop the public/private membership split on /groups: a single sorted
  list now shows every group the user belongs to.
- Remove the per-row Accept (UserCheck) and Remove-public (UserX)
  buttons and their handlers.
- Surface the joinedAt timestamp from the group service on each row
  ("Joined Mon DD, YYYY"). Plumbed through Group type and resolveGroups.
- Drop the now-unused .org-list__divider/__accept-btn/__remove-public-btn
  styles and add a small .org-list__item-meta style for the date line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a dropdown above the group list with four sort modes:
joined oldest first (default), joined newest first,
name A→Z, name Z→A. Rows missing joinedAt always sort to
the bottom of joined-* views. The control is hidden when
there are 0 or 1 groups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the labeled sort dropdown below the description with a
single ArrowUpDown icon next to the group count. The native select
sits transparently on top of the icon so clicking it opens the OS
menu with the four sort options. Current mode is reflected in the
button's title attribute.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Accepted items from docs/groups-list-improvements/review-round-1.md:
- Sort comparator: drop redundant lowercasing, use locale-aware
  case-insensitive comparison (sensitivity: "base").
- Decorate-sort-undecorate: parse joinedAt once per group instead
  of on every comparison, and reuse the display label.
- aria-hidden -> aria-hidden="true" on the icon for codebase
  consistency.
- Validate the SortMode value in the select onChange against the
  hardcoded option set before calling setState.
- Move the title attribute onto the <select> (so keyboard users see
  the tooltip) and surface the current mode in aria-label so screen
  readers announce it.
- Replace the dead-end "Owners can't leave the group" tooltip with
  actionable guidance pointing at group settings.
- Extract a local displayLabel const inside renderOrgItem to remove
  six repetitions of "displayName || handle".

Rejected items recorded with rationale in the same doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both round-2 reviewers returned ship verdicts. Accepted items
(see docs/groups-list-improvements/review-round-2.md):

- Drop the "current: <label>" suffix from the <select>'s aria-label;
  native screen readers already announce the selected option text
  after the role, making the suffix duplicate the announcement.
  Title attribute kept for sighted keyboard/mouse users.
- Update stale "lowercased label" comment in sortedOrgs and add a
  one-liner noting that ES2019 sort stability handles equal-key ties.
- Extract isSortMode type predicate for the onChange validator;
  widen SORT_VALUES to ReadonlySet<string> so the predicate input
  is plain string and the two `as SortMode` casts go away.

Rejected items (with rationale) recorded in the same doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
feat(groups): unify list, show join date, add sort control
Previously the profile display rendered an empty 180px-tall grey
gradient placeholder when the user had no banner. Now the entire
.profile-card__banner block is omitted in that case. The edit-profile
page is unchanged — its banner upload affordance still surfaces.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The xrpc proxy collapses every status >= 500 to "Internal server
error" and the OAuth restore catch silently turns every failure into
"Session expired". Both branches dropped the original error on the
floor, leaving us nothing to diagnose with when a user hits a save
failure.

Add console.error in both spots so the real upstream cause (XRPC
status, name, message, cause, stack) lands in the Vercel logs while
the client-facing message stays generic. No behavior change.

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

Saves to repos hosted on bsky.social were failing with `Error: expected non-null
body source` when the PDS responded 401 + DPoP-Nonce on the first request and
the OAuth client retried without a re-readable body. 0.6.1 reworks the DPoP
fetch path so the body survives the retry.

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

The DPoP-nonce retry against bsky.social was crashing with
"expected non-null body source" because @atproto/api 0.13 brought in
@atproto/xrpc 0.6, which builds a Request with a one-shot body that can't
survive the second attempt. 0.19 uses xrpc 0.7, which keeps the body
re-sendable. Also drops the deprecated rkeyEnd/rkeyStart params from the
listRecords proxy and routes the per-method body casts through `unknown`
to satisfy the stricter input schemas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vercel/next.js#90826: on Node ≥ 24.14, the patched fetch crashes with
"expected non-null body source" when given a Request whose body has been
consumed and the response is an error. The atproto DPoP wrapper passes a
Request to fetch, and bsky's PDS reliably returns 401 + DPoP-Nonce on the
first hit, so every putRecord against bsky.social was failing. Buffer the
body and re-issue with (url, init) form before Next.js's wrapper sees it.

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

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

@vercel

vercel Bot commented May 9, 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 9, 2026 5:18pm
certified-app (staging) Ready Ready Preview, Comment May 9, 2026 5:18pm

Request Review

@coderabbitai

coderabbitai Bot commented May 9, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@holkexyz has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 44 minutes and 23 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 991a7c41-9596-4e59-ad8d-f86d4e3c6f6e

📥 Commits

Reviewing files that changed from the base of the PR and between 675c65f and e448085.

📒 Files selected for processing (5)
  • AGENTS.md
  • src/app/api/xrpc/[...method]/route.ts
  • src/app/groups/page.tsx
  • src/components/profile/profile-client.tsx
  • src/lib/auth/oauth-client.ts
📝 Walkthrough

Walkthrough

This PR implements group list sorting improvements, refactors the groups page UI to show joined dates with unified rendering, enhances OAuth error handling via request-body-aware fetch, improves xRPC error logging and type casting, fixes profile banner rendering, and updates the @atproto/api dependency to ^0.19.16.

Changes

Groups List Improvements

Layer / File(s) Summary
Data Contracts
src/lib/groups/types.ts
Group interface adds optional joinedAt?: string field (ISO timestamp from group service).
API Resolution
src/lib/groups/api.ts
resolveGroups() populates joinedAt from remote membership data (rm.joinedAt).
Page Sorting & Display
src/app/groups/page.tsx
Adds sortMode state and memoized sortedOrgs computation supporting user-selectable sorting by joined date (oldest/newest) or name (A–Z/Z–A). Replaces prior accepted/pending split with unified list. Updates renderOrgItem to show joined date and only leave button (disabled for owners). Removes handleAcceptMembership flow.
Styling
src/app/globals.css
Adds .org-list__header-right container, .org-list__sort-icon-btn with hover/focus styling, .org-list__sort-icon-select overlay (opacity 0, clickable), and .org-list__item-meta for metadata display.
Review Documentation
docs/groups-list-improvements/review-round-1.md, docs/groups-list-improvements/review-round-2.md
Documents review verdicts, accepted/rejected items with implementation directives, and workflow threshold decisions.

Auth Infrastructure & Supporting Changes

Layer / File(s) Summary
OAuth SafeFetch Wrapper
src/lib/auth/oauth-client.ts
getOAuthClient() now passes custom safeFetch to NodeOAuthClient constructor. safeFetch detects Request inputs, buffers bodies via arrayBuffer(), and re-issues calls using reconstructed URL + init object to avoid body-consumption errors during DPoP/401 flows.
xRPC Error Handling & Casting
src/app/api/xrpc/[...method]/route.ts
xrpcError helper extracts upstream error fields and emits structured console.error logs (status, text, cause, stack). Both GET/POST OAuth restore flows capture/log errors before session deletion and 401 response. com.atproto.repo.listRecords no longer destructures rkeyEnd/rkeyStart. Write-method and void-method cases now cast body via body as unknown as <InputSchema> pattern.
Profile Banner Fix
src/components/profile/profile-client.tsx
Banner container now conditionally renders only when bannerUrl exists instead of always rendering container with conditional image.
Dependency Update
package.json
Updates @atproto/api from ^0.13.20 to ^0.19.16.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Poem

🐰 Hop along as groups now sort with grace,
By dates they joined, or name's embrace.
OAuth flows now handle errors so clean,
With logged details—the best we've seen!
Testing waves through, no body left behind. ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the three main themes of the changeset: auth/bsky-PDS write fix, groups list overhaul, and UX/observability fixes, matching the actual changes across multiple files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch staging

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.

@coderabbitai coderabbitai 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.

Actionable comments posted: 3

🧹 Nitpick comments (2)
src/app/groups/page.tsx (1)

13-24: 💤 Low value

Consider deferring date locale to the user's runtime locale.

date.toLocaleDateString("en-US", JOINED_DATE_FORMAT) always renders dates in Mon DD, YYYY form regardless of where the user is. Passing undefined (or omitting the argument) lets Intl.DateTimeFormat pick up the browser/runtime locale, which keeps the rest of the field structure (year/month/day) but formats per the user's expectations (e.g., 15 Jan 2024 in en-GB, 15. Jan. 2024 in de). If en-US is a deliberate product choice for consistency with the rest of the UI, ignore.

♻️ Proposed change
-  return date.toLocaleDateString("en-US", JOINED_DATE_FORMAT)
+  return date.toLocaleDateString(undefined, JOINED_DATE_FORMAT)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/groups/page.tsx` around lines 13 - 24, The formatJoinedDate helper
currently forces the "en-US" locale when calling date.toLocaleDateString, which
prevents using the user's runtime/browser locale; update formatJoinedDate to
call date.toLocaleDateString without the explicit "en-US" argument (i.e., pass
only JOINED_DATE_FORMAT or undefined for the locale) so Intl.DateTimeFormat uses
the user's locale, keeping the JOINED_DATE_FORMAT structure; leave the constant
JOINED_DATE_FORMAT and validation in place and only change the
toLocaleDateString call in formatJoinedDate (unless "en-US" is intentionally
required).
src/lib/auth/oauth-client.ts (1)

133-165: 💤 Low value

safeFetch is correctly implemented; TDZ concern is not an issue in practice.

The wrapper properly converts Request(url, init) to work around vercel/next.js#90826, and the ...init spread last preserves standard fetch precedence. Good comment explaining the bug.

The module-level const safeFetch is declared after getOAuthClient references it, which initially suggests a potential temporal dead zone (TDZ) issue. However, this is not a problem: @atproto/oauth-client-node's constructor does not call fetch synchronously, so it won't try to access the fetch option during construction. The const binding is fully established during module initialization before getOAuthClient is ever invoked at runtime.

Minor note: body: buffer && buffer.byteLength > 0 ? buffer : undefined silently converts an explicit zero-length body into a no-body request. Unlikely to affect OAuth/DPoP flows (bodies are non-empty form data) but technically a semantic shift; acceptable given the workaround context.

As a purely optional improvement for code clarity, safeFetch could be declared as a function (which benefits from declaration hoisting) and moved before getOAuthClient. This eliminates any reader confusion about module evaluation order, but the current code is functionally safe.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/auth/oauth-client.ts` around lines 133 - 165, Move the safeFetch
implementation before getOAuthClient and convert the module-level const
safeFetch into a hoisted function declaration (keep the same parameter
types/behavior and use globalThis.fetch) so readers won't worry about a
temporal-dead-zone; update any references to safeFetch (used when constructing
NodeOAuthClient in getOAuthClient) to call the new function name (safeFetch) —
keep the Request → (url, init) buffering logic and the final ...init precedence
unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@package.json`:
- Line 12: Update the version guidance in AGENTS.md to reflect the bumped
`@atproto/api` major/minor (0.19.x) used in package.json instead of the old 0.13
pin: locate the guideline entry that pins `@atproto/api` to 0.13 (around the
section that also references `@atproto/oauth-client-node` and `@atproto/jwk-jose`)
and change the text to indicate `@atproto/api` 0.19.x is allowed/used in this
repo; also add a short note clarifying that `@atproto/oauth-client` (browser) is
at 0.6.1 transitively while `@atproto/oauth-client-node` remains pinned at 0.3.17
as per the existing guideline.

In `@src/app/api/xrpc/`[...method]/route.ts:
- Line 208: The handler currently double-casts request bodies to types like
ComAtprotoRepoCreateRecord.InputSchema (e.g., where body is cast before calling
agent.com.atproto.repo.createRecord) which bypasses runtime checks; add runtime
schema validation (for each case: com.atproto.repo.createRecord and the other
listed cases at lines referenced) using a validator such as Zod or AJV that
mirrors the InputSchema shape, call safeParse/validate on the raw body, return a
400 JSON response on validation failure, and only then pass the validated data
(parsed.data) into the SDK call instead of the direct double-cast.

In `@src/components/profile/profile-client.tsx`:
- Around line 137-141: The banner image in the ProfileClient component is
decorative and currently has alt="", so add aria-hidden="true" to the <img>
element rendering the banner (the image inside the JSX block that checks
bannerUrl in profile-client.tsx) so screen readers skip it; locate the JSX
fragment with {bannerUrl && (<div className="profile-card__banner"><img ...
/></div>)} and add the aria-hidden="true" attribute to that img element.

---

Nitpick comments:
In `@src/app/groups/page.tsx`:
- Around line 13-24: The formatJoinedDate helper currently forces the "en-US"
locale when calling date.toLocaleDateString, which prevents using the user's
runtime/browser locale; update formatJoinedDate to call date.toLocaleDateString
without the explicit "en-US" argument (i.e., pass only JOINED_DATE_FORMAT or
undefined for the locale) so Intl.DateTimeFormat uses the user's locale, keeping
the JOINED_DATE_FORMAT structure; leave the constant JOINED_DATE_FORMAT and
validation in place and only change the toLocaleDateString call in
formatJoinedDate (unless "en-US" is intentionally required).

In `@src/lib/auth/oauth-client.ts`:
- Around line 133-165: Move the safeFetch implementation before getOAuthClient
and convert the module-level const safeFetch into a hoisted function declaration
(keep the same parameter types/behavior and use globalThis.fetch) so readers
won't worry about a temporal-dead-zone; update any references to safeFetch (used
when constructing NodeOAuthClient in getOAuthClient) to call the new function
name (safeFetch) — keep the Request → (url, init) buffering logic and the final
...init precedence unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5478d940-7547-4aec-a039-e931c7b3419a

📥 Commits

Reviewing files that changed from the base of the PR and between f910556 and 675c65f.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (10)
  • docs/groups-list-improvements/review-round-1.md
  • docs/groups-list-improvements/review-round-2.md
  • package.json
  • src/app/api/xrpc/[...method]/route.ts
  • src/app/globals.css
  • src/app/groups/page.tsx
  • src/components/profile/profile-client.tsx
  • src/lib/auth/oauth-client.ts
  • src/lib/groups/api.ts
  • src/lib/groups/types.ts

Comment thread package.json
case "com.atproto.repo.createRecord": {
const result = await agent.com.atproto.repo.createRecord(
body as ComAtprotoRepoCreateRecord.InputSchema
body as unknown as ComAtprotoRepoCreateRecord.InputSchema

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Consider adding runtime schema validation before double-cast.

The change to body as unknown as <InputSchema> bypasses TypeScript's type checking. While the code includes some field-level validation (repo, collection allowlist), there's no comprehensive schema validation ensuring the body matches the expected InputSchema shape before passing it to the @atproto/api SDK.

Risk: Malformed payloads could reach the SDK, potentially causing runtime errors or unexpected behavior.

Recommendation: Consider using a runtime validation library (e.g., Zod) to validate the body shape against the expected schema, or document why the double-cast is necessary (e.g., if @atproto/api SDK performs its own validation).

Example with Zod validation
// At the top of the file, define schemas matching InputSchema shapes
import { z } from 'zod'

const createRecordSchema = z.object({
  repo: z.string(),
  collection: z.string(),
  rkey: z.string().optional(),
  validate: z.boolean().optional(),
  record: z.record(z.unknown()),
  // ... other fields from ComAtprotoRepoCreateRecord.InputSchema
})

// In the handler
case "com.atproto.repo.createRecord": {
  const parsed = createRecordSchema.safeParse(body)
  if (!parsed.success) {
    return NextResponse.json(
      { error: "Invalid request body" },
      { status: 400 }
    )
  }
  const result = await agent.com.atproto.repo.createRecord(
    parsed.data as ComAtprotoRepoCreateRecord.InputSchema
  )
  return NextResponse.json(result.data)
}

Also applies to: 214-214, 220-220, 258-258, 265-265, 271-271, 281-281

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/api/xrpc/`[...method]/route.ts at line 208, The handler currently
double-casts request bodies to types like ComAtprotoRepoCreateRecord.InputSchema
(e.g., where body is cast before calling agent.com.atproto.repo.createRecord)
which bypasses runtime checks; add runtime schema validation (for each case:
com.atproto.repo.createRecord and the other listed cases at lines referenced)
using a validator such as Zod or AJV that mirrors the InputSchema shape, call
safeParse/validate on the raw body, return a 400 JSON response on validation
failure, and only then pass the validated data (parsed.data) into the SDK call
instead of the direct double-cast.

Comment thread src/components/profile/profile-client.tsx
@holkexyz

holkexyz commented May 9, 2026

Copy link
Copy Markdown
Member Author

Automated review (3 agents, parallel)

Must address before merging to main

🔴 Token leakage in upstream-error logging — flagged by 2 of 3 reviewers, and we have direct evidence from today's runtime logs that this is happening.

src/app/api/xrpc/[...method]/route.ts:50-57 logs err.cause and err.stack for any upstream ≥500. The atproto SDK keeps the upstream Response/Request on error.cause, and the SDK-thrown XRPCError.message can contain the offending request body. We observed full DPoP JWTs and Authorization headers in the [xrpc] upstream error log lines while debugging the bsky-PDS bug today. DPoP proofs are short-lived (≤ a few minutes, bound to one htm/htu), but anyone with Vercel log access can replay the proof until it expires.

Recommended: drop cause and stack from the structured log, keep only name/status/error/message, and run the message through a redactor that strips Authorization/DPoP/access_token/refresh_token/email substrings. Same redaction also applies to the bare err logged at lines 85 and 173 ([xrpc] oauth restore failed).

If the team treats Vercel logs as confidential and is comfortable with that posture, document it in a comment so the next reviewer doesn't re-flag.

Should consider — safeFetch correctness nits

In src/lib/auth/oauth-client.ts:

  1. Empty-body edge case (line 156): body: buffer && buffer.byteLength > 0 ? buffer : undefined drops a legitimately-empty body. No XRPC call hits this today, but a 0-byte PUT/POST through the same wrapper would lose Content-Length: 0. Safer: body: input.body ? buffer : undefined — preserve "had a body" rather than "had bytes".
  2. Dead defense in ...init spread (line 161): the dpop wrapper always calls fetch.call(this, request) with no init, so the spread is currently inert. If a future caller ever passes init.body (e.g. as a stream), the spread would overwrite our buffered body and re-introduce the very bug this fix exists to prevent. Either drop the spread or make it explicit: ...init, body: init?.body ?? buffer. Add a comment documenting the assumption either way.
  3. uploadBlob double-buffers 4MB (line 152): the route handler at route.ts:243 already arrayBuffers the upload, then safeFetch arrayBuffers it again from the Request the agent constructs. ~8MB transient is fine on Vercel's 1GB function — but worth a comment so future readers don't try to "fix" it by removing one of the buffers.

Optional — broader scope

  • Proxy body validation is partial (route.ts, several lines): as unknown as InputSchema strips compile-time checks. The existing runtime guards (body.repo === did, ALLOWED_WRITE_COLLECTIONS) remain in place and the OAuth token is repo-scoped server-side, so this doesn't regress security — but rkey, record shape, swapRecord, swapCommit are still unvalidated and a malformed record reaches the PDS as a 400 the user sees. Per-method Zod schemas would be the natural fix; not a blocker for this PR.
  • docs/groups-list-improvements/review-round-{1,2}.md ship with PR feat(groups): unify list, show join date, add sort control #53 — confirm those are intended to land in main and aren't process docs that should live elsewhere.
  • No tests for the new groups sort logic at src/app/groups/page.tsx:48-77. If repo conventions require coverage for new behavior, that's the gap.

Things that passed review

  • safeFetch body buffering covers every body type xrpc actually emits (JSON Uint8Array, uploadBlob Uint8Array). Headers/signal/redirect/credentials/cache all forwarded correctly. OAuthSession.fetchHandler's ReadableStream early-return means streams never reach the wrapper.
  • SSRF surface unchanged: input.url is fully constructed by the OAuth client (PDS-metadata + DID resolution) before safeFetch ever sees it. No user-controlled URL path.
  • Cast-through-unknown change in route.ts is a TypeScript narrowing forced by stricter @atproto/api 0.19 types; runtime guards unchanged.
  • Dep bumps: bundled changelogs clean, no CVEs noted. oauth-client@0.6.0 actually hardens session fixation (deletes pre-existing OAuth sessions for a given sub on new sign-in). NPM advisory database wasn't reachable from the review sandbox — a quick npm audit outside the agent before merge is worthwhile.
  • Banner fix (12ce79f): getBannerUrl returns null for both "no record" and "record with missing/unrecognized ref"; the truthy guard at profile-client.tsx:137 handles both.
  • Groups commits self-consistent across 09184194633cc0be8e6776ba2787fe4313d. No contradictions across the round-1/round-2 review feedback. Mobile Safari native-picker positioning on the icon-collapse design was explicitly deferred per round-1 doc R6.

🤖 Synthesized from 3 parallel review agents.

…eview nits

Review feedback from PR #54.

- xrpc proxy: drop `err.cause` and `err.stack` from `[xrpc] upstream error`
  logs and route the message through a `redactSecrets` helper that strips
  JWTs (covers DPoP proofs and bearer tokens), `Authorization`/`DPoP`/`Cookie`
  header lines, `access_token`/`refresh_token`/`id_token` query/body params,
  and email addresses. The bare `err` previously logged at oauth-restore
  failure now goes through the same helper. Confirmed against a sample
  containing a real DPoP JWT, an email, and an `access_token=` param.
- safeFetch: preserve "had a body" instead of "had bytes" so a legitimate
  zero-byte POST/PUT keeps `Content-Length: 0`. Drop the `...init` spread
  since the dpop wrapper never passes a second argument and a future caller
  passing `init.body` would re-introduce the bug we're fixing. Comment the
  uploadBlob double-buffer so future readers don't try to "fix" it.
- AGENTS.md: bump the documented `@atproto/api` line to 0.19, note that
  `@atproto/oauth-client` 0.6 is pulled in transitively.
- Banner img: add `aria-hidden="true"` since it's decorative (alt="").
- Groups join-date: drop the forced "en-US" locale from `toLocaleDateString`
  so it follows the user's runtime locale.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hb-agent
hb-agent merged commit 3cef675 into main May 9, 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