Claude: changes for issue #78 #80

Merged
Grandiras merged 2 commits from claude/issue-78 into main 2026-08-14 02:56:39 +00:00
Collaborator

Closes #78.

Closes #78.
Give a board its own list of who may see it
All checks were successful
CI / build (pull_request) Successful in 38s
96df675c3a
A board is now either everyone's or a named few's. Board.Visibility says
which, and BoardMember is the grant list the second one consults — a join
table beside Board rather than a column on it, because a board is not
owned.

The switch exists so that an empty grant list never has to be interpreted.
Read as "everyone" it turns taking the last person off a board into
opening that board to the whole deployment, from a screen that was
removing access; read as "nobody" it hides every board that predates the
feature. One column settles both, and it defaults to Everyone — so the
migration needs no backfill and nobody's board changes by deploying it.

Nothing an ordinary member sees is new, which was the requirement. There
is no lock icon and no greyed-out row: the switcher lists the boards you
have, "/" lands on the first of those, and a board you were not given is
simply not in the menu. The only screens written for this are empty
states, because a link can still be pasted into a chat — "no such board",
"there is, and it is not yours", and "you have none yet", told apart in
the DOM by data-outcome rather than by their copy.

The rule lives in one expression, BoardAccess.VisibleTo, so that the same
predicate runs in Postgres and in the tests. Every read in BoardService
comes in two — one taking a viewer, one ending in ForAdmin — and neither
takes an optional one: a default of "no filter" is a hole that opens
itself the first time a call site forgets an argument, which is why the
old one-argument ResolveAsync is gone rather than kept.

The two .ics endpoints mattered most here, being the only paths out of a
board that are plain HTTP: a member could otherwise have walked board ids
and read every deadline on the deployment out of the URL bar. Both build
a viewer — one from the cookie principal, one from the DID behind the
feed token — and 404 a board that is not theirs.

Admins see every board, and the access page says so beside each admin in
the list. They can grant themselves any board in one click and the admin
pages have to be able to edit a board nobody shared with them, so hiding
one would be theatre.

/admin/access[/{id}] is the screen, the same shape as the columns and
tags pages. Save is disabled until something differs from the database,
which is also how the e2e suite knows a write landed without reading a
toast in whichever language the run negotiated.

Verified: dotnet build + the 165 unit tests (Debug and Release), the
migration applied to a real Postgres, and the whole grant/revoke flow
driven in Chromium against a local instance — including the existing
boards.spec.js, board.spec.js, calendar.spec.js and filter.spec.js, which
still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Author
Collaborator

🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.

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

🤖 Reviewing this PR — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/189); the reply will link the full session transcript. 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/pr-80/run-189.md) — updating every ~5 minutes while the task runs.
claude-reviewer requested changes 2026-08-14 02:12:12 +00:00
Dismissed
claude-reviewer left a comment

🛑 Verdict: request changes — blocking issues below.

This is a large, well-documented feature (per-board visibility with an Everyone/Restricted switch and a BoardMember grant list). The core design is sound: the visibility rule lives in one Expression<Func<Board,bool>> (BoardAccess.VisibleTo) that's compiled for unit tests and translated to SQL for real reads, admins always see everything, BoardPage/Calendar re-derive the viewer and re-resolve the board on every reload so a revoked grant lands on an open page, the two .ics endpoints (the only plain-HTTP paths onto a board) are re-checked per request, CardService correctly stays untouched since there's no HTTP surface onto a card, the migration needs no backfill (Visibility defaults to Everyone), BoardMember cascades on both FKs (unlike the structural Restrict FKs elsewhere, for a documented reason), and both locales/e2e/unit tests were extended together.

One real gap: NavHeader's own board switcher does not follow the "always re-check" discipline the rest of the feature is built around — see the inline finding. It caches BoardViewer (in particular the IsAdmin flag) for the life of the circuit and only refreshes the board list on BoardsChanged, which a role change (MemberService.SetRoleAsync) does not raise. A demoted admin therefore keeps seeing every board's name in the switcher, including boards restricted to other people, until their circuit fully reconnects — contrary to the PR's own stated goal that a board not shared with you is simply absent from the menu. Card/board content stays protected (the fresh checks in BoardPage/Calendar still deny it), so this is a name/existence leak rather than a data leak, but it's a real hole in an otherwise carefully closed feature and should be easy to fix by having NavHeader refresh its viewer alongside the board list.

Everything else checked out: BoardService's viewer-taking vs ForAdmin method split, the ResolveAsync/CanViewAsync logic and its three BoardOutcomes, SetAccessAsync's diffing of grants (including dropping DIDs that aren't real members), the migration/model snapshot, the encryption-at-rest boundary (visibility and grants are plaintext by design, matching the rest of the schema's rule), and the en/de translation parity.


⏱ 3m 32s · 15 turns · tokens: 123.0k in / 18.9k out (+1734.4k cache-read)
📜 Full session transcript

🛑 **Verdict: request changes** — blocking issues below. This is a large, well-documented feature (per-board visibility with an `Everyone`/`Restricted` switch and a `BoardMember` grant list). The core design is sound: the visibility rule lives in one `Expression<Func<Board,bool>>` (`BoardAccess.VisibleTo`) that's compiled for unit tests and translated to SQL for real reads, admins always see everything, `BoardPage`/`Calendar` re-derive the viewer and re-resolve the board on every reload so a revoked grant lands on an open page, the two `.ics` endpoints (the only plain-HTTP paths onto a board) are re-checked per request, `CardService` correctly stays untouched since there's no HTTP surface onto a card, the migration needs no backfill (`Visibility` defaults to `Everyone`), `BoardMember` cascades on both FKs (unlike the structural `Restrict` FKs elsewhere, for a documented reason), and both locales/e2e/unit tests were extended together. One real gap: `NavHeader`'s own board switcher does not follow the "always re-check" discipline the rest of the feature is built around — see the inline finding. It caches `BoardViewer` (in particular the `IsAdmin` flag) for the life of the circuit and only refreshes the board list on `BoardsChanged`, which a role change (`MemberService.SetRoleAsync`) does not raise. A demoted admin therefore keeps seeing every board's name in the switcher, including boards restricted to other people, until their circuit fully reconnects — contrary to the PR's own stated goal that a board not shared with you is simply absent from the menu. Card/board *content* stays protected (the fresh checks in `BoardPage`/`Calendar` still deny it), so this is a name/existence leak rather than a data leak, but it's a real hole in an otherwise carefully closed feature and should be easy to fix by having `NavHeader` refresh its viewer alongside the board list. Everything else checked out: `BoardService`'s viewer-taking vs `ForAdmin` method split, the `ResolveAsync`/`CanViewAsync` logic and its three `BoardOutcome`s, `SetAccessAsync`'s diffing of grants (including dropping DIDs that aren't real members), the migration/model snapshot, the encryption-at-rest boundary (visibility and grants are plaintext by design, matching the rest of the schema's rule), and the en/de translation parity. --- ⏱ 3m 32s · 15 turns · tokens: 123.0k in / 18.9k out (+1734.4k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/pr-80/run-189.md) <!-- claude-verdict:request_changes --> <!-- claude-reviewed-head:96df675c3ae470ba40577c45ef5d29ee9fb4724d -->
Owner

@claude-bot Please work on the review findings

@claude-bot Please work on the review findings
Author
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/191); the reply will link the full session transcript. 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/issue-80/run-191.md) — updating every ~5 minutes while the task runs.
Ask again who is looking, every time the boards are re-read
All checks were successful
CI / build (pull_request) Successful in 33s
dd2517755d
NavHeader built its BoardViewer once, when the circuit started, and then
re-used it for every refresh. The DID in it cannot change under a live
circuit, but the other half can: IsAdmin is read from the allowlist
snapshot, and a demotion reloads that snapshot without ending the circuit
— PersistingRevalidatingAuthStateProvider asks whether the member is
still allowed, not whether they still administer.

So a demoted admin kept the whole deployment's board list in their
switcher, names of other people's restricted boards included, until they
next reconnected. Their content was never reachable — BoardPage and
Calendar re-resolve on every reload and the two .ics endpoints re-check
per request — but a board somebody was not given is supposed to be
absent from that menu, not present and inert.

The viewer is now derived inside LoadBoardsAsync, so there is no cached
copy left to go stale, and SetRoleAsync fires BoardsChanged: the snapshot
was already being reloaded there, but nothing re-read it on its own, so
the change reached an open page only when something else happened to
move the boards.

Three things in access.spec.js besides the test for it, all found by
running the suite rather than by reading it. The signup handle was 19
characters and the PDS refuses a first label over 18, so both tests that
need a second member failed at the signup. Deleting the board in the
finally, and opening the switcher, were unretried clicks on Blazor
handlers — one that lands before the circuit is up does nothing, which
left a board behind for every later spec file in the first case and read
as "the board is not in the switcher" in the second. Both now retry
through the pattern the rest of the file already uses.
Author
Collaborator

🤖 Reviewing this PR — follow along in the action run; the reply will link the full session transcript.

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

🤖 Reviewing this PR — follow along in [the action run](https://git.grandiras.net/Grandiras/claude-bot/actions/runs/201); the reply will link the full session transcript. 📜 [Live transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/pr-80/run-201.md) — updating every ~5 minutes while the task runs.
Author
Collaborator

Fixed and pushed to claude/issue-78 (commit dd25177).

The finding

NavHeader built its BoardViewer once in OnInitializedAsync and reused it for every refresh. The DID in it can't change under a live circuit, but IsAdmin can: it comes from the allowlist snapshot, and MemberService.SetRoleAsync reloads that snapshot without ending the circuit — PersistingRevalidatingAuthStateProvider asks whether the member is still allowed, not whether they still administer. A demoted admin therefore kept the whole board list in their switcher, other people's restricted board names included, until they next reconnected.

Two halves to the fix:

  • NavHeader.razor — the viewer is derived inside LoadBoardsAsync, so there is no cached copy left to go stale (the field is gone, not just refreshed).
  • MemberService.SetRoleAsync — now fires NotifyBoardsChanged(). The snapshot was already reloaded there, but nothing re-read it on its own, so a demotion reached an open page only when something else happened to move the boards. A role change is an answer to "which boards are there, for me".

Also CLAUDE.md: a line under the access bullets on why the switcher re-asks who is looking, and the role change added to the BoardsChanged list.

Verification

I brought up the full compose stack (the dind daemon at the gateway, per the notes in CLAUDE.md) and ran against it.

  • dotnet build Ponente.sln clean; dotnet test 165/165.
  • New e2e test a demotion takes the other boards out of the switcher in access.spec.js: two browser contexts — the member watches their switcher while an admin demotes them from the other session, and the board has to leave the menu with nothing reloading their page. I ran it against the unfixed code first and it fails there, so it pins the finding rather than the fix.
  • access.spec.js 3/3, four times running (three consecutive clean runs after the last change).

Three bugs in access.spec.js itself that only showed up by running it — worth knowing since the review couldn't see them:

  1. The signup handle was e2eacc${Date.now()} = 19 characters, and the PDS refuses a first label over 18 (InvalidHandle: Handle too long). Both tests needing a second member failed at signup — the file had never passed against the current PDS image.
  2. deleteBoard's click in the finally was unretried, so a click landing before the circuit is up silently did nothing and left a board behind — which changes the nav header every other spec file asserts against.
  3. Same race on the switcher toggle, where a lost click reads as "the board is not in the switcher" (~50% failure rate on that assertion).

All three now use the retry pattern the rest of the file already documents.

One thing I did not touch: full-suite runs in this container are flaky under CPU contention (it shares 8 cores with other CI jobs). Baseline on the pristine commit: 24 failed / 141 passed; with my changes: 21 failed / 145 passed — different, overlapping sets, dominated by the same unretried-delete race in boards.spec.js's own cleanup. That flake predates this PR, so I left it alone rather than widening the diff; it's a candidate for the same retry treatment if you want it.


⏱ 41m 2s · 135 turns · tokens: 161.0k in / 39.2k out (+16293.1k cache-read)
📜 Full session transcript

Fixed and pushed to `claude/issue-78` (commit `dd25177`). ## The finding `NavHeader` built its `BoardViewer` once in `OnInitializedAsync` and reused it for every refresh. The DID in it can't change under a live circuit, but `IsAdmin` can: it comes from the allowlist snapshot, and `MemberService.SetRoleAsync` reloads that snapshot *without* ending the circuit — `PersistingRevalidatingAuthStateProvider` asks whether the member is still allowed, not whether they still administer. A demoted admin therefore kept the whole board list in their switcher, other people's restricted board names included, until they next reconnected. Two halves to the fix: - **`NavHeader.razor`** — the viewer is derived inside `LoadBoardsAsync`, so there is no cached copy left to go stale (the field is gone, not just refreshed). - **`MemberService.SetRoleAsync`** — now fires `NotifyBoardsChanged()`. The snapshot was already reloaded there, but nothing re-read it on its own, so a demotion reached an open page only when something else happened to move the boards. A role change *is* an answer to "which boards are there, for me". Also `CLAUDE.md`: a line under the access bullets on why the switcher re-asks who is looking, and the role change added to the `BoardsChanged` list. ## Verification I brought up the full compose stack (the dind daemon at the gateway, per the notes in `CLAUDE.md`) and ran against it. - `dotnet build Ponente.sln` clean; `dotnet test` **165/165**. - **New e2e test** `a demotion takes the other boards out of the switcher` in `access.spec.js`: two browser contexts — the member watches their switcher while an admin demotes them from the other session, and the board has to leave the menu with nothing reloading their page. I ran it against the **unfixed** code first and it fails there, so it pins the finding rather than the fix. - `access.spec.js` **3/3, four times running** (three consecutive clean runs after the last change). Three bugs in `access.spec.js` itself that only showed up by running it — worth knowing since the review couldn't see them: 1. The signup handle was `e2eacc${Date.now()}` = 19 characters, and the PDS refuses a first label over 18 (`InvalidHandle: Handle too long`). **Both tests needing a second member failed at signup** — the file had never passed against the current PDS image. 2. `deleteBoard`'s click in the `finally` was unretried, so a click landing before the circuit is up silently did nothing and left a board behind — which changes the nav header every other spec file asserts against. 3. Same race on the switcher toggle, where a lost click reads as "the board is not in the switcher" (~50% failure rate on that assertion). All three now use the retry pattern the rest of the file already documents. One thing I did **not** touch: full-suite runs in this container are flaky under CPU contention (it shares 8 cores with other CI jobs). Baseline on the pristine commit: **24 failed / 141 passed**; with my changes: **21 failed / 145 passed** — different, overlapping sets, dominated by the same unretried-delete race in `boards.spec.js`'s own cleanup. That flake predates this PR, so I left it alone rather than widening the diff; it's a candidate for the same retry treatment if you want it. --- ⏱ 41m 2s · 135 turns · tokens: 161.0k in / 39.2k out (+16293.1k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/issue-80/run-191.md)
claude-reviewer left a comment

Verdict: approve — no blocking issues found.

This re-review confirms the one blocking issue from my previous pass is fixed. NavHeader.LoadBoardsAsync no longer treats BoardViewer as circuit-lifetime state — it now re-derives the viewer (via BoardViewer.For(state.User, Allowlist)) fresh on every call, including IsAdmin, which comes straight from the allowlist snapshot MemberService.SetRoleAsync reloads before notifying. And SetRoleAsync now fires NotifyBoardsChanged() in addition to NotifyAllBoardsChanged(), specifically so a role change reaches NavHeader's BoardsChanged subscription and triggers that re-derivation. The new access.spec.js test "a demotion takes the other boards out of the switcher" pins exactly this scenario with two browser contexts. The prior leak — a demoted admin's switcher continuing to show other members' restricted boards until reconnect — is closed.

The rest of the feature is unchanged in substance from the last pass and remains sound: the single BoardAccess.VisibleTo expression compiled for tests and translated to SQL for real reads, admins always seeing everything (by design, stated on screen), BoardPage/Calendar re-resolving on every reload and closing any open card modal when access is lost, the two .ics endpoints re-checking per request since they're the only plain-HTTP paths onto a board, BoardMember cascading on both FKs unlike the structural Restrict FKs, the migration needing no backfill, and en/de translation parity for the new keys.

No new issues found in this pass. Approving.


⏱ 0m 53s · 3 turns · tokens: 96.7k in / 4.7k out (+279.5k cache-read)
📜 Full session transcript

✅ **Verdict: approve** — no blocking issues found. This re-review confirms the one blocking issue from my previous pass is fixed. `NavHeader.LoadBoardsAsync` no longer treats `BoardViewer` as circuit-lifetime state — it now re-derives the viewer (via `BoardViewer.For(state.User, Allowlist)`) fresh on every call, including `IsAdmin`, which comes straight from the allowlist snapshot `MemberService.SetRoleAsync` reloads before notifying. And `SetRoleAsync` now fires `NotifyBoardsChanged()` in addition to `NotifyAllBoardsChanged()`, specifically so a role change reaches `NavHeader`'s `BoardsChanged` subscription and triggers that re-derivation. The new `access.spec.js` test "a demotion takes the other boards out of the switcher" pins exactly this scenario with two browser contexts. The prior leak — a demoted admin's switcher continuing to show other members' restricted boards until reconnect — is closed. The rest of the feature is unchanged in substance from the last pass and remains sound: the single `BoardAccess.VisibleTo` expression compiled for tests and translated to SQL for real reads, admins always seeing everything (by design, stated on screen), `BoardPage`/`Calendar` re-resolving on every reload and closing any open card modal when access is lost, the two `.ics` endpoints re-checking per request since they're the only plain-HTTP paths onto a board, `BoardMember` cascading on both FKs unlike the structural `Restrict` FKs, the migration needing no backfill, and en/de translation parity for the new keys. No new issues found in this pass. Approving. --- ⏱ 0m 53s · 3 turns · tokens: 96.7k in / 4.7k out (+279.5k cache-read) 📜 [Full session transcript](https://git.grandiras.net/Grandiras/claude-bot/src/branch/claude-logs/transcripts/Grandiras/Ponente/pr-80/run-201.md) <!-- claude-verdict:approve --> <!-- claude-reviewed-head:dd2517755d7c1c9dbf25a77d80d2db178e448e18 -->
Grandiras deleted branch claude/issue-78 2026-08-14 02:56:40 +00:00
Grandiras referenced this pull request from a commit 2026-08-14 02:56:40 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 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!80
No description provided.