Developer Docs

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:

  1. Navigate to /orgs/create?then=checkout&plan=pro
  2. Create an Organization (which auto-provisioned a Starter subscription)
  3. Use the new Organization context to create Malets
  4. 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