Subscription Hub — Migration Plan

Problem

Push and email subscriptions are siloed. No unified state. Four gate surfaces each implement their own subscription checks independently:

Surface File Current check
Book chapter read bookpost.html + BOOK_GATE_CONFIG localStorage.is_subscriber
Book PDF download bookpost.html + BOOK_PDF_CONFIG pdfMethods.includes(channel) hardcoded
Post/story paywall post.html, storypost.html AppUtils.initPaywall() → boolean isSubscribed()
Resource gate resource_gate.js 3 hardcoded checkers per channel

is_subscriber localStorage is a boolean with no channel info. PDF gate and read gate are separate configs in the same layout with no shared logic.

Architecture

Two layers: channels → capabilities → gates

channels (how user subscribed)
  email  ──┐
  push   ──┼──► CAPABILITY_MAP ──► caps Set ──► all gates check caps only
  pwa    ──┤
  native ──┘

Capability map (single source of truth for business logic)

// subscription-hub.js
const CAPABILITY_MAP = {
  read:     s => s.email || s.push || s.pwa || s.native,
  download: s => s.push || s.native,     // matches BOOK_PDF_CONFIG.pdfMethods
  early:    s => s.push,
  offline:  s => s.pwa || s.native,
};
// New channel → update relevant lines here only
// New capability → add one line here + use in resources.yml

native = Capacitor wrapper (window.Capacitor.isNativePlatform()). Already detected in subscription-manager.js and BOOK_PDF_CONFIG.pdfMethods. Must be included as a first-class channel.

State schema (IndexedDB, replaces boolean is_subscriber)

{
  channels: {
    email:  { active: bool, bookIds: ['general', 'library-access'], ts: ISO },
    push:   { active: bool, endpoint: string, ts: ISO },
    pwa:    { active: bool, ts: ISO },
    native: { active: bool, ts: ISO },
  }
}

caps is always derived from state — never stored. Storing derived state causes sync bugs.

Event flow

[Any surface]
  fires → sub:request { channel: 'email'|'push'|'pwa'|'native', meta: {...} }

[subscription-hub.js]
  1. Read/write channels state → IndexedDB
  2. Derive caps Set from CAPABILITY_MAP
  3. Fire sub:caps-changed { caps: Set<string>, state: {email,push,pwa,native} }
  4. Fire sub:sync:email | sub:sync:push  (worker sync, if channel changed)

[email-router.js]     listens sub:sync:email → POST book-gate worker
[push-router.js]      listens sub:sync:push  → POST push-gate worker

[resource_gate.js]    listens sub:caps-changed → caps.has(resource.requires)
[bookpost gate JS]    listens sub:caps-changed → caps.has('read')
[bookpost PDF JS]     listens sub:caps-changed → caps.has('download')
[AppUtils.initPaywall] listens sub:caps-changed → caps.has('read')

Files to create / modify

New files

Modified files

resources.yml renames (4 entries)

requires: email  →  read      # pdf-python-book, pdf-programmer-book, pdf-prompt-book, book-chapter-locked
requires: push   →  early     # podcast-episode-early

Front matter — NO CHANGES needed

gate_methods: 'any' in bookpost front matter stays as-is. Hub interprets 'any' as caps.has('read') at runtime.

Backwards compatibility

subscription-hub.js seeds initial state from existing localStorage.is_subscriber on first load. Any user who was subscribed before the hub ships gets email: { active: true } seeded automatically. No re-subscribe required.

Inline TODOs found during ESM migration session

In converted JS files (esm-migration branch)

In bookpost.html

In resources.yml

Known slug inconsistency in Cloudflare KV

email-subscribe.js CLI script

Build order

  1. subscription-hub.js — build first, seeds from localStorage, fires caps events
  2. Update resource_gate.js — swap checkers, listen sub:caps-changed
  3. Update bookpost.html inline JS — fire sub:request, listen sub:caps-changed
  4. Update AppUtils.initPaywall() — use caps.has('read')
  5. Update email_gate.js, email_subscribe.js, push_subscribe.js — fire sub:request
  6. Rename requires: in resources.yml
  7. Remove now-redundant BOOK_PDF_CONFIG.pdfMethods / readMethods from bookpost