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
- Deploy with
BUZZ_CORS_ORIGINS=https://<your-host> (i.e. the shipped .env.example default,
line 13).
- Connect the desktop app to that relay. Chat works; you are
owner in buzz-admin list-members.
- 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.
Summary
This is not another report of the missing Tauri origins in
deploy/compose— that is alreadywell 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 signalanywhere:
tower_http=debugrelay_invitesI 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,
/queryand/eventswere loggingstatus=200— so every visible signal pointed away from CORS. Fixing the two points below wouldhave turned that into a two-minute fix, and would help everyone who lands on the already-reported
config problem.
Environment
"version": "0.2.0"http://tauri.localhost)deploy/composebehind Cloudflare Tunnel, TLS terminated at the edge,RELAY_URL=wss://<host>BUZZ_CORS_ORIGINSset exactly asdeploy/compose/.env.examplesuggests — the public host onlyReproduce
BUZZ_CORS_ORIGINS=https://<your-host>(i.e. the shipped.env.exampledefault,line 13).
ownerinbuzz-admin list-members.Observed:
Couldn't copy the invite link. Try again.SELECT count(*) FROM relay_invites;→0docker logs buzz-relay | grep -i invite→ nothing, including withRUST_LOG=buzz_relay=debug,buzz_auth=debug,tower_http=debugExpected: 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-192merged .layer(middleware::from_fn(track_metrics)) .layer(http_trace_layer()) .layer(build_cors_layer(&state.config.cors_origins))The last
.layer()is outermost, soCorsLayerwrapsTraceLayer. A preflightOPTIONSwith anon-allowlisted
Originis answered by the CORS layer and never reaches the inner service — soTraceLayernever sees it and nothing is emitted at any log level.This also explains why the usual debugging advice doesn't help here: raising
RUST_LOGchangesnothing, because the request never gets far enough to be traced.
Additionally,
build_cors_layer(router.rs:423-446) usesAllowOrigin::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!. Therejection path has no equivalent.
Suggested fix (either or both):
Put
http_trace_layer()outsidebuild_cors_layer(...)so every request is traced regardless ofCORS outcome.
Log rejected origins at
warn!with the receivedOriginand the configured allowlist. For aself-hoster this single line replaces hours of guessing:
Problem 2 — the invite dialog reports a network failure as a clipboard failure
desktop/src/features/community-members/ui/InviteLinkSection.tsx:72-84One
trycovers two unrelated operations — a network call and a clipboard write — and thecatchis bare, discarding the error. Any
mintInvitefailure (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
MembershipDeniedscreen uses the samewriteTextToClipboard), so the message sentme 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.
invitePost(desktop/src/shared/api/invites.ts:78-104) already throws a usefulError— eitherthe server's
json.errorstring orHTTP <status>— so the message is there, just discarded.The second branch matters on its own: because
relay_invitesstores onlytoken_hash(
migrations/0025_relay_invites.sql), an invite whose code fails to reach the clipboard isunrecoverable. 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 whosewebview origin differs again. The config default fixes one instance; these two changes fix the
class.
Also worth noting:
/api/invitesis reached via the webview'sfetch(), whereas/queryand/eventsgo through the Tauri native side and bypass CORS entirely. That asymmetry is what makesthe deployment look healthy while invites silently fail — the same applies to
getJoinPolicyandacceptJoinPolicyininvites.ts. It may be worth documenting, or routing the invite callsthrough native networking as #2862 did for join policies.
Workaround for anyone who finds this first
Recreate the container afterwards — restarting is not enough for env changes to apply.