Add a simple profile edit page #115

Closed
opened 2026-08-19 02:26:14 +00:00 by Grandiras · 2 comments
Owner

A simple profile page, where things like display name and avatar can be changed. Should hook into the standard way of doing this on ATProto, so something like an existing lexicon, instead of inventing our own. We probably need to request new permissions for this, but keep the requested ones as minimal as possible and if possible, explain why it's needed.

A simple profile page, where things like display name and avatar can be changed. Should hook into the standard way of doing this on ATProto, so something like an existing lexicon, instead of inventing our own. We probably need to request new permissions for this, but keep the requested ones as minimal as possible and if possible, explain why it's needed.
Collaborator

🤖 On it — follow along in the action run; the reply will link the full session transcript.

📜 Live transcript — updating every ~5 minutes while the task runs.

🤖 On it — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/310); the reply will link the full session transcript. <!-- claude-run:comment --> 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/issue-115/run-310.md) — updating every ~5 minutes while the task runs.
Collaborator

Pushed claude/issue-115.

What I built

/profile — reached by tapping your own face at the top of the account menu (the identity block is now the door; no fifth menu row saying the same thing). It shows your account and, where the deployment allows it, edits your display name and avatar.

It hooks into the standard lexicon, not an invented one: the app.bsky.actor.profile record at rkey self, read with com.atproto.repo.getRecord, written with putRecord, avatar uploaded with uploadBlob. So a name set here is the name the Bluesky app shows tomorrow.

The permission — and why the default is still "none"

The narrowest scope the OAuth spec can express for this is repo:app.bsky.actor.profile + blob:image/*: one collection, one media type, no posts, follows, likes or preferences. No account server accepts it yet. Measured today:

$ curl -s https://bsky.social/.well-known/oauth-authorization-server | jq .scopes_supported
["atproto","transition:email","transition:generic","transition:chat.bsky"]

Same answer from pds.ponente.app and pds.grandiras.net (both the official ghcr.io/bluesky-social/pds image). An unknown scope is rejected, not narrowed — asking for the granular pair today breaks sign-in for every member at once. The only scope that works is transition:generic, whose consent screen covers the whole account, to write one record.

Handing over everyone's account so they can change a name on a card is your call, not this branch's, so it's configuration: AtProto:ProfileScope = none (default) | granular (spec-correct, ready for the day a server takes it) | generic (works now). An unrecognised value reads as none. The app logs which of the three at every start, and the page itself explains to the member which permission was asked and why. With none the page still works — it shows your account and re-fetches Ponente's cached copy of your name and picture (useful on its own: that's the fix for an avatar here that's still the one you replaced elsewhere last year). One caveat when you do turn it on: everyone already signed in holds an atproto-only token, so the page shows them a "sign in again" link rather than a 403 mid-save.

Set it with ATPROTO_PROFILE_SCOPE=generic in deploy/.env. Reasoning and the dated measurement are in docs/auth.md.

The profile page in both states

Details worth knowing

  • The record is merged as JSON, not round-tripped through a typed model. Deserialising into the SDK's ProfileRecord drops every field it has no property for — including the self-labels behind "no logged-out visibility". Renaming yourself is not a reason to un-hide your account from the logged-out web. Pinned by ProfileRecordTests.
  • swapRecord carries the CID just read, so a change made in another app meanwhile is a translated sentence and a re-read, not a silent overwrite.
  • The cached avatar becomes the PDS blob URL, true the instant the record is written, rather than the appview CDN URL that only becomes true once indexed. Avatars capped at 1 MB (Bluesky's own limit for the field).
  • MemberService.UpdateProfileAsyncRefreshProfileAsync (a lookup elsewhere; blank name means "not found"), plus a new SetProfileAsync (the member speaking; blank means blank). New BoardNotifier.ProfileChanged, so the nav header swaps the face in the corner without a reload.
  • Copy in both locales, a Changelog.Unreleased bullet, and docs/auth.md / ui.md / orientation.md / README / .env.example updated.

What I verified

  • dotnet build and dotnet test green — 373 passing, including 22 new (ProfileScopeTests, ProfileRecordTests) and DocumentationTests.
  • The write path end to end, for real. No container engine in this job, so I ran the app on a local Postgres against a throwaway stand-in PDS (in /tmp, not committed) with a seeded OAuth token, and did the save from a real browser. uploadBlob got the 1524-byte PNG; putRecord carried swapRecord, the new displayName, the new avatar blob — and the untouched description, createdAt and !no-unauthenticated self-label. The screenshots above are that run. The nav header avatar updated live.
  • Not run: tests/e2e/profile.spec.js (three tests, written following the existing specs). The e2e suite needs the compose stack and this runner has no container engine, so it is unverified — worth a local npx playwright test profile before merging. Note it can only ever cover the read-only half: /dev/login/{did} stores no OAuth token by design, so the editable half is a manual check with ATPROTO_PROFILE_SCOPE set.

⏱ 110m 28s · 180 turns · tokens: 546.7k in / 110.9k out (+34161.1k cache-read)
📜 Full session transcript
→ Opened PR: #128
→ Filed follow-up issue #129: Switch AtProto:ProfileScope to granular once a PDS accepts the narrow scopes
→ Filed follow-up issue #130: MemberService.UpdateDisplayNameAsync and UpdateAvatarAsync have no callers

Pushed **`claude/issue-115`**. ## What I built `/profile` — reached by tapping your own face at the top of the account menu (the identity block is now the door; no fifth menu row saying the same thing). It shows your account and, where the deployment allows it, edits your **display name** and **avatar**. It hooks into the standard lexicon, not an invented one: the `app.bsky.actor.profile` record at rkey `self`, read with `com.atproto.repo.getRecord`, written with `putRecord`, avatar uploaded with `uploadBlob`. So a name set here is the name the Bluesky app shows tomorrow. ## The permission — and why the default is still "none" The narrowest scope the OAuth spec can express for this is `repo:app.bsky.actor.profile` + `blob:image/*`: one collection, one media type, no posts, follows, likes or preferences. **No account server accepts it yet.** Measured today: ``` $ curl -s https://bsky.social/.well-known/oauth-authorization-server | jq .scopes_supported ["atproto","transition:email","transition:generic","transition:chat.bsky"] ``` Same answer from `pds.ponente.app` and `pds.grandiras.net` (both the official `ghcr.io/bluesky-social/pds` image). An unknown scope is **rejected, not narrowed** — asking for the granular pair today breaks sign-in for every member at once. The only scope that works is `transition:generic`, whose consent screen covers the whole account, to write one record. Handing over everyone's account so they can change a name on a card is your call, not this branch's, so it's configuration: **`AtProto:ProfileScope`** = `none` (default) | `granular` (spec-correct, ready for the day a server takes it) | `generic` (works now). An unrecognised value reads as `none`. The app logs which of the three at every start, and the page itself explains to the member which permission was asked and why. With `none` the page still works — it shows your account and re-fetches Ponente's cached copy of your name and picture (useful on its own: that's the fix for an avatar here that's still the one you replaced elsewhere last year). One caveat when you do turn it on: everyone already signed in holds an `atproto`-only token, so the page shows them a "sign in again" link rather than a 403 mid-save. Set it with `ATPROTO_PROFILE_SCOPE=generic` in `deploy/.env`. Reasoning and the dated measurement are in `docs/auth.md`. ![The profile page in both states](https://git.grandiras.net/attachments/746ca739-c00b-4ba0-aae7-a88639eaf1d3) ## Details worth knowing - **The record is merged as JSON, not round-tripped through a typed model.** Deserialising into the SDK's `ProfileRecord` drops every field it has no property for — including the self-labels behind "no logged-out visibility". Renaming yourself is not a reason to un-hide your account from the logged-out web. Pinned by `ProfileRecordTests`. - `swapRecord` carries the CID just read, so a change made in another app meanwhile is a translated sentence and a re-read, not a silent overwrite. - The cached avatar becomes the **PDS blob URL**, true the instant the record is written, rather than the appview CDN URL that only becomes true once indexed. Avatars capped at 1 MB (Bluesky's own limit for the field). - `MemberService.UpdateProfileAsync` → `RefreshProfileAsync` (a lookup elsewhere; blank name means "not found"), plus a new `SetProfileAsync` (the member speaking; blank means blank). New `BoardNotifier.ProfileChanged`, so the nav header swaps the face in the corner without a reload. - Copy in both locales, a `Changelog.Unreleased` bullet, and `docs/auth.md` / `ui.md` / `orientation.md` / `README` / `.env.example` updated. ## What I verified - `dotnet build` and `dotnet test` green — **373 passing**, including 22 new (`ProfileScopeTests`, `ProfileRecordTests`) and `DocumentationTests`. - **The write path end to end, for real.** No container engine in this job, so I ran the app on a local Postgres against a throwaway stand-in PDS (in `/tmp`, not committed) with a seeded OAuth token, and did the save from a real browser. `uploadBlob` got the 1524-byte PNG; `putRecord` carried `swapRecord`, the new `displayName`, the new avatar blob — and the untouched `description`, `createdAt` and `!no-unauthenticated` self-label. The screenshots above are that run. The nav header avatar updated live. - **Not run: `tests/e2e/profile.spec.js`** (three tests, written following the existing specs). The e2e suite needs the compose stack and this runner has no container engine, so it is unverified — worth a local `npx playwright test profile` before merging. Note it can only ever cover the read-only half: `/dev/login/{did}` stores no OAuth token by design, so the editable half is a manual check with `ATPROTO_PROFILE_SCOPE` set. --- ⏱ 110m 28s · 180 turns · tokens: 546.7k in / 110.9k out (+34161.1k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/issue-115/run-310.md) → Opened PR: https://git.grandiras.net/Grandiras/Ponente/pulls/128 → Filed follow-up issue [#129](https://git.grandiras.net/Grandiras/Ponente/issues/129): Switch AtProto:ProfileScope to `granular` once a PDS accepts the narrow scopes → Filed follow-up issue [#130](https://git.grandiras.net/Grandiras/Ponente/issues/130): MemberService.UpdateDisplayNameAsync and UpdateAvatarAsync have no callers
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Grandiras/Ponente#115
No description provided.