feat(marketplace): thread configSchema form values into install POST (Refs #2026, Refs #2042) (#2043)

PR #2038 shipped the configSchema RENDER side on AppDetail.svelte —
form inputs bound to local state, defaults seeded from the Go catalog.
What was missing: the customer's chosen values never reached the
install POST. This PR threads the SHAPE end-to-end:

Frontend
- cart.ts: `appConfigs: Record<slug, Record<key, value>>` field +
  `setAppConfig(slug, values)` setter. Keyed by app SLUG (NOT id, so
  the cart survives catalog id reshuffles).
- AppDetail.svelte: persist on every form mutation via setAppConfig;
  re-hydrate from cart on mount so navigating /app -> /addons -> /app
  keeps the customer's choices.
- CheckoutStep.svelte: forward `cart.appConfigs` as `app_configs`
  in the createTenant POST body.
- api.ts: `CreateTenantRequest.app_configs?` (optional, legacy-safe).

Backend
- store.Tenant.AppConfigs: `map[string]map[string]any` with
  `bson:"app_configs,omitempty" json:"app_configs,omitempty"`.
- CreateOrg: accept `app_configs` in body, persist on the new tenant.
- Round-trips on the `tenant.created` event payload via the existing
  *store.Tenant embed — no wrapper change needed.

Tests
- tenant_created_wire_test.go: TestTenantCreatedWire_AppConfigs_RoundTrip
  asserts the publisher to consumer wire round-trip preserves
  app_configs.<slug>.<key>=<value> byte-for-byte (numbers as float64
  per JSON decode of any).
- tenant_created_wire_test.go: TestTenantCreatedWire_EmptyAppConfigs_Omitted
  asserts omitempty drops nil app_configs so legacy clients see the
  pre-TBD-V18-D wire shape.
- customer-journey.spec.ts 12b: playwright assertion that the
  POST /api/tenant/orgs body carries `app_configs.wordpress.replicas=3,
  disk_gb=50, backups_enabled=true` when the cart has them.

Scope NOT in this PR (per anti-theater discipline)

The HelmRelease-values binding (Path A SME-controller-via-Org-CR or
Path B gitops-commit-to-tenant-repo) is gated on TBD-V26 (#2040).
This PR threads the SHAPE so that flipping the Path A/B switch
lights up the values without a second upstream change. Pillar 1
step 2 STAYS UNVERIFIED — only an operator-walk-with-screenshot on
a fresh prov can flip TBD-V18 (#2026) to verified-done.

Refs #2026
Refs #2042
Refs #2040

Co-authored-by: hatiyildiz <hatice.yildiz@openova.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
e3mrah 2026-05-20 06:30:24 +04:00 committed by GitHub
parent db1e452ac3
commit 0feb0b4006
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 317 additions and 19 deletions

View File

@ -540,6 +540,90 @@ test.describe('marketplace customer-journey (17-step regression gate)', () => {
).toBeLessThan(hits.indexOf('startProvisioning'))
})
// TBD-V18-D follow-up to PR #2038 — assert the install POST body
// carries the customer-chosen configSchema values (from the
// AppDetail form) into the createTenant call. We cannot walk the
// entire AppDetail surface here without /app?slug=postgres in the
// mock catalog; the canonical seed-cart path already simulates the
// customer's choices via cart.appConfigs. This proves the
// CheckoutStep → createTenant wire honours the cart contract; the
// AppDetail → cart half is exercised at unit level in cart.ts's
// setAppConfig and indirectly via the 03b configSchema render test
// (which already asserts the form is reactive).
test('12b createTenant POST body carries app_configs from cart (TBD-V18-D)', async ({ page }) => {
let capturedBody: Record<string, unknown> | null = null
await page.route('**/api/tenant/orgs', (route) => {
if (route.request().method() === 'POST') {
const raw = route.request().postData()
try {
capturedBody = raw ? JSON.parse(raw) : null
} catch {
capturedBody = null
}
route.fulfill({
status: 201,
contentType: 'application/json',
body: JSON.stringify({ id: 'tenant-1', slug: 'demo-co', name: 'Demo Co', status: 'active' }),
})
} else {
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) })
}
})
await page.route('**/api/billing/checkout', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ order_id: 'order-1', paid_by_credit: true, session_url: null }),
})
)
await page.route('**/api/provisioning/start', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ id: 'prov-1', tenant_id: 'tenant-1', status: 'running', steps: [] }),
})
)
await page.addInitScript(() => {
try {
localStorage.setItem('sme-token', 'mock-jwt-token')
localStorage.setItem('sme-refresh-token', 'mock-refresh-token')
} catch (_) {}
})
// Seed cart with appConfigs as if the customer mutated the
// AppDetail form for the canonical Postgres-backed bundle. Values
// match the seed catalog defaults' shape (replicas + disk_gb +
// backups_enabled), but the customer overrode the defaults.
await seedCart(page, {
appConfigs: {
wordpress: {
replicas: 3,
disk_gb: 50,
backups_enabled: true,
},
},
})
await page.goto('/checkout')
const launch = page.getByRole('button', { name: /Launch my tenant|Purchase/i }).first()
await expect(launch).toBeVisible({ timeout: 10_000 })
await Promise.all([
page.waitForURL(/console\.openova\.io|console\..*\.(works|homes|rest|trade)/, { timeout: 15_000 }).catch(() => null),
launch.click(),
])
expect(capturedBody, 'POST /api/tenant/orgs body parsed').not.toBeNull()
const body = capturedBody as { app_configs?: Record<string, Record<string, unknown>> }
expect(body.app_configs, 'app_configs sibling present in body').toBeDefined()
expect(body.app_configs!.wordpress, 'wordpress bucket present').toBeDefined()
// Each customer-set value round-trips byte-for-byte from cart to
// the wire. A regression that drops the field or coerces the
// type (e.g. JSON-stringifies the inner map) would fail here.
expect(body.app_configs!.wordpress.replicas, 'replicas threaded').toBe(3)
expect(body.app_configs!.wordpress.disk_gb, 'disk_gb threaded').toBe(50)
expect(body.app_configs!.wordpress.backups_enabled, 'backups_enabled threaded').toBe(true)
})
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:
//

View File

@ -1,6 +1,6 @@
<script lang="ts">
import { getApps, type App, type ConfigField } from '../lib/api';
import { readCart, toggleApp, toggleAgent, SANDBOX_AGENTS } from '../lib/cart';
import { readCart, toggleApp, toggleAgent, setAppConfig, SANDBOX_AGENTS } from '../lib/cart';
interface Props {
slug?: string;
@ -16,8 +16,10 @@
// declared on app.configSchema. Initialised from each field's
// `default` so the rendered form is always populated for the
// canonical Postgres-backed bundle (replicas=1, disk_gb=5,
// backups_enabled=false). Threading these into the install POST is
// a follow-up — for now this proves the schema renders end-to-end.
// backups_enabled=false). TBD-V18-D follow-up to PR #2038: every
// mutation now also persists to cart.appConfigs[app.slug] via
// setAppConfig(), so CheckoutStep can thread the values into the
// install POST body (createTenant /api/tenant/orgs `app_configs`).
let configValues = $state<Record<string, number | string | boolean>>({});
const inCart = $derived(app ? cart.apps.includes(app.id) : false);
@ -52,17 +54,31 @@
.filter((a): a is App => !!a);
// Seed configValues from per-field defaults. Falls back to a
// type-appropriate zero when `default` is missing so the form
// always has a coherent initial state.
// always has a coherent initial state. TBD-V18-D: when the
// operator already visited this AppDetail in the current cart
// session (e.g. navigated forward to /addons then back), prefer
// their previously-saved values from cart.appConfigs[slug] so
// we don't blow away their edits on every mount.
const fields = app?.configSchema ?? [];
const seeded: Record<string, number | string | boolean> = {};
const previouslySaved = (cart.appConfigs ?? {})[app?.slug ?? ''] ?? {};
for (const f of fields) {
if (f.default !== undefined && f.default !== null) {
if (Object.prototype.hasOwnProperty.call(previouslySaved, f.key)) {
seeded[f.key] = previouslySaved[f.key];
} else if (f.default !== undefined && f.default !== null) {
seeded[f.key] = f.default;
} else {
seeded[f.key] = f.type === 'int' ? 0 : f.type === 'bool' ? false : '';
}
}
configValues = seeded;
// Persist the freshly-seeded values back so the cart has a
// coherent snapshot from the moment the AppDetail mounts, even
// when the customer never mutates a field (silent acceptance of
// defaults still needs to thread through the install POST).
if (app?.slug && fields.length > 0) {
cart = setAppConfig(app.slug, seeded);
}
loading = false;
}).catch(() => { loading = false; });
});
@ -83,6 +99,14 @@
}
function setValue(key: string, v: number | string | boolean): void {
configValues = { ...configValues, [key]: v };
// TBD-V18-D — persist on every change so the cart matches the
// on-screen form when the customer leaves AppDetail (no submit
// button on this surface: the cart IS the buffer). Guarded on
// `app?.slug` so we never write a stub `undefined` key when the
// detail page is still loading.
if (app?.slug) {
cart = setAppConfig(app.slug, configValues);
}
}
function toggle() {
@ -167,8 +191,12 @@
walk ("Click the canonical Postgres-backed bundle → app card
opens; configSchema renders"). One input widget per
ConfigField.type — matches the Go store.ConfigField contract
exactly. Threading these into the install POST is a follow-up
(TBD-V18-D). -->
exactly. TBD-V18-D follow-up to PR #2038: every mutation is
persisted to cart.appConfigs[slug] so CheckoutStep can
thread the values into the install POST (createTenant
/api/tenant/orgs `app_configs`). The downstream HelmRelease-
values binding is gated on TBD-V26 (#2040) Path A/B; this
file ships the SHAPE end-to-end. -->
{#if hasConfigSchema}
<section class="detail-section" data-testid="config-schema-section">
<h2>Configuration</h2>

View File

@ -247,6 +247,17 @@
// only acts on this when `apps` contains 'sandbox'; for all
// other carts it's persisted and ignored.
agents: cart.agents || [],
// TBD-V18-D (follow-up to PR #2038) — thread the
// customer-chosen configSchema values into the install POST
// body, keyed by app slug. Tenant-service persists this on
// store.Tenant.AppConfigs and re-emits it on the
// tenant.created event so any downstream consumer (Path A
// SME-controller-via-Org-CR, Path B
// gitops-commit-to-tenant-repo, per TBD-V26 #2040) can read
// the values when materialising the HelmRelease values.
// Empty record when no app in the cart exposes a
// configSchema (Ghost / Nextcloud / Sandbox today).
app_configs: cart.appConfigs || {},
});
return { id: t.id, slug: t.slug || s };
} catch (e: any) {

View File

@ -351,8 +351,10 @@ export interface App {
// TBD-V18 (#2026) — per-instance tunables (replicas / disk / backup
// for Postgres-backed bundles, replicas / persistence for Redis,
// etc.). Empty array when the catalog has no tunables for this app.
// Threading the customer's chosen values into the install payload is
// a follow-up — see CartState extension TODO in cart.ts.
// The customer's chosen values are persisted to
// `CartState.appConfigs[slug]` (see cart.ts::setAppConfig) and
// threaded into the install POST as `CreateTenantRequest.app_configs`
// (TBD-V18-D follow-up to PR #2038).
configSchema?: ConfigField[];
}
@ -433,6 +435,14 @@ export interface CreateTenantRequest {
// matching spec.agentCatalogue. Optional so legacy clients keep
// working unchanged.
agents?: string[];
// TBD-V18-D follow-up to PR #2038 — per-instance configSchema
// values, keyed by app slug. Optional so legacy clients (older cart
// shape, machine-to-machine callers) keep working unchanged. Wire
// mirror of `store.Tenant.AppConfigs` (bson:"app_configs"). The
// backend tenant-service decodes via the same JSON tag and
// round-trips on the `tenant.created` event payload — see
// `tenant_created_wire_test.go`.
app_configs?: Record<string, Record<string, number | string | boolean>>;
}
export interface Tenant {

View File

@ -19,6 +19,22 @@ export interface CartState {
// controller consumes to materialize a Sandbox CR with the matching
// spec.agentCatalogue. Empty when Sandbox isn't in the cart.
agents: string[];
// TBD-V18-D follow-up to PR #2038 — per-app config values keyed by
// the marketplace app SLUG (NOT id, so the persisted cart survives a
// catalog id reshuffle). Shape per slug is the dict of
// `ConfigField.key` → user-chosen value, matching the ConfigField
// schema declared by the catalog. Threaded into the install POST
// body (createTenant → /tenant/orgs) under the `app_configs`
// sibling field. Empty record when no app exposes a configSchema
// (e.g. cart is Sandbox-only, or all picks are Ghost/Nextcloud which
// ship empty schemas today).
//
// Independent of TBD-V26 (#2040): this wires the SHAPE end-to-end;
// the backend HelmRelease consumption is gated on Path A/B of
// TBD-V26 and lives in its own track. The shape is correct today so
// that flipping the Path A/B switch lights up the form values
// without a second frontend round-trip.
appConfigs: Record<string, Record<string, number | string | boolean>>;
}
const STORAGE_KEY = 'sme-cart';
@ -38,6 +54,7 @@ const defaultCart: CartState = {
tld: DEFAULT_TLD,
email: '',
agents: [],
appConfigs: {},
};
// The 6 agents the Sandbox CRD (sandbox.openova.io/v1) accepts in
@ -121,6 +138,24 @@ export function setTLD(tld: string): CartState {
return cart;
}
// setAppConfig stores the customer-chosen configSchema field values
// for a single app, keyed by the app's marketplace SLUG. Called by
// AppDetail.svelte whenever the user mutates any field in the rendered
// ConfigField form — Svelte's reactive update fires this so the cart
// always reflects the on-screen state. Empty `values` is a legitimate
// signal that the operator wiped the form; we keep the slot present
// rather than deleting it so the install-POST shape stays stable. See
// TBD-V18-D follow-up to PR #2038.
export function setAppConfig(
appSlug: string,
values: Record<string, number | string | boolean>,
): CartState {
const cart = readCart();
cart.appConfigs = { ...(cart.appConfigs || {}), [appSlug]: { ...values } };
writeCart(cart);
return cart;
}
// toggleAgent flips one agent slug in/out of cart.agents. Used by the
// Sandbox detail page (AppDetail.svelte) when slug === 'sandbox'. The
// list is kept stable-ordered by toggling in-place — order in the cart

View File

@ -171,6 +171,16 @@ func (h *Handler) CreateOrg(w http.ResponseWriter, r *http.Request) {
// event the sandbox-controller consumes to mint a Sandbox CR
// with `spec.agentCatalogue` = these slugs. Tolerated empty.
Agents []string `json:"agents"`
// TBD-V18-D follow-up to PR #2038 — per-app configSchema
// values, keyed by app SLUG. Each inner map is `ConfigField.Key`
// → field-typed primitive (int / string / bool). Persisted on
// `store.Tenant.AppConfigs`; round-trips on the `tenant.created`
// event payload via the *store.Tenant embed (no separate
// wrapper field needed). The downstream HelmRelease-values
// binding is gated on TBD-V26 (#2040) Path A/B; this field
// threads the SHAPE end-to-end so flipping the binding switch
// works without a second upstream change. Tolerated empty.
AppConfigs map[string]map[string]any `json:"app_configs"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
respond.Error(w, http.StatusBadRequest, "invalid JSON body")
@ -205,16 +215,17 @@ func (h *Handler) CreateOrg(w http.ResponseWriter, r *http.Request) {
}
tenant := &store.Tenant{
Slug: body.Slug,
Name: body.Name,
OrgType: body.OrgType,
Industry: body.Industry,
OwnerID: userID,
PlanID: body.PlanID,
Apps: body.Apps,
AddOns: body.AddOns,
Subdomain: body.Slug,
Status: "provisioning",
Slug: body.Slug,
Name: body.Name,
OrgType: body.OrgType,
Industry: body.Industry,
OwnerID: userID,
PlanID: body.PlanID,
Apps: body.Apps,
AddOns: body.AddOns,
AppConfigs: body.AppConfigs,
Subdomain: body.Slug,
Status: "provisioning",
}
if err := h.Store.CreateTenant(r.Context(), tenant); err != nil {

View File

@ -47,6 +47,11 @@ type consumerPayloadMirror struct {
Tier string `json:"tier,omitempty"`
BillingMode string `json:"billing_mode,omitempty"`
ParentDomain string `json:"parent_domain,omitempty"`
// TBD-V18-D follow-up to PR #2038 — the per-instance configSchema
// values bubble through the publisher wrapper via the embedded
// *store.Tenant. A consumer that wants to honour them adds this
// field with a matching tag — see TBD-V26 #2040 Path A/B.
AppConfigs map[string]map[string]any `json:"app_configs,omitempty"`
}
func TestTenantCreatedWire_PublisherToConsumer_RoundTrip(t *testing.T) {
@ -148,3 +153,103 @@ func TestTenantCreatedWire_EmptyEmailOmitted_StillDecodes(t *testing.T) {
t.Errorf("non-email fields lost on empty-email wire: id=%q slug=%q", got.ID, got.Slug)
}
}
// TestTenantCreatedWire_AppConfigs_RoundTrip — TBD-V18-D follow-up to
// PR #2038. The publisher wrapper embeds *store.Tenant; the AppConfigs
// field (json tag `app_configs`) MUST round-trip into the consumer's
// flat decode shape so a downstream HelmRelease-values binding can
// read replicas / disk_gb / backups_enabled without a second hop.
// A regression that drops the bson/json tag, renames the field, or
// wraps the Tenant in a nested key would fail here before reaching
// staging.
func TestTenantCreatedWire_AppConfigs_RoundTrip(t *testing.T) {
tenant := &store.Tenant{
ID: "tenant-abc",
Slug: "acme",
Name: "ACME Corp",
OwnerID: "user-xyz",
PlanID: "sme-pool-basic",
Apps: []string{"wordpress", "postgres"},
Status: "provisioning",
Subdomain: "acme",
AppConfigs: map[string]map[string]any{
"postgres": {
"replicas": float64(3),
"disk_gb": float64(50),
"backups_enabled": true,
},
"wordpress": {
"replicas": float64(2),
},
},
}
tenantCreatedPayload := struct {
*store.Tenant
OwnerEmail string `json:"owner_email,omitempty"`
}{Tenant: tenant, OwnerEmail: "owner@example.com"}
wire, err := json.Marshal(tenantCreatedPayload)
if err != nil {
t.Fatalf("publisher marshal failed: %v", err)
}
if !strings.Contains(string(wire), `"app_configs"`) {
t.Errorf("wire bytes missing app_configs sibling: %s", string(wire))
}
var got consumerPayloadMirror
if err := json.Unmarshal(wire, &got); err != nil {
t.Fatalf("consumer decode failed: %v\nwire=%s", err, string(wire))
}
if got.AppConfigs == nil {
t.Fatalf("app_configs nil on consumer side; wire=%s", string(wire))
}
pg := got.AppConfigs["postgres"]
if pg == nil {
t.Fatalf("postgres bucket missing from app_configs; got=%+v", got.AppConfigs)
}
// JSON numbers decode as float64 into `any`; equality compares the
// canonical decoded shape, not the publisher's int literal.
if pg["replicas"] != float64(3) {
t.Errorf("postgres.replicas: got %v (%T) want 3 (float64)", pg["replicas"], pg["replicas"])
}
if pg["disk_gb"] != float64(50) {
t.Errorf("postgres.disk_gb: got %v want 50", pg["disk_gb"])
}
if pg["backups_enabled"] != true {
t.Errorf("postgres.backups_enabled: got %v want true", pg["backups_enabled"])
}
wp := got.AppConfigs["wordpress"]
if wp == nil || wp["replicas"] != float64(2) {
t.Errorf("wordpress.replicas lost; got=%+v", wp)
}
}
// TestTenantCreatedWire_EmptyAppConfigs_Omitted — omitempty tag drops
// an empty `app_configs` field so legacy clients keep observing the
// pre-TBD-V18-D wire shape. The consumer's flat decode tolerates the
// absence: app_configs == nil is a legitimate signal that the
// customer never opened an AppDetail surface with a non-empty
// configSchema.
func TestTenantCreatedWire_EmptyAppConfigs_Omitted(t *testing.T) {
tenant := &store.Tenant{
ID: "tenant-abc",
Slug: "acme",
Name: "ACME Corp",
OwnerID: "user-xyz",
PlanID: "sme-pool-basic",
// AppConfigs deliberately nil.
}
tenantCreatedPayload := struct {
*store.Tenant
OwnerEmail string `json:"owner_email,omitempty"`
}{Tenant: tenant, OwnerEmail: "owner@example.com"}
wire, err := json.Marshal(tenantCreatedPayload)
if err != nil {
t.Fatalf("publisher marshal failed: %v", err)
}
if strings.Contains(string(wire), `"app_configs"`) {
t.Errorf("expected omitempty to drop nil app_configs, but field present: %s", string(wire))
}
}

View File

@ -26,6 +26,20 @@ type Tenant struct {
// "installing" | "uninstalling" | "failed". Absent means the app is in
// its steady state (installed when the ID is in Apps; gone otherwise).
AppStates map[string]string `bson:"app_states,omitempty" json:"app_states,omitempty"`
// AppConfigs carries per-instance configSchema values chosen by
// the customer on the marketplace AppDetail surface, keyed by app
// SLUG (e.g. "postgres" or "wordpress"). The inner map keys are
// `ConfigField.Key` names (e.g. "replicas", "disk_gb",
// "backups_enabled") and values are the field-typed primitives
// (int / string / bool). Empty when no app in the cart shipped a
// configSchema (Ghost / Nextcloud today) or when the cart predates
// TBD-V18-D (#2026 follow-up to PR #2038). Down-stream consumers
// (provisioning, blueprint-controller) read this when rendering
// HelmRelease values — the actual binding lands behind the
// TBD-V26 (#2040) Path A/B decision; this field threads the
// SHAPE end-to-end so the binding lights up without a second
// upstream change.
AppConfigs map[string]map[string]any `bson:"app_configs,omitempty" json:"app_configs,omitempty"`
AddOns []string `bson:"addons" json:"addons"`
Subdomain string `bson:"subdomain" json:"subdomain"`
CustomDomains []string `bson:"custom_domains" json:"custom_domains"`