Malet-First Ownership Architecture
The Malet-First Ownership flow enforces a payment-before-provisioning architecture for Pro and Enterprise Malet Owners. Instead of allowing Organization creation before payment (which previously enabled a paywall bypass exploit), the platform now provisions Organizations and Malets atomically after Stripe checkout confirmation.
This document covers the architectural rationale, the shared store refactor, the wizard flow changes, and the completion route that handles post-checkout provisioning.
Problem: The Pre-Payment Exploit
Prior to this architecture, the Org Subscription Bootstrap flow allowed users to:
- Navigate to
/orgs/create?then=checkout&plan=pro - Create an Organization (which auto-provisioned a Starter subscription)
- Use the new Organization context to create Malets
- Never complete the Stripe checkout โ effectively getting Pro features for free
The isCheckoutFunnel flag in orgAccessUtils.ts bypassed the tier gate, trusting that the user would complete payment downstream. This trust was exploitable.
Security Fix
The isCheckoutFunnel bypass has been removed entirely. The /orgs/create route is now restricted to platform administrators. Regular users are redirected to /create-malet?tier=pro, which enforces the payment-first flow.
Architecture: Wizard Flow
The Malet Creation Wizard has been extended from 5 steps to 6 for Pro users:
Starter Flow: Vertical โ Identity โ Configure โ Plan โ Launch
Pro Flow: Vertical โ Identity โ Configure โ Plan โ Organization โ Launch & Subscribe โ
Step 6: Organization Details (Pro Only)
When a user selects the Pro tier and does not already have an Organization, a new Organization Details step is inserted before the Review & Launch step. This collects:
| Field | Description |
|---|---|
newOrgName |
Organization display name |
newOrgSlug |
URL handle (auto-derived from name, manually overridable) |
newOrgCategory |
Optional industry category |
The `needsNewOrg` Computed
The wizard dynamically determines whether the Organization step is visible:
get needsNewOrg(): boolean {
return this.tier === 'pro' && !this.isOrgContext;
}
get visibleSteps(): number[] {
if (this.isOrgContext) return [1, 2, 3, 5]; // org exists โ skip plan + org
if (this.tier === 'pro') return [1, 2, 3, 4, 6, 5]; // Pro w/o org
return [1, 2, 3, 4, 5]; // Starter โ standard
}
Architecture: Checkout Redirect
When a Pro user clicks "Launch & Subscribe โ" on the Review step, the following sequence executes:
sequenceDiagram
participant UI as Create-Malet Wizard
participant LS as localStorage
participant API as Federation API
participant Stripe as Stripe Checkout
participant WH as Webhook (payments)
participant Orgs as organizations subgraph
participant Malets as malets subgraph
UI->>LS: saveDraft() โ full wizard state
UI->>API: createMaletCheckoutSession(wizard metadata)
API->>Stripe: Create Checkout Session with metadata
Stripe-->>UI: redirect to hosted checkout
Stripe->>WH: checkout.session.completed event
WH->>Orgs: createOrganization (HTTP + x-internal-caller)
WH->>Malets: createMalet (HTTP + x-org-id)
WH->>Stripe: Update subscription metadata (orgId, maletId)
WH->>API: emit('org_and_malet_created')
Stripe-->>UI: return to /create-malet/complete
UI->>API: Poll for created org + malet
UI->>LS: clearDraft()
UI-->>UI: redirect to /{handle}/manage
Draft Persistence
The wizard state is persisted to localStorage under the key mallnline:create-malet-draft with a 48-hour TTL. This ensures the wizard can resume after:
- Stripe checkout abandonment and retry
- Browser tab closure during checkout
- Page refreshes during the wizard
The draft includes all wizard fields: tier, name, handle, tagline, vertical config, org details, and ownership context. On return, a "Draft restored from your last session" banner appears with a "Start fresh" dismiss option.
Shared Store: `@mallnline/ui`
The createMaletStore has been refactored from duplicated 616-line files in The Deck and the storefront into a single source of truth in the @mallnline/ui workspace package.
Source Export Pattern
Because Svelte 5 runes ($state, $derived) must be compiled by the consuming app's Svelte compiler, the store is exported as a source file, not pre-compiled dist:
// @mallnline/ui/package.json
{
"exports": {
"./stores/createMaletStore.svelte": {
"svelte": "./src/lib/stores/createMaletStore.svelte.ts"
}
}
}
Dependency Injection
App-specific dependencies (GraphQL client functions and Mall Sections data) are injected at runtime:
// In each app's wrapper:
import { createMaletState } from '@mallnline/ui/stores/createMaletStore.svelte';
import { MALL_SECTIONS } from '$lib/utils/mallSections';
import { reserveHandle, releaseHandleReservation } from '$lib/utils/handleReservation';
export function initCreateMaletStore() {
createMaletState.init(MALL_SECTIONS, {
reserve: (handle) => reserveHandle(handle),
release: (handle) => releaseHandleReservation(handle)
});
}
Enterprise Tier: Contact Sales
The Enterprise tier is no longer self-serve selectable in the TierPicker component. It renders as a non-interactive info card with a "Contact Sales โ" CTA that links to /contact?subject=enterprise. The pricing displays "Custom" instead of the previous hardcoded $99.
Completion Route: `/create-malet/complete`
This route handles the return from Stripe Checkout. In the current architecture (Phase 2), the actual provisioning happens server-side via the Stripe checkout.session.completed webhook โ the completion page is primarily a verification and wait screen.
Backend Atomic Flow (Phase 2 โ Live)
When the Stripe webhook fires checkout.session.completed with metadata.action === 'create_malet', the payments subgraph webhook handler orchestrates the full creation sequence:
| Step | Action | Service | Auth |
|---|---|---|---|
| 1 | Validate payment_status === 'paid' | payments | โ |
| 2 | Create Organization | organizations (HTTP) | x-internal-caller: payments |
| 3 | Create Malet under org | malets (HTTP) | x-org-id header |
| 4 | Update Stripe subscription metadata | Stripe API | STRIPE_SECRET_KEY |
| 5 | Emit notification | alerts (TCP) | internal |
The createOrganization mutation is protected by the InternalCallerGuard, which restricts access to internal services (identified by the x-internal-caller header) and Platform Administrators. Regular users can no longer call this mutation directly via GraphQL.
Slug Collision Protection
Before creating an Organization, the resolver checks for existing slugs:
if (input.slug) {
const existing = await this.orgModel.findOne({ slug }).select('id').lean().exec();
if (existing) {
throw new BadRequestException(
`The slug "${slug}" is already taken. Please choose a different one.`,
);
}
}
Orphan Cleanup
If Organization creation succeeds but Malet creation fails (e.g., network error), the org becomes an "orphan" with maletsCount === 0. The cleanup script handles these:
make cleanup-orphan-orgs # Interactive confirmation
make cleanup-orphan-orgs -- --force # Skip prompt
IMPORTANT
The bootstrap_org_subscription TCP event has been removed in Phase 2. Organizations no longer auto-provision Starter subscriptions. The get_active_tier TCP call defaults to starter when no BillingSubscription record exists, so tier enforcement continues to work correctly.
Configuration
| Variable | Value | Location |
|---|---|---|
| Draft localStorage key | mallnline:create-malet-draft |
createMaletStore.svelte.ts |
| Draft TTL | 48 hours | loadDraft() method |
| Enterprise contact URL | /contact?subject=enterprise |
TierPicker component |
Related
- Org Subscription Bootstrap & Ownership Picker โ How Organizations previously received Starter subscriptions (deprecated in Phase 2)
- Onboarding & Malet Creation Architecture โ Full wizard flow and onboarding funnel
- Subscription & Billing Architecture โ Stripe integration and plan management
- Subscription Checkout Identity & Idempotency โ Checkout session handling
- Organization Context & Tier Access โ How tier gates resolve across contexts
- Payments Subgraph โ Webhook handler and subscription service internals
- Organizations Subgraph โ InternalCallerGuard and slug collision protection
- User Guide: Creating Your Malet โ User-facing creation guide
- User Guide: Pro Plan Malet Creation โ Pro-specific user guide
- User Guide: Organization Billing & Checkout โ User-facing billing guide
- Starter to Pro Upgrade Flow โ How the atomic provisioning webhook supports upgrading existing Starter Malets.