H1Landing Page Printer API Docs
Molino-index → Molino-spaces integration reference
Standalone Spaces app treated as remote page/slider/render service
H2Table of Contents
- Architecture Overview
- Authentication
- External API Endpoints
- Recommended New Endpoints (to implement)
- Prisma Schema
- Section Type Registry
- JSON Payload Patterns Per Section
- Slider Engine Data Structures
- Seed Templates
- Route & Alias Management
- Publish Workflow
- Molino-side Integration Guide
- AppScript / Automated Seeding
- Environment Variables
- Error Responses & Validation
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_KEYin 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_TOKENin both apps'.env - Simpler than JWT for service-to-service
H23. External API Endpoints
H3Existing Endpoints
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET | /api/spaces | Required | List all spaces for authenticated user |
POST | /api/spaces | Required | Create a new space with sections |
GET | /api/spaces/{id} | Required | Get single space with sections + routes |
PATCH | /api/spaces/{id} | Required | Update space title, status, sections |
DELETE | /api/spaces/{id} | Required | Delete a space |
POST | /api/spaces/{id}/publish | Required | Validate and publish a space |
POST | /api/spaces/fix | Optional | Auto-fix a broken space |
POST | /api/admin/seed-from-template | Required | Seed a space from a template |
GET | /api/seed | None | Seed 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
| # | Type | Key Fields | Use Case |
|---|---|---|---|
| 1 | hero | title, subtitle, backgroundImage, ctaText, ctaLink | Hero banner with CTA |
| 2 | rich_text | html | Arbitrary HTML content |
| 3 | cards | items[] | Card grid with images + links |
| 4 | cta | subtitle, content, ctaText, ctaLink, mediaUrl | Single call-to-action block |
| 5 | slider_native | items[] | Native image carousel |
| 6 | features | title, subtitle, items[] | Feature list |
| 7 | testimonials | title, subtitle, content, items[] | Testimonial quotes |
| 8 | pricing | title, subtitle, content, plans[] | Pricing tiers |
| 9 | faq | subtitle, content, faqs[] | FAQ accordion |
| 10 | footer | text | Footer text |
| 11 | poster | title, subtitle, date, time, location, image, theme, badgeText | Event poster |
| 12 | slider_html | slides[], style, autoPlay, interval | HTML-based slider |
| 13 | mission_statement | title, content | Mission/about text |
| 14 | feature_grid | title, items[] | Grid with icons |
| 15 | tour_cards | items[] | Tour/experience cards with duration + price |
| 16 | destination_grid | title, items[] | Destination image grid |
| 17 | itinerary | title, 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 charsubtitle: optionalbackgroundImage: optional, must be valid URL or empty stringctaText: optionalctaLink: 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: requiredctaLink: 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: requiredtheme: 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: booleaninterval: 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 stringtitle: 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
| ID | Name | Source | Section Count | Sections |
|---|---|---|---|---|
cultural-event | Cultural Event | portfolio | 7 | poster, mission_statement, rich_text, features, testimonials, cta, footer |
travel-experience | Travel Experience | portfolio | 7 | hero, itinerary, destination_grid, tour_cards, pricing, faq, cta |
product-showcase | Product Showcase | portfolio | 8 | poster, slider_native, cards, feature_grid, testimonials, pricing, faq, footer |
creative-portfolio | Creative Portfolio | portfolio | 8 | poster, slider_native, rich_text, features, cards, testimonials, cta, footer |
business-services | Business Services | portfolio | 9 | hero, 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:
| Input | Output |
|---|---|
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
| Field | Type | Values | Description |
|---|---|---|---|
path | String | unique | Full URL path, e.g. /my-page |
pathFingerprint | String | unique | Normalized, e.g. my-page |
kind | String | root, domain, private_preview | Route type |
enabled | Boolean | — | Whether route is active |
isPrimary | Boolean | — | One primary route per space |
H3Route Resolution Flow
- Request hits
/[...slug]/page.tsx - Path normalized + fingerprint generated
- Query
SpaceRouteAliasbypathFingerprintwhereenabled: true - Return associated
Spacewith sections - Visibility check: non-owners only see
publishedspaces
H211. Publish Workflow
H3Steps
- Load space with sections and routes
- Validate each section using Zod schemas
- Validate at least one route exists
- If validation fails:
- Set
status = "broken" - Set
lastValidationError = JSON.stringify(errors) - Return errors to caller
- Set
- If validation passes:
- Set
status = "published" - Set
publishedAt = new Date() - Clear
lastValidationError - Revalidate all routes
- Set
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)
projectToSpaceserver 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
| Column | Required | Description | Example |
|---|---|---|---|
Title | Yes | Page title | "Andalusia Heritage Tour" |
Slug | Yes | URL slug | "andalusia-heritage-tour" |
Subtitle | No | Hero subtitle | "7 days of culture" |
HeroImage | No | Hero background URL | https://picsum.photos/... |
CTAText | No | CTA button text | "Book Now" |
CTALink | No | CTA destination | "/book" |
CTASubtitle | No | CTA section subtitle | "Ready to go?" |
CTABody | No | CTA section body | "Start your journey today." |
BodyHTML | No | HTML body content | <p>Trip details...</p> |
Itinerary | No | Pipe-separated days | `Day 1: Arrival |
FooterText | No | Footer text | "© 2026 Molino" |
EntityType | No | Molino entity type | "trip", "experience", "offer" |
EntityId | No | Molino entity ID | "trip_abc123" |
Visibility | No | Page visibility | "public", "private", "unlisted" |
H313.3 Apps Script Setup
- Open Google Sheet → Extensions → Apps Script
- Paste the script above
- Go to Project Settings → Script Properties
- Add:
SPACES_API_URL=https://spaces-next-app-3kui34kvnq-ew.a.run.appSPACES_API_TOKEN=your-shared-secret
- Create sheet named
LandingPageswith headers from section 13.2 - 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)
| Variable | Required | Description |
|---|---|---|
DATABASE_URL | Yes | PostgreSQL connection string |
API_KEY | Yes | Static API key for server-to-server auth |
SPACES_API_TOKEN | Yes | Shared bearer secret for Molino integration |
JWT_SECRET | Yes | JWT signing key (currently hardcoded — fix in prod) |
NEXTAUTH_SECRET | No | NextAuth secret (if using auth) |
H3Molino-index (main app)
| Variable | Required | Description |
|---|---|---|
SPACES_API_URL | Yes | URL of standalone Spaces app |
SPACES_API_TOKEN | Yes | Must 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
| Code | Meaning |
|---|---|
200 | Success |
201 | Created |
400 | Bad request (invalid JSON, missing fields) |
401 | Unauthorized (missing/invalid auth) |
403 | Forbidden (not owner) |
404 | Not found |
409 | Conflict (duplicate slug/path) |
422 | Validation failed (publish) |
500 | Server 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
| Issue | Cause | Fix |
|---|---|---|
Title is required | Hero section missing title | Add title to hero content |
Invalid URL | Image field not a valid URL | Use valid https:// URL or empty string |
No routes found | Space has no route aliases | Add at least one route with isPrimary: true |
Path already taken | Duplicate route path | Use unique slug/path |
Unauthorized | Missing or wrong auth header | Set 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