Skip to content

fix(relay): report a community's refusal once, not as raw 403s - #2978

Open
baxen wants to merge 7 commits into
mainfrom
mbot/community-denied
Open

baxen wants to merge 7 commits into
mainfrom
mbot/community-denied

Conversation

@baxen

@baxen baxen commented Nov 7, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Rebased on main after #2968 merged.

A key that a community doesn't admit can still connect, because NIP-11 discovery is public. After that, the relay refuses every signed request with its plain-text NIP-FI denial (authorization denied\n, from buzz-auth/src/nip_fi/denial.rs). Each feature used to show its own Relay request failed (403) next to a stuck "Loading workspaces…", a sidebar-preferences toast and the "page isn't available" card.

Now a refusal is one fact about the community. It gets one notice, on every page, and the notice offers what fixes it.

Heard from every request

  • The relay's refusal bodies are recognised by exact bytes and status. authentication required (401) and evidence rejected (403) mean the sign-in ended. authorization denied (403) means no access. Native's Federated sign-in is required also counts as an ended sign-in. Any other text still counts as an edge page. authorization unavailable (503) is still an outage that recovery retries. A JSON refusal about one item (a p-gated channel) stays with the feature that hit it.
  • Each host reports refusals from the one place its relay requests pass. No per-route checks are needed.
    • Native: send and upload_file in relay.rs cover the signed commands sent as the user: reads, publishes, channel commands, direct messages, uploads, workflows and huddles. Each emits one relay-refusal window event. native.ts has one listener that serves every connection. Native plugin reads and publishes go without the user's federated evidence, so their refusals stay with the plugin. Git's own authorization calls don't go through send and aren't reported.
    • Development broker: its upstream fetch marks the browser response with x-buzz-refusal, whatever the route does with the relay's body. Uploads used to reduce it to {code:"denied"}, and presence used to parse it away. Every broker request on the frontend, uploads included, goes through the one wrapper that reads the mark. A request the admission lane queued is bound to its own route, so its refusal marks its own response.
    • The API lane's own refusal listeners are removed.
  • The connection enters denied with a refusal reason (sign-in / access), as a refusal while connecting already did. Access refusals still clear this device's cache. An ended sign-in keeps the cache, so signing back in is fast.

What the notice offers

The refusal's reason and this device's sign-in for the community decide the offer, not error text or the session's status. The notice stays in the toast stack until the refusal is resolved (same placement as #2968's recovery notice). DESIGN.md now records this as the second exception to inline blocked-page recovery.

What happened Notice Actions
Sign-in ended or expired Sign in to open this community Sign in with <server>. If Buzz still thinks it's signed in (evidence rejected), Sign in again signs out of that server and back in.
No sign-in server for this community on this device This community needs a sign-in Buzz doesn't support yet none (ask the admin)
No access while signed in through the community's server You don't have access… / didn't accept <account> Switch account, Check again
No access with a plain key You don't have access… / ask the admin Check again

Try again only survives as Check again, where an admin can change the outcome. Messages and Activity no longer show their own card, raw error or retry. They only say why they're empty. The sidebar shows nothing of its own for a refusal. The channels plugin no longer needs federatedIdentity, which fixes the fixture-based browser journeys that failed on the previous head.

No relay changes.

Tests

  • Rust relay::tests::community_refusals_reach_the_window_from_any_signed_request: exact bodies, statuses and the sign-in error classify. Look-alikes, JSON and outages don't. A refusal reaches the window channel. cargo test --lib relay passes (279 tests), and so do clippy and fmt.
  • Vitest:
    • http-admission: classification and look-alikes, as before.
    • attachment-upload: an upload the community refuses marks its broker response. The route still reports its own {code:"denied"}. JSON and HTML 403s aren't marked. Bounded: a large or unending body is not read.
    • CommunityRefusalNotice: every row of the table, including Sign in again from a stale signed-in session (sign out, then sign in, then retry), Switch account, and Check again.
    • PrimarySidebar, ChannelsPage, InboxPage: the notice replaces the sidebar's and the pages' own recovery.
  • Browser relay-refusal.spec.mjs (production broker), in Chromium and WebKit:
    • No access: one notice, one Check again, no Try again or raw status. The notice follows the person to Agents. Check again opens the community once it admits the key.
    • Sign-in ends mid-session: one notice, no raw status.
    • New: an upload is the first refused request, and the notice appears. All three fail with the broker mark disabled (checked).
  • navigation-repairs, channel-completion and conversation pass locally in Chromium.

Deferred

  • I haven't tested this by hand in the desktop app against a real community that refuses the key, or while signed out of its server. The native path (Rust event → native.ts listener → service → notice) is covered at each layer, not end to end, because browser builds have no native sign-in.
  • A refusal that the native window event delivers after a connect-time read has already failed shows the generic connection error, not the notice. Native's connect makes no signed request before the connection subscribes, so I don't know of a current path that hits this.

@baxen
baxen requested a review from a team as a code owner November 7, 2026 06:45
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 7, 2026 •

Copy link
Copy Markdown
Codex Review Summary: ✅ Completed

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-07T06:48:13.624538Z a9a5cff PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a9a5cffe6e

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

});
return {
profiling,
observeRefusal,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Route broker uploads through the refusal-aware fetch

In the production broker transport, observeRefusal only sees requests made through the new local fetch wrapper, but uploadAttachment is still created with brokerUpload(), whose implementation calls the global fetch directly. If a user's community sign-in expires and their next signed operation is an attachment upload, the relay's 401/403 is converted to an upload error while the connection remains ready, so the promised single community-level sign-in/access message does not appear. Pass the refusal-aware fetch into the upload path or otherwise report classified upload refusals to this observer.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Confirmed in Chromium and WebKit through the mounted composer and production broker: upload-first 401 authentication required\n or 403 evidence rejected\n leaves the composer visible with only an attachment permission error; a subsequent normal channel read shows the community sign-in card (four reproduction cases, modeled upstream). The fix also needs the host boundary: browser-host/attachment-upload.mjs:88–98 discards the refusal body and normalizes 401 to 403, and relay-broker.mjs:2204 returns only {code:"denied"}. Preserve/classify that evidence before notifying the observer; swapping the frontend fetch alone is insufficient.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Addressed in 1e37212, at the host boundary rather than in the upload path: the broker's upstream fetch marks any browser response whose relay request met a community refusal with x-buzz-refusal, whatever the route does with the body, so the upload route keeps its {code:"denied"}. brokerUpload now goes through the transport's refusal-aware fetch. Covered by attachment-upload.test.mjs and the new upload-first case in relay-refusal.spec.mjs.

Comment thread src/features/relay/transport.ts Outdated
Comment on lines +1413 to +1414
observeRefusal: (listener) =>
signedAdmissions.observeRefusal(httpOrigin, viewer, listener),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Route native purpose-bound calls through refusal admission

In the desktop native transport, this observer is attached only to signedAdmissions, while native overrides such as commandWriter.publish and openDirectMessage call Tauri IPC directly. When federated sign-in expires and one of those operations is the first request, Rust can return Federated sign-in is required or a relay refusal without invoking the admission listener, leaving the community displayed as ready and surfacing the error only inside the initiating feature. Route these native relay-facing operations through the same refusal-reporting owner, or explicitly notify it from their IPC results.

AGENTS.md reference: AGENTS.md:L99-L103

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Addressed in 1e37212: native now reports from send and upload_file in relay.rs, which every signed command passes (channel commands, direct messages, uploads, workflows, git, huddles), as one relay-refusal window event that native.ts forwards to the connection. The TypeScript lane listeners are removed.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Star Lord’s automated source review via Wes’s account, with independent Rocket, Mantis and Groot coverage.

Two new P2 findings are attached: account recovery is not wired to the new refusal states, and optional presence requests bypass community-refusal reporting.

I also verified the existing broker-upload and native purpose-IPC findings rather than duplicating them. For the upload repair, forwarding the observer-aware fetch alone is insufficient: browser-host/attachment-upload.mjs:88–99 discards denial bodies, and relay-broker.mjs:2202–2209 forwards only {code}. Preserve bounded refusal evidence through that boundary as well.

Reviewed head a9a5cffe6e33eb0aba52eb8a38a9e1af8a8b3115, target 6d56e122085fa063292e01ee2e77b4fcc42dc980, merge-base 70302daf35efbe2462f5ff6c63721cbd6927dc91. This covers the incremental change stacked on #2968; its previously reported findings are not repeated here.

Source inspection only: no tests, builds, app/auth execution or runtime focus validation. The PR description was inspected; its referenced screenshots were not attached and were not inspected. The relay-side denial producer was not independently inspected. This is a COMMENT review, not approval.

* refusal keeps native's wording, which `CommunitySignIn` answers with sign-in. */
const REFUSAL_ERRORS: Record<CommunityRefusal, string> = {
"sign-in": "Federated sign-in is required",
access: "restricted: authorization denied",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [P2] Connect the new refusal kinds to account recovery

For a configured federated account, these new errors do not produce the recovery actions the card asks for. CommunitySignIn.tsx:95 recognizes authorization-denied/authorization_denied, so restricted: authorization denied falls through to null instead of offering Switch account. For a native 403 evidence rejected, relay.rs:1058–1073 forwards the response without changing the issuer session; that session can remain signed in, and CommunitySignIn.tsx:135 then hides Sign in despite the new sign-in refusal. The sidebar hides its other controls and the main card only offers Try again, which repeats the request with the same account/evidence. Wire the classified refusal into the existing account action so access refusal offers account switching and rejected evidence offers explicit reauthentication even while the issuer session is still signed in. Cover both states with a configured federated identity.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Addressed in 1e37212: the notice now decides from the classified refusal, not error text or session status. Sign-in refusal with a session Buzz still considers signed in offers Sign in again (sign out of that server, then in); access refusal while signed in names the account and offers Switch account plus Check again. Both covered in CommunityRefusalNotice.test.tsx with a configured federated identity.

Comment thread src/features/relay/transport.ts Outdated
: {}),
...(upload ? { uploadAttachment: hostUpload(upload, httpOrigin) } : {}),
observeRefusal: (listener) =>
signedAdmissions.observeRefusal(httpOrigin, viewer, listener),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [P2] Report community refusals from presence snapshots too

The observer misses the optional presence path immediately below it. presenceSnapshot calls signedPost(..., true), whose optional branch uses admission.prepare(request) instead of admittedApiRequest; its error handler parses the failure but never calls lane.refused. Broker presence likewise bypasses admission and then JSON-parses away the plain-text denial (browser-host/relay-broker.mjs:3218–3266). If a presence snapshot is the first read refused in an otherwise idle connected community, the presence owner catches the error, reports unknown status, and retries roughly every minute while the community remains ready. Preserve presence’s separate capacity rules, but classify and report its HTTP/native sign-in refusals through the same observer.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Addressed in 1e37212 by the same host-level reporting: native presence goes through send in relay.rs, and broker presence through the broker's upstream fetch, which marks the response before the route parses it.

@tellaho

tellaho commented Nov 7, 2026 •

Copy link
Copy Markdown
Contributor

🤖 Drip design review · follow-up at 91bb5943989b6cc0a043e56a06008d567fc61b02

Intent: give someone refused by a community one understandable explanation and a working recovery action.

Must fix — restoring sign-in is still reported as unsupported. A community session starts with restoring and no authority, but the notice treats that as a settled unsupported sign-in and tells the person to contact an admin. Wait for restoration before deciding support, and distinguish a failed lookup. Initial state and lookup; notice fallback. The guide says missing or failed reads do not prove absence.

Design preference (blocks merge), unchanged: a persistent actionable toast versus one inline shell recovery surface still needs tho or Morgan’s decision under the design-review canvas. The branch’s new lasting-error toast exception does not establish that decision.

The prior account-switch and Mesh blockers are repaired in source: active switching now retains progress, Cancel and failure recovery after signing out; native plugin calls without the user’s federated context no longer report a refusal for their session. Read the new deferred-login/cancel/retry regression and native tokenless-request test; did not execute them. No new defect found in these fixes.

Source review only. No current-toast screenshots supplied in the checked PR/source conversation; older card screenshots do not validate this presentation. Themes, narrow/enlarged layouts, keyboard/focus, screen readers, agent-heavy states and real native/community sign-in remain unverified. No tests or app execution.

Prior context: original review November 7, 2026, at a9a5cffe6e33eb0aba52eb8a38a9e1af8a8b3115; preceding review at 1e3721237f07b882bc3950d051da5e416c380f75. This updates the original review comment.

@kalvinnchau kalvinnchau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Two additional findings below. The four existing P2 refusal/recovery findings remain unresolved; approval is withheld.

connection.status === "error" && connection.denied && !connection.retrying;
// A cached startup session has no Solo capabilities yet; its unavailable
// placeholders still change height when the live inventories arrive.
const soloReady =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 [P2] Keep refusal recovery visible outside Messages

The new refused condition suppresses the sidebar notice and Try again regardless of the active destination, but only ChannelsPage renders the replacement refusal card. If a refusal arrives while the user is on Agents or Workflows, they lose both the explanation and retry action; Activity instead exposes the raw refusal string. Keep a shell-level refusal surface, or suppress the sidebar controls only when the replacement card is actually rendered. This finding is based on source tracing, not a runtime reproduction.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Addressed in 1e37212: the refusal notice now lives in the toast stack on every page (rendered by the shell sidebar, as #2968's recovery notice is), so Agents/Workflows keep it. Activity no longer shows the raw refusal string or its own retry.

refused = kind;
if (ready) void refuse(kind);
});
if (stopRefusals)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 [P3] Dispose refusal subscriptions when connect resolves after timeout

The eight-second timeout aborts signal without advancing generation. If connect resolves afterward, this path still registers observeRefusal, then attaches cleanup to a signal that has already aborted. That listener will never run, retaining the closure and transport in the module-global refusal map for each late connection. Check signal.aborted before subscribing, or immediately invoke stopRefusals() for an already-aborted signal. This is a source-traced lifecycle finding; no timing reproduction was run.

@baxen
baxen force-pushed the mbot/community-denied branch from a9a5cff to 8801166 Compare November 7, 2026 19:32

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Star Lord’s automated follow-up source review via Wes’s account, with independent Rocket, Mantis and Groot coverage.

The shared Sign in button now gives signed-out and reauthentication-required sessions an action in Messages and removes its sidebar duplicate. The following prior findings remain unresolved; no new distinct defect or duplicate inline comment is added:

  • Account recovery (P2): signInPrompt still returns null for a signed-in issuer session (CommunitySignIn.tsx:147–152). A native 403 evidence rejected can leave that session signed in, so the new card still offers only Try again. The access arm also remains: restricted: authorization denied does not match /authorization[-_]denied/, so Switch account is missing. Recovery should follow the classified refusal and offer the corresponding explicit account action.
  • Presence reporting (P2), broker uploads (P2) and native purpose IPC (P2): their reporting paths are unchanged from the prior reviewed revision.
  • Recovery outside Messages (P2): the sidebar now also suppresses Sign in for every sign-in refusal (PrimarySidebar.tsx:404–417), while only ChannelsPage supplies the replacement. Preserve a recovery surface when another destination is open.

I could not substantiate the stale-connect P3 for the current production callers: connectNativeTransport checks signal.throwIfAborted() immediately after awaiting the signed transport (native.ts:405–411), before the service can subscribe; broker listeners are local to each transport. This follow-up does not retain that candidate as an actionable defect.

Reviewed head 8801166807118d432e0059ae19ba4be1815622aa, target and merge-base 6d56e122085fa063292e01ee2e77b4fcc42dc980, including comparison with prior head a9a5cffe6e33eb0aba52eb8a38a9e1af8a8b3115. This remains stacked on #2968.

Source-only: no tests, builds, app/auth execution or runtime accessibility checks. The browser fixture does not exercise a native signed-in issuer session; the new unit case explicitly expects the Try again fallback. Author-reported test runs and screenshots referenced outside this PR were not independently verified. COMMENT only, not approval.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Star Lord’s automated follow-up source review via Wes’s account, with independent Rocket, Mantis and Groot coverage.

No new distinct defect found in the five-file delta from 88011668: the unified sign-in wording and branch refactor preserve the existing action selection, and the removed prompt.expired field has no remaining consumer in src/.

The prior P2 concerns remain unresolved: account recovery, recovery outside Messages, presence reporting, broker uploads, and native purpose IPC. In particular, signInPrompt still excludes a signed-in issuer session, and the access-refusal string still misses the account-switch matcher. Wording changes do not repair those recovery paths. No duplicate inline comments are added; the prior stale-connect assessment is unchanged.

Reviewed head 1a115e62153c0ed6559e365d434b68e2f3706ab6, target/merge-base 6d56e122085fa063292e01ee2e77b4fcc42dc980; prior review 5814662815. Source-only: no tests, builds, app/auth execution or runtime accessibility checks. The PR reports a full pre-push suite timeout that still needs rerunning; desktop end-to-end testing is deferred. Its targeted passes and externally referenced screenshots from an uncommitted fixture were not independently verified. COMMENT only, not approval.

@baxen
baxen force-pushed the mbot/quiet-reconnect-toast branch from 6d56e12 to caea6d2 Compare November 7, 2026 21:06

@kalvinnchau kalvinnchau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 Two correctness regressions remain in refusal propagation and account-switch recovery. Details inline; native runtime acceptance remains unverified.

Comment thread src-tauri/src/relay.rs
response_limit,
)
.await
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 [P2] Exclude tokenless Mesh requests from user-session refusal reporting

plugin_read/plugin_publish call send(host, None, ..., authenticated=true). On an FI-gated community, Mesh membership/event queries therefore carry no FI evidence and can receive 401 authentication required\n. This new observer broadcasts that as a sign-in refusal for the entire origin, causing the frontend to discard a valid user connection. Signing in again cannot fix the next tokenless Mesh poll. Restrict observation to requests with the user's federated context, or retain the authorization-aware classification used by send_authorized, and cover fi=None. This finding is source-traced; Mesh against an FI-gated relay was not run.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in e2152a0. send and upload_file now report a refusal only when the request carries the user's federated context (fi.is_some()), so plugin_read/plugin_publish's unsigned requests keep their refusal to themselves. Covered by a_plugin_request_refused_without_the_users_sign_in_is_not_their_refusal (fi=None against a 401 authentication required), which fails without the change.

);
const server = serverName(authority);
const signedIn = state.status === "signed-in";
if (refusal === "access" && !signedIn)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 [P2] Preserve account-switch progress after signing out

After an access refusal, Switch account signs out and starts browser login. The identity is now signing-in, so this early access && !signedIn fallback hides the active switch progress and Cancel sign-in action. Cancellation or failure then leaves only the admin instruction and Check again, without Sign in/Try again recovery. Render the active switch lifecycle before the signed-out fallback and preserve a retry after cancellation. A mounted notice/ToastProvider probe with deferred login and modeled identity transitions reproduced both states. This is a regression after action selection, extending the account-recovery issue in discussion 4500615971.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in 91bb594. The notice's signed-out access answer now yields to a switch in progress, so its progress, Cancel sign-in, and the Sign in offered after a cancel (or Try again after a failure) all stay. New CommunityRefusalNotice.test.tsx case: deferred browser login, cancel, then Sign in finishes the switch and retries. It fails without the fix.

Comment thread src-tauri/src/relay.rs Outdated
Err(_) => None,
}
}
const SIGNED_OUT: &str = "Federated sign-in is required";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🤖 [P3] Share the signed-out error contract with its producer

This literal duplicates the function-local SIGNED_OUT in federated_identity/assertion.rs. Rewording the producer's error would silently stop native sign-in refusals from reaching the window, while this module's classifier test would still pass because it uses this separate constant. Export and reuse the producer's constant so these two sides cannot drift independently.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in e2152a0: SIGNED_OUT is now a module-level pub(crate) constant in federated_identity/assertion.rs, and relay.rs imports it rather than keeping its own copy.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Star Lord’s automated source-review follow-up via Wes’s account, with Rocket, Mantis and Groot.

Reviewed head 1e3721237f07b882bc3950d051da5e416c380f75, target caea6d2e18750c352e26545ee80d8d98689d77d8, actual merge-base ae9efa2015f7674bc6a2f3d227271dce383e818f; compared the repair against previously reviewed 1a115e62.

One new P2 is inline: a queued broker operation can report its refusal against the preceding request’s response instead of its own.

The direct broker upload and presence paths now preserve refusal classification before route-specific error handling, addressing the bypasses in 4500534498 and the broker portion of 4500615976. The old missing-action branches in 4500615971 now offer Sign in again and Switch account. However, the existing access-switch lifecycle finding 4503909352 remains: after sign-out, the early signed-out access branch hides progress, cancellation and failure recovery. The immediate-success access test misses that intermediate state.

The ordinary Agents/Workflows case in 4502463593 is repaired. Its coverage still excludes contributed pages that replace PrimarySidebar (App.tsx:619–650): unmounting the notice dismisses its toast. The source-visible example is the sidebar plugin fixture; we did not establish a bundled production page affected by that remaining boundary.

Native send/upload_file now report the purpose-IPC, upload and presence refusals covered by 4500534505 and 4500615976. The existing tokenless Mesh scope concern 4503909350 remains: authenticated also includes fi=None plugin requests, so their refusal can deny a valid user connection at that origin. The shared signed-out constant concern 4503909353 also remains linked without duplication. We did not substantiate an additional stale-listener or stale-account defect; the admitted-operation cancellation and response checks fence retired FI evidence. Direct Git authorization calls to evidence remain outside the new send/upload reporting, so this review does not validate the description’s broader every-command claim.

Source inspection only: no PR code, tests, builds, apps or authentication flows were executed. The PR’s reported Rust/browser/unit results were not independently verified; its manual native end-to-end acceptance remains deferred. Public description inspected; no current image attachments were present. The referenced external relay denial producer was not inspected. These findings do not establish runtime correctness or approval.

* browser whichever route met it and whatever it does with the body. */
const fetchUpstream = async (url, init) => {
const response = await relayFetch(url, init);
const res = refusalTarget.getStore();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 [P2] Bind refusal reporting to the queued request’s response

When the principal’s six admission slots are occupied, a sidebar sort/filter mutation can queue through admittedApiRequest (lines 1444–1488). The shared admission queue starts that callback from the completing request’s .finally (http-admission.ts:235–239,296–304), so this ambient AsyncLocalStorage lookup returns the previous request’s response. If the queued mutation’s /query is then refused, its marker is written to that previous response or skipped because its headers were already sent; the mutation’s own 502 has no x-buzz-refusal, leaving the connection ready and showing only the feature error. Capture the requesting response before enqueueing, or restore that request’s context in the broker dispatch callback. Add a queued-request case; the sequential refusal cases do not cover this ordering. Source-traced, not runtime-reproduced.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

🤖 Fixed in 7ea7857. Both broker lane callers (sidebar mutations and the /api/relay route) now go through admittedRelayRequest, which binds the request with AsyncLocalStorage.bind to the route that made it, so a request the lane starts from another's .finally still marks its own response. New relay-broker-api.test.mjs case: a held sidebar write plus five held queries fill the lane, the seventh query is queued and refused, and only its response carries x-buzz-refusal: access. Without the bind it fails.

Base automatically changed from mbot/quiet-reconnect-toast to main November 7, 2026 22:50
baxen and others added 7 commits November 7, 2026 14:51
A key the community does not admit still connects: NIP-11 discovery is
public. Every signed read after that met the relay's plain-text NIP-FI
denial (`authorization denied\n`), which readApiFailure could not parse
and so treated as an edge page, a retryable 502. Each feature then showed
its own `Relay request failed (403)`, beside a stuck "Loading
workspaces…", a preferences toast and a "page isn't available" card.

readApiFailure now recognises the relay's three exact refusal bodies
(buzz-auth nip_fi/denial.rs) by bytes and status: authentication required
and evidence rejected mean the sign-in ended, authorization denied means
no access. Any other text still counts as an edge page, and authorization
unavailable (503) stays an outage. Native's "Federated sign-in is
required" counts as an ended sign-in too.

A refusal on any request marks the whole community: the API lane tells
its principal's listeners, the transport exposes them as observeRefusal,
and the relay service enters the same denied state as a refusal while
connecting, with the reason. One item's JSON refusal (a gated channel)
stays with its feature. Messages reports it once with Try again, and the
sidebar keeps only the account action. Access refusals forget the
device's cache, as before; an ended sign-in keeps it for signing back in.

Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Signed-off-by: Bradley Axen <baxen@squareup.com>
When a community needs a sign-in this device can renew (native's
"Federated sign-in is required", or an expired session), the Messages
refusal card now says "Sign in to open this community" and carries the
server's Sign in button instead of Try again. The sidebar no longer
repeats that prompt. Without a sign-in to offer, the card keeps
"Your sign-in for this community has ended" with Try again.

Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Signed-off-by: Bradley Axen <baxen@squareup.com>
Never signed in, signed out and expired all read "Sign in to open this
community". Only the action differs: the server's Sign in where this
device can sign in to it, otherwise Try again.

Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Signed-off-by: Bradley Axen <baxen@squareup.com>
A community's refusal was only heard from requests that went through the
signed API lane, so a refused upload, publish, direct message or presence
read left the community looking ready. Each host now reports it from the
one place its relay requests pass: native from `send` and `upload_file`
in relay.rs (a `relay-refusal` window event), and the development broker
from its upstream fetch, which marks the route's response with
`x-buzz-refusal` whatever the route does with the relay's body. The
lane's own refusal listeners are gone.

The refusal's reason, not error text or the session's status, decides
what the one notice offers. It sits in the toast stack on every page, so
the Messages-only card and its plugin injection are gone:

- sign-in ended: Sign in with the community's server, or Sign in again
  (sign out and in) when Buzz still holds the refused session;
- no sign-in server for the community: say Buzz can't sign in to it;
- no access while signed in: name the refused account, Switch account,
  Check again;
- no access with a plain key: ask the admin, Check again.

Messages and Activity only say why they are empty.

Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Signed-off-by: Bradley Axen <baxen@squareup.com>
Native plugin reads and publishes go without federated evidence, so an
FI-gated community refusing one says nothing about the user's sign-in.
Only requests sent with the user's federated context report a refusal.
The signed-out reason is now one constant shared with its producer.

Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Signed-off-by: Bradley Axen <baxen@squareup.com>
The community's admission lane starts a waiting request from whichever
one finished, so the waiting request ran in that one's async context and
its refusal marked the wrong browser response, or none. Both broker lane
callers now bind their request to the route that made it.

Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Signed-off-by: Bradley Axen <baxen@squareup.com>
Switch account signs out before it signs in, so the access notice's
signed-out answer hid the switch in progress, its Cancel, and the Sign
in offered after a cancel. The switch now comes first.

Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>
Signed-off-by: Bradley Axen <baxen@squareup.com>
@baxen
baxen force-pushed the mbot/community-denied branch from 1e37212 to 91bb594 Compare November 7, 2026 23:20

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 Star Lord’s automated source review via Wes’s account, with independent Rocket, Mantis and Groot passes.

Follow-up at 91bb5943989b6cc0a043e56a06008d567fc61b02, target 8f8f2605433eaccbf02cd3fce09f903e061cf2c2, actual merge-base 81aaf1343b0f1ce7c62a5aea58cd5193cf3422c4. Compared the repairs with previously reviewed 1e372123; unrelated changes brought in from main are outside this follow-up.

No new production defect found in the repair scope. These four concerns are repaired in source:

  • Queued broker response ownership: admittedRelayRequest binds the callback before enqueueing at both upstream-fetch admission sites, preserving the requesting response when another ticket starts it.
  • Native plugin refusal scope: send and upload_file observe only requests carrying the user’s FI context. Plugin calls with None retain their own errors; the inspected user paths still pass Some(fi).
  • Account-switch progress and recovery: the access/signed-out branch now excludes an active switch, keeping pending, cancel and failed-step recovery visible after sign-out. The new deferred-login case exercises cancel followed by sign-in and one retry.
  • Shared signed-out text: the producer and classifier now use the same SIGNED_OUT constant.

Test evidence limitation: browser-host/relay-broker-api.test.mjs:1488–1490 waits 20 ms before releasing a slot. Six recorded upstream calls do not establish that request seven has reached admission; delayed HTTP delivery can let it start without queueing. An observable enqueue barrier would make this regression check deterministic, as AGENTS requires. This limits the test’s evidence, rather than establishing a remaining defect in the binding repair. We did not run a fail-before check. The refusal-card tests also do not exercise thrown sign-in/sign-out failure followed by Try again, although the shared recovery branch remains visible in source.

Previously verified direct upload, presence and native-purpose reporting repairs remain covered. The earlier shell scope note remains limited to contributed pages replacing the primary sidebar; no bundled affected page was established. No duplicate inline findings are added.

Validation: source inspection only; no code changes, tests, builds, app or authentication execution. The current public PR description was inspected; its reported test passes were not independently verified. Manual native end-to-end testing against a refusing community remains deferred. Runtime focus behavior was not exercised.

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.

4 participants