A rejected CORS preflight is invisible on both sides: no relay log at any level, and the desktop reports it as a clipboard failure
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
## 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://`
- `BUZZ_CORS_ORIGINS` set exactly as `deploy/compose/.env.example` suggests — the public host only
## Reproduce
1. Deploy with `BUZZ_CORS_ORIGINS=https://` (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`
```rust
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`
```ts
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.
```ts
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 ` — 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://,tauri://localhost,http://tauri.localhost,https://tauri.localhost
```
Recreate the container afterwards — restarting is not enough for env changes to apply.
Contributor guide
Assessment
This issue has not been assessed yet.