From e64d5450f8a89f1ef3a8cc417fb607c5bd20b9e0 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 25 May 2026 16:25:55 +0200 Subject: [PATCH] fix(invites): build share URL on the client, not the server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit server/invites.ts derived the share URL from c.req.url — i.e., from the API request's host. In production the API and SPA share an origin so this happened to work; in dev where the SPA runs on :5173 and the API on :3000, the generated link pointed at the API (http://localhost:3000/invite/) which serves nothing. Fix: the server no longer returns a `url` field. The token is the canonical artefact; the SPA builds the share link itself via `${window.location.origin}/invite/${token}`, which is always the right origin regardless of split-process dev or single-process prod. - shared/types.ts: InviteEntry.url removed - server/invites.ts: drop originOf() and the URL field in toEntry() - frontend Profile.svelte: new inviteUrl() helper; the displayed and the clipboard payload both use it - tests/social.test.ts: assertion checks token shape instead of the URL field 93 tests still pass. --- frontend/src/components/Profile.svelte | 15 ++++++++++++--- server/invites.ts | 16 +++++++--------- shared/types.ts | 8 +++++--- tests/social.test.ts | 4 +++- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/frontend/src/components/Profile.svelte b/frontend/src/components/Profile.svelte index 66038cd..daffd77 100644 --- a/frontend/src/components/Profile.svelte +++ b/frontend/src/components/Profile.svelte @@ -167,13 +167,22 @@ } } + /** Build the shareable invite URL using the SPA's own origin. The server + * used to compute this but in dev that yielded the API host (port 3000), + * not the SPA host (port 5173). The token is the canonical artefact; + * the URL is just a presentation concern the SPA owns. */ + function inviteUrl(inv: InviteEntry): string { + return `${window.location.origin}/invite/${inv.token}`; + } + async function copyInviteUrl(inv: InviteEntry) { + const url = inviteUrl(inv); try { - await navigator.clipboard.writeText(inv.url); + await navigator.clipboard.writeText(url); copiedToken = inv.token; setTimeout(() => { copiedToken = null; }, 1500); } catch { - window.prompt('Kopier denne lenken:', inv.url); + window.prompt('Kopier denne lenken:', url); } } @@ -372,7 +381,7 @@ {#each invites as inv (inv.token)}
- {inv.url} + {inviteUrl(inv)} {formatDate(inv.created_at)}
diff --git a/server/invites.ts b/server/invites.ts index 80e4fc9..c3b1e32 100644 --- a/server/invites.ts +++ b/server/invites.ts @@ -35,19 +35,22 @@ interface InviteRow { claimed_email: string | null; } -function toEntry(row: InviteRow, origin: string): InviteEntry { +function toEntry(row: InviteRow): InviteEntry { let claimedByDisplay: string | null = null; if (row.claimed_at && row.claimed_email) { if (row.claimed_display_name && row.claimed_display_name.trim()) { claimedByDisplay = row.claimed_display_name; } else { + // No display name — fall back to the email prefix. Acceptable here + // because the claimed-by info is only ever shown to the INVITER (who + // shared the link in the first place, so they presumably know who got + // it). Don't surface email anywhere public. const at = row.claimed_email.indexOf('@'); claimedByDisplay = at > 0 ? row.claimed_email.slice(0, at) : row.claimed_email; } } return { token: row.token, - url: `${origin}/invite/${row.token}`, created_at: row.created_at, claimed_at: row.claimed_at, claimed_by_display: claimedByDisplay, @@ -75,11 +78,6 @@ export function claimInvite(token: string, claimerUserId: string): string | null return row.inviter_user_id; } -function originOf(c: { req: { url: string } }): string { - const u = new URL(c.req.url); - return `${u.protocol}//${u.host}`; -} - // --- GET /api/invites ------------------------------------------------------- // List the caller's invites (active first, then claimed). invitesRoutes.get('/', requireAuth, (c) => { @@ -96,7 +94,7 @@ invitesRoutes.get('/', requireAuth, (c) => { ORDER BY (i.claimed_at IS NOT NULL) ASC, i.created_at DESC `) .all(userId) as InviteRow[]; - return c.json(rows.map((r) => toEntry(r, originOf(c)))); + return c.json(rows.map(toEntry)); }); // --- POST /api/invites ------------------------------------------------------ @@ -115,7 +113,7 @@ invitesRoutes.post('/', requireAuth, (c) => { FROM invites i WHERE i.token = ? `) .get(token) as InviteRow; - return c.json(toEntry(row, originOf(c)), 201); + return c.json(toEntry(row), 201); }); // --- DELETE /api/invites/:token --------------------------------------------- diff --git a/shared/types.ts b/shared/types.ts index 652dd4e..b9a750c 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -42,12 +42,14 @@ export interface SettingsUpdateRequest { // --- Invites --------------------------------------------------------------- export interface InviteEntry { + /** The token itself. The shareable URL is built client-side as + * `${window.location.origin}/invite/${token}` — server-side URL + * construction would point at the API host in split-process dev + * environments. */ token: string; - /** Absolute URL the inviter shares: /invite/. */ - url: string; created_at: number; claimed_at: number | null; - /** Display name (or email prefix) of the user who claimed it, if any. */ + /** Display name (or username) of the user who claimed it, if any. */ claimed_by_display: string | null; } diff --git a/tests/social.test.ts b/tests/social.test.ts index 3a7a115..991f157 100644 --- a/tests/social.test.ts +++ b/tests/social.test.ts @@ -119,7 +119,9 @@ describe('invites', () => { cookie: inviter.cookie, expect: 201, }); expect(inv.token).toBeTruthy(); - expect(inv.url).toMatch(/\/invite\/[A-Za-z0-9_-]+/); + // URL is built client-side from window.location.origin + the token, so + // the server response no longer carries one. Just verify token shape. + expect(inv.token).toMatch(/^[A-Za-z0-9_-]+$/); expect(inv.claimed_at).toBeNull(); // A new user signs up with that invite token.