Two planning documents for the next major architectural steps: - sexpr-js-runtime-plan: isomorphic JS s-expression runtime for client-side rendering, content-addressed component caching, and native hypermedia mutations - ghost-removal-plan: full Ghost CMS replacement covering content (Lexical→sexp), membership, newsletters, Stripe subscriptions, and media uploads Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
14 KiB
Ghost Removal Plan
Replace Ghost CMS entirely with native infrastructure.
What Ghost Currently Provides
Ghost is deeply integrated across three major areas:
1. Content Management (blog service)
- Post/page storage in Lexical JSON format
- Author and tag entities with many-to-many relationships
- WYSIWYG editing via Ghost Admin API
- Media uploads (images, audio/video, files) via Ghost's upload endpoints
- OEmbed lookups for embedded media
- Content sync: Ghost → local DB via Content API + webhooks
- ActivityPub publishing triggered after post sync
2. Membership & Subscriptions (account service)
- Members: Ghost is the member store — users have
ghost_id, synced bidirectionally - Labels: tagging/segmentation of members (M2M via
GhostLabel/UserLabel) - Newsletters: newsletter entities with per-user subscription tracking (
GhostNewsletter/UserNewsletterwithsubscribedflag) - Tiers: membership levels (free/paid) stored in
GhostTier - Subscriptions: paid plans with Stripe integration — cadence, price, Stripe customer/subscription IDs stored in
GhostSubscription - Bidirectional sync: Ghost → DB (
sync_all_membership_from_ghost,sync_single_member) and DB → Ghost (sync_member_to_ghost)
3. Infrastructure
- JWT token generation for Admin API (
ghost_admin_token.py) - Webhook handlers for real-time sync (member, post, page, author, tag events)
- Email campaign sending (newsletter selection on publish,
email_segmentparameter) - Stripe payment processing for paid subscriptions (handled entirely by Ghost)
- Ghost Docker container (Node.js app alongside our Python stack)
Environment Variables
GHOST_API_URL, GHOST_ADMIN_API_URL, GHOST_PUBLIC_URL
GHOST_CONTENT_API_KEY, GHOST_ADMIN_API_KEY
GHOST_WEBHOOK_SECRET
Ghost-Related Files
blog/bp/blog/ghost/ghost_sync.py # Content fetch & sync
blog/bp/blog/ghost/ghost_posts.py # Post CRUD via Admin API
blog/bp/blog/ghost/ghost_admin_token.py # JWT generation
blog/bp/blog/ghost/lexical_validator.py # Lexical JSON validation
blog/bp/blog/ghost/editor_api.py # Media upload proxy
blog/bp/blog/ghost_db.py # Ghost DB client
blog/bp/blog/web_hooks/routes.py # Webhook handlers
shared/infrastructure/ghost_admin_token.py # JWT generation (shared copy)
shared/models/ghost_content.py # Post, Author, Tag, junction tables
shared/models/ghost_membership_entities.py # Label, Newsletter, Tier, Subscription
account/services/ghost_membership.py # Membership sync service
Ghost-Related Database Tables
# Content
posts (ghost_id, uuid, slug, title, status, lexical, html, ...)
authors (ghost_id, slug, name, email, ...)
tags (ghost_id, slug, name, ...)
post_authors (post_id, author_id, sort_order)
post_tags (post_id, tag_id, sort_order)
# Membership
ghost_labels (ghost_id, name, slug)
user_labels (user_id, label_id)
ghost_newsletters (ghost_id, name, slug, description)
user_newsletters (user_id, newsletter_id, subscribed)
ghost_tiers (ghost_id, name, slug, type, visibility)
ghost_subscriptions (ghost_id, user_id, status, cadence, price_amount,
price_currency, stripe_customer_id, stripe_subscription_id,
tier_id, raw)
# User model fields
users.ghost_id, users.ghost_status, users.ghost_subscribed,
users.ghost_note, users.ghost_raw, users.stripe_customer_id
Problems
- Two sources of truth for content AND membership — constant sync overhead
- Every edit round-trips through Ghost's API — we don't own the write path
- Ghost sync is fragile (advisory locks, error recovery, partial sync states)
- Lexical JSON is opaque — we validate but never truly control the format
- Ghost is an entire Node.js application running alongside our Python stack
- Stripe integration is locked inside Ghost — we can't customize payment flows
- Newsletter/email is Ghost-native — no control over templates, scheduling, deliverability
- Membership tiers are Ghost concepts that don't map cleanly to our cooperative model
Target State
Everything Ghost does is handled natively by our services:
| Ghost Feature | Replacement |
|---|---|
| Post/page content | Sexp in posts.body_sexp column |
| Lexical editor | WYSIWYG editor saving sexp directly to DB |
| Media uploads | Direct upload to our storage (S3/local) — blog service endpoint |
| Authors | Already in our DB — just drop ghost_id column |
| Tags | Already in our DB — just drop ghost_id column |
| Members | Already our users table — drop Ghost sync, Ghost fields |
| Labels | Rename ghost_labels → labels, drop ghost_id |
| Newsletters | Native newsletter service (see Phase 7 below) |
| Tiers | Native membership tiers on account service |
| Subscriptions | Direct Stripe integration on orders service (already has SumUp) |
| Email sending | Transactional email service (Postmark/SES/SMTP) |
| Webhooks | Not needed — we own the write path |
| Ghost Docker container | Removed entirely |
Migration Phases
Phase 1: Lexical → Sexp Converter
Write a one-time conversion script that transforms Lexical JSON into equivalent sexp.
| Lexical Node | S-expression |
|---|---|
paragraph |
(p ...) |
heading (level 1-6) |
(h1 ...) ... (h6 ...) |
text (plain) |
"string" |
text (bold) |
(strong "string") |
text (italic) |
(em "string") |
text (bold+italic) |
(strong (em "string")) |
text (code) |
(code "string") |
link |
(a :href "url" "text") |
list (bullet) |
(ul (li ...) ...) |
list (number) |
(ol (li ...) ...) |
quote |
(blockquote ...) |
image |
(use "image" :src "url" :alt "text" :caption "text") |
code-block |
(pre (code :class "language-x" "...")) |
divider |
(hr) |
embed |
(use "embed" :url "..." :type "...") |
Run against all existing posts, verify round-trip fidelity by rendering both versions and comparing HTML output.
Phase 2: Schema Changes — Content
- Add
body_sexptext column toPostmodel (or repurposelexicalcolumn) - Keep all existing metadata columns (title, slug, status, published_at, feature_image, etc.)
- Drop
ghost_idfromPost,Author,Tagtables (after full migration) - Drop
mobiledoccolumn (legacy Ghost format, unused)
Phase 3: Editor Integration
Update the WYSIWYG editor to save sexp instead of Lexical JSON:
- Editor toolbar actions produce sexp nodes
- Save endpoint writes directly to our DB (no Ghost Admin API call)
- Preview renders via the same sexp pipeline used for the public view
- Draft/publish workflow stays the same — just a
statuscolumn update
Phase 4: Media Uploads
Replace Ghost's upload proxy with native endpoints on the blog service:
POST /admin/upload/image/— accept image, store to S3/local, return URLPOST /admin/upload/media/— audio/videoPOST /admin/upload/file/— generic filesGET /admin/oembed/?url=...— OEmbed lookup (call providers directly)
The editor already posts to proxy endpoints in editor_api.py — just retarget them to store directly rather than forwarding to Ghost.
Phase 5: Rendering Pipeline
Update post_data() and related functions:
- Parse
body_sexpthrough the sexp evaluator - Render to HTML via the existing
shared/sexp/html.pypipeline - Components referenced in post content (
use "image-gallery", etc.) resolve from the component registry - Context variables (author data, related posts, etc.) passed as environment bindings
Phase 6: Membership Decoupling
Migrate membership from Ghost to native account service:
Labels → native labels:
- Rename
ghost_labels→labels, dropghost_idcolumn user_labelsstays as-is- Admin UI manages labels directly (no Ghost sync)
Tiers → native membership tiers:
- Rename
ghost_tiers→membership_tiers, dropghost_id - Add tier management to account admin UI
- Tier assignment logic moves from Ghost webhook handler to account service
User model cleanup:
- Drop:
ghost_id,ghost_status,ghost_subscribed,ghost_note,ghost_raw - Keep:
stripe_customer_id(needed for direct Stripe integration) - Add:
membership_tier_idFK,membership_statusenum (free/active/cancelled)
Phase 7: Newsletter System
Replace Ghost's newsletter infrastructure with a native implementation:
Newsletter model (replaces ghost_newsletters):
newsletters (id, name, slug, description, from_email, reply_to, template_sexp, created_at)
user_newsletters (user_id, newsletter_id, subscribed, subscribed_at, unsubscribed_at)
Email sending:
- Integrate a transactional email provider (Postmark, AWS SES, or direct SMTP)
- Newsletter templates as sexp — rendered to HTML email via the same pipeline
- Send endpoint on account or blog service: select newsletter, select segment (by label/tier), queue sends
- Unsubscribe handling: tokenized unsubscribe links, one-click List-Unsubscribe header
Post → email campaign:
- On publish, optionally select newsletter + segment (replaces Ghost's
?newsletter=slug&email_segment=...) - Render post body sexp to email-safe HTML (inline styles, table layout for email clients)
- Queue via background task (Celery or async worker)
What we gain over Ghost:
- Email templates are sexp — same format as everything else
- Full control over deliverability (SPF/DKIM/DMARC on our domain)
- Segment by any user attribute, not just Ghost's limited filter syntax
- Send analytics stored in our DB
Phase 8: Subscription & Payment
Replace Ghost's Stripe integration with direct Stripe on the orders service:
Current state: Orders service already handles SumUp payments for marketplace/events. Adding Stripe for recurring subscriptions follows the same pattern.
Implementation:
- Stripe Checkout for subscription creation (redirect flow, PCI compliant)
- Stripe Webhooks for subscription lifecycle (created, updated, cancelled, payment_failed)
subscriptionstable (replacesghost_subscriptions):subscriptions (id, user_id, tier_id, stripe_subscription_id, stripe_customer_id, status, cadence, price_amount, price_currency, current_period_start, current_period_end, cancelled_at)- Customer portal: link to Stripe's hosted portal for card updates/cancellation
- Webhook handler on orders service (same pattern as SumUp webhooks)
What we gain:
- Unified payment handling (SumUp for one-off, Stripe for recurring)
- Custom subscription logic (cooperative membership models, sliding scale, etc.)
- Direct access to Stripe customer data without Ghost intermediary
Phase 9: Remove Ghost
Delete all Ghost integration code:
| File/Directory | Action |
|---|---|
blog/bp/blog/ghost/ |
Delete entire directory |
blog/bp/blog/ghost_db.py |
Delete |
blog/bp/blog/web_hooks/ |
Delete |
shared/infrastructure/ghost_admin_token.py |
Delete |
account/services/ghost_membership.py |
Delete |
| Ghost Docker service | Remove from docker-compose |
| Ghost env vars | Remove all GHOST_* variables |
| Ghost webhook blueprint registration | Remove from blog routes |
Startup sync (sync_all_content_from_ghost) |
Remove from blog init |
Startup sync (sync_all_membership_from_ghost) |
Remove from account init |
Advisory lock 900001 |
Remove from blog init |
Rename models:
ghost_content.py→content.pyghost_membership_entities.py→membership.py- Drop all
ghost_idcolumns via Alembic migration
Phase 10: Content-Addressable Caching (ties into sexpr.js)
Once posts are sexp and the JS client runtime exists:
- Hash post body → content address
- Client caches post bodies in localStorage keyed by hash
- Server sends manifest of slug → hash mappings
- Unchanged posts served entirely from client cache
- Only the data envelope (metadata, component params) travels on repeat visits
What Stays the Same
Postmodel and all its metadata fields (minus ghost-specific ones)- URL structure (
/slug/) - Tag, author, and tag group systems
- Draft/publish workflow
- Admin edit UI (updated to save sexp instead of Lexical)
- RSS feeds (rendered from sexp → HTML)
- Search indexing (extract text content from sexp)
- ActivityPub federation (triggered on publish, same as now)
- Alembic migrations (add/modify/drop columns)
- OAuth2 auth system (already independent of Ghost)
Ordering & Dependencies
Phase 1-2 (Content schema) ──→ Phase 3 (Editor) ──→ Phase 5 (Rendering)
Phase 4 (Uploads) ──┘
Phase 6 (Membership) ──→ Phase 8 (Payments)
Phase 7 (Newsletters) ── independent, needs email provider choice
Phase 9 (Remove Ghost) ── after all above complete
Phase 10 (Content-addressed) ── after sexpr.js runtime exists
Phases 1-5 (content) and Phases 6-8 (membership/payments) can proceed in parallel — they touch different services.
Risk Mitigation
- Data safety: Run Lexical → sexp converter in dry-run mode first, diff HTML output for every post
- Rollback: Keep
lexicalcolumn and Ghost running during transition, feature flag to switch renderers - Editor UX: Editor remains WYSIWYG — authors never see sexp syntax
- SEO continuity: URLs don't change, HTML output structurally identical
- Email deliverability: Set up SPF/DKIM/DMARC before sending first newsletter from our domain
- Payment migration: Run Ghost Stripe and direct Stripe in parallel during transition, migrate active subscriptions via Stripe API (change the subscription's application)
- Membership data: One-time migration script to clean User model fields, verified against Ghost export
Dependencies
- Stable sexp parser + evaluator (already built:
shared/sexp/) - Component registry with post-relevant components: image, embed, gallery, code-block
- Editor sexp serialization (new work)
- Email provider account (Postmark/SES/SMTP)
- Stripe account with recurring billing enabled (may already exist via Ghost)
- Optional: sexpr.js client runtime for content-addressable caching (see
sexpr-js-runtime-plan.md)