fix(email): build SPA-only links from the SPA origin, not the apex #502

Merged
longjacksonle merged 4 commits from fix/MAPPS-425-request-link-origin into main 2026-08-08 04:50:02 +02:00

Closes MAPPS-425.

The report

A client request-form link emailed from staging opened a 404. The URL was https://a8n.systems/request-forms/19279f6c-….G3sRzov…: correct path, correct token, wrong host. a8n.systems is bunyip-web; /request-forms/:token exists only in mokosh-apps at msp.a8n.systems, so the apex served bunyip's own 404. The same email also arrived with the subject longle request form from {{tenant_name}}.

Why it happened

The link was built from CLIENT_ORIGIN, which on both deployed environments points at the apex on purpose: bunyip-web hosts the login UI and opens the Google OAuth popup, so the popup's parent-origin check needs the apex byte-for-byte. BASE_URL points there for the same class of reason, because bunyip-web owns the canonical /reset-password. Nothing named the mokosh-apps origin at all, so a link to a mokosh-apps-only page had no correct base available.

What this changes

SPA_BASE_URL names the mokosh-apps origin and defaults to CLIENT_ORIGIN, so a single-origin dev stack is untouched and needs no new configuration.

The audit found four more links broken the same way, all live on staging and production this whole time and none of them reported:

  • the portal set-password link, /portal/set-password
  • the invoice Pay Now link, /portal/invoices/{id}
  • the emailed quote link, /portal/quotes/{id}
  • the Stripe checkout success and cancel URLs, both /portal/invoices/{id}

Every one is a mokosh-apps route the apex does not serve. All now take spa_base_url.

The four remaining CLIENT_ORIGIN consumers are correct and stay: /reset-password (bunyip-web owns the page, mokosh-apps only redirects there), the invitation link (bare login origin), the OAuth postMessage origin, and the not-a-frontend fallback. The reason is recorded next to the auth service so nobody "fixes" them later.

The subject is repaired by supplying tenant_name in the dispatch context rather than by rewriting the template: migrations are immutable once applied and every tenant already holds the seeded subject, so filling the value fixes all of them at once.

Deployment, required

This is only half the fix. Both environments must set the new variable or their links keep pointing at the apex:

  • c-01 / staging: SPA_BASE_URL: https://msp.a8n.systems
  • nc-01 / production: SPA_BASE_URL: https://msp.psa.systems

A follow-up PR against NiceGuyIT/docker carries that. Links already sent cannot be repaired; they have to be reissued after the deploy.

Verification

Full integration suite: 743 passed, 0 failed. just check clean.

The new test was verified to actually catch the bug: reverting the one router line makes it fail, restoring it makes it pass. The harness passes a spa_base_url that differs from client_origin precisely so a wrong-host link cannot slip through.

Why CI missed it

mokosh-apps has a recurrence gate (emailed_link_routes, MAPPS-396) asserting every link mokosh-server emails resolves to a real SPA route. It passed, because /request-forms/:token is a real route. The gate compares paths; the defect was the host. Worth extending it, or adding a server-side equivalent, so "right path, wrong origin" is caught too. Not done here.

Closes MAPPS-425. ## The report A client request-form link emailed from staging opened a 404. The URL was `https://a8n.systems/request-forms/19279f6c-….G3sRzov…`: correct path, correct token, wrong host. `a8n.systems` is bunyip-web; `/request-forms/:token` exists only in mokosh-apps at `msp.a8n.systems`, so the apex served bunyip's own 404. The same email also arrived with the subject `longle request form from {{tenant_name}}`. ## Why it happened The link was built from `CLIENT_ORIGIN`, which on both deployed environments points at the apex on purpose: bunyip-web hosts the login UI and opens the Google OAuth popup, so the popup's parent-origin check needs the apex byte-for-byte. `BASE_URL` points there for the same class of reason, because bunyip-web owns the canonical `/reset-password`. Nothing named the mokosh-apps origin at all, so a link to a mokosh-apps-only page had no correct base available. ## What this changes `SPA_BASE_URL` names the mokosh-apps origin and defaults to `CLIENT_ORIGIN`, so a single-origin dev stack is untouched and needs no new configuration. **The audit found four more links broken the same way**, all live on staging and production this whole time and none of them reported: * the portal set-password link, `/portal/set-password` * the invoice Pay Now link, `/portal/invoices/{id}` * the emailed quote link, `/portal/quotes/{id}` * the Stripe checkout success and cancel URLs, both `/portal/invoices/{id}` Every one is a mokosh-apps route the apex does not serve. All now take `spa_base_url`. The four remaining `CLIENT_ORIGIN` consumers are correct and stay: `/reset-password` (bunyip-web owns the page, mokosh-apps only redirects there), the invitation link (bare login origin), the OAuth `postMessage` origin, and the not-a-frontend fallback. The reason is recorded next to the auth service so nobody "fixes" them later. The subject is repaired by supplying `tenant_name` in the dispatch context rather than by rewriting the template: migrations are immutable once applied and every tenant already holds the seeded subject, so filling the value fixes all of them at once. ## Deployment, required This is only half the fix. Both environments must set the new variable or their links keep pointing at the apex: * c-01 / staging: `SPA_BASE_URL: https://msp.a8n.systems` * nc-01 / production: `SPA_BASE_URL: https://msp.psa.systems` A follow-up PR against `NiceGuyIT/docker` carries that. Links already sent cannot be repaired; they have to be reissued after the deploy. ## Verification Full integration suite: 743 passed, 0 failed. `just check` clean. The new test was verified to actually catch the bug: reverting the one router line makes it fail, restoring it makes it pass. The harness passes a `spa_base_url` that differs from `client_origin` precisely so a wrong-host link cannot slip through. ## Why CI missed it mokosh-apps has a recurrence gate (`emailed_link_routes`, MAPPS-396) asserting every link mokosh-server emails resolves to a real SPA route. It passed, because `/request-forms/:token` *is* a real route. The gate compares paths; the defect was the host. Worth extending it, or adding a server-side equivalent, so "right path, wrong origin" is caught too. Not done here.
CLIENT_ORIGIN is the apex on every deployed environment, not the SPA: bunyip-web hosts the login UI and opens the Google OAuth popup there, so the popup's parent-origin check needs the apex byte-for-byte (staging https://a8n.systems, prod https://psa.systems), while mokosh-apps lives at msp.<apex>. Nothing named the SPA origin, so any emailed link to a mokosh-apps page had no correct base to be built from.

SPA_BASE_URL falls back to CLIENT_ORIGIN, so a single-origin dev stack needs no new configuration and behaves exactly as before. Deployed environments must set it explicitly; .env.example carries both values and the reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011myibMMwyb6za3GVWJGkiX
MAPPS-425: a client request-form link emailed from staging opened bunyip's 404. The path was right and the host was wrong: /request-forms/:token exists only in mokosh-apps, and the link was built from CLIENT_ORIGIN, which is the apex.

Auditing the other link bases found the same defect in four more places, all silently broken on staging and production the whole time: the portal set-password link (/portal/set-password), the invoice Pay Now link (/portal/invoices/{id}), the emailed quote link (/portal/quotes/{id}), and the Stripe checkout success/cancel URLs. Every one is a mokosh-apps route that the apex does not serve. All now take spa_base_url.

The four remaining CLIENT_ORIGIN consumers stay as they are, and the reason is recorded next to the auth service so nobody "fixes" them later: /reset-password is owned by bunyip-web at the apex and mokosh-apps only redirects there, the invitation link is the bare login origin, the OAuth postMessage origin must match the popup's opener, and the not-a-frontend fallback links back to the shell.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011myibMMwyb6za3GVWJGkiX
MAPPS-425: clients received "<form> request form from {{tenant_name}}". The body rendered correctly, so the template engine was fine; the dispatch context simply never carried tenant_name, which the subject seeded in 101_form_request_tokens.sql asks for. No other template uses that placeholder, so nothing else covered the gap.

Supplied from the tenants row rather than corrected by a new migration. Migrations are immutable once applied and every tenant already holds the seeded subject, so filling the value repairs all of them at once while a rewrite would only fix rows it touched. Read inside the tenant transaction, matching InvitationsService.

The email fields move into a RequestLinkEmail struct at the same time: five adjacent &str parameters is a transposition waiting to happen, where the tenant's name would be emailed as the form's and nothing would fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011myibMMwyb6za3GVWJGkiX
test(forms): assert the emailed link's host and a placeholder-free subject
All checks were successful
Check / fmt + clippy + build + tests (pull_request) Successful in 4m26s
E2E / Playwright against staging (pull_request) Successful in 4m25s
Create release / Gate (release-branch merges only) (pull_request) Successful in 1s
Create release / Create release from merged PR (pull_request) Has been skipped
Integration / integration tests (pull_request) Successful in 7m13s
a5eace1eab
The test harness now passes a spa_base_url that deliberately differs from client_origin, so a link built from the wrong one cannot pass by coincidence. Verified by reverting the router line: the new test fails, and passes again with the fix.

Asserts the whole failure mode rather than the symptom: the link is on the SPA origin, it is not on the login origin, the subject names the MSP, and neither subject nor body contains "{{". The last one is the cheap general guard, since an unresolved placeholder reaching a client is a recurring family (PMS-701, PMS-702).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011myibMMwyb6za3GVWJGkiX
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-08-08 04:47:23 +02:00
longjacksonle deleted branch fix/MAPPS-425-request-link-origin 2026-08-08 04:50:03 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-server!502
No description provided.