Repository navigation
BUZZ-269: Cover community-ban entry points - #8239
bradseiler wants to merge 14 commits into
Conversation
🔐 Codex Security Review
|
|
|
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 Thanks for this. Two blocking problems, both in what the new coverage proves rather than in production behavior: the inventory guard misses a supported router shape, and the positive controls pass when the allowed operation is broken. Both were reproduced by mutation at this head.
Blocking
1. A second direct mount of the same router escapes the inventory (correctness). check-community-ban-route-inventory.py L286–304 keeps one prefix per router builder (prefixes[candidates[0]] = prefix), and L340–363 emits that builder's routes only under that one prefix. Adding .nest("/extra", api::buzz_v1::router(state.clone())) before the existing /buzz/v1 mount exposes /extra/me/sidebar and /extra/me/read-state on the relay, but the guard still exits 0 and reports 74 routes. An ordinary added .route(...) does fail it, as intended. This is the direct, literal .nest shape TESTING.md says is supported, not one of the listed gaps (route_service, macros, opaque composition), so new endpoints can skip both classification and the signed route matrix.
Fix: collect every mount prefix per builder (or fail closed when a builder is mounted more than once), and add a guard regression with two direct mounts of the same builder.
2. Positive controls accept unrelated failures (correctness). assert_allowed_control in community_ban_routes.rs L443–465 accepts any 2xx, 400, 404, 409, 415, or 422 for every control case and only checks that the body isn't a ban denial. Keeping the /count membership/ban gate but returning 404 immediately after it leaves all three new tests passing (community_http_ban_matrix, banned_root_websocket_is_refused, ban_is_scoped_to_host_resolved_community). Removing the gate instead does fail the matrix (/count returns 200 {"count":1} to the banned member), so the ban assertions are sound. The problem is that the unbanned side doesn't prove the operation still works, and moderation reads with valid inputs could likewise return 400/404 and pass.
Fix: give each control case its expected status and response: 200 plus response shape for valid reads and submissions (e.g. /count returns a numeric count), and the exact validation or missing-resource response for fixtures that are deliberately malformed or absent. Seed an accessible resource where resource authorization is part of what the case claims.
Scores
- Minimalism 8/10: the PR also carries the mesh receive, scraper, and push-lease fixture fixes that #8237 carries. They look correct; landing them once would keep this diff to the coverage change.
- Elegance 9/10: the route table, guard script, and signed matrix are a clean split, and the public/internal classifications and the two pending invitation/webhook rows are explicit rather than silently skipped.
- Correctness 7/10: the two items above. What holds up: ban denials require 403 plus the route's specific denial, not any 401/404; an empty matrix fails the case-set comparison; the ignored tests run by name in CI (Relay E2E and PostgreSQL jobs) and pass locally. The
bridge.rsmoderation-read change is a real fix: fresh owner proofs are now evaluated at the verified NIP-98 time, like the other NIP-98 routes, with a PostgreSQL regression. Themesh_boot.rsecho loop keeps one in-flight receive across drain ticks and drops it only on shutdown. Theingest.rs/relay_admin.rsedits are comment-only, and the 200 → 403 timed-out-admin expectation matches the shared gate on main.
Nonblocking
- The banned delegated agent has no matching unbanned-agent control outside the moderation reads. On
/buzz/v1/*the denial check only looks forcode == "forbidden", on media only forrelay membership required, and HEAD is bodyless, so those cases can't tell an owner-ban denial from some other agent-specific denial. A clear owner/agent pair per case would fix that. - The guard ignores
fallback/fallback_service, including the SPA fallback inrouter.rs.TESTING.mdlistsroute_service/nest_serviceas known gaps but not this one. TESTING.mdrestates the unmerged invitation contract (72h / 30-day lifetimes, v1invalid_after), which will drift if #8237 changes.- The description doesn't mention the moderation-read fix in
bridge.rs, and still says "Draft only" while the PR is ready for review.
wesbillman
left a comment
There was a problem hiding this comment.
🤖 Star Lord’s automated source review via Wes’s account, integrating independent Rocket (systems/lifecycle), Mantis (correctness/security/concurrency), and Groot (product/source-visible flows) lanes.
Two existing P2 coverage defects remain at this head, independently confirmed from source. They are already described in review 5511682177, so I am linking that feedback rather than duplicating inline comments:
- Preserve every direct router mount. In
scripts/check-community-ban-route-inventory.py:286–304,prefixes[candidates[0]] = prefixoverwrites an earlier mount; lines 342–363 emit only the retained prefix. A second direct mount of the same builder can therefore expose endpoints absent from the inventory without changing the guard’s result. Collect all prefixes, or fail explicitly on multiple mounts, and cover that supported composition shape with a guard regression. - Make allowed controls assert the expected outcome.
crates/buzz-test-client/tests/community_ban_routes.rs:443–465accepts any 2xx, 400, 404, 409, 415, or 422 for every control. A valid/countor moderation read returning an unrelated 404 after admission still passes. Assert each case’s expected status and response shape; use exact validation/missing-resource responses for deliberately invalid fixtures, and accessible resources where resource authorization is part of the claim.
The mesh echo retains its in-flight receive across housekeeping ticks, and the moderation-read change supplies the verified NIP-98 timestamp to owner-ban evaluation. No additional actionable defect was established in this review. The explicitly pending invitation/webhook siblings and final integrated zero-pending requirement remain unchanged.
Coverage limitation: the HTTP matrix’s CI relay has membership enforcement off; closed-relay admission is covered by separate targeted regressions, not by that full route matrix.
Minimalism 9/10; elegance 9/10; correctness 8/10 because of the two coverage defects above.
Reviewed head dda7ef8815cf7675b399bcee4512da3fd6ec7945 against base/merge-base 326e2301cb4b1edcb8a72d01ac4b19a83545365f. Source-only: no tests, guard scripts, mutation checks, builds, applications, or live services were executed. A read-only CI snapshot showed 74 successful and 33 skipped checks; GitHub also reports merge conflicts. These checks do not establish runtime, cross-pod, or deployment readiness. Public PR description and discussion were inspected; no attached images or actionable publication disclosure found. This is a non-blocking COMMENT review; humans decide what must change.
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 Thanks for the scanner hardening. The repeated-nest gap from my last review is fixed, and the duplicate .nest and .route_service registrations now fail the guard, which I confirmed by adding each one to a copy of this head. Two blockers remain: the positive controls are unchanged, and the guard misses routers composed with .merge.
Blocking
1. Positive controls still accept unrelated failures (correctness). This is unchanged since my last review. assert_allowed_control in community_ban_routes.rs L446–469 still accepts any 2xx, 400, 404, 409, 415, or 422 for every control and only checks that the body isn't a ban denial. A handler that admits the unbanned member and then returns 404 (the /count mutation from the last review) still passes the matrix. The new profile_search case inherits the same check, and its control body is a deliberately malformed {, so it proves only that the request got past auth.
Fix: give each control case its expected status and response: 200 plus the response shape for valid reads and writes (e.g. /count returns a numeric count), and the exact validation or missing-resource response for fixtures that are deliberately malformed or absent.
2. The guard misses router builders composed with .merge (correctness). The registration check at L392 looks at route, nest, the *_service calls, and fallback, but not merge. builder_prefixes (L317–347) only reads .nest in build_router, and L430 emits each builder's routes once, under that builder's nest prefix or "". So a builder merged into a nested router, or merged in more than one place, exposes routes the inventory never sees. To check, I added .merge(crate::api::git::git_policy_router(state.clone())) inside api::buzz_v1::router in a copy of this head. The guard still exits 0 and reports the same 77 routes, even though /buzz/v1/internal/git/policy now has no row. This is the same class of gap as the repeated .nest and the shapes in the earlier inline thread, through the one composition call the scanner doesn't read. The relay already uses it (build_router merges four routers, git_router merges settings::router).
Fix: track .merge(<builder>(...)) the way .nest is tracked, so a merged builder's routes are emitted under the merging router's effective prefix, and reject a builder that is merged or nested more than once. Alternatively, reject .merge of a builder call outside build_router. Add a fixture for each case.
Scores
- Minimalism 8/10: the PR still carries the mesh-receive, boot, scraper, and push-lease fixture fixes shared with #8237, #8241, and #8242. They look right, but landing them once would keep this diff to the coverage change.
- Elegance 9/10: keying constants by module and failing every unclassified shape through one error path is a clear improvement. The allow-listed fallbacks are pinned to their builder and exact argument, which keeps the exemptions honest.
- Correctness 7/10: the two items above. What holds up: the duplicate-nest and
route_servicemutations both fail the guard (exit 1), all 13 scanner fixtures pass, and the baseline guard reports 77 routes, 24 HTTP cases, 19 supporting regressions, and 2 pending. The two seed commits (6eb83c0e0,9a2c8704a) only adapt the test seeds to the newadd_relay_membersignature andMembershipWritereturn type.
Nonblocking
TESTING.mdL106–114 still says the scanner doesn't discover.route_service(...)or.nest_service(...), which contradicts L43–47 now that both are hard errors. It also lists "conditional/opaque router composition" as a gap. Once item 2 is fixed, that paragraph should say which composition the guard handles.- Still open from the last review: the banned delegated agent has no matching unbanned non-moderator agent control, so on
/buzz/v1/*, media, and HEAD cases the denial check can't tell an owner-ban denial from some other agent-specific denial. - Still open from the last review:
TESTING.mdL78–81 restates the unmerged invitation contract from #8237 (72-hour default, 30-day maximum,invalid_after), which will drift if #8237 changes. - The description still doesn't mention the moderation-read fix in
bridge.rs(fresh owner proofs now evaluated at the verified NIP-98 time). Luke asked about this too. It's a real security fix and should be named, or split out so it can be reverted or backported on its own.
## Summary A workflow whose saved owner is banned from the community can no longer create runs through any trigger: webhook, channel-event, cron, or signed command trigger. Every one of those doors already calls `WorkflowEngine::check_owner_authority`, which re-checks the owner's channel role. That gate now also reads the owner's authoritative restriction state. A ban denies the run, and a failed lookup fails closed. Unbanning restores admission. On the webhook door the denial is the same generic 404 as before, and no run is queued. On the event and cron paths, a ban denial is logged at WARN at most once per workflow every ten minutes, and at DEBUG in between. A failed lookup and every other denial still warn every time. The handler-local ban lookup in `api/bridge.rs` is removed because the shared gate covers it. This is a point-in-time check: a ban committed after the lookup can race with run creation. It does not cancel existing runs. Community timeouts do not block runs in this change. This PR also carries the shared mesh receive fix needed for independent validation (see Shared carry). ### Related BUZZ-272. - Invitations: [#8237](#8237). - Route coverage: [#8239](#8239). - Live-session revalidation: [#8241](#8241). - Staging runbook: [#8235](#8235). ### Testing Postgres lane: - `on_event_denies_run_while_owner_banned`: a banned owner's channel-event trigger creates no run, and after unban it creates one. - `cron_tick_skips_banned_owners_without_consuming_the_claim`: one tick skips a banned owner and an agent whose human owner is banned, while an unbanned workflow on the same tick still fires. After unban, a tick for the same scheduled instant fires both, so the denial did not consume their scheduled-fire claim. - `on_event_denies_run_when_restriction_lookup_fails`: the event path fails closed when the restriction read errors, and fires once the read succeeds. - `webhook_admission_enforces_owner_ban_tenant_scope_and_lookup_failure`: the HTTP door returns the same generic 404 for a wrong secret, a banned owner, a forbidden role and a failed restriction lookup, with no run created. It also covers another tenant's allowed control and unban. Unit lane: - `ban_denial_warns_once_per_interval_while_other_denials_always_warn`. Results: - At `81c367f48`, CI passed: Unit Tests 572/572, PostgreSQL Tests 1169/1169 and Relay E2E. A local-relay E2E also passed: - unbanned webhook: 202 and one run; - banned webhook: generic 404 and no run; - banned event trigger: no run; - after unban: one run. - At `72c8d8bbc` (merged with main), CI passed: Unit Tests (including all 171 buzz-workflow unit tests), PostgreSQL Tests 1227/1227 (all four ban tests listed by name), and both `Build (linux/amd64|arm64)` image jobs, which build with `--locked`. A Blox run at the same head also passed `cargo metadata --locked`, fmt and clippy, and the buzz-workflow unit tests passed there with no database reachable. - Not covered: the signed trigger has no dedicated test. It calls the same gate, which was confirmed by reading the code. ### Shared carry #8237, #8239, #8241 and #8242 each carry the same mesh-receive fix, boot and scraper regressions, and push-lease fixture correction. Whichever lands first owns them, and the others drop them on their next main merge. ### What got simpler The ban check now lives in the one gate every run-creation door already shares, so there is one fewer handler-local branch. Cron, event and signed triggers get the same protection without per-door code. The cron loop body is now `cron_tick(now)`, so a test can drive one tick without the 60-second sleep. buzz-workflow's infra-free unit tests ran in no CI lane. `just test-unit` and its cargo fallback in `scripts/run-tests.sh` now run them. buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: seiler <seiler@squareup.com> Signed-off-by: Brad Seiler <seiler@squareup.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Codex <codex@openai.com> Co-authored-by: Luke Tornquist <tornquist@squareup.com> Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: seiler <seiler@squareup.com> Co-authored-by: Codex <noreply@openai.com> (cherry picked from commit dcb83eaec669ed044ab2f16c8b7612bd54ed1d9c) Signed-off-by: Brad Seiler <seiler@squareup.com>
Signed-off-by: seiler <seiler@squareup.com> Co-authored-by: Codex <noreply@openai.com> (cherry picked from commit 5bbf2b64a82115c40fdadeb981dbfdf7122241f2) Signed-off-by: Brad Seiler <seiler@squareup.com>
Signed-off-by: seiler <seiler@squareup.com> Co-authored-by: Codex <noreply@openai.com> (cherry picked from commit 2374e1c05352db37eafa42d502a148b57dc21080) Signed-off-by: Brad Seiler <seiler@squareup.com>
Signed-off-by: seiler <seiler@squareup.com> Co-authored-by: Codex <noreply@openai.com> (cherry picked from commit 31968772ebe1b30c2862966e46b75a9ad4b967b6) Signed-off-by: Brad Seiler <seiler@squareup.com>
Keep invitation and webhook-owner gaps explicit until their implementations are integrated. Co-authored-by: Codex <noreply@openai.com> Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Codex <codex@openai.com> Signed-off-by: Brad Seiler <seiler@squareup.com>
Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com>
Signed-off-by: Local Builder <1c476cb1127ea270cb900bdc3891060140bdd1173f91498030ee9b4db01c2f20@buzz.block.builderlab.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com>
The scanner keyed `&str` route constants by bare name, so main's `api::profiles::SEARCH_PATH` collided with `api::gifs::SEARCH_PATH` and reported a false duplicate. Key constants by module and resolve qualified references against that module. Unclassified registrations are now hard errors instead of silent passes: `.route_service`, `.nest_service`, and `.fallback_service` (except the allow-listed SPA fallback in `build_router`), `.nest` outside `router::build_router`, and nest targets that do not resolve to exactly one router builder. A fixture self-test pins each shape and runs in `just test-unit` and `scripts/run-tests.sh unit`. Classify the three routes main added: `POST /profiles/search` (member gate, now in the HTTP ban matrix) and the operator-plane `GET /operator/communities/joined` and `GET /operator/communities/nip-fi-binding`. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com> Signed-off-by: Brad Seiler <seiler@squareup.com>
The route inventory allow-listed every `.fallback_service` inside `router::build_router`, and did not scan `.fallback` at all, so a new sub-router fallback could add an unclassified handler without changing the inventory. Allow-list entries now name the builder, call, and exact whitespace-normalized argument, and each may appear once. The existing buzz_v1 404 fallback is allow-listed explicitly. `builder_prefixes` kept one prefix per builder, so nesting a builder at a second prefix silently overwrote the first. A repeated nest is now a hard error. Five new fixtures cover a non-SPA fallback service, a repeated SPA fallback, a handler fallback inside and outside build_router, and a builder nested twice. All five fail against the previous scanner. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com> Signed-off-by: Brad Seiler <seiler@squareup.com>
Main (#8482) added a relay_keypair parameter to Db::add_relay_member. The merge in 60df787 kept three ban-matrix test call sites at four arguments, so the buzz-relay lib tests did not compile. Pass a generated key, as the other tests do. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com> Signed-off-by: Brad Seiler <seiler@squareup.com>
add_relay_member returns a #[must_use] MembershipWrite on main, so clippy -D warnings rejected the three ban-matrix seed calls. Map to the outcome before expect, as the neighboring tests do. Co-authored-by: Elrond <28d6302a099e5225b02c4155ac4236e4912603df2ab08dbfc2f4fef08ce598c8@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Luke Tornquist <tornquist@squareup.com> Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Sarnow <1894dcbafaa4df1ac607a9bf879ac0b7d77fbccb0e0f4619ecc252c8adbad8a2@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com>
Co-authored-by: Sarnow <1894dcbafaa4df1ac607a9bf879ac0b7d77fbccb0e0f4619ecc252c8adbad8a2@seiler-test.communities.public.builderlabstaging.xyz> Signed-off-by: Brad Seiler <seiler@squareup.com>
9a2c870 to
69edf32
Compare
wpfleger96
left a comment
There was a problem hiding this comment.
🤖 Both blockers from my last review are fixed. Every positive control now pins an exact status plus an exact body or shape, and the guard rejects any new or repeated .merge. One gap is left, in the one merge the guard still allows.
Blocking
1. The allowed Git settings merge keeps its root path when its parent is nested (correctness). builder_prefixes (L317–348) only gives a prefix to the builder a build_router .nest names directly. The api::git::transport::git_router → super::settings::router() merge is allowed at L361 no matter what prefix its parent has. Then L451 emits the settings routes with prefixes.get(..., ""). So if build_router mounts api::git::transport::git_router(state.clone()) under .nest("/alternate", ...) instead of merging it at the root, the guard moves the three transport routes under /alternate. The two default-branch routes stay at /git/{owner}/{repo}/default-branch, even though Axum nests all five. Once someone updates the three transport rows, the guard passes with two stale settings rows and without the two real routes. The root-merge-inside-nest check at L419–427 doesn't catch this, because the allowed merge sits inside the transport builder, not inside a build_router nest argument. This is the last case of the nested-merge gap from my last review. It's also the case the description promises to fail ("an existing root merge moved into a nested target").
Fix: either carry the parent's prefix through the allowed settings merge, or reject that merge when git_router has a prefix. Add a scanner fixture that nests git_router and expects the prefixed settings routes or a hard error.
Scores
- Minimalism 9/10: the shared prep fixes are gone. The CI,
Justfileandrun-tests.shchanges only wire in the guard and the matrix. - Elegance 9/10: per-case outcome contracts in one place, plus one error path for every unsupported shape, read clearly and fit the existing approach.
- Correctness 8/10: item 1. What holds up: I ran the PR head against a private relay, PostgreSQL, Redis and MinIO.
community_ban_routespasses 3/3,regression_relay_admin_ban_gatepasses 1/1, the guard reports 77 routes, 24 HTTP cases, 23 supporting regressions and 0 pending, and all 18 scanner fixtures pass. Both false greens from my last review now fail. With.merge(crate::api::git::git_policy_router(state.clone()))added insideapi::buzz_v1::router, the guard exits 1 ("production .merge registration is not classified"). With/countreturning 404 after admission, replay and membership checks, the matrix fails atcommunity_ban_routes.rs:475(404 vs 200). Thebridge.rsmoderation-read change passessigned_created_attoenforce_relay_membershipthe same way the other NIP-98 bridge routes do, and its new test separates the fresh-owner, stored-owner and clear-owner cases. Theingest.rs,relay_admin.rsand roster-regression changes match the restriction gate already onmain, which runs before relay-admin dispatch and refuses timeouts, so they don't change policy.
Nonblocking
- Two lines in the new
TESTING.mdsection aren't wrapped like the rest of it: the one ending in`scripts/check-community-ban-route-inventory.test.py`and the "or a resolved string constant and a direct call to another recognized router function" line. - The four nonblocking items from my last review are all handled: the scanner limits are documented consistently, ordinary-member routes run a clear delegated-agent control, the invitation section links to the authoritative contract, and the description names the moderation-read fix.
Units 6-9 of the 23-30 Oct desktop Playwright flake audit. All are test-only. origin/main (1eb67bb, now including #8680) hasn't touched these specs since fae3f03. No open PR touches the tests changed here: #6134 (conflicting since 10-19) only swaps page.goto calls in three of these files, #8187's hunk starts just after workflow-local-controls:151, #5778 (draft, conflicting) creates projects-v3-screenshots from an older base, and #8622, #7452, #7812, #8244 and #3342 edit other tests in the same files. Unit 6: measuring before an animation finishes onboarding.spec.ts:1487, "hosted community address line stays within the card for a long name" Run: #8715 (Integration shard 1), which doesn't touch onboarding. Signature: the centring check at :1545 expects <= 2 and receives 5.13. Cause: "Connect and continue" ends in showPage("owned") (WelcomeSetup.tsx:323), which mounts an OnboardingSlideTransition. Its content slides in from translateX(48px) over 650 ms (components.css:521, :676). fill() doesn't wait for stability, and the surface, input and suffix boxes come from three boundingBox round trips, so they can be read at different points of the slide. Change: waitForAnimations(page) after the fill, before measuring. onboarding.spec.ts:2289, "connected first-community profile keeps navigation inside the card and balances the avatar editor" Runs: main 40746677244 and 40925172215, and #8666 (Integration shard 1). Signature: the spacing check at :2485 expects <= 4 and receives 4.255. Cause: the avatar dialog opens with MODAL_CONTENT_MOTION_CLASS, a 200 ms zoom-in-95 (modalMotion.ts:5), and has a 250 ms height transition. dialogBox is read in one round trip and the preview and editor boxes in a later one, so a scale change between them skews the gap comparison. Change: waitForAnimations(page) once the dialog is visible. projects-v3-screenshots.spec.ts:280, "projects v3 workspace screenshot states", failing in the expectInsetSection call at :893 Runs: main 39643531998, 40759209995 and 40969909906 (Smoke shard 8), each passing on retry. Signature: the header's right inset at :326 expects 16 and receives 8. Four more runs fail the menu edge check at :328 with fractional values (expected 984, received 975.6875): 39622053330, 39641311877, 40281448810 and 40856574572. Cause: switching the context rail back from chat to repository narrows it from the thread panel's 380 px to the repository's 280 px (useProjectPanelWidths.ts:5, :17), animated by transition-[width] duration-200 (ProjectContextRail.tsx:31). The workspace widens with it, and expectInsetSection reads the workspace, header and menu boxes in three separate round trips. The fractional widths are mid-transition values. Change: waitForAnimations(page) before that call, the only one that follows a rail width change. Unit 7: fill() racing a caret move the app makes a frame later mention-recipients.spec.ts:376, "editing to a longer typed member drops the original shorter reference", and :97, "ambiguous added mention blocks editing before clearing the draft" Runs: main 40969965981 (Smoke shard 5, a run with every earlier fix) for :376. 40969858676 for :97, before the fixes. Signature: the edit_message poll at :428 receives null. The error context shows the edited message as "@scout hello @scout Jones hello": fill() appended. Cause: entering edit mode sets the content, then focuses its end in a requestAnimationFrame (MessageComposer.tsx:413). toHaveText passes as soon as the content lands, before that frame. If the frame lands between fill()'s select-all and its typing, the selection collapses and the text is appended. #8682's replaceComposerText handles this, but these two call sites (:115 and :403) were missed. Change: replaceComposerText at both. workflow-local-controls.spec.ts:151, "inserts template variables with keyboard control and restores the caret" Runs: main 40926393897 and 40957616146 (before the fixes), and #8239, which changes no desktop files. Signature: :194 expects "Keep {{auth" and receives "Hello {{trigger.text}}Keep {{auth". Cause: selectVariable restores the caret in a requestAnimationFrame (WorkflowTemplateTextarea.tsx:66). Setting the controlled value has already put the caret at the end, so the selectionStart poll passes before that frame runs. The frame then collapses fill()'s select-all. Change: after the poll, wait two animation frames, then fill. This is a frame barrier, not a timer. The app queued its frame before the evaluate, so it runs first. Unit 8: clicks that hit the wrong element profile.spec.ts:1128, "renders agent profile ingress subviews from the Playwright mock bridge", failing at :1543 Run: #8523, which changes no desktop files. Its base (ef4130d) already includes #8681. Signature: archiveAgentRow.click() times out because three stacked Sonner success toasts (data-expanded="true") intercept the pointer. Cause: the test's Stop and Start steps raise success toasts (useAgentLifecycleActions.ts:51, :61). Sonner stacks them bottom right, over the profile panel's action rows, and pauses their dismissal while the pointer is over them. #8681 fixed this test's motion window, and fixed the toast problem only in agent-availability:602. Change: press the five action rows (:1528-:1547) through a local pressActionRow helper: toBeEnabled, focus, Enter, as agent-availability.spec.ts:744-745 does. toBeEnabled keeps the wait click() gave, since these rows are disabled while a delete or archive is pending. config-bridge-screenshots.spec.ts:314, "07 — profile side panel — Configuration section" Runs: main 40967147375 (before the fixes), and #8235, which changes no desktop files. Signature: "Model" isn't visible within 10 s at :352. The error context shows nadia's panel ("Not managed on this device"), not Charlie's. Cause: the spec clicked .last() of the rows with a [data-testid^="message-avatar-"] descendant. That matches only UserAvatar's -image and -fallback children. Radix renders the image only once it loads, and each fallback only after its own 200 ms delayMs timer (UserAvatar.tsx:67, :119). Neither avatar in #agents has a URL, so the matching set fills in row by row as the timers fire. Resolved in between, .last() is nadia's row (seeded at -85 s), just above Charlie's "reporting in." row (-80 s). The note blamed a misplaced click; the code points at locator resolution instead. Change: pick Charlie's row by its text, as profile.spec.ts does for Charlie's other #agents message. Then check the panel's name row reads charlie before opening Runtime, so a wrong panel fails there and says so. Unit 9: the createWorkflow copies (preventive) navigation.spec.ts:153, "direct workflow detail links close back to workflows" Runs: 7 failures on main, the last at 29 Oct 16:12Z, none after #8681. Signature: the Edit workflow dialog has no "Trigger: Message Posted" at :176. Runs: 14 first-attempt failures in Smoke shard 7, all passing on retry; on main 39827234118, 40718018673, 40789486699, 40802051064, 40840949170, 40891643205 and 40909291143. Cause: the same race #8681 fixed in workflows.spec's createWorkflow. Picking a channel opens the trigger pane (WorkflowDialog.tsx:702), and for Message Posted the conditions editor expands "Message text" by default (WorkflowTriggerConditions.tsx:157), rendering an input with that label. After "Send Message", AnimatePresence mode="wait" (WorkflowFormBuilder.tsx:907) keeps the trigger body mounted through its exit and holds the step body back. An unscoped getByLabel("Message text") can fill the trigger's filter. The step then has no text, which yamlToFormState rejects (workflowFormTypes.ts:348), so the Edit dialog opens in YAML mode (WorkflowDialog.tsx:88). That is the signature the earlier audit recorded for navigation:153. Change: wait for the step inspector's "Action" header and "Step details" control, then fill inside the inspector, as workflows.spec does. workflows.spec.ts:716 ("captures the built editor at desktop and narrow widths", fill at :726) and :820 ("pane routes use stable IDs and Form/YAML changes stay synchronized", fill at :831) pick a channel and then add a Send Message step the same way, with no failures in the window. So the wait moves into one addSendMessageStep helper in tests/helpers/workflows.ts, rather than four copies. Both specs' createWorkflow and those two tests call it. A change to the inspector's structure then needs one fix. Left out: workflow-reaction-picker.spec.ts:203-204. The note listed it, but its trigger is Reaction Added, whose condition fields are "Reaction emoji", "Author" and "Message" (workflowConditionExpression.ts:40). No trigger control there is labelled "Message text", so the unscoped fill can only reach the step's textarea. Verification Not run locally: Playwright bogs this machine down, and the Flake Check lane will repeat the touched specs. pnpm typecheck and pnpm check pass. biome's 3 warnings are in untouched files. An ad hoc tsc ran over the eight touched specs, using the desktop tsconfig with only those files, and over HEAD's copies of them. Both gave the same errors: onboarding 16, navigation 7, profile 7, workflow-local-controls 7, projects-v3-screenshots 2, and none in mention-recipients, config-bridge-screenshots or workflows. The messages are identical once line numbers are stripped, and all are pre-existing (no Node types, import.meta.env, MockBridgeOptions drift and the like). Once the helper moved to tests/helpers/workflows.ts, a rerun over navigation, workflows and the helper gave navigation's same 7 and none in the other two. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com>
…kspace (#8744) ## Summary Units 6–9 and 14 of the 23–30 Oct desktop Playwright flake audit, in two commits. The specs now settle before they measure or act, and remote-owned-mentions clears its draft reliably. Everything is test-only. This is one of six PRs split from the reviewed `flakey-tests` branch (siblings below). ### Unit 6: measuring before an animation finishes Each spec now calls `waitForAnimations(page)` before measuring. - `onboarding.spec.ts:1487` (#8715, Integration shard 1; the centring check at :1545 receives 5.13): `showPage("owned")` slides content in from `translateX(48px)` over 650 ms. The spec reads its three boxes in separate `boundingBox` round trips, so they can come from different points of the slide. - `onboarding.spec.ts:2289` (main 40746677244 and 40925172215, and #8666; the spacing check at :2485 receives 4.255): the avatar dialog has a 200 ms zoom-in and a 250 ms height transition. - `projects-v3-screenshots.spec.ts:280`, at :893 (main 39643531998, 40759209995 and 40969909906, plus four runs with fractional menu-edge values): the context rail narrows from 380 to 280 px over 200 ms. ### Unit 7: `fill()` racing a caret move the app makes a frame later - `mention-recipients.spec.ts:376` and `:97` (main 40969965981 and 40969858676): edit mode focuses the end of the composer in a `requestAnimationFrame`. If that frame lands mid-`fill()`, the edit is appended instead of replacing the text. **Change:** use #8682's `replaceComposerText` at both call sites. - `workflow-local-controls.spec.ts:151` (main 40926393897 and 40957616146, and #8239): `selectVariable` restores the caret in a `requestAnimationFrame` after the spec's poll has passed. **Change:** wait two animation frames before filling. ### Unit 8: clicks that hit the wrong element - `profile.spec.ts:1128`, at :1543 (#8523): stacked Sonner success toasts intercept the pointer. **Change:** a local `pressActionRow` helper presses the five action rows with `toBeEnabled`, focus and Enter. - `config-bridge-screenshots.spec.ts:314` (main 40967147375, and #8235): `.last()` resolved while avatar fallbacks were still filling in on 200 ms timers, so it opened nadia's panel instead of Charlie's. **Change:** pick Charlie's row by its text, and check the panel's name before opening Runtime. ### Unit 9: the `createWorkflow` copies (preventive) - `navigation.spec.ts:153` (7 failures on main, none since #8681; 14 first-attempt failures in Smoke shard 7): an unscoped `getByLabel("Message text")` can fill the trigger's filter while `AnimatePresence` keeps it mounted. **Change:** wait for the step inspector, then fill inside it, as `workflows.spec` already does. The wait now lives in one `addSendMessageStep` helper in `tests/helpers/workflows.ts`, which `navigation.spec.ts` and `workflows.spec.ts` (`createWorkflow`, :716, :820) all call. - `workflow-reaction-picker.spec.ts:203-204` is left out. Its Reaction Added trigger has no control labelled "Message text". ### Unit 14 (second commit): `remote-owned-mentions.spec.ts:566` - **Run:** main 40998438103 (Smoke shard 9, job 123877347301). It passed on retry and failed 1 of the 6 attempts it got in the window. `sourceRecord()` at :673 returned thread A's draft instead of null. - **Cause:** on ProseMirror, `fill("")` selects through the DOM, then presses Delete. If ProseMirror hasn't seen the selection yet, it blocks the Delete. Navigating to B then saves A's live text as its draft. This is not a product bug: had the editor really been empty, the cleanup would have cleared A's draft. - **Change:** delete the text with one Backspace per character from the end, then wait for the composer to read empty. The guard assertions are unchanged. ### Related issue None found. Sibling #8743 also edits `navigation.spec.ts`, in a separate hunk. The two merge cleanly in either order. No open PR touches the tests changed here. #6134, #8187, #5778, #8622, #7452, #7812, #8244 and #3342 edit other tests in the same files. ### Testing Not run locally, because Playwright bogs this machine down. Flake Check will repeat the touched specs. `pnpm typecheck` and `pnpm check` pass, and biome's 3 warnings are in untouched files. Ad hoc `tsc` runs over the touched specs and the helper found the same pre-existing errors as on their `main` copies. Pushed with `git push --no-verify`, because the pre-push suites fail when the laptop is under load. ### Readiness An agent review of the combined `flakey-tests` branch (b432453af) found no blockers. `buzz-review-completed` is not added, because the human test step (AGENTS.md "Before opening a PR", step 3) hasn't happened yet. ### Siblings - #8739 ci: Hermit fetch deadline and sccache fallback - #8740 test(relay): thread_window aux-page hold and `api::media` Postgres tests - #8741 test(mobile): RunnerTests title fade and alert wait - #8742 fix(desktop): deep-linked message hold (product change) - #8743 test(desktop): four specs racing scroll settles and focus 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Matt Toohey <contact@matttoohey.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Summary
Classify all 77 registered relay routes and bind community-ban checks to signed requests with exact allowed controls. New or opaque router composition fails the inventory guard, including unclassified
.merge(...)calls and an existing root merge moved into a nested target.Replace broad positive-status allowances with case-specific statuses and response bodies or shapes. Ordinary-member routes also exercise a clear owner-delegated agent. GIF controls require no provider key. Media reads, Git reads and workflows use absent resources; malformed JPEG upload and an invalid Git-push repository name assert their exact validation errors.
The moderation-read gate now uses the signed NIP-98 creation time for owner-proof validation. Integrated invitation, workflow and live-session regressions replace stale pending-sibling rows. The inventory requires zero pending cases.
Related issue
BUZZ-269. Stack: #8529 → #8241 → #8239 → #8235. #8237 and #8242 are merged. Shared preparation fixes already on main were dropped from this layer.
Testing
The integrated candidate at
45efe62fpassedcargo test --locked -p buzz-test-client --test community_ban_routes -- --ignored --nocapture(3/3) and--test regression_relay_admin_ban_gate -- --ignored --nocapture(1/1) against a private relay, PostgreSQL and Redis. The roster regression used itsREPRO_RELAY_HTTPandREPRO_HOSTsettings.The relay, database and test-client source matches this rebased layer at
69edf322. Object storage used a private missing-object response fixture; this is not MinIO upload/download validation. The Git conformance startup probe was disabled. Failed launcher and fixture-assumption attempts remain in the local receipts; the final controls retain exact outcomes.Independent source review accepted the corrections. Previous-head review completion was removed. Ready for review at Brad’s request; fresh review and human verification are not attested by this status change.
Generated with Codex