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) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-05-17 15:26:07 -05:00
parent caa9fd0f40
commit 159a36ed99
2 changed files with 11 additions and 9 deletions

View file

@ -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` |

View file

@ -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/<id> - the SPA lives at /app, so a bare
// /#/accept-invite/<id> 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/<id> - the SPA
// lives at /app, so a bare /#/accept-invite/<id> 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({