fix(invites): build share URL on the client, not the server

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/<token>) 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
    <code> and the clipboard payload both use it
  - tests/social.test.ts: assertion checks token shape instead of
    the URL field

93 tests still pass.
This commit is contained in:
Ole-Morten Duesund 2026-05-25 16:25:55 +02:00
commit e64d5450f8
4 changed files with 27 additions and 16 deletions

View file

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