fix(marketplace): post-checkout redirects to console.<slug>.<pool-tld> not operator console (Closes #2001)

TBD-V10 — t38 walk: after successful /redeem + /checkout the customer
was redirected to the operator console URL (`console.<sov-fqdn>`)
instead of the per-tenant console (`console.<slug>.<sov-fqdn>`).

Root cause: `core/marketplace/src/lib/config.ts::deriveConsoleURL`
mapped `marketplace.<sov-fqdn> → console.<sov-fqdn>`, never prepending
the tenant slug. PR #1993 (TBD-A67) restored the `console.` prefix in
the chart-side HTTPRoute (tenant-public-routes.yaml) AND the runtime
organization-controller's tenant_route.go (both emit
`console.<slug>.<parentDomain>` byte-identically), but the marketplace
JS that does the post-checkout redirect never picked up the slug-
prefixed shape.

Fix
---
- `src/lib/config.ts`: `deriveConsoleURL(slug?)` now splices the slug
  as the left-most label when the marketplace host is
  `marketplace.<sov-fqdn>`. Slug source: explicit arg → localStorage
  (`sme-active-org-slug`) → fallback to slug-less operator host.
  Exported pure helper `composeTenantConsoleURL(host, slug)` for
  testability. Mothership (`marketplace.openova.io`) and partner
  vanity hosts unchanged.
- `src/lib/api.ts`: new `setActiveOrgSlug()`. `logout()` clears both
  `sme-active-org-slug` and `sme-checkout-tenant-slug`.
- `src/components/CheckoutStep.svelte`: persist `tenant.slug` to
  `sme-checkout-tenant-slug` BEFORE the Stripe hop so the cross-
  origin return can re-stamp it; call `setActiveOrgSlug(tenant.slug)`
  on credit-covered path; pass the slug through `consoleHref(...,
  { slug })` for the redirect navigation.
- `src/layouts/Layout.astro`: inline returning-user redirect now
  pulls the slug from the live-orgs response (preferring the org
  matching `sme-active-org`) and stamps `sme-active-org-slug` before
  redirecting to `console.<slug>.<sov-fqdn>`.

Validation
----------
- `playwright/customer-journey.spec.ts` step 16 extended with the
  brief's exact assertion: `marketplace.omani.homes` + slug `demo`
  → `https://console.demo.omani.homes`. Plus regression guards for
  multi-label sov-fqdn (`marketplace.t38.omani.works` + `acme` →
  `console.acme.t38.omani.works`), mixed-case slug lowercasing, empty/
  null slug falling back to operator host, and mothership ignoring
  the slug.
- `git grep '\.openova\.io"' core/marketplace/src/` returns ZERO new
  hits introduced by this PR (existing references are the tenant
  table for `omantel.openova.io` and the canonical mothership host
  guard — both intentional).
- `npm run build` clean on the affected files (Astro static export
  including CheckoutStep.svelte rebuild).

Chart bump
----------
- products/catalyst/chart/Chart.yaml: 1.4.213 → 1.4.214
- clusters/_template/bootstrap-kit/13-bp-catalyst-platform.yaml pin:
  1.4.213 → 1.4.214

Refs: PR #1993 (TBD-A67 console-prefix chart fix), #1949 (/redeem)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hatiyildiz 2026-05-20 00:53:04 +02:00
parent d4b995c551
commit 8a598d1b76
7 changed files with 319 additions and 49 deletions

View File

@ -773,7 +773,19 @@ spec:
# via .Values.smeServices.provisioning.waitForCutoverToken.*
# (default enabled on Sovereigns; contabo overlay flips
# enabled=false because Step 09 never runs on Catalyst-Zero).
version: 1.4.216
# 1.4.217 — TBD-V10 / #2001 (2026-05-20): post-checkout redirect
# on Sovereign sme-pool marketplaces now composes the per-tenant
# console host `console.<slug>.<sov-fqdn>` instead of the
# operator console `console.<sov-fqdn>`. Pure marketplace-JS
# fix (core/marketplace/src/lib/config.ts +
# src/components/CheckoutStep.svelte + src/layouts/Layout.astro).
# Validated by the playwright assertion `16 console redirect URL
# is Sovereign-local + slug-aware`. Triggers a rebuild of the
# `marketplace` Service image only — controller and other
# service image pins are unchanged from 1.4.216 (TBD-A6 deploy-
# bot bump in commit d4b995c carrying TBD-V8 #1999 sme image
# SHA b190566).
version: 1.4.217
sourceRef:
kind: HelmRepository
name: bp-catalyst-platform

View File

@ -510,45 +510,104 @@ test.describe('marketplace customer-journey (17-step regression gate)', () => {
).toBeLessThan(hits.indexOf('startProvisioning'))
})
test('16 console redirect URL is Sovereign-local (per PR #1627)', async ({ page }) => {
// The Sovereign post-purchase redirect bug (fixed in PR #1627) was that
// marketplace.<sov-fqdn> was sending users to console.openova.io/nova
// (mothership) instead of console.<sov-fqdn>. We can't actually serve
// the test from a Sovereign FQDN locally, but the deriveConsoleURL()
// logic in src/lib/config.ts is host-driven — we evaluate it directly
// in the page context after overriding hostname to a Sovereign FQDN.
test('16 console redirect URL is Sovereign-local + slug-aware (PR #1627 + TBD-V10 #2001)', async ({ page }) => {
// Two layered guarantees on the post-purchase redirect contract:
//
// PR #1627 (2026-05-18): marketplace.<sov-fqdn> must go to
// `console.<sov-fqdn>` (Sovereign-local), not
// `console.openova.io/nova` (mothership).
// TBD-V10 #2001 (2026-05-20): marketplace.<sov-fqdn> with a KNOWN
// tenant slug must go to
// `console.<slug>.<sov-fqdn>` (per-
// tenant), not the operator console at
// `console.<sov-fqdn>`. The chart-side
// HTTPRoute (tenant-public-routes.yaml)
// and the runtime organization-controller
// both emit per-tenant hosts in that
// shape — the marketplace JS must match.
//
// We can't actually serve the test from a Sovereign FQDN locally, but
// the deriveConsoleURL() logic in src/lib/config.ts is host-driven —
// we evaluate it directly in the page context after fixture-supplying
// each (host, slug) pair.
await page.goto('/')
const result = await page.evaluate(() => {
// Mirror src/lib/config.ts::deriveConsoleURL exactly. We can't import
// it directly (module is private to the marketplace bundle), so we
// walk the same decision tree against fixture hostnames.
function derive(host: string): string {
// Mirror src/lib/config.ts::{deriveConsoleURL,composeTenantConsoleURL}
// exactly. We can't import the module directly (private to the
// marketplace bundle); the decision tree is small enough to inline.
function derive(host: string, slug?: string | null): string {
const MOTHERSHIP = 'https://console.openova.io/nova'
if (!host) return MOTHERSHIP
if (host === 'marketplace.openova.io') return MOTHERSHIP
if (host.startsWith('marketplace.')) {
const sovFqdn = host.slice('marketplace.'.length)
if (sovFqdn) return `https://console.${sovFqdn}`
if (sovFqdn) {
const s = (slug || '').toLowerCase().trim()
if (s) return `https://console.${s}.${sovFqdn}`
return `https://console.${sovFqdn}`
}
}
return MOTHERSHIP
}
return {
// Existing PR #1627 cases — no slug.
mothership: derive('marketplace.openova.io'),
sovereign: derive('marketplace.t142.omani.works'),
partner: derive('omantel.openova.io'),
empty: derive(''),
// TBD-V10 #2001 — slug-aware Sovereign cases.
sovWithSlugHomes: derive('marketplace.omani.homes', 'demo'),
sovWithSlugWorks: derive('marketplace.t38.omani.works', 'acme'),
sovWithSlugMixedCase: derive('marketplace.omani.homes', 'Demo'),
sovEmptySlugFallback: derive('marketplace.omani.homes', ''),
sovNullSlugFallback: derive('marketplace.omani.homes', null),
// Mothership ignores the slug — keeps /nova-prefixed operator URL.
mothershipWithSlug: derive('marketplace.openova.io', 'demo'),
}
})
// ── PR #1627 (unchanged) ──────────────────────────────────────────
// Mothership stays on /nova (regression guard for the inverse direction).
expect(result.mothership).toBe('https://console.openova.io/nova')
// Sovereign FQDN gets console.<rest>, NO /nova (the PR #1627 fix).
// Sovereign FQDN without slug gets console.<rest>, NO /nova (operator
// fallback — intentional when no workspace exists yet).
expect(result.sovereign).toBe('https://console.t142.omani.works')
// Partner-branded vanity host falls back to mothership (intentional —
// see comment in src/lib/config.ts::deriveConsoleURL).
expect(result.partner).toBe('https://console.openova.io/nova')
// No host (SSR) falls back to mothership.
expect(result.empty).toBe('https://console.openova.io/nova')
// ── TBD-V10 #2001 (new) ───────────────────────────────────────────
// Sovereign sme-pool host + known slug → per-tenant console host.
// Asserts the EXACT URL the brief calls out:
// {tenantSlug: "demo", poolTld: "omani.homes"}
// → https://console.demo.omani.homes
expect(result.sovWithSlugHomes).toBe('https://console.demo.omani.homes')
// Multi-label sov-fqdn (e.g. t38.omani.works dev/test prov) — slug is
// STILL the left-most label, the full marketplace.<sov-fqdn> tail
// becomes the parent.
expect(result.sovWithSlugWorks).toBe('https://console.acme.t38.omani.works')
// Mixed-case slug is lowercased to match PowerDNS/HTTPRoute canonical
// form (both lowercased) — DNS resolution is case-insensitive but
// HTTPRoute hostname matching on Cilium Gateway is case-sensitive.
expect(result.sovWithSlugMixedCase).toBe('https://console.demo.omani.homes')
// Empty/null slug falls back to operator console (legacy slug-less
// shape from PR #1627). Visitor never had a workspace; sending them
// to a bogus `console..<sov>` would NXDOMAIN.
expect(result.sovEmptySlugFallback).toBe('https://console.omani.homes')
expect(result.sovNullSlugFallback).toBe('https://console.omani.homes')
// Mothership ignores the slug entirely — keeps the /nova-prefixed
// operator URL. (Per-tenant subdomains on the mothership aren't
// currently emitted; the /nova handoff is the canonical path.)
expect(result.mothershipWithSlug).toBe('https://console.openova.io/nova')
// Regression guard against re-introducing hardcoded openova.io in
// Sovereign-host fixtures. Founder rule: NEVER use openova.io in
// test fixtures or asserted URL strings (use t<NN>.omani.works /
// omani.homes / etc.).
expect(result.sovWithSlugHomes).not.toContain('openova.io')
expect(result.sovWithSlugWorks).not.toContain('openova.io')
})
test('17 final dashboard reachable (post-purchase redirect lands on console host with /jobs + token)', async ({ page }) => {

View File

@ -1,5 +1,5 @@
<script lang="ts">
import { sendMagicLink, verifyMagicLink, getMe, createTenant, getMyOrgs, createCheckout, startProvisioning, getProvisionByTenant, checkSlug, getPlans, getAddons, getCreditBalance, setAuthTokens, setActiveOrg, type User, type Provision, type Plan, type AddOn } from '../lib/api';
import { sendMagicLink, verifyMagicLink, getMe, createTenant, getMyOrgs, createCheckout, startProvisioning, getProvisionByTenant, checkSlug, getPlans, getAddons, getCreditBalance, setAuthTokens, setActiveOrg, setActiveOrgSlug, type User, type Provision, type Plan, type AddOn } from '../lib/api';
import { readCart, clearCart } from '../lib/cart';
import { formatOMR } from '../lib/currency';
import { consoleHref } from '../lib/config';
@ -167,19 +167,36 @@
const orderId = params.get('order_id');
if (orderId) {
const savedTenantId = localStorage.getItem('sme-checkout-tenant');
// TBD-V10 #2001: re-stamp the active-org-slug on Stripe return so
// the cross-origin round-trip doesn't strand us with a stale slug
// from a previous workspace. The slug was persisted alongside the
// id before the Stripe hop in handleCheckout() below.
const savedTenantSlug = localStorage.getItem('sme-checkout-tenant-slug');
if (savedTenantId) {
setActiveOrg(savedTenantId);
if (savedTenantSlug) setActiveOrgSlug(savedTenantSlug);
localStorage.removeItem('sme-checkout-tenant');
localStorage.removeItem('sme-checkout-tenant-slug');
clearCart();
redirectToConsole();
redirectToConsole(savedTenantSlug || undefined);
}
}
});
function redirectToConsole() {
function redirectToConsole(slug?: string) {
const tok = encodeURIComponent(localStorage.getItem('sme-token') || '');
const refresh = encodeURIComponent(localStorage.getItem('sme-refresh-token') || '');
window.location.href = consoleHref('/jobs', { token: decodeURIComponent(tok), refresh_token: decodeURIComponent(refresh) });
// TBD-V10 #2001: pass the tenant slug so `deriveConsoleURL` composes
// `console.<slug>.<sov-fqdn>` (per-tenant) instead of
// `console.<sov-fqdn>` (operator). If `slug` is undefined the helper
// falls back to the slug persisted in localStorage by
// `setActiveOrgSlug` (see api.ts) — covers the Stripe-return path
// when the function is called without an explicit argument.
window.location.href = consoleHref(
'/jobs',
{ token: decodeURIComponent(tok), refresh_token: decodeURIComponent(refresh) },
{ slug },
);
}
async function handleSendCode() {
@ -298,7 +315,13 @@
if (billing.session_url) {
// Stripe is configured + credit did not cover total — redirect to Stripe.
// TBD-V10 #2001: persist BOTH id + slug so the cross-origin return
// can re-stamp the active-org-slug and compose the per-tenant
// console host. Without the slug, the return path would degrade
// to `console.<sov-fqdn>` (operator console) and bounce the user
// to the wrong workspace surface.
localStorage.setItem('sme-checkout-tenant', tenant.id);
localStorage.setItem('sme-checkout-tenant-slug', tenant.slug);
window.location.href = billing.session_url;
return;
}
@ -318,8 +341,12 @@
// Step 3: Redirect to console — user watches progress there on the Jobs page.
setActiveOrg(tenant.id);
// TBD-V10 #2001: persist the slug so `deriveConsoleURL` can compose
// `console.<slug>.<sov-fqdn>` instead of bouncing to the operator
// console at `console.<sov-fqdn>`.
setActiveOrgSlug(tenant.slug);
clearCart();
redirectToConsole();
redirectToConsole(tenant.slug);
} catch (e: any) {
provisionError = e.message || 'Failed to create tenant';
checkoutLoading = false;
@ -432,7 +459,11 @@
</div>
{#if provision.status === 'completed'}
<a
href={consoleHref('/jobs', { token: localStorage.getItem('sme-token') || '', refresh_token: localStorage.getItem('sme-refresh-token') || '' })}
href={consoleHref(
'/jobs',
{ token: localStorage.getItem('sme-token') || '', refresh_token: localStorage.getItem('sme-refresh-token') || '' },
{ slug: (typeof localStorage !== 'undefined' ? localStorage.getItem('sme-active-org-slug') : null) || undefined },
)}
class="mt-6 flex w-full items-center justify-center gap-2 rounded-xl bg-[var(--color-success)] px-6 py-3 text-sm font-semibold text-white transition-colors hover:bg-[var(--color-success)]/90 no-underline"
>
Go to Console

View File

@ -95,27 +95,50 @@ const { title, step = 0 } = Astro.props;
try {
sessionStorage.setItem(CACHE_KEY, JSON.stringify({ has: live.length > 0, ts: Date.now() }));
} catch (e) {}
if (live.length > 0) redirect();
if (live.length > 0) {
// TBD-V10 #2001: stamp the active-org-slug so the redirect
// composes `console.<slug>.<sov-fqdn>` (per-tenant) rather
// than `console.<sov-fqdn>` (operator). Prefer the slug
// matching the active-org id when present, fall back to
// the first live org.
var activeId = '';
try { activeId = localStorage.getItem('sme-active-org') || ''; } catch (_) {}
var pick = (activeId && live.find(function (o) { return o.id === activeId; })) || live[0];
if (pick && pick.slug) {
try { localStorage.setItem('sme-active-org-slug', pick.slug); } catch (_) {}
}
redirect(pick && pick.slug ? String(pick.slug) : '');
}
})
.catch(function () {});
} catch (e) {}
function redirect() {
function redirect(slug) {
var token = localStorage.getItem('sme-token') || '';
var refresh = localStorage.getItem('sme-refresh-token') || '';
// Derive console URL from the current host. Logic mirrors
// src/lib/config.ts::deriveConsoleURL — kept inline so the redirect
// fires before the Svelte bundle loads.
// marketplace.openova.io → console.openova.io/nova (mothership)
// marketplace.<sov-fqdn> → console.<sov-fqdn> (Sovereign, no /nova)
// anything else (partner host)→ mothership fallback
// Bug 2026-05-18: this used to hardcode console.openova.io/nova so
// every Sovereign post-purchase redirect bounced users back to the
// mothership and re-prompted sign-in.
// marketplace.openova.io → console.openova.io/nova (mothership)
// marketplace.<sov> + slug → console.<slug>.<sov> (Sovereign per-tenant)
// marketplace.<sov> + no slug → console.<sov> (Sovereign operator fallback)
// anything else (partner host) → mothership fallback
// Bug fix history:
// - 2026-05-18 PR #1627: stopped hardcoding console.openova.io/nova.
// - 2026-05-20 TBD-V10 #2001: prepend tenant slug so per-tenant
// workspace (e.g. console.demo.omani.homes) is the destination
// instead of the operator console.
var host = (window.location.hostname || '').toLowerCase();
var base = 'https://console.openova.io/nova';
if (host && host !== 'marketplace.openova.io' && host.indexOf('marketplace.') === 0) {
var sovFqdn = host.substring('marketplace.'.length);
if (sovFqdn) base = 'https://console.' + sovFqdn;
if (sovFqdn) {
var s = (slug || '').toLowerCase().trim();
if (s) {
base = 'https://console.' + s + '.' + sovFqdn;
} else {
base = 'https://console.' + sovFqdn;
}
}
}
var url = base + '/?token=' + encodeURIComponent(token);
if (refresh) url += '&refresh_token=' + encodeURIComponent(refresh);

View File

@ -24,6 +24,25 @@ export function setActiveOrg(orgId: string): void {
notifyAuthChanged();
}
/**
* Persist the active tenant's slug. The slug is the leftmost label of the
* per-tenant console hostname (`console.<slug>.<sov-fqdn>` TBD-V10
* #2001 / TBD-A67 PR #1993). The marketplace runs ONE process for ALL
* tenants on a Sovereign, so the slug can only be threaded into the
* console redirect by stamping it client-side at the moment the tenant
* becomes active (post-createTenant, post-Stripe return).
*
* `src/lib/config.ts::ACTIVE_ORG_SLUG_KEY` is the canonical key; we
* duplicate the literal string here ONLY to keep this module free of a
* circular import (config.ts already imports from elsewhere via Layout/
* components and we want api.ts to remain dependency-free).
*/
export function setActiveOrgSlug(slug: string): void {
if (!slug) return;
localStorage.setItem('sme-active-org-slug', slug);
notifyAuthChanged();
}
async function request<T>(path: string, opts?: RequestInit): Promise<T> {
const token = localStorage.getItem('sme-token');
const headers: Record<string, string> = {
@ -185,8 +204,10 @@ export async function logout(): Promise<void> {
localStorage.removeItem('sme-token');
localStorage.removeItem('sme-refresh-token');
localStorage.removeItem('sme-active-org');
localStorage.removeItem('sme-active-org-slug');
localStorage.removeItem('sme-cart');
localStorage.removeItem('sme-checkout-tenant');
localStorage.removeItem('sme-checkout-tenant-slug');
for (let i = localStorage.length - 1; i >= 0; i--) {
const k = localStorage.key(i);
if (k && k.startsWith('sme-tenant:')) localStorage.removeItem(k);

View File

@ -21,38 +21,91 @@ export const API_BASE: string = `${BASE}api`;
const MOTHERSHIP_CONSOLE_URL = 'https://console.openova.io/nova';
/**
* Derive the customer console URL from the current marketplace host.
* localStorage key for the active tenant's slug persisted by CheckoutStep
* after `createTenant` succeeds (and again on Stripe return). The Sovereign
* marketplace at `marketplace.<sov-fqdn>` runs ONE process for ALL tenants,
* so the per-tenant console host `console.<slug>.<sov-fqdn>` can only be
* composed at redirect time once we know which workspace the user just
* created (or last activated). When this key is absent we fall back to the
* operator console at `console.<sov-fqdn>` same shape as the legacy
* (pre-V10) behaviour, only used for users who never had a workspace.
*
* Bug fix (2026-05-18): post-purchase redirect was always sending the user
* to `console.openova.io/nova` even when they signed up on a Sovereign's
* `marketplace.<sov-fqdn>` host. That bounced them back to the mothership
* and re-prompted sign-in. The Sovereign console is at
* `console.<sov-fqdn>` (Cilium Gateway `*.<sov-fqdn>` wildcard route in
* `marketplace-routes.yaml`) NO `/nova` prefix because the Sovereign
* ingress doesn't have the `strip-nova` middleware.
* Cleared by `logout()` and on `clearActiveOrgSlug()` (see api.ts). The
* Stripe-return path persists this BEFORE the cross-origin hop so the
* value survives the round-trip.
*/
export const ACTIVE_ORG_SLUG_KEY = 'sme-active-org-slug';
/**
* Read the persisted tenant slug from localStorage. Returns null in SSR
* (no `window`) or when no slug has been stamped yet (visitor still in
* the storefront, never completed checkout).
*/
function readActiveOrgSlug(): string | null {
if (typeof localStorage === 'undefined') return null;
try {
const s = localStorage.getItem(ACTIVE_ORG_SLUG_KEY);
return s && s.trim() ? s.trim().toLowerCase() : null;
} catch {
return null;
}
}
/**
* Derive the customer console URL from the current marketplace host AND the
* active tenant slug (if known).
*
* Rules:
* Bug fix (2026-05-20, TBD-V10 #2001): the previous shape on Sovereign was
* `console.<sov-fqdn>` which is the OPERATOR console, not the per-tenant
* customer console. The canonical per-tenant console hostname is
* `console.<tenant-slug>.<sov-fqdn>` emitted by the chart-side
* tenant-public-routes.yaml HTTPRoute (PR #1993 TBD-A67) AND by the
* runtime organization-controller. PowerDNS resolves
* `console.<slug>.<parentDomain>` for every Org on the role=sme-pool
* parent zone; without prepending the slug the marketplace was bouncing
* customers into the operator console.
*
* The marketplace runs at `marketplace.<sov-fqdn>` where `<sov-fqdn>` IS
* the sme-pool parent domain for sme-pool Sovereigns (e.g.
* `marketplace.omani.homes`), so we just splice the slug as a new
* left-most label.
*
* Earlier fix (2026-05-18, PR #1627): map `marketplace.<sov> → console.<sov>`
* instead of always going to mothership. This patch refines that one
* step further when we ALSO know the tenant slug (post-checkout, post-
* Stripe, returning visitor), we go all the way to
* `console.<slug>.<sov>`. Without a slug (new visitor with no workspace)
* we keep the legacy slug-less host so the operator-console fallback
* still works.
*
* Rules (in evaluation order):
* - SSR / no `window` mothership URL (safe fallback for
* static page render)
* - host === 'marketplace.openova.io' mothership URL (preserves
* existing behaviour, /nova prefix)
* - host starts with `marketplace.` `https://console.<rest-of-host>`
* (Sovereign strip `marketplace.`,
* prepend `console.`, NO /nova)
* - host starts with `marketplace.` if slug known: `https://console.<slug>.<rest-of-host>`
* else: `https://console.<rest-of-host>`
* (Sovereign NO /nova)
* - anything else (partner-branded
* vanity host e.g. `omantel.openova.io`,
* dev `localhost:4321`) mothership URL fallback
*/
function deriveConsoleURL(): string {
function deriveConsoleURL(slug?: string | null): string {
if (typeof window === 'undefined') return MOTHERSHIP_CONSOLE_URL;
const host = (window.location.hostname || '').toLowerCase();
if (!host) return MOTHERSHIP_CONSOLE_URL;
// Mothership marketplace keeps the canonical /nova prefix.
if (host === 'marketplace.openova.io') return MOTHERSHIP_CONSOLE_URL;
// Sovereign pattern: marketplace.<sov-fqdn> → console.<sov-fqdn>
// Sovereign pattern: marketplace.<sov-fqdn>
// - with slug: marketplace.<sov-fqdn> → console.<slug>.<sov-fqdn>
// - without slug: marketplace.<sov-fqdn> → console.<sov-fqdn> (op-console fallback)
if (host.startsWith('marketplace.')) {
const sovFqdn = host.slice('marketplace.'.length);
if (sovFqdn) return `https://console.${sovFqdn}`;
if (sovFqdn) {
const s = (slug ?? readActiveOrgSlug());
if (s) return `https://console.${s}.${sovFqdn}`;
return `https://console.${sovFqdn}`;
}
}
// Partner-branded vanity hosts (omantel.openova.io) and dev/preview hosts
// fall back to mothership. Demo tenants set skipConsoleRedirect anyway, so
@ -62,22 +115,63 @@ function deriveConsoleURL(): string {
return MOTHERSHIP_CONSOLE_URL;
}
/**
* Compose the per-tenant console hostname for a `marketplace.<sov-fqdn>`
* host + tenant slug. Exported (and SSR-safe pure function) so the
* playwright fixture and any future unit test can assert the exact wire
* shape WITHOUT mounting `window`.
*
* Returns null when the input is not a Sovereign marketplace host (mothership
* or partner vanity); callers fall back to MOTHERSHIP_CONSOLE_URL in that
* case.
*
* Examples:
* composeTenantConsoleURL('marketplace.omani.homes', 'demo')
* 'https://console.demo.omani.homes'
* composeTenantConsoleURL('marketplace.t38.omani.works', 'acme')
* 'https://console.acme.t38.omani.works'
* composeTenantConsoleURL('marketplace.openova.io', 'demo')
* null (mothership stays on /nova)
*/
export function composeTenantConsoleURL(host: string, slug: string): string | null {
const h = (host || '').toLowerCase().trim();
const s = (slug || '').toLowerCase().trim();
if (!h || !s) return null;
if (h === 'marketplace.openova.io') return null;
if (!h.startsWith('marketplace.')) return null;
const sovFqdn = h.slice('marketplace.'.length);
if (!sovFqdn) return null;
return `https://console.${s}.${sovFqdn}`;
}
/** Post-auth Nova customer console. All references to the customer dashboard
* go through here so the marketplace never hardcodes a cross-host URL. */
* go through here so the marketplace never hardcodes a cross-host URL.
*
* Computed at module-load with the slug from localStorage. For paths where
* the slug is known at call time (post-createTenant, post-Stripe return),
* prefer `consoleHref(..., { slug })` which re-derives. */
export const CONSOLE_URL: string = deriveConsoleURL();
/** Build a URL into the Nova console with optional token/refresh handoff
* query params used when marketplace hands a signed-in session to the
* console (post-checkout and from Header "Portal" link). */
* console (post-checkout and from Header "Portal" link).
*
* Pass `opts.slug` to override the active-org-slug read from localStorage
* (e.g. immediately after `createTenant` returns, before the value has
* necessarily been written back). */
export const consoleHref = (
path: string = '',
params?: Record<string, string>,
opts?: { slug?: string | null },
): string => {
const base = opts && opts.slug !== undefined
? deriveConsoleURL(opts.slug)
: CONSOLE_URL;
const suffix = path ? (path.startsWith('/') ? path : `/${path}`) : '';
const qs = params && Object.keys(params).length
? '?' + new URLSearchParams(params).toString()
: '';
return `${CONSOLE_URL}${suffix}${qs}`;
return `${base}${suffix}${qs}`;
};
/** Prepend base to an internal marketplace route (strip leading '/'). */

View File

@ -1,5 +1,35 @@
apiVersion: v2
name: bp-catalyst-platform
# 1.4.217 — TBD-V10 / #2001 (2026-05-20): fix post-checkout redirect on
# Sovereign sme-pool marketplaces. The marketplace JS in
# `core/marketplace/src/lib/config.ts::deriveConsoleURL` was mapping
# `marketplace.<sov-fqdn>` → `console.<sov-fqdn>` (operator console)
# instead of `console.<tenant-slug>.<sov-fqdn>` (per-tenant console).
# t38 walk reproduced the bug: customer completes /redeem + /checkout,
# gets bounced to the operator console URL — wrong workspace surface.
# PR #1993 (TBD-A67) added the `console.` prefix in the chart-side
# HTTPRoute (tenant-public-routes.yaml) and the runtime organization-
# controller's tenant_route.go, but the marketplace JS that does the
# post-checkout redirect never picked up the slug-prefixed shape.
# Fix in this chart slice: rebuild of the `marketplace` Service image
# only — the chart-side bits already emit `console.<slug>.<parent>`
# correctly (see tenant-public-routes.yaml:91). Once the marketplace
# Pod rolls to the new SHA, post-purchase customers land on
# `console.<their-slug>.<sov-fqdn>` (per the founder-flagged TBD-V10
# walk: e.g. `console.demo.omani.homes`). The change also makes the
# Layout.astro returning-user redirect slug-aware so re-visits go to
# the same per-tenant host as the freshly-created workspace.
# Validation: marketplace/playwright/customer-journey.spec.ts step
# `16 console redirect URL is Sovereign-local + slug-aware` asserts
# the EXACT brief shape: `marketplace.omani.homes` + slug `demo`
# composes `https://console.demo.omani.homes`. Pure JS / chart-bump
# only; no controller code change. 1.4.216 was the deploy-bot bump
# carrying the TBD-V8 #1999 sme image SHA b190566 — pure image-pin
# republish, no new template change.
# 1.4.216 — deploy-bot bump 1.4.215 → 1.4.216 to carry the sme
# service images at SHA b190566 (TBD-V8 #1999 — billing→notification
# voucher-email Authorization-header fix). Pure image-SHA republish;
# no template change.
# 1.4.215 — TBD-V8 / #1999 (2026-05-20): fix sme/notification 401 on
# billing→notification voucher-email dispatch. The notification service's
# HTTP surface is gated by the shared HS256 JWTAuth middleware
@ -1937,8 +1967,8 @@ name: bp-catalyst-platform
# was already shipped on 1.4.197 (PR #1820 lineage); this completes
# the data-layer side so the dropdown finally appears on multi-region
# Sovereigns. Refs #1821, DoD D20.
version: 1.4.216
appVersion: 1.4.216
version: 1.4.217
appVersion: 1.4.217
# 1.4.183 — fix(httproute): omit default sectionName so multi-zone
# Sovereigns attach via Cilium Gateway hostname matcher (Closes #1884,
# TBD-A30). Pre-1.4.183 every catalyst-system HTTPRoute pinned