Skip to content

A rejected CORS preflight is invisible on both sides: no relay log at any level, and the desktop reports it as a clipboard failure #3636

Description

@xBFour

Summary

This is not another report of the missing Tauri origins in deploy/compose — that is already
well covered by #2872, #3490, #2617, #2908 and the open fixes in #2888 / #3595.

This issue is about why that misconfiguration takes so long to diagnose. When the desktop
webview's origin is not in BUZZ_CORS_ORIGINS, the failure produces zero usable signal
anywhere
:

  • no relay log line, even with tower_http=debug
  • no row in relay_invites
  • a desktop toast that names the wrong subsystem entirely

I spent several hours on a self-host bring-up chasing this. Everything else about the deployment
was healthy — WebSocket chat connected, NIP-42 auth passed, /query and /events were logging
status=200 — so every visible signal pointed away from CORS. Fixing the two points below would
have turned that into a two-minute fix, and would help everyone who lands on the already-reported
config problem.

Environment

  • Relay: ghcr.io/block/buzz@sha256:5aaf1f0e34e3806e69eea2a271c4809f08517bfb259b59a900d7ca71233eeeb3, NIP-11 reports "version": "0.2.0"
  • Desktop: Windows (origin http://tauri.localhost)
  • Deployment: deploy/compose behind Cloudflare Tunnel, TLS terminated at the edge,
    RELAY_URL=wss://<host>
  • BUZZ_CORS_ORIGINS set exactly as deploy/compose/.env.example suggests — the public host only

Reproduce

  1. Deploy with BUZZ_CORS_ORIGINS=https://<your-host> (i.e. the shipped .env.example default,
    line 13).
  2. Connect the desktop app to that relay. Chat works; you are owner in buzz-admin list-members.
  3. Open the community members panel and click Copy link to mint an invite.

Observed:

  • Toast: Couldn't copy the invite link. Try again.
  • SELECT count(*) FROM relay_invites;0
  • docker logs buzz-relay | grep -i invite → nothing, including with
    RUST_LOG=buzz_relay=debug,buzz_auth=debug,tower_http=debug

Expected: some indication that the request was rejected, and by what.


Problem 1 — the CORS layer sits outside the trace layer, so rejections are never logged

crates/buzz-relay/src/router.rs:189-192

merged
    .layer(middleware::from_fn(track_metrics))
    .layer(http_trace_layer())
    .layer(build_cors_layer(&state.config.cors_origins))

The last .layer() is outermost, so CorsLayer wraps TraceLayer. A preflight OPTIONS with a
non-allowlisted Origin is answered by the CORS layer and never reaches the inner service — so
TraceLayer never sees it and nothing is emitted at any log level.

This also explains why the usual debugging advice doesn't help here: raising RUST_LOG changes
nothing, because the request never gets far enough to be traced.

Additionally, build_cors_layer (router.rs:423-446) uses AllowOrigin::list(origins)
exact string matching, no wildcards — but never logs a rejected origin. Note there is already a
good precedent right there: the "no valid origins could be parsed" branch logs at error!. The
rejection path has no equivalent.

Suggested fix (either or both):

  • Put http_trace_layer() outside build_cors_layer(...) so every request is traced regardless of
    CORS outcome.

  • Log rejected origins at warn! with the received Origin and the configured allowlist. For a
    self-hoster this single line replaces hours of guessing:

    WARN CORS rejected origin "http://tauri.localhost" — not in BUZZ_CORS_ORIGINS
         (allowed: ["https://buzz.example.com"])
    

Problem 2 — the invite dialog reports a network failure as a clipboard failure

desktop/src/features/community-members/ui/InviteLinkSection.tsx:72-84

async function handleCopy() {
  if (copyStatus === "copying") return;
  setCopyStatus("copying");
  try {
    const invite = await mintInvite({ ttlSecs, maxUses });
    await writeTextToClipboard(invite.url);
    setCopyStatus("copied");
    toast.success("Invite link copied");
  } catch {
    setCopyStatus("idle");
    toast.error("Couldn't copy the invite link. Try again.");
  }
}

One try covers two unrelated operations — a network call and a clipboard write — and the catch
is bare, discarding the error. Any mintInvite failure (CORS, 401 NIP-98, 403 role, timeout, 5xx)
surfaces as "couldn't copy".

This actively misled me. Clipboard copy demonstrably worked elsewhere in the app (the Copy
button on the MembershipDenied screen uses the same writeTextToClipboard), so the message sent
me looking at Tauri clipboard permissions instead of the network. Worth noting that the closed
Wayland clipboard issues (#2896, #2904, #2922) make this wording especially sticky — searching the
tracker for it leads to real-but-unrelated clipboard bugs.

Suggested fix: separate the two failure modes and surface the underlying message.

let invite;
try {
  invite = await mintInvite({ ttlSecs, maxUses });
} catch (error) {
  setCopyStatus("idle");
  toast.error(
    `Couldn't create the invite: ${error instanceof Error ? error.message : "unknown error"}`,
  );
  return;
}
try {
  await writeTextToClipboard(invite.url);
  setCopyStatus("copied");
  toast.success("Invite link copied");
} catch {
  setCopyStatus("idle");
  toast.error("Invite created, but copying to the clipboard failed.");
}

invitePost (desktop/src/shared/api/invites.ts:78-104) already throws a useful Error — either
the server's json.error string or HTTP <status> — so the message is there, just discarded.

The second branch matters on its own: because relay_invites stores only token_hash
(migrations/0025_relay_invites.sql), an invite whose code fails to reach the clipboard is
unrecoverable. Telling the user it was created but not copied is materially different from telling
them to just try again.


Why this is worth fixing separately from the config default

Even after #2888 / #3595 land, the same silence will affect any other origin mismatch — a reverse
proxy rewriting Origin, a browser-based client, a custom deployment, a future platform whose
webview origin differs again. The config default fixes one instance; these two changes fix the
class.

Also worth noting: /api/invites is reached via the webview's fetch(), whereas /query and
/events go through the Tauri native side and bypass CORS entirely. That asymmetry is what makes
the deployment look healthy while invites silently fail — the same applies to getJoinPolicy and
acceptJoinPolicy in invites.ts. It may be worth documenting, or routing the invite calls
through native networking as #2862 did for join policies.

Workaround for anyone who finds this first

BUZZ_CORS_ORIGINS=https://<your-host>,tauri://localhost,http://tauri.localhost,https://tauri.localhost

Recreate the container afterwards — restarting is not enough for env changes to apply.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions