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:
parent
d4b995c551
commit
8a598d1b76
@ -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
|
||||
|
||||
@ -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 }) => {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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 '/'). */
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user