Repository navigation
fix(invite): route /invite through the backend and unify the permissions - #1893
Conversation
#1889 added the /invite redirect to vercel.json, but production does not go through Vercel: lucky.lucassantana.tech routes through the Cloudflare tunnel to the homelab nginx (config-lucky.yml ingress -> http://nginx:80). So the canonical invite URL still returned 200 with the SPA, which then bounced to the landing page. Verified against production after the v2.37.2 deploy: curl -o /dev/null -w '%{http_code} %{redirect_url}' \ https://lucky.lucassantana.tech/invite -> 200, no Location header That URL is what the Top.gg listing, the README CTA and every tracked campaign link point at, so every invite click was being swallowed and no utm_source ever recorded an install. Adds the rule where production actually serves it. vercel.json keeps its copy for preview deploys. Exact match (location = /invite) so nothing else is caught, and 302 rather than 301 because browsers cache 301 permanently, which would make a wrong client_id or permission set unfixable for anyone who had already clicked. Permission set matches getBotInviteUrl() in lib/discord.ts: 3165184, not Administrator. Config validated with `nginx -t` against nginxinc/nginx-unprivileged:1.31 -alpine, the same image Dockerfile.nginx builds from.
📝 WalkthroughWalkthroughThe nginx configuration adds an exact-match ChangesInvite redirect
Estimated code review effort: 1 (Trivial) | ~2 minutes 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Failed to generate code suggestions for PR |
There was a problem hiding this comment.
All reported issues were addressed across 1 file
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
cubic was right on #1893: redirecting at the edge fixes the click but drops the attribution. backend/src/routes/invite.ts already existed and logs the utm_* parameters before redirecting, but nginx only proxies /api, so /invite never reached it and fell through to the SPA catch-all. nginx now proxies the exact path to the backend instead of returning a redirect itself, so tracked links keep emitting `[invite] click`. Chasing that turned up a third permission value. The codebase shipped: backend invite.ts 36970496 Manage Messages, Connect, Speak frontend discord.ts 3165184 View Channels, Send Messages, Embed Links, Connect, Speak docs page (fixed #1889) 8 Administrator The backend one is the worst of the three: it asks for Manage Messages, which is message deletion, while omitting View Channels and Send Messages, so it is over-scoped and non-functional at the same time. It is also the value that every tracked link resolved to. Single source of truth is now BOT_INVITE_PERMISSIONS in shared, consumed by the backend redirect and the frontend helper. Test pins the bitfield: no Administrator, no Manage Messages, exactly the five needed bits, and the applications.commands scope whose absence caused #1885. vercel.json points at the backend too, so preview deploys log the same. nginx -t clean against nginxinc/nginx-unprivileged:1.31-alpine. Suites green: shared 1398, frontend 1019, backend 1350.
|
Good catch, and it was more than a P2. Fixed in You were right that nginx now proxies What that turned up. Following your pointer to the backend handler surfaced a third permission value:
The backend value is the worst of the three: it requests Manage Messages (message deletion) while omitting View Channels and Send Messages, so it is simultaneously over-scoped and non-functional. And since it is the handler every tracked link resolves to, it is what real users were being asked to approve — while the Top.gg listing says "No admin permission" and implies a minimal set.
|
|
Size Change: +287 B (+0.06%) Total Size: 498 kB 📦 View Changed
ℹ️ View Unchanged
|
Semgrep's missing-internal rule fires on the new location block. It is a false positive here and worth recording so it is not re-litigated. `internal;` makes a location unreachable from outside, which would break the invite link entirely — this is a public entry point by design, same as the /api and / blocks, neither of which uses it either. The rule's SSRF concern needs request-derived input in the proxy target. The upstream here is a fixed literal, so a client cannot influence where the proxy connects.
|
False positive, annotated with
The rule's SSRF concern requires request-derived input in the proxy target, so that a client can steer where the proxy connects. Here the upstream is a fixed literal: set $invite_upstream http://backend:3000;
proxy_pass $invite_upstream;No part of the request reaches that value. The |
|
🤖 I have created a release *beep* *boop* --- <details><summary>2.37.3</summary> ## [2.37.3](v2.37.2...v2.37.3) (2026-07-27) ### Bug Fixes * **invite:** route /invite through the backend and unify the permissions ([#1893](#1893)) ([9372961](9372961)) </details> --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Release 2.37.3 fixes /invite handling by routing it through the backend and unifying permission checks. This makes invite scopes consistent and reduces the risk of incorrect permissions. <sup>Written for commit 35099e8. Summary will update on new commits.</sup> <a href="https://cubic.dev/pr/LucasSantana-Dev/Lucky/pull/1894?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->
I fixed this twice in the wrong layer. lucky.lucassantana.tech is served by Cloudflare Pages (project lucky-webapp, deploy-frontend-cf.yml), not by Vercel and not by the homelab nginx behind the tunnel. So neither the vercel.json redirect (#1889) nor the nginx location block (#1893) applied to the public site, and /invite kept returning 200 with the SPA. What gave it away: the live response carries a CSP allowing static.cloudflareinsights.com, which appears only in packages/frontend/public/_headers. The nginx config serves a different CSP, so the request was never reaching it. Meanwhile lucky-api.lucassantana.tech DOES go through the tunnel, and already returns the correct 302 with permissions=3165184, so the backend handler and the nginx work from #1893 are both fine and stay. _redirects rules are evaluated top to bottom, so /invite is placed above the SPA catch-all, which is what was swallowing it. Points at the backend rather than Discord directly so the utm_* attribution logging still runs. Verified the file lands in packages/frontend/dist after a build, which is the directory `wrangler pages deploy` uploads.
lucky.lucassantana.tech is served by Cloudflare Pages (project lucky-webapp), not by Vercel and not by the homelab nginx behind the tunnel. So neither the vercel.json redirect (#1889) nor the nginx location block (#1893) applied to the public site, and /invite kept returning 200 with the SPA, whose catch-all bounces to the landing page. _redirects rules are evaluated top to bottom, so /invite now sits above the SPA catch-all that was swallowing it. It points at the backend rather than Discord directly, so the utm_* attribution logging still runs. The nginx block and shared BOT_INVITE_PERMISSIONS from #1893 stay: they are what make lucky-api.lucassantana.tech/invite return the correct 302. Closes #1888.
#1893 changed the invite permission set without reading decisions/2026-06-18-invite-permission-scope.md, landing on 3165184 (music only). Its prerequisite #1498 is closed, so the ADR's curated set should already have been live. Adopts 3173504: music + ManageMessages (auto-mod cleanup) + ViewAuditLog, which auditHandler needs to attribute moderation cases — without it that attribution was silently degraded on every fresh install. Tracing the call sites found a fifth copy: GuildService.generateBotInviteUrl hardcoded permissions='8' (Administrator), backing the dashboard's "add Lucky to this server" button. cubic then found a sixth in the e2e fixtures, which mocked an Administrator response and so could not have caught either regression. All six now derive from the shared constant. Also corrects docs/TOP_GG_SUBMISSION.md, which specified 36970496 with a breakdown summing to a different number entirely, and drops the vanity and zero-incident claims removed from the README in #1912. Tests pin the bitfield to the ADR and were verified to fail when the value is reverted. Closes #1923.
## Summary Nothing in the repo said which of the three independent hosting layers (Cloudflare Pages, Cloudflare Tunnel + homelab nginx, legacy Vercel preview) serves which host, or which config file governs each. Fixing the /invite redirect took three PRs (#1889, #1893, #1895) before landing in the right file. - Added a "Deployment & hosting" section to docs/ARCHITECTURE.md: the host/layer/config table, the CSP trick for identifying which layer answered a request, and the nginx.conf/_redirects gotchas that cost time before. - Added a one-line pointer to that section at the top of nginx.conf and _redirects. - vercel.json and _headers intentionally left untouched: vercel.json is strict JSON with no safe comment syntax, and _headers' Cloudflare Pages comment support isn't proven in this repo the way _redirects' is (its existing comment block already works in production - _headers has none to point to as precedent). Not worth guessing on a live config file for a one-line pointer when docs/ARCHITECTURE.md already names both files. ## Test plan - [x] Read-only doc/comment change, no code paths touched - [x] nginx.conf comment uses `#`, the format nginx already uses throughout this file - [x] _redirects comment extends the file's own pre-existing `#` comment block, proven safe since it's already live in production Closes #1924 <!-- This is an auto-generated description by cubic. --> --- ## Summary by cubic Documents which of the three hosting layers (Cloudflare Pages, Cloudflare Tunnel + homelab nginx, legacy Vercel preview) serves which host and which config file governs each, so fixes like `/invite` land in the right file instead of taking three PRs. - Adds a Deployment & hosting section to `docs/ARCHITECTURE.md` with the host/layer/config table, the CSP trick for identifying which layer answered, the `nginx.conf` `/api`-proxying rule plus its `/invite`, `/webhook/`, `/webhooks/` exceptions, and the `_redirects` top-to-bottom gotcha. - Adds pointers to that section at the top of `nginx.conf` and `_redirects`. - Read-only change; leaves `vercel.json` and `_headers` untouched. Closes #1924. <sup>Written for commit 4b401a0. Summary will update on new commits.</sup> <a href="https://cubic.dev/pr/LucasSantana-Dev/Lucky/pull/2253?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->



Follow-up to #1889 / #1888. That PR put the
/inviteredirect invercel.json, which does not apply to production.Why the first fix missed
lucky.lucassantana.techdoes not go through Vercel. It routes through the Cloudflare tunnel to the homelab nginx:So the canonical invite URL still served the SPA, whose catch-all bounces unknown paths to the landing page. Verified against production after the v2.37.2 deploy:
That URL is what the Top.gg listing body, the README CTA and every tracked campaign link in
.agents/tracking-urls.mdpoint at, so every invite click was swallowed and noutm_sourceever recorded an install.Change
Adds the redirect to
nginx/nginx.conf, where production actually serves it.vercel.jsonkeeps its copy for preview deploys.location = /invite(exact match) so no other path is caught.302, not301: browsers cache 301 permanently, which would make a wrongclient_idor permission set unfixable for anyone who had already clicked.permissions=3165184matchesgetBotInviteUrl()inlib/discord.ts. Not Administrator, per the public listing claim.Verification
Config validated with
nginx -tagainstnginxinc/nginx-unprivileged:1.31-alpine, the same baseDockerfile.nginxbuilds from:Will re-verify against production once deployed.
Blocks
The Top.gg resubmission. The listing description links
/invite, so a reviewer clicking it would land on the homepage rather than an invite prompt.Summary by cubic
Route
/invitethrough the backend to preserve UTM attribution and redirect to Discord OAuth with the minimal permissions. Fixes invite links (Top.gg, README, campaigns) being swallowed by the SPA and stops attribution loss.nginx/nginx.conf: exact-matchlocation = /invitenow proxies to the backend so UTM params are logged before redirect; SPA no longer catches it; added notes on whyinternalis not used (Semgrep false positive).@lucky/shared(BOT_CLIENT_ID,BOT_INVITE_PERMISSIONS=3165184,buildBotInviteUrl()), updated frontend/backend to use it with tests to pin the bits and scope;vercel.jsonnow points/inviteto the backend for previews.Written for commit 7c2c907. Summary will update on new commits.
Summary by CodeRabbit
/invitelink that redirects visitors to the configured Discord authorization page.