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.
channels (how user subscribed)
email ──┐
push ──┼──► CAPABILITY_MAP ──► caps Set ──► all gates check caps only
pwa ──┤
native ──┘
// 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.
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.
[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')
assets/js/subscription-hub.js — state + CAPABILITY_MAP + event routingassets/js/resource_gate.js — remove hardcoded checkers, listen sub:caps-changedassets/js/email_gate.js — fire sub:request instead of user:subscribedassets/js/email_subscribe.js — fire sub:request instead of user:subscribedassets/js/push_subscribe.js — fire sub:request instead of user:push-subscribedassets/js/app-core.js — AppUtils.initPaywall() uses caps.has('read')_layouts/bookpost.html — inline gate JS fires sub:request, listens sub:caps-changed_data/resources.yml — rename requires: values (see below)requires: email → read # pdf-python-book, pdf-programmer-book, pdf-prompt-book, book-chapter-locked
requires: push → early # podcast-episode-early
gate_methods: 'any' in bookpost front matter stays as-is.
Hub interprets 'any' as caps.has('read') at runtime.
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.
push-router.js and subscribe-router.js still dispatch separate events
(user:push-subscribed, user:subscribed) — replace with sub:request in step 5push_subscribe.js — dispatches user:push-subscribed internally — replace in step 5email_gate.js / email_subscribe.js — dispatch user:subscribed — replace in step 5localStorage.setItem('is_subscriber', 'true') — direct write, bypass hublocalStorage.getItem('is_subscriber') — direct read, bypass hubBOOK_PDF_CONFIG.pdfMethods and readMethods arrays are hardcoded — hub makes them redundantgate_url: javascript:PushSubscribe&&PushSubscribe.showCard() on podcast-episode-early
— direct coupling to PushSubscribe global, should become sub:request {channel:'push'}sub:ahmedbouchefra2:prompt-engineering:... vs sub:ahmedbouchefra2:promptengineering:...
— two different bookId slugs for the same book. Check bookId in subscribe form vs book
registry. One entry affected: [email protected] on promptengineering.cli/email-subscribe.js line 62 looks for window.AppCoreConfig = window.AppCoreConfig || {
— pattern no longer exists after ESM migration. Fix: update parser to match
const AppCoreConfig = window.AppCoreConfig || {.subscription-hub.js — build first, seeds from localStorage, fires caps eventsresource_gate.js — swap checkers, listen sub:caps-changedbookpost.html inline JS — fire sub:request, listen sub:caps-changedAppUtils.initPaywall() — use caps.has('read')email_gate.js, email_subscribe.js, push_subscribe.js — fire sub:requestrequires: in resources.ymlBOOK_PDF_CONFIG.pdfMethods / readMethods from bookpost