MolinoPro

landing-page-printer-api-docs

Master Codebase Guidebook
Markdown + HTML Dev-Docs Renderer - Frontend Client Module

Default Index
Open README.md
Root: README.md_PRD
Milestones
H1Landing Page Printer API Docs

Molino-index → Molino-spaces integration reference
Standalone Spaces app treated as remote page/slider/render service


H2Table of Contents
H21. Architecture Overview
┌─────────────────────┐         POST /api/spaces/upsert         ┌──────────────────────┐
│   Molino-index      │ ──────────────────────────────────────► │   Molino-spaces      │
│   (Main App)        │                                         │   (Standalone App)   │
│                     │ ◄────────────────────────────────────── │                      │
│   - Trips           │          { spaceId, publicUrl }         │   - Space rendering  │
│   - Experiences     │                                         │   - Section registry │
│   - Offers          │                                         │   - Slider engine    │
│   - Orders          │                                         │   - Route aliases    │
│   - Users           │                                         │   - Publish/validate │
│   - Payments        │                                         │   - Public landing   │
│   - Bookings        │                                         └──────────────────────┘
│   - FareHarbor      │
└─────────────────────┘

Rule: Molino sends projection payloads. Spaces owns rendering and route publishing. Molino stores projection metadata only.


H22. Authentication

Spaces supports two auth methods on all API routes:

H3Method A — JWT Bearer Token
Authorization: Bearer <JWT_TOKEN>
  • 2-hour expiry
  • Tied to a User record in Spaces DB
  • Use when you have a real user session
H3Method B — Static API Key (Recommended for server-to-server)
Authorization: ApiKey <API_KEY>
  • Set API_KEY in Spaces .env
  • Returns synthetic user: { id: "api", email: "api@system", name: "API User" }
  • Use for Molino → Spaces projection calls
H3Method C — Shared Bearer Secret (Recommended for new /api/spaces/upsert)
Authorization: Bearer <SPACES_API_TOKEN>
  • Single shared secret between Molino and Spaces
  • Set SPACES_API_TOKEN in both apps' .env
  • Simpler than JWT for service-to-service

H23. External API Endpoints
H3Existing Endpoints
MethodPathAuthPurpose
GET/api/spacesRequiredList all spaces for authenticated user
POST/api/spacesRequiredCreate a new space with sections
GET/api/spaces/{id}RequiredGet single space with sections + routes
PATCH/api/spaces/{id}RequiredUpdate space title, status, sections
DELETE/api/spaces/{id}RequiredDelete a space
POST/api/spaces/{id}/publishRequiredValidate and publish a space
POST/api/spaces/fixOptionalAuto-fix a broken space
POST/api/admin/seed-from-templateRequiredSeed a space from a template
GET/api/seedNoneSeed database with portfolio spaces (dev only)

H24. Recommended New Endpoints (to implement)

These endpoints should be added to Spaces to support the Molino integration cleanly.

H34.1 `POST /api/spaces/upsert` — Create or Update by External Reference

Auth: Authorization: Bearer <SPACES_API_TOKEN>

Request Body:

{
  "externalRef": {
    "app": "molino",
    "entity": "trip",
    "entityId": "trip_abc123"
  },
  "space": {
    "title": "Andalusia Heritage Tour",
    "slug": "andalusia-heritage-tour",
    "description": "7-day cultural experience",
    "visibility": "public"
  },
  "routes": [
    {
      "path": "/trips/andalusia-heritage",
      "kind": "root",
      "isPrimary": true,
      "enabled": true
    }
  ],
  "sections": [
    {
      "key": "hero_001",
      "type": "hero",
      "order": 0,
      "enabled": true,
      "content": {
        "title": "Discover Andalusia",
        "subtitle": "7 days of authentic Spanish culture",
        "backgroundImage": "https://picsum.photos/seed/andalusia/1200/600",
        "ctaText": "Book Now",
        "ctaLink": "/book"
      }
    }
  ]
}

Response (200):

{
  "success": true,
  "space": {
    "id": "space_xyz",
    "title": "Andalusia Heritage Tour",
    "slug": "andalusia-heritage-tour",
    "status": "draft",
    "visibility": "public",
    "publicUrl": "/trips/andalusia-heritage"
  },
  "action": "created"
}

Response (200) — Updated existing:

{
  "success": true,
  "space": { ... },
  "action": "updated"
}

H34.2 `POST /api/spaces/publish` — Publish by External Reference

Auth: Authorization: Bearer <SPACES_API_TOKEN>

Request Body:

{
  "externalRef": {
    "app": "molino",
    "entity": "trip",
    "entityId": "trip_abc123"
  }
}

Response (200):

{
  "success": true,
  "space": {
    "id": "space_xyz",
    "status": "published",
    "publishedAt": "2026-05-05T12:00:00.000Z",
    "publicUrl": "/trips/andalusia-heritage"
  }
}

Response (422) — Validation failed:

{
  "success": false,
  "errors": [
    {
      "sectionKey": "hero_001",
      "field": "title",
      "message": "Title is required"
    }
  ]
}

H34.3 `POST /api/spaces/unpublish` — Unpublish by External Reference

Auth: Authorization: Bearer <SPACES_API_TOKEN>

Request Body:

{
  "externalRef": {
    "app": "molino",
    "entity": "trip",
    "entityId": "trip_abc123"
  }
}

H34.4 `GET /api/spaces/by-external-ref` — Lookup by External Reference

Auth: Authorization: Bearer <SPACES_API_TOKEN>

Query Params: ?app=molino&entity=trip&entityId=trip_abc123

Response (200):

{
  "success": true,
  "space": {
    "id": "space_xyz",
    "title": "Andalusia Heritage Tour",
    "slug": "andalusia-heritage-tour",
    "status": "published",
    "visibility": "public",
    "publicUrl": "/trips/andalusia-heritage",
    "publishedAt": "2026-05-05T12:00:00.000Z",
    "sectionCount": 5
  }
}

Response (404):

{
  "success": false,
  "error": "No space found for molino:trip:trip_abc123"
}

H25. Prisma Schema
H3Current Models
model User {
  id        String   @id @default(cuid())
  email     String   @unique
  password  String
  name      String?
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  spaces    Space[]
  personas  Persona[]
}

model Space {
  id                  String            @id @default(cuid())
  ownerId             String
  owner               User              @relation(fields: [ownerId], references: [id])
  title               String
  slug                String
  status              String            @default("draft")
  visibility          String            @default("private")
  version             Int               @default(1)
  locale              String            @default("en")
  data                String?
  lastValidationError String?
  publishedAt         DateTime?
  archivedAt          DateTime?
  scheduledAt         DateTime?
  createdAt           DateTime          @default(now())
  updatedAt           DateTime          @updatedAt
  sections            SpaceSection[]
  routes              SpaceRouteAlias[]
  externalRefs        SpaceExternalRef[]  // ADD THIS
}

model SpaceSection {
  id               String   @id @default(cuid())
  spaceId          String
  space            Space    @relation(fields: [spaceId], references: [id], onDelete: Cascade)
  key              String
  type             String
  order            Int
  enabled          Boolean  @default(true)
  content          String   // JSON string
  settings         String?  // JSON string (currently null for all types)
  validationStatus String   @default("valid")
  validationErrors String?  // JSON string
  createdAt        DateTime @default(now())
  updatedAt        DateTime @updatedAt
}

model SpaceRouteAlias {
  id              String   @id @default(cuid())
  spaceId         String
  space           Space    @relation(fields: [spaceId], references: [id], onDelete: Cascade)
  path            String   @unique
  pathFingerprint String   @unique
  kind            String   @default("root")
  enabled         Boolean  @default(true)
  isPrimary       Boolean  @default(false)
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt
}
H3ADD: SpaceExternalRef Model
model SpaceExternalRef {
  id        String   @id @default(cuid())
  spaceId   String
  app       String
  entity    String
  entityId  String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  space     Space    @relation(fields: [spaceId], references: [id], onDelete: Cascade)
  @@unique([app, entity, entityId])
  @@index([spaceId])
}

Why: Enables idempotent upserts. Molino can call /api/spaces/upsert with the same externalRef and Spaces will find the existing Space and update it instead of creating duplicates.

H3TypeScript Types
type SpaceStatus = "draft" | "published" | "scheduled" | "archived" | "broken"
type SpaceVisibility = "private" | "unlisted" | "public"
type SpaceRouteKind = "root" | "domain" | "private_preview"
type SpaceSectionValidationStatus = "valid" | "invalid" | "warning"

H26. Section Type Registry

17 section types are available. Each stores content as a JSON string in the database. settings is currently null for all types.

H3Section Type Summary Table
#TypeKey FieldsUse Case
1herotitle, subtitle, backgroundImage, ctaText, ctaLinkHero banner with CTA
2rich_texthtmlArbitrary HTML content
3cardsitems[]Card grid with images + links
4ctasubtitle, content, ctaText, ctaLink, mediaUrlSingle call-to-action block
5slider_nativeitems[]Native image carousel
6featurestitle, subtitle, items[]Feature list
7testimonialstitle, subtitle, content, items[]Testimonial quotes
8pricingtitle, subtitle, content, plans[]Pricing tiers
9faqsubtitle, content, faqs[]FAQ accordion
10footertextFooter text
11postertitle, subtitle, date, time, location, image, theme, badgeTextEvent poster
12slider_htmlslides[], style, autoPlay, intervalHTML-based slider
13mission_statementtitle, contentMission/about text
14feature_gridtitle, items[]Grid with icons
15tour_cardsitems[]Tour/experience cards with duration + price
16destination_gridtitle, items[]Destination image grid
17itinerarytitle, days[]Day-by-day itinerary
H3Section Key Format
${type}_${timestamp}_${index}

Example: hero_1714867200000_0

Generate keys server-side. The timestamp can be Date.now().


H27. JSON Payload Patterns Per Section
H37.1 `hero`
{
  "key": "hero_1714867200000_0",
  "type": "hero",
  "order": 0,
  "enabled": true,
  "content": {
    "title": "Discover Andalusia",
    "subtitle": "7 days of authentic Spanish culture, history and cuisine",
    "backgroundImage": "https://picsum.photos/seed/andalusia-hero/1200/600",
    "ctaText": "Book Now",
    "ctaLink": "/book"
  },
  "settings": null
}

Validation (Zod):

  • title: required, min 1 char
  • subtitle: optional
  • backgroundImage: optional, must be valid URL or empty string
  • ctaText: optional
  • ctaLink: optional

H37.2 `rich_text`
{
  "key": "rich_text_1714867200001_1",
  "type": "rich_text",
  "order": 1,
  "enabled": true,
  "content": {
    "html": "<p>Join us for an unforgettable evening of flamenco, traditional cuisine, and local artisan crafts.</p>"
  },
  "settings": null
}

Validation:

  • html: required string

H37.3 `cards`
{
  "key": "cards_1714867200002_2",
  "type": "cards",
  "order": 2,
  "enabled": true,
  "content": {
    "items": [
      {
        "title": "Ceramic Vases",
        "description": "Hand-painted traditional designs",
        "image": "https://picsum.photos/seed/vases/400/300",
        "link": "/shop/vases"
      },
      {
        "title": "Textile Throws",
        "description": "Alpujarran woven blankets",
        "image": "https://picsum.photos/seed/throws/400/300",
        "link": "/shop/throws"
      }
    ]
  },
  "settings": null
}

Validation:

  • items: required array
  • Each item: title (required), description (required), image (optional URL), link (optional)

H37.4 `cta`
{
  "key": "cta_1714867200003_3",
  "type": "cta",
  "order": 3,
  "enabled": true,
  "content": {
    "subtitle": "Ready to start?",
    "content": "Invite the audience to take the next step with a direct, purposeful CTA.",
    "ctaText": "Join Now",
    "ctaLink": "#",
    "mediaUrl": ""
  },
  "settings": null
}

Validation:

  • ctaText: required
  • ctaLink: required
  • All others optional

H37.5 `slider_native`
{
  "key": "slider_native_1714867200004_4",
  "type": "slider_native",
  "order": 4,
  "enabled": true,
  "content": {
    "items": [
      {
        "image": "https://picsum.photos/seed/ceramics/800/600",
        "title": "Handmade Ceramics",
        "description": "Traditional techniques"
      },
      {
        "image": "https://picsum.photos/seed/textiles/800/600",
        "title": "Woven Textiles",
        "description": "Natural fibers"
      },
      {
        "image": "https://picsum.photos/seed/leather/800/600",
        "title": "Leather Goods",
        "description": "Artisan crafted"
      }
    ]
  },
  "settings": null
}

Validation:

  • items: required array
  • Each item: image (required URL), title (optional), description (optional)

H37.6 `features`
{
  "key": "features_1714867200005_5",
  "type": "features",
  "order": 5,
  "enabled": true,
  "content": {
    "title": "Festival Highlights",
    "subtitle": "What awaits you",
    "items": [
      { "title": "Live Flamenco", "description": "Authentic performances by local artists" },
      { "title": "Artisan Market", "description": "Handmade crafts and traditional goods" },
      { "title": "Gastronomy", "description": "Taste the flavors of Andalusia" }
    ]
  },
  "settings": null
}

Validation:

  • items: required array
  • Each item: title (required), description (required)
  • title, subtitle: optional

H37.7 `testimonials`
{
  "key": "testimonials_1714867200006_6",
  "type": "testimonials",
  "order": 6,
  "enabled": true,
  "content": {
    "title": "Past Attendees",
    "subtitle": "What people say",
    "content": "Testimonials increase trust and help visitors feel safer.",
    "items": [
      {
        "quote": "The most authentic cultural experience in Spain.",
        "author": "Maria L.",
        "role": "Visitor",
        "avatar": "https://i.pravatar.cc/150",
        "logo": ""
      },
      {
        "quote": "Incredible atmosphere and amazing performances.",
        "author": "James T.",
        "role": "Traveler",
        "avatar": "",
        "logo": ""
      }
    ]
  },
  "settings": null
}

Validation:

  • items: required array
  • Each item: quote (required), author (required), role (required), avatar (optional URL), logo (optional)

H37.8 `pricing`
{
  "key": "pricing_1714867200007_7",
  "type": "pricing",
  "order": 7,
  "enabled": true,
  "content": {
    "title": "Tour Packages",
    "subtitle": "Choose your adventure",
    "content": "Present clear, transparent pricing aligned with your value.",
    "plans": [
      {
        "name": "Essential",
        "price": "349",
        "period": "per person",
        "description": "Core experience",
        "features": ["3-day tour", "Hotel included", "Expert guide"],
        "buttonText": "Book",
        "buttonLink": "/book/essential"
      },
      {
        "name": "Complete",
        "price": "899",
        "period": "per person",
        "description": "Full immersion",
        "features": ["7-day tour", "Boutique hotels", "All meals", "Private transport", "Wine tasting"],
        "buttonText": "Book",
        "buttonLink": "/book/complete",
        "isPopular": true
      }
    ]
  },
  "settings": null
}

Validation:

  • plans: required array
  • Each plan: name (required), price (required), period (required), description (required), features (required string array), buttonText (required), buttonLink (required), isPopular (optional boolean)

H37.9 `faq`
{
  "key": "faq_1714867200008_8",
  "type": "faq",
  "order": 8,
  "enabled": true,
  "content": {
    "subtitle": "Everything you need to know",
    "content": "",
    "faqs": [
      {
        "question": "What is included in the price?",
        "answer": "All tours include accommodation, guided visits, entry tickets, and most meals."
      },
      {
        "question": "Is this suitable for families?",
        "answer": "Absolutely! Our tours are designed for all ages."
      }
    ]
  },
  "settings": null
}

Validation:

  • faqs: required array
  • Each FAQ: question (required), answer (required)

H37.10 `footer`
{
  "key": "footer_1714867200009_9",
  "type": "footer",
  "order": 9,
  "enabled": true,
  "content": {
    "text": "© 2026 Heritage Festival. All rights reserved."
  },
  "settings": null
}

Validation:

  • text: required string

H37.11 `poster`
{
  "key": "poster_1714867200010_10",
  "type": "poster",
  "order": 10,
  "enabled": true,
  "content": {
    "title": "HERITAGE FESTIVAL",
    "subtitle": "CELEBRATING TRADITION",
    "date": "15 JUNIO",
    "time": "18:00 H",
    "location": "PLAZA MAYOR, GRANADA",
    "image": "https://picsum.photos/seed/heritage-poster/800/600",
    "theme": "yellow",
    "badgeText": "ENTRADA LIBRE"
  },
  "settings": null
}

Validation:

  • title: required
  • theme: required, must be "yellow" | "blue" | "bw"
  • All others optional

Settings (optional):

{ "padding": "normal" }
  • padding: "normal" | "large" | "fullscreen"

H37.12 `slider_html`
{
  "key": "slider_html_1714867200011_11",
  "type": "slider_html",
  "order": 11,
  "enabled": true,
  "content": {
    "slides": [
      {
        "image": "https://picsum.photos/1200/600",
        "title": "Slide 1",
        "description": "First slide",
        "link": "",
        "linkText": "Learn More"
      },
      {
        "image": "https://picsum.photos/1200/601",
        "title": "Slide 2",
        "description": "Second slide",
        "link": "",
        "linkText": "Learn More"
      }
    ],
    "style": "hero",
    "autoPlay": true,
    "interval": 5000
  },
  "settings": null
}

Validation:

  • slides: required array
  • Each slide: image (required), title (optional), description (optional), link (optional), linkText (optional)
  • style: "hero" | "poster" | "minimal"
  • autoPlay: boolean
  • interval: number (milliseconds)

H37.13 `mission_statement`
{
  "key": "mission_statement_1714867200012_12",
  "type": "mission_statement",
  "order": 12,
  "enabled": true,
  "content": {
    "title": "Our Mission",
    "content": "To celebrate and preserve the rich cultural heritage of Andalusia through immersive experiences."
  },
  "settings": null
}

Validation:

  • content: required string
  • title: optional

H37.14 `feature_grid`
{
  "key": "feature_grid_1714867200013_13",
  "type": "feature_grid",
  "order": 13,
  "enabled": true,
  "content": {
    "title": "Why Choose Us",
    "items": [
      { "title": "Expert Guides", "description": "Local experts with 10+ years experience", "icon": "star" },
      { "title": "Best Price", "description": "Competitive rates with no hidden fees", "icon": "dollar" },
      { "title": "Flexible Booking", "description": "Free cancellation up to 48 hours", "icon": "calendar" }
    ]
  },
  "settings": null
}

Validation:

  • items: required array
  • Each item: title (required), description (required), icon (optional)

H37.15 `tour_cards`
{
  "key": "tour_cards_1714867200014_14",
  "type": "tour_cards",
  "order": 14,
  "enabled": true,
  "content": {
    "items": [
      {
        "title": "City Explorer",
        "description": "Seville & Cordoba highlights",
        "image": "https://picsum.photos/seed/city-tour/400/300",
        "duration": "3 days",
        "price": "349"
      },
      {
        "title": "Complete Tour",
        "description": "All Andalusia highlights",
        "image": "https://picsum.photos/seed/complete/400/300",
        "duration": "7 days",
        "price": "899"
      }
    ]
  },
  "settings": null
}

Validation:

  • items: required array
  • Each item: title (required), description (required), image (optional URL), duration (optional), price (optional)

H37.16 `destination_grid`
{
  "key": "destination_grid_1714867200015_15",
  "type": "destination_grid",
  "order": 15,
  "enabled": true,
  "content": {
    "title": "Destinations",
    "items": [
      { "name": "Seville", "image": "https://picsum.photos/seed/seville/400/300", "description": "Capital of Andalusia" },
      { "name": "Cordoba", "image": "https://picsum.photos/seed/cordoba/400/300", "description": "Great Mosque & patios" },
      { "name": "Granada", "image": "https://picsum.photos/seed/granada/400/300", "description": "Alhambra Palace" }
    ]
  },
  "settings": null
}

Validation:

  • items: required array
  • Each item: name (required), image (required URL), description (optional)

H37.17 `itinerary`
{
  "key": "itinerary_1714867200016_16",
  "type": "itinerary",
  "order": 16,
  "enabled": true,
  "content": {
    "title": "Your Journey",
    "days": [
      {
        "day": "1",
        "title": "Arrival in Seville",
        "activities": ["Airport pickup", "Welcome dinner", "Evening walk"]
      },
      {
        "day": "2",
        "title": "Seville Explorer",
        "activities": ["Real Alcazar", "Cathedral tour", "Tapas crawl"]
      },
      {
        "day": "3",
        "title": "Cordoba Day",
        "activities": ["Great Mosque", "Jewish Quarter", "Flamenco show"]
      }
    ]
  },
  "settings": null
}

Validation:

  • days: required array
  • Each day: day (required string), title (required), activities (required string array)

H28. Slider Engine Data Structures
H3SliderLayer
{
  "id": "layer-badge-1",
  "type": "shape",
  "position": { "x": "l", "y": "t" },
  "content": "DISCOVER",
  "style": {
    "backgroundColor": "#b8db1b",
    "color": "#fff",
    "padding": "8px 16px",
    "borderRadius": "0 20px 20px 0"
  },
  "animations": {
    "initial": { "opacity": 0, "y": 20 },
    "animate": { "opacity": 1, "y": 0 },
    "transition": { "delay": 0.3, "duration": 0.6 }
  }
}

Layer Types: "text" | "image" | "button" | "shape" | "group"
Position X: "c" | "l" | "r" | "left" | "right"
Position Y: "c" | "t" | "m" | "b" | "top" | "bottom" | "center"

H3SliderConfig (full)
{
  "id": "travellerCarousel1",
  "type": "carousel",
  "layout": "fullwidth",
  "responsiveLevels": [640, 768, 1024, 1280],
  "gridWidth": [320, 640, 768, 1024, 1280],
  "gridHeight": [300, 400, 500, 600],
  "autoRotate": true,
  "rotateInterval": 5000,
  "slides": [
    {
      "backgroundImage": "https://alandalus-experience.com/travel/wp-content/uploads/2021/10/hero16-1600x900.jpg",
      "layers": [
        {
          "id": "badge-1",
          "type": "shape",
          "position": { "x": "l", "y": "t" },
          "content": "DISCOVER",
          "style": { "backgroundColor": "#b8db1b", "color": "#fff", "padding": "8px 16px" }
        },
        {
          "id": "title-1",
          "type": "text",
          "position": { "x": "l", "y": "m" },
          "content": "Al-Andalus Experience",
          "style": { "fontSize": "48px", "fontWeight": "bold", "color": "#fff" }
        }
      ]
    }
  ]
}

Slider Types: "hero" | "carousel"
Layout: "fullwidth" | "fullscreen"


H29. Seed Templates
H3TemplateSeed Interface
interface TemplateSeed {
  id: string;
  name: string;
  description: string;
  source: "portfolio" | "appscript" | "molino";
  thumbnail?: string;
  spaceData: {
    title: string;
    slug: string;
    description: string;
    status: "draft" | "published";
    sections: Array<{
      type: string;
      content: any;
    }>;
  };
}
H3Available Templates
IDNameSourceSection CountSections
cultural-eventCultural Eventportfolio7poster, mission_statement, rich_text, features, testimonials, cta, footer
travel-experienceTravel Experienceportfolio7hero, itinerary, destination_grid, tour_cards, pricing, faq, cta
product-showcaseProduct Showcaseportfolio8poster, slider_native, cards, feature_grid, testimonials, pricing, faq, footer
creative-portfolioCreative Portfolioportfolio8poster, slider_native, rich_text, features, cards, testimonials, cta, footer
business-servicesBusiness Servicesportfolio9hero, mission_statement, feature_grid, features, testimonials, pricing, faq, cta, footer
H3Template Seeding via API
POST /api/admin/seed-from-template
Authorization: Bearer <JWT> or ApiKey <KEY>

{
  "templateId": "travel-experience",
  "userEmail": "admin@molino.com"
}

Response:

{
  "success": true,
  "space": {
    "id": "space_xyz",
    "title": "Travel Experience",
    "slug": "travel-experience-2",
    "status": "draft",
    "publicUrl": "/travel-experience-2"
  }
}

H210. Route & Alias Management
H3Path Normalization Rules
  • Must start with /
  • No spaces, no double slashes
  • Max 100 characters
  • Auto-lowercased and trimmed
  • Spaces converted to hyphens
  • Multiple hyphens collapsed

Examples:

InputOutput
My Cool Page/my-cool-page
/Trips/Andalusia 2026//trips/andalusia-2026
H3Reserved Paths (cannot be used)

/, /trips, /studio, /spaces, /offers, /orders, /api, /admin

H3SpaceRouteAlias Fields
FieldTypeValuesDescription
pathStringuniqueFull URL path, e.g. /my-page
pathFingerprintStringuniqueNormalized, e.g. my-page
kindStringroot, domain, private_previewRoute type
enabledBooleanWhether route is active
isPrimaryBooleanOne primary route per space
H3Route Resolution Flow
  1. Request hits /[...slug]/page.tsx
  2. Path normalized + fingerprint generated
  3. Query SpaceRouteAlias by pathFingerprint where enabled: true
  4. Return associated Space with sections
  5. Visibility check: non-owners only see published spaces

H211. Publish Workflow
H3Steps
  1. Load space with sections and routes
  2. Validate each section using Zod schemas
  3. Validate at least one route exists
  4. If validation fails:
    • Set status = "broken"
    • Set lastValidationError = JSON.stringify(errors)
    • Return errors to caller
  5. If validation passes:
    • Set status = "published"
    • Set publishedAt = new Date()
    • Clear lastValidationError
    • Revalidate all routes
H3Status Lifecycle
draft ──publish──► published
  │                    │
  │ fix                │ unpublish
  ▼                    ▼
draft ◄───────────── draft
  │
  │ archive
  ▼
archived ──unarchive──► draft
H3Broken Space Auto-Fix
POST /api/spaces/fix

{ "spaceId": "space_xyz" }

Fixes:

  • Missing routes → creates default route
  • Missing sections → seeds base sections
  • Invalid content → marks for review
  • Missing primary route → assigns first route as primary
  • Broken status → resets to draft

H212. Molino-side Integration Guide
H312.1 Environment Variables

In Molino .env:

SPACES_API_URL=https://spaces-next-app-3kui34kvnq-ew.a.run.app
SPACES_API_TOKEN=your-shared-secret-here

In Spaces .env:

SPACES_API_TOKEN=your-shared-secret-here
API_KEY=your-shared-secret-here
H312.2 Molino Server Action — `projectToSpace`
"use server";

type ProjectToSpaceInput = {
  entity: "trip" | "experience" | "offer" | "order" | "partner" | "product";
  entityId: string | number;
  title: string;
  slug: string;
  routePath: string;
  sections: Array<{
    key: string;
    type: string;
    order: number;
    enabled?: boolean;
    content: unknown;
    settings?: unknown;
  }>;
};

export async function projectToSpace(input: ProjectToSpaceInput) {
  const baseUrl = process.env.SPACES_API_URL;
  const token = process.env.SPACES_API_TOKEN;

  if (!baseUrl) throw new Error("SPACES_API_URL is not configured");
  if (!token) throw new Error("SPACES_API_TOKEN is not configured");

  const response = await fetch(`${baseUrl}/api/spaces/upsert`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({
      externalRef: {
        app: "molino",
        entity: input.entity,
        entityId: String(input.entityId),
      },
      space: {
        title: input.title,
        slug: input.slug,
        visibility: "public",
      },
      routes: [
        {
          path: input.routePath,
          kind: input.routePath.split("/").filter(Boolean).length > 1 ? "domain" : "root",
          isPrimary: true,
          enabled: true,
        },
      ],
      sections: input.sections,
    }),
    cache: "no-store",
  });

  if (!response.ok) {
    const body = await response.text();
    throw new Error(`Spaces projection failed: ${body}`);
  }

  return response.json();
}
H312.3 Trip-to-Space Section Mapper
export function computeTripSpaceSections(trip: any) {
  const ts = Date.now();
  let order = 0;

  return [
    {
      key: `hero_${ts}_${order}`,
      type: "hero",
      order: order++,
      enabled: true,
      content: {
        title: trip.title ?? "Private Andalusia Trip",
        subtitle: trip.summary ?? "A flexible route through cities, experiences, and local sessions.",
        backgroundImage: trip.mainImage
          ? trip.mainImage
          : "https://picsum.photos/seed/default-trip/1200/600",
        ctaText: "Request this trip",
        ctaLink: `/trips/${trip.id}`,
      },
    },
    {
      key: `rich_text_${ts}_${order}`,
      type: "rich_text",
      order: order++,
      enabled: true,
      content: {
        html: `<p>${trip.description ?? trip.summary ?? "Trip details coming soon."}</p>`,
      },
    },
    {
      key: `itinerary_${ts}_${order}`,
      type: "itinerary",
      order: order++,
      enabled: !!trip.itinerary?.length,
      content: {
        title: "Your Journey",
        days: (trip.itinerary ?? []).map((day: any, i: number) => ({
          day: String(i + 1),
          title: day.title ?? `Day ${i + 1}`,
          activities: day.activities ?? [],
        })),
      },
    },
    {
      key: `tour_cards_${ts}_${order}`,
      type: "tour_cards",
      order: order++,
      enabled: !!trip.experiences?.length,
      content: {
        items: (trip.experiences ?? []).map((exp: any) => ({
          title: exp.title,
          description: exp.description ?? "",
          image: exp.image ?? "",
          duration: exp.duration ?? "",
          price: exp.price ?? "",
        })),
      },
    },
    {
      key: `cta_${ts}_${order}`,
      type: "cta",
      order: order++,
      enabled: true,
      content: {
        subtitle: "Continue planning",
        content: "Use the main app to request, customize, or confirm this trip.",
        ctaText: "Open trip",
        ctaLink: `/trips/${trip.id}`,
        mediaUrl: "",
      },
    },
  ];
}
H312.4 Usage Flow
1. Admin clicks "Generate Space Page" on Trip detail
2. Molino calls computeTripSpaceSections(trip)
3. Molino calls projectToSpace({ ...sections, entity: "trip", entityId: trip.id })
4. Spaces creates/updates Space via upsert endpoint
5. Spaces returns { spaceId, publicUrl }
6. Molino stores projection metadata on Trip model:

   projectionMeta: {
     spaceId: "space_xyz",
     publicUrl: "/trips/andalusia-heritage",
     lastProjectedAt: "2026-05-05T12:00:00.000Z",
     status: "draft" | "published"
   }

7. Admin can click "Open Space" → navigate to Spaces publicUrl
8. Admin can click "Publish Space" → Molino calls /api/spaces/publish
H312.5 What Molino Does NOT Need
  • Space editor UI
  • Route alias editor
  • Slider UI
  • Section builder controls
  • Raw Space Prisma models
  • Page builder dashboard
H312.6 What Molino Only Needs
  • Spaces API client (fetch wrapper)
  • projectToSpace server action
  • Entity-to-space section mappers (computeTripSpaceSections, etc.)
  • Stored projection metadata on entity models
  • Optional "Open Space" button
  • Optional "Regenerate Space" button

H213. AppScript / Automated Seeding
H313.1 Google Apps Script — Seed Space from Molino Data

Use this script to push landing page data from Google Sheets (or any Apps Script source) directly into Spaces.

/**
 * Google Apps Script — Seed a Space in Molino-spaces
 * 
 * Prerequisites:
 * - Set SPACES_API_URL and SPACES_API_TOKEN in script properties
 * - Prepare trip/landing page data in Google Sheet
 */

function seedSpaceFromSheet() {
  const SPACES_API_URL = PropertiesService.getScriptProperties().getProperty("SPACES_API_URL");
  const SPACES_API_TOKEN = PropertiesService.getScriptProperties().getProperty("SPACES_API_TOKEN");
  
  if (!SPACES_API_URL || !SPACES_API_TOKEN) {
    Logger.log("Error: Set SPACES_API_URL and SPACES_API_TOKEN in Script Properties");
    return;
  }

  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("LandingPages");
  const data = sheet.getDataRange().getValues();
  const headers = data[0];
  const rows = data.slice(1);

  rows.forEach(function(row) {
    var payload = buildPayload(row, headers);
    if (!payload) return;
    
    var response = upsertSpace(SPACES_API_URL, SPACES_API_TOKEN, payload);
    Logger.log("Result: " + JSON.stringify(response));
  });
}

function buildPayload(row, headers) {
  var map = {};
  headers.forEach(function(h, i) { map[h] = row[i]; });
  
  if (!map["Title"] || !map["Slug"]) return null;
  
  var ts = Date.now();
  var order = 0;
  var sections = [];

  // Hero section
  sections.push({
    key: "hero_" + ts + "_" + order,
    type: "hero",
    order: order++,
    enabled: true,
    content: {
      title: map["Title"],
      subtitle: map["Subtitle"] || "",
      backgroundImage: map["HeroImage"] || "",
      ctaText: map["CTAText"] || "Learn More",
      ctaLink: map["CTALink"] || "#"
    }
  });

  // Rich text section
  if (map["BodyHTML"]) {
    sections.push({
      key: "rich_text_" + ts + "_" + order,
      type: "rich_text",
      order: order++,
      enabled: true,
      content: {
        html: map["BodyHTML"]
      }
    });
  }

  // Itinerary section (parse from pipe-separated)
  if (map["Itinerary"]) {
    var days = parseItinerary(map["Itinerary"]);
    sections.push({
      key: "itinerary_" + ts + "_" + order,
      type: "itinerary",
      order: order++,
      enabled: true,
      content: {
        title: "Your Journey",
        days: days
      }
    });
  }

  // CTA section
  sections.push({
    key: "cta_" + ts + "_" + order,
    type: "cta",
    order: order++,
    enabled: true,
    content: {
      subtitle: map["CTASubtitle"] || "",
      content: map["CTABody"] || "",
      ctaText: map["CTAText"] || "Book Now",
      ctaLink: map["CTALink"] || "#",
      mediaUrl: ""
    }
  });

  // Footer
  sections.push({
    key: "footer_" + ts + "_" + order,
    type: "footer",
    order: order++,
    enabled: true,
    content: {
      text: map["FooterText"] || "© 2026 Molino. All rights reserved."
    }
  });

  return {
    externalRef: {
      app: "molino",
      entity: map["EntityType"] || "trip",
      entityId: map["EntityId"] || map["Slug"]
    },
    space: {
      title: map["Title"],
      slug: map["Slug"],
      visibility: map["Visibility"] || "public"
    },
    routes: [
      {
        path: "/" + map["Slug"],
        kind: "root",
        isPrimary: true,
        enabled: true
      }
    ],
    sections: sections
  };
}

function parseItinerary(raw) {
  // Format: "Day 1: Title | Activity 1, Activity 2 || Day 2: Title | Activity 1"
  var dayBlocks = raw.split("||");
  return dayBlocks.map(function(block, i) {
    var parts = block.trim().split("|");
    var dayTitle = parts[0] ? parts[0].trim() : "Day " + (i + 1);
    var activities = parts[1] ? parts[1].split(",").map(function(a) { return a.trim(); }) : [];
    return {
      day: String(i + 1),
      title: dayTitle,
      activities: activities
    };
  });
}

function upsertSpace(baseUrl, token, payload) {
  var options = {
    method: "post",
    contentType: "application/json",
    headers: {
      "Authorization": "Bearer " + token
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true
  };

  var response = UrlFetchApp.fetch(baseUrl + "/api/spaces/upsert", options);
  var code = response.getResponseCode();
  var body = response.getContentText();
  
  if (code >= 400) {
    Logger.log("Error " + code + ": " + body);
    return null;
  }
  
  return JSON.parse(body);
}
H313.2 Google Sheet Column Headers
ColumnRequiredDescriptionExample
TitleYesPage title"Andalusia Heritage Tour"
SlugYesURL slug"andalusia-heritage-tour"
SubtitleNoHero subtitle"7 days of culture"
HeroImageNoHero background URLhttps://picsum.photos/...
CTATextNoCTA button text"Book Now"
CTALinkNoCTA destination"/book"
CTASubtitleNoCTA section subtitle"Ready to go?"
CTABodyNoCTA section body"Start your journey today."
BodyHTMLNoHTML body content<p>Trip details...</p>
ItineraryNoPipe-separated days`Day 1: Arrival
FooterTextNoFooter text"© 2026 Molino"
EntityTypeNoMolino entity type"trip", "experience", "offer"
EntityIdNoMolino entity ID"trip_abc123"
VisibilityNoPage visibility"public", "private", "unlisted"
H313.3 Apps Script Setup
  1. Open Google Sheet → Extensions → Apps Script
  2. Paste the script above
  3. Go to Project Settings → Script Properties
  4. Add:
    • SPACES_API_URL = https://spaces-next-app-3kui34kvnq-ew.a.run.app
    • SPACES_API_TOKEN = your-shared-secret
  5. Create sheet named LandingPages with headers from section 13.2
  6. Run seedSpaceFromSheet()
H313.4 Template Seeding via cURL
# Seed from built-in template
curl -X POST "https://spaces-next-app-3kui34kvnq-ew.a.run.app/api/admin/seed-from-template" \
  -H "Content-Type: application/json" \
  -H "Authorization: ApiKey ${SPACES_API_TOKEN}" \
  -d '{
    "templateId": "travel-experience",
    "userEmail": "admin@molino.com"
  }'

# Seed from built-in template (cultural event)
curl -X POST "https://spaces-next-app-3kui34kvnq-ew.a.run.app/api/admin/seed-from-template" \
  -H "Content-Type: application/json" \
  -H "Authorization: ApiKey ${SPACES_API_TOKEN}" \
  -d '{
    "templateId": "cultural-event",
    "userEmail": "admin@molino.com"
  }'
H313.5 Programmatic Template Creation

To add new templates from Molino or AppScript, add entries to lib/templates.ts:

{
  id: "my-custom-template",
  name: "My Custom Template",
  description: "Custom landing page for X department",
  source: "appscript",
  thumbnail: "https://picsum.photos/seed/custom/400/300",
  spaceData: {
    title: "Custom Template",
    slug: "custom-template",
    description: "Generated from AppScript",
    status: "draft",
    sections: [
      { type: "hero", content: { title: "Custom Hero", subtitle: "" } },
      { type: "rich_text", content: { html: "<p>Custom content</p>" } },
      { type: "cta", content: { ctaText: "Action", ctaLink: "#" } },
      { type: "footer", content: { text: "© 2026" } }
    ]
  }
}

H214. Environment Variables
H3Spaces (standalone app)
VariableRequiredDescription
DATABASE_URLYesPostgreSQL connection string
API_KEYYesStatic API key for server-to-server auth
SPACES_API_TOKENYesShared bearer secret for Molino integration
JWT_SECRETYesJWT signing key (currently hardcoded — fix in prod)
NEXTAUTH_SECRETNoNextAuth secret (if using auth)
H3Molino-index (main app)
VariableRequiredDescription
SPACES_API_URLYesURL of standalone Spaces app
SPACES_API_TOKENYesMust match Spaces' SPACES_API_TOKEN

H215. Error Responses & Validation
H3Standard Error Format
{
  "success": false,
  "error": "Human-readable message",
  "errors": [
    {
      "sectionKey": "hero_001",
      "type": "hero",
      "field": "title",
      "code": "required",
      "message": "Title is required"
    }
  ]
}
H3HTTP Status Codes
CodeMeaning
200Success
201Created
400Bad request (invalid JSON, missing fields)
401Unauthorized (missing/invalid auth)
403Forbidden (not owner)
404Not found
409Conflict (duplicate slug/path)
422Validation failed (publish)
500Server error
H3Section Validation Errors

When publishing fails, Spaces returns per-section validation errors:

{
  "success": false,
  "errors": [
    {
      "sectionKey": "hero_1714867200000_0",
      "type": "hero",
      "errors": [
        { "field": "title", "message": "Required" }
      ]
    },
    {
      "sectionKey": "pricing_1714867200007_7",
      "type": "pricing",
      "errors": [
        { "field": "plans[0].price", "message": "Required" },
        { "field": "plans[0].features", "message": "Must be an array" }
      ]
    }
  ],
  "spaceStatus": "broken"
}
H3Common Failure Modes
IssueCauseFix
Title is requiredHero section missing titleAdd title to hero content
Invalid URLImage field not a valid URLUse valid https:// URL or empty string
No routes foundSpace has no route aliasesAdd at least one route with isPrimary: true
Path already takenDuplicate route pathUse unique slug/path
UnauthorizedMissing or wrong auth headerSet correct SPACES_API_TOKEN

H2Quick Reference Card
H3Minimal Upsert Payload
{
  "externalRef": { "app": "molino", "entity": "trip", "entityId": "123" },
  "space": { "title": "My Trip", "slug": "my-trip", "visibility": "public" },
  "routes": [{ "path": "/my-trip", "kind": "root", "isPrimary": true, "enabled": true }],
  "sections": [
    { "key": "hero_0_0", "type": "hero", "order": 0, "enabled": true, "content": { "title": "Hello" } },
    { "key": "cta_0_1", "type": "cta", "order": 1, "enabled": true, "content": { "ctaText": "Go", "ctaLink": "#" } }
  ]
}
H3cURL One-liner
curl -X POST "${SPACES_API_URL}/api/spaces/upsert" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer ${SPACES_API_TOKEN}" \
  -d @payload.json

Last updated: May 2026