From 159a36ed9932cdc7d8e59b4fe433c0122711133a Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Sun, 17 May 2026 15:26:07 -0500 Subject: [PATCH] fix(workspaces): use APP_URL env var for invite-accept URL generation Slice 1+3 (c4fbd2b) introduced PUBLIC_URL as the env var name for the public-facing origin used to construct invite-accept URLs. The README has long documented APP_URL as the canonical name for this concept (used for Stripe callbacks in the existing codebase). The new code should have read APP_URL from the start; PUBLIC_URL was unintentional naming drift. Caught during prod-deploy survey on 2026-05-17: APP_URL was set on the production systemd unit and documented in the README, but read by no code path on origin/main. PUBLIC_URL was read by slice-1 code but set nowhere. The bug was masked in 99% of cases by the request-derived fallback (${req.protocol}://${req.get('host')}) which produces the correct URL when invites are triggered from browsers behind Cloudflare. It would have manifested for any future non-browser-triggered invite path. README updated to note APP_URL covers both Stripe callbacks and invite-accept URL generation. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- server/routes/workspaces.js | 18 ++++++++++-------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 42b4fb7..0a8cf06 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ Schema migrations run automatically on first boot — no manual migration comman | `SELF_HOSTED` | First user gets all features unlocked | `false` | | `DISABLE_REGISTRATION` | Block new account creation (including OAuth auto-signup). First-user setup on an empty DB is still allowed. | `false` | | `DISABLE_HOMEPAGE` | Redirect `/` to `/app` instead of serving the marketing landing page. For internal-only self-hosted deployments. | `false` | -| `APP_URL` | Your public URL (used for Stripe callbacks) | _(none)_ | +| `APP_URL` | Your public URL (used for Stripe callbacks and invite-accept URLs in emailed invites) | _(none)_ | | `JWT_SECRET` | JWT signing key (auto-generated if not set) | _(auto)_ | | `SSL_CERT` | Path to SSL certificate | `server/certs/cert.pem` | | `SSL_KEY` | Path to SSL private key | `server/certs/key.pem` | diff --git a/server/routes/workspaces.js b/server/routes/workspaces.js index 9f0c7a6..86f092a 100644 --- a/server/routes/workspaces.js +++ b/server/routes/workspaces.js @@ -259,16 +259,18 @@ router.post('/:id/invites', async (req, res) => { return res.status(409).json({ error: 'An invite for this email is already pending' }); } - // Build accept URL. PUBLIC_URL env var (when set) pins the public-facing + // Build accept URL. APP_URL env var (when set) pins the public-facing // origin regardless of how the request arrived - recommended in prod so // invites triggered from non-browser sources (curl, future API automation) - // always carry the canonical origin. Falls back to request-derived for - // local dev and when PUBLIC_URL isn't set; with trust proxy on, req.protocol - // + req.get('host') reflect Cloudflare-forwarded X-Forwarded-Proto + Host. - // Path is /app#/accept-invite/ - the SPA lives at /app, so a bare - // /#/accept-invite/ would land on the marketing landing page in dev - // (and rely on the DISABLE_HOMEPAGE redirect in prod). /app is explicit. - const publicBase = process.env.PUBLIC_URL || `${req.protocol}://${req.get('host')}`; + // always carry the canonical origin. Same env var the rest of the codebase + // uses for Stripe callbacks (see README env-var table). Falls back to + // request-derived for local dev and when APP_URL isn't set; with trust + // proxy on, req.protocol + req.get('host') reflect Cloudflare-forwarded + // X-Forwarded-Proto + Host. Path is /app#/accept-invite/ - the SPA + // lives at /app, so a bare /#/accept-invite/ would land on the + // marketing landing page in dev (and rely on the DISABLE_HOMEPAGE + // redirect in prod). /app is explicit. + const publicBase = process.env.APP_URL || `${req.protocol}://${req.get('host')}`; const acceptUrl = `${publicBase}/app#/accept-invite/${inviteId}`; const org = db.prepare('SELECT name FROM organizations WHERE id = ?').get(ws.organization_id); const { subject, text } = buildInviteEmail({