fix(email): build SPA-only links from the SPA origin, not the apex #502
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/MAPPS-425-request-link-origin"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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.systemsis bunyip-web;/request-forms/:tokenexists only in mokosh-apps atmsp.a8n.systems, so the apex served bunyip's own 404. The same email also arrived with the subjectlongle 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_URLpoints 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_URLnames the mokosh-apps origin and defaults toCLIENT_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:
/portal/set-password/portal/invoices/{id}/portal/quotes/{id}/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_ORIGINconsumers are correct and stay:/reset-password(bunyip-web owns the page, mokosh-apps only redirects there), the invitation link (bare login origin), the OAuthpostMessageorigin, 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_namein 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:
SPA_BASE_URL: https://msp.a8n.systemsSPA_BASE_URL: https://msp.psa.systemsA follow-up PR against
NiceGuyIT/dockercarries that. Links already sent cannot be repaired; they have to be reissued after the deploy.Verification
Full integration suite: 743 passed, 0 failed.
just checkclean.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_urlthat differs fromclient_originprecisely 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/:tokenis 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.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_011myibMMwyb6za3GVWJGkiXMAPPS-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_011myibMMwyb6za3GVWJGkiXThe 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