Date: 2026-05-20 Goal: Two self-pluggable, multi-site-ready modules — one for email-gated learning, one for gamified social-share-to-earn-books.
A drop-in module that gates learning content behind email auth. Users log in once via magic link / OTP, then track progress, earn badges, and unlock books as they advance.
Pluggable interface (same pattern as emailGate, pushNotify):
window.AppCoreConfig = {
academy: {
enabled: true,
workerUrl: 'https://academy-gate.techiediaries.workers.dev',
siteId: 'ahmedbouchefra2',
loginMode: 'otp', // 'otp' | 'magic-link'
redirectUrl: '/academy/',
strings: { // Arabic overrides
loginTitle: 'أدخل بريدك للمتابعة',
otpTitle: 'أدخل الرمز المُرسَل إليك',
}
}
}
Frontend module — assets/js/academy.js:
[data-academy-gate] element clicked or window.Academy.login() calledlocalStorage.academy_tokenwindow.Academy.isLoggedIn() → gates contentwindow.Academy.markComplete(lessonId) → POST to WorkersiteId — all KV keys are namespaced ${siteId}:${email}:*Backend Worker — academy-gate (BackendKit createHandler pattern):
POST /send-otp { siteId, email } → sends 6-digit OTP via Resend API
POST /verify-otp { siteId, email, otp } → validates, issues JWT (jose), stores session in KV
GET /me + Authorization: Bearer JWT → returns { email, level, badges, progress }
POST /progress { lessonId } → marks lesson complete, checks badge triggers
GET /badges → list earned badges
POST /redeem { bookId } → unlock book if level requirement met
Data files:
_data/academy_lessons.yml — lesson registry with level requirements_data/academy_badges.yml — badge definitions (icon, trigger condition, point value)_data/academy_books.yml — which books unlock at which academy levelLevel system:
Users complete social quests (follow, share, subscribe, install) to earn points, then redeem points for free books from the library.
Pluggable interface:
window.AppCoreConfig = {
questGate: {
enabled: true,
workerUrl: 'https://quest-gate.techiediaries.workers.dev',
siteId: 'ahmedbouchefra2',
currency: 'نقطة', // Arabic "point"
strings: {
title: 'اكسب الكتب مجاناً',
balance: 'رصيدك',
}
}
}
Quest types (honor-based by default, verifiable ones noted):
| Quest | Points | Verification |
|---|---|---|
| Subscribe to newsletter | 50 | Hooks into emailGate — confirmed by Worker |
| Subscribe to push notifications | 50 | Hooks into pushNotify — Notification.permission === 'granted' |
| Install as PWA | 30 | display-mode: standalone check |
| Follow on X/Twitter | 40 | Honor + manual admin verify option |
| Follow on LinkedIn | 40 | Honor |
| Share a post (generates unique link) | 60 | Worker tracks link clicks |
| Refer a friend (they subscribe) | 100 | Worker checks referral code on signup |
| Write a review (URL proof) | 80 | URL submitted → admin queue |
| Complete an academy lesson | 20/each | Academy Worker cross-call |
Frontend module — assets/js/quest-gate.js:
[data-quest-panel] trigger → slides up quest panel?ref=<hash> URL per user per questacademy:lesson-complete, emailGate:subscribed, pushNotify:subscribed eventsBackend Worker — quest-gate (BackendKit createHandler):
POST /identify { siteId, email } → register user (email is the identifier)
GET /quests { siteId, email } → list quests + completion status
POST /claim { siteId, email, questId } → award points (with abuse guard: once per quest)
GET /balance { siteId, email } → current points
GET /rewards { siteId } → books available for redemption + costs
POST /redeem { siteId, email, bookId } → redeem points for book, return access URL
POST /share-click { ref } → track click on share link
GET /leaderboard { siteId } → top 10 by points
Data files:
_data/quests.yml — quest registry (id, title_ar, points, type, verifiable)_data/quest_rewards.yml — books with point costs (pulls from library.yml IDs)Both modules follow the exact same pattern as existing modules (emailGate, pushNotify):
app-core.js loadsCustomEvent on documentBoth Workers are full BackendKit apps, not bare Cloudflare Workers. BackendKit contributes at every layer:
ComputeRouter) — deployment portability// academy-worker/backendkit.config.js
compute: {
providers: [
{ type: 'cloudflare', priority: 1 }, // production
{ type: 'supabase-edge', priority: 2 }, // auto-fallback on rate_limited / down
{ type: 'local', priority: 99 }, // dev (node-server.js)
]
}
Same worker code runs on Cloudflare Worker, Supabase Edge, or Node locally — zero changes. BackendKit’s rotator.js handles providerSignal: 'rate_limited' failover. The academy-worker is a BackendKit app in exactly the same sense as the bookmarks demo.
DataRouter) — storage abstraction, no raw KVInstead of hardcoded env.KV.get/put, route functions call dataRouter.query(). Both adapters already exist (adapters/data/pocketbase.js, adapters/data/supabase.js):
// academy-worker/backendkit.config.js
data: {
strategy: 'read-fallback',
providers: [
{ type: 'pocketbase', url: process.env.PB_URL, priority: 1 },
{ type: 'supabase', url: process.env.SUPABASE_URL, priority: 2 },
]
}
// route function — zero provider coupling
async function saveProgress({ body }, { dataRouter }) {
return dataRouter.query({
collection: 'academy_progress',
action: 'create',
body: { email: body.email, siteId: body.siteId, lessonId: body.lessonId }
})
}
Swap PocketBase for Supabase or Turso — the route doesn’t change. Data schema lives in PocketBase collections (academy_sessions, academy_progress, academy_badges, quest_claims, quest_balance) — real schema, not KV string hacks.
authAdapter) — already built and tested (12 tests)BackendKit Phase 3 delivered createJwtAuthAdapter({ secret, expiresIn, dataRouter }) using jose. Academy uses it directly — no reimplementation:
// loadConfig wires it from config:
// auth: { type: 'jwt', secret: process.env.JWT_SECRET, expiresIn: '7d' }
// verify-otp route:
const token = await authAdapter.sign({ sub: email, siteId, level: 1 })
// protected routes (GET /me, POST /progress):
const user = await authAdapter.verify(req.headers.get('Authorization')?.slice(7))
if (!user) return { status: 401, data: { error: 'غير مصرح' } }
QuestKit identifies users by email only (no password) — no authAdapter.login() needed, just authAdapter.sign() after OTP verification.
createHandler) — runtime-portable routing// academy-worker/index.js
import { loadConfig } from 'backendkit/config-loader'
import { createHandler } from 'backendkit/handler'
import { serve } from 'backendkit/node-server'
import * as routes from './routes/index.js'
const { dataRouter, authAdapter } = await loadConfig('./backendkit.config.js')
const ctx = { dataRouter, authAdapter }
const handler = createHandler([
['POST', /^\/send-otp$/, (r) => routes.sendOtp(r, ctx)],
['POST', /^\/verify-otp$/, (r) => routes.verifyOtp(r, ctx)],
['GET', /^\/me$/, (r) => routes.getProfile(r, ctx)],
['POST', /^\/progress$/, (r) => routes.saveProgress(r, ctx)],
['GET', /^\/badges$/, (r) => routes.getBadges(r, ctx)],
])
export default { fetch: handler } // Cloudflare Worker
// serve(handler, { port: 3001 }) // Node dev — uncomment locally
academy-worker/ quest-worker/
├─ index.js ├─ index.js
│ createHandler([...]) │ createHandler([...]) ← BackendKit HTTP
├─ routes/ ├─ routes/
│ sendOtp, verifyOtp, │ identify, claim, ← thin fns, only domain logic
│ progress, badges, │ balance, redeem,
│ getProfile │ shareClick, leaderboard
└─ backendkit.config.js └─ backendkit.config.js
compute: CF→SBedge→local compute: CF→SBedge→local ← BackendKit Compute
data: PB→Supabase data: PB→Supabase ← BackendKit Data
auth: jwt/7d (no auth — email=identity) ← BackendKit Auth
New things written per worker: OTP send (Resend API call) + domain-specific route bodies. Everything else: BackendKit.
siteId field)No KV string hacks — proper collections via BackendKit DataRouter:
Collection Fields
──────────────────────────────────────────────────────────────
academy_users siteId, email, level, xp, createdAt
academy_progress siteId, email, lessonId, completedAt
academy_badges siteId, email, badgeId, earnedAt
academy_sessions siteId, email, token (JWT), expiresAt
quest_claims siteId, email, questId, claimedAt
quest_balance siteId, email, points
quest_redeemed siteId, email, bookId, redeemedAt, accessUrl
quest_share_clicks ref (hash), questId, email, clickedAt, ip
Multi-site isolation: every record has a siteId field. Same PocketBase instance serves ahmedbouchefra2, webtutpro, etc. — dataRouter filters by siteId on every query. No cross-site data leakage.
// academy.js emits after lesson complete:
document.dispatchEvent(new CustomEvent('academy:lesson-complete', { detail: { lessonId } }))
// quest-gate.js listens:
document.addEventListener('academy:lesson-complete', ({ detail }) => claimQuest('academy-lesson'))
// emailGate.js already dispatches emailGate:subscribed — quest-gate listens
document.addEventListener('emailGate:subscribed', () => claimQuest('newsletter'))
| Phase | Deliverable |
|---|---|
| A1 | academy-worker: /send-otp + /verify-otp + KV session (Resend + jose) |
| A2 | academy.js: login UI (bottom-sheet, OTP input), session check, logout |
| A3 | _data/academy_lessons.yml + /progress endpoint + Academy.markComplete() |
| A4 | _data/academy_badges.yml + badge triggers in Worker + badge shelf in UI |
| A5 | Book unlock by academy level — cross-check resources.yml + academy level |
| A6 | cli/academy.js + MCP tools (academy_status, academy_lessons, academy_badges) |
| A7 | ab admin integration — view enrolled users, progress, badge breakdown |
| Phase | Deliverable |
|---|---|
| Q1 | _data/quests.yml + _data/quest_rewards.yml — data model + CLI viewer |
| Q2 | quest-worker: /identify + /claim + /balance + /rewards + /redeem |
| Q3 | quest-gate.js: quest panel UI, claim buttons, balance display, redemption |
| Q4 | Share link system: Worker generates ?ref=<hash> + /share-click tracker |
| Q5 | Referral system: ?ref=<hash> attribution on signup → 100 pts to referrer |
| Q6 | Leaderboard: /leaderboard endpoint + optional embed widget |
| Q7 | cli/quests.js + MCP tools (quests_list, quests_balance, quests_redeem) |
| Phase | Deliverable |
|---|---|
| I1 | Cross-module events: emailGate → quest, pushNotify → quest, academy → quest |
| I2 | Library page upgrade: show quest point cost per book alongside gate options |
| I3 | npm publish: @techiediaries/academy-kit + @techiediaries/quest-kit |
Free tier: 3,000 emails/month. No credit card for dev.
// Inside academy-worker
await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: { 'Authorization': `Bearer ${RESEND_API_KEY}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
from: '[email protected]',
to: email,
subject: 'رمز الدخول إلى أكاديمية أحمد',
html: `<p>رمزك: <strong>${otp}</strong> — صالح 10 دقائق</p>`,
})
})
| Existing | Reused by |
|---|---|
emailGate.js email flow pattern |
Academy login UI structure |
resource_gate.js bottom-sheet component |
Quest panel + Academy gate UI |
book-gate Worker KV patterns |
Academy + Quest KV structure |
BackendKit createHandler |
Both Workers |
| BackendKit JWT auth (Phase 3, jose) | Academy session tokens |
_data/library.yml book entries |
Quest rewards catalog |
_data/resources.yml |
Academy book unlock targets |
AppCoreConfig + app-core.js loader |
Both module configs |
cli/hub.js + mcp-server.mjs |
Academy + Quest CLI/MCP tools |