From e733f70d0d640e7e9e6f84660e0e923afb7ffb9b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 03:13:09 +0000 Subject: [PATCH 1/2] fix(plugin-auth): make invitation accept URL an absolute https link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The invitation email built the accept URL directly from `config.baseUrl` with no scheme guarantee. When `baseUrl` is a bare host (e.g. `cloud.objectos.ai`), the emailed link was relative-looking and email clients wouldn't linkify it — clicking led nowhere. Route the accept URL through `getCanonicalOrigin()` and have that helper guarantee an absolute origin: prepend `https://` when the configured baseUrl has no scheme. This also hardens the OAuth issuer / consent / device-flow URLs that share the same helper. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NT4mx1fHXtkxery6MtLTsp --- .../plugin-auth/src/auth-manager.test.ts | 29 +++++++++++++++++++ .../plugins/plugin-auth/src/auth-manager.ts | 10 +++++-- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index ba1adb12c..ec7f130e3 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -1342,6 +1342,35 @@ describe('AuthManager', () => { const { capturedConfig } = await boot({ plugins: { magicLink: true } }); expect(capturedConfig.plugins.find((p: any) => p.id === 'magic-link')).toBeDefined(); }); + + // The emailed accept URL must be an absolute link — a bare host in baseUrl + // (no scheme) produced relative-looking links email clients wouldn't open. + it('sendInvitationEmail builds an https:// accept URL when baseUrl lacks a scheme', async () => { + const { capturedConfig, emailService } = await boot({ baseUrl: 'cloud.objectos.ai' }); + const orgPlugin = capturedConfig.plugins.find((p: any) => p.id === 'organization'); + await orgPlugin._opts.sendInvitationEmail({ + email: 'invitee@example.com', + invitation: { id: 'tok123', organizationId: 'o1', role: 'member' }, + organization: { name: 'Org' }, + inviter: { user: { email: 'admin@example.com' } }, + }); + expect(emailService.sendTemplate).toHaveBeenCalledTimes(1); + const { data } = emailService.sendTemplate.mock.calls[0][0]; + expect(data.acceptUrl).toBe('https://cloud.objectos.ai/accept-invitation/tok123'); + }); + + it('sendInvitationEmail preserves an explicit scheme in baseUrl', async () => { + const { capturedConfig, emailService } = await boot({ baseUrl: 'http://localhost:3000/' }); + const orgPlugin = capturedConfig.plugins.find((p: any) => p.id === 'organization'); + await orgPlugin._opts.sendInvitationEmail({ + email: 'invitee@example.com', + invitation: { id: 'tok456', organizationId: 'o1', role: 'member' }, + organization: { name: 'Org' }, + inviter: { user: { email: 'admin@example.com' } }, + }); + const { data } = emailService.sendTemplate.mock.calls[0][0]; + expect(data.acceptUrl).toBe('http://localhost:3000/accept-invitation/tok456'); + }); }); describe('getPublicConfig', () => { diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index 5e496f5d1..a82933492 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -1513,8 +1513,7 @@ export class AuthManager { // operators / UI can fall back to copy-paste flows. Replace this // with a real mail integration when available. sendInvitationEmail: async ({ email: recipientEmail, invitation, organization: org, inviter }) => { - const baseUrl = (this.config.baseUrl ?? '').replace(/\/$/, ''); - const acceptUrl = `${baseUrl}/accept-invitation/${invitation.id}`; + const acceptUrl = `${this.getCanonicalOrigin()}/accept-invitation/${invitation.id}`; // #2766 V1.5 — placeholder addresses are never real recipients. if (isPlaceholderEmail(recipientEmail)) { throw new Error( @@ -2360,7 +2359,12 @@ export class AuthManager { /** Canonical origin of this deployment (config `baseUrl`, auto-detected in dev). */ private getCanonicalOrigin(): string { - return (this.config.baseUrl || 'http://localhost:3000').replace(/\/$/, ''); + const raw = (this.config.baseUrl || 'http://localhost:3000').trim().replace(/\/$/, ''); + // Guarantee an absolute origin with a scheme. A bare host (e.g. baseUrl + // configured as `cloud.objectos.ai`) yields relative-looking links that + // email clients won't linkify and that break when clicked — prepend + // https:// so invitation / OAuth URLs open correctly. + return /^https?:\/\//i.test(raw) ? raw : `https://${raw}`; } /** From 875b8f4e09991cdd827a66d0cdfd65928f0984f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Jul 2026 14:14:13 +0000 Subject: [PATCH 2/2] fix(plugin-auth): route invitation accept link through the Console base path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verified against the objectui Console frontend: the accept-invitation page is a React Router route (`/accept-invitation/:invitationId`) mounted under the SPA basename (`import.meta.env.BASE_URL` = `/_console/`), the same router that serves `/login` and `/register`. The Console's own "copy invitation link" action builds `${origin}${BASE_URL}accept-invitation/`. The emailed link now matches that canonical form: `${origin}${uiBasePath}/accept-invitation/`, where the origin is forced absolute (https:// prepended when baseUrl lacks a scheme) and uiBasePath defaults to `/_console`. Previously the link was `${baseUrl}/accept-invitation/ ` — missing both the scheme (relative-looking, unclickable in email) and the Console prefix (404 at the host root). Added tests for the bare-host, explicit-scheme, and custom-uiBasePath cases. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NT4mx1fHXtkxery6MtLTsp --- .../invitation-accept-url-console-base.md | 27 ++++++++++++++++++ .../plugin-auth/src/auth-manager.test.ts | 28 +++++++++++++++---- .../plugins/plugin-auth/src/auth-manager.ts | 10 ++++++- 3 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 .changeset/invitation-accept-url-console-base.md diff --git a/.changeset/invitation-accept-url-console-base.md b/.changeset/invitation-accept-url-console-base.md new file mode 100644 index 000000000..4e6c71027 --- /dev/null +++ b/.changeset/invitation-accept-url-console-base.md @@ -0,0 +1,27 @@ +--- +"@objectstack/plugin-auth": patch +--- + +fix(auth): invitation accept link is now an absolute URL under the Console base + +`sendInvitationEmail` built the accept URL straight from `config.baseUrl` with +no scheme guarantee and no UI mount prefix — `${baseUrl}/accept-invitation/`. +Two problems surfaced in real deployments: + +1. When `baseUrl` was a bare host (e.g. `cloud.objectos.ai`, no scheme), the + emailed link was relative-looking; email clients would not linkify it and + clicking it went nowhere. +2. The accept-invitation page is a Console SPA route mounted under `uiBasePath` + (default `/_console`) — the same router/basename as `/login`, `/register` + and `/oauth/consent`, and the exact link the Console itself generates for its + "copy invitation link" action (`${origin}${BASE_URL}accept-invitation/`). + The root-path link omitted that prefix, so it 404'd at the host root instead + of resolving to the page. + +The link is now built as +`${origin}${uiBasePath}/accept-invitation/` via a hardened +`getCanonicalOrigin()` that guarantees an absolute origin (prepends `https://` +when `baseUrl` has no scheme). The scheme hardening also applies to the OAuth +issuer / consent / device-flow URLs that share the helper. Deployments that +mount the Console elsewhere are honoured through the existing `uiBasePath` +config. diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index ec7f130e3..64ad82fc9 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -1343,9 +1343,11 @@ describe('AuthManager', () => { expect(capturedConfig.plugins.find((p: any) => p.id === 'magic-link')).toBeDefined(); }); - // The emailed accept URL must be an absolute link — a bare host in baseUrl - // (no scheme) produced relative-looking links email clients wouldn't open. - it('sendInvitationEmail builds an https:// accept URL when baseUrl lacks a scheme', async () => { + // The emailed accept URL must be an absolute link pointing at the Console + // SPA route, which is mounted under `uiBasePath` (default `/_console`) — + // the same basename as /login. A bare host in baseUrl (no scheme) also + // produced relative-looking links email clients wouldn't open. + it('sendInvitationEmail builds an absolute https:// accept URL under the Console base', async () => { const { capturedConfig, emailService } = await boot({ baseUrl: 'cloud.objectos.ai' }); const orgPlugin = capturedConfig.plugins.find((p: any) => p.id === 'organization'); await orgPlugin._opts.sendInvitationEmail({ @@ -1356,7 +1358,7 @@ describe('AuthManager', () => { }); expect(emailService.sendTemplate).toHaveBeenCalledTimes(1); const { data } = emailService.sendTemplate.mock.calls[0][0]; - expect(data.acceptUrl).toBe('https://cloud.objectos.ai/accept-invitation/tok123'); + expect(data.acceptUrl).toBe('https://cloud.objectos.ai/_console/accept-invitation/tok123'); }); it('sendInvitationEmail preserves an explicit scheme in baseUrl', async () => { @@ -1369,7 +1371,23 @@ describe('AuthManager', () => { inviter: { user: { email: 'admin@example.com' } }, }); const { data } = emailService.sendTemplate.mock.calls[0][0]; - expect(data.acceptUrl).toBe('http://localhost:3000/accept-invitation/tok456'); + expect(data.acceptUrl).toBe('http://localhost:3000/_console/accept-invitation/tok456'); + }); + + it('sendInvitationEmail honours a custom uiBasePath (Console mounted elsewhere)', async () => { + const { capturedConfig, emailService } = await boot({ + baseUrl: 'https://acme.example.com', + uiBasePath: '/console/', + }); + const orgPlugin = capturedConfig.plugins.find((p: any) => p.id === 'organization'); + await orgPlugin._opts.sendInvitationEmail({ + email: 'invitee@example.com', + invitation: { id: 'tok789', organizationId: 'o1', role: 'member' }, + organization: { name: 'Org' }, + inviter: { user: { email: 'admin@example.com' } }, + }); + const { data } = emailService.sendTemplate.mock.calls[0][0]; + expect(data.acceptUrl).toBe('https://acme.example.com/console/accept-invitation/tok789'); }); }); diff --git a/packages/plugins/plugin-auth/src/auth-manager.ts b/packages/plugins/plugin-auth/src/auth-manager.ts index a82933492..19524427c 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.ts @@ -1513,7 +1513,15 @@ export class AuthManager { // operators / UI can fall back to copy-paste flows. Replace this // with a real mail integration when available. sendInvitationEmail: async ({ email: recipientEmail, invitation, organization: org, inviter }) => { - const acceptUrl = `${this.getCanonicalOrigin()}/accept-invitation/${invitation.id}`; + // The accept-invitation page is a Console SPA route mounted under + // `uiBasePath` (default `/_console`) — the SAME router/basename as + // /login, /register and /oauth/consent. The Console builds the very + // same link for its "copy invitation link" action + // (`${origin}${BASE_URL}accept-invitation/`), so the emailed link + // MUST carry that prefix (and an absolute https origin) or it 404s at + // the host root instead of resolving to the page. + const uiBase = (this.config.uiBasePath ?? '/_console').replace(/\/$/, ''); + const acceptUrl = `${this.getCanonicalOrigin()}${uiBase}/accept-invitation/${invitation.id}`; // #2766 V1.5 — placeholder addresses are never real recipients. if (isPlaceholderEmail(recipientEmail)) { throw new Error(