Changelog
What we shipped, when. Built in public. Most recent first.
Operator dashboard auto-auth via Google session + price cut
Operator dashboard no longer requires pasting an admin token. New `ADMIN_EMAILS` env var (comma-separated allowlist) — if the signed-in user's email matches, `/api/internal/*` routes accept the session cookie. Bootstrap token still works as a CI/script fallback. Subscription pricing simplified to $19/mo Pro and $39/mo Power (down from $24/$99) with Power credits adjusted to $30/mo to keep the cost-per-credit ratio similar. 6 new isolation tests assert that a non-admin email signed in with a valid session still gets 403 on admin routes.
CORS preflight allowlist for x-admin-token
Operator dashboard's GET to /api/internal/stats was being killed by the browser's OPTIONS preflight because x-admin-token wasn't in the backend's allowHeaders. The browser dropped the request silently and fetch() resolved to "Failed to fetch" with no clear signal. Added the header to the allowlist. The hardest debugging signal was that a bare fetch() from the same page (no custom header, so no preflight) returned 200 with real data — that divergence pointed straight at the CORS layer.
TOTP secrets encrypted at rest (AES-256-GCM)
Before this push, users.totp_secret was stored as plaintext base32 in D1. Anyone who breached the database could read every user's TOTP seed and bypass 2FA on every account. Now wrapped in a versioned envelope (v1:<base64(iv || ciphertext+tag)>) with a 12-byte random IV per encryption and a 32-byte KEK held in a Worker secret. Decrypt rejects tampered ciphertext via the GCM auth tag. 12 new vitest cases cover round-trip, IV uniqueness, tamper-detection, wrong-key rejection, Unicode + long content, and legacy plaintext compat. Threat model: protects against passive D1 dumps. Does not protect against full Cloudflare-account compromise — that would need an external HSM/KMS.
Observability test made timezone-deterministic
The "today" assertion in tests/observability.test.ts used Date.now() as the synthetic clock, which made the "1 hour ago" event land yesterday-UTC whenever the suite happened to run between 00:00 and 01:00 UTC. Pinned the synthetic clock to noon UTC so the event always falls inside the same UTC day. Strengthened the affected assertions from toBeGreaterThan(0) to exact toBe(N). 13/13 tests now pass at any hour.
Content-Security-Policy header on every page
Final piece of the public-launch security audit. CSP locks script-src, style-src, font-src, img-src, connect-src, and form-action down to known origins (self + jsdelivr for the markdown lib, Google Fonts, the API subdomain, Stripe Checkout). frame-ancestors 'none' blocks clickjacking; object-src 'none' kills the legacy plugin attack surface; base-uri 'self' prevents base-tag hijacking. 'unsafe-inline' is needed for the inline scripts/styles the app uses heavily but not 'unsafe-eval' — verified no real eval() in the bundle.
Notebook sidebar overflow — real fix
The notebook sidebar was getting clipped on narrow viewports. First fix blamed the Export button width — that was wrong. Real cause: <input type="search"> and <select> have UA default min-widths that inflate CSS grid tracks past their declared max. Added min-width:0; width:100%; box-sizing:border-box to .notebook-search and .notebook-filters. Sidebar now respects its track at every viewport from 390px up.
OAuth + TOTP login no longer silently no-ops
The 2FA challenge form had onsubmit="...twoFAChallengeSubmit('${id}', ${JSON.stringify(email)});". When the email came from Google OAuth as "google account", the rendered attribute contained a quote mid-call that terminated the attribute and broke the submit handler — users would type a valid TOTP code, click Verify, and nothing would happen. Dropped the unused email parameter; the function only needs the challenge id. Also collapsed the notebook Export button to icon-only (40px) and added overflow-wrap to the empty-state title so long topics don't overflow.
OAuth rate-limit no longer shares the signup bucket
OAuth start was using the signup rate-limit (5 / hr). Real users with brief session expiry kept getting blocked when they bounced through Google one extra time. New dedicated bucket: oauth-start: 20 per 15 min.
Hard auth gate now exempts the OAuth 2FA callback
The hard auth gate added in PUSH 98 was kicking the post-OAuth ?oauth_2fa_challenge=… redirect back to /, breaking the OAuth+TOTP flow before the challenge modal could mount. Exempted that query-string marker from the gate's redirect.
30-day signups sparkline on operator dashboard
Mirror of the AI-cost sparkline shipped in PUSH 138, on the supply side. Buckets users.created_at by UTC day, zero-fills empty days, reuses the existing chart renderer. Operator now sees both the supply side (signups in) and the demand side (AI cost out) side-by-side with identical visual treatment.
Low-credit alert refactor — 3 skipped tests now run
The "you're below 20% AI credits" email logic lived inline in teacher.ts inside a waitUntil(...) block. Three end-to-end tests had to be skipped because the workerd vitest pool doesn't reliably resolve fire-and-forget promises. Extracted the decision + send into a pure maybeFireLowCreditAlert(env, user, plan) function that returns {fired, reason}. Tests now call it directly with await; the teacher route still wraps it in waitUntil for production. Net: 5 new direct-function tests replace the 3 skipped ones, every reason code is asserted, and backend suite is now 683 passing / 3 skipped.
30-day system-wide AI cost sparkline in the operator dashboard
A single GROUP BY day across ai_usage_daily aggregates cost across all users for the last 30 days. Reuses the existing per-user sparkline helper switched to the cost_usd metric so the chart style matches the rest of the dashboard. Header shows the date range; footer rolls up total spend, peak day, total requests, and active days — runaway-cost days are now obvious at a glance. 4 new vitest cases cover zero-fill, cross-user aggregation, ordering, and that the existing today/week aggregate fields still come through.
Live "● operational" status badge in public footers
Landing, /changelog, and /about footers now show a tiny pulsing dot + label that reflects /api/status. Green pulse when operational, amber when degraded. Uses the existing edge-cached endpoint (60 s TTL) so it costs effectively nothing per page view, and fails quiet — a backend hiccup just leaves the neutral "status" label instead of blocking page render.
30-day uptime sparkline on the public /status page
The public status page now shows a 30-bar timeline of daily uptime — green at 100%, amber for partial outages (95–99.9%), red below 95%, and a dashed empty bar where probe data is missing. Backed by a new /api/status/history endpoint that buckets the existing 15-minute probe data by UTC day, edge-cached for 60 s. Builds DOM safely via setAttribute instead of innerHTML to keep the XSS surface zero.
Newsletter — operator pings + dashboard widget
New signups to the waitlist now trigger a real-time webhook (Discord / Slack / generic) AND an email to the operator — same channels as account signups, separate event type so they're filterable. The operator dashboard surfaces the list with masked emails and a "% converted to user" stat.
TypeScript cleanup + 70 broken-link replacements
15 strict-null TypeScript errors fixed across the backend src/. The roadmap content also got a sweep — 39 external links that had moved or 404'd were replaced with verified canonical successors. Lucia (deprecated) became Better Auth, Outlines docs picked up their new URL structure, gone-stale freeCodeCamp posts redirect to the news index, etc.
Sitemap lastmod dates on all 22 URLs
Each marketing + app + stage + legal URL now carries a <lastmod> timestamp for Google's crawl prioritization. Faster re-indexing after content updates.
Daily sweep of abandoned signups
The daily cleanup cron now deletes user rows that are unverified AND > 30 days old AND have no payment AND no OAuth login AND have never opened the app. Conservative criteria — a real user who paused for a month with any app activity is preserved. Keeps the table tidy as the product scales.
Font perf + OG image refresh
Dropped unused Google Fonts weights (Inter 500/600, JetBrains Mono 500) and switched the stylesheet to async-load with the preload+onload pattern — page now paints in system fonts immediately, custom fonts swap in when ready. Also regenerated the OG share image with the current red brand and Sonnet 4.6 reference.
Landing-page conversion polish
Added a "See it in action" section with four pure-HTML mockups (greeting, lesson runner, quiz, notebook) — no images means they can't drift out of date. Plus a stats strip with real product facts instead of fake testimonials: 266 topics, 853 glossary entries, 5-step lesson loop, zero third-party trackers.
SEO — JSON-LD structured data + sitemap expansion
Added schema.org Organization, Course, and FAQPage JSON-LD blocks so the landing is eligible for Google's Courses carousel and rich-result FAQs. Sitemap grew from 2 to 18 URLs — each of the 10 stages now has its own indexable entry.
Privacy / Terms / Refund pages + signup consent
Shipped three legal pages — Privacy Policy (GDPR + CCPA aligned), Terms of Service (Delaware jurisdiction, EU consumer rights preserved), and Refund Policy (7-day no-questions-asked on first paid month). Signup modal now shows the standard "by creating an account, you agree to…" consent with inline links.
Custom 404 + fixed 2FA cancel buttons
Stale links now hit a branded 404 instead of the default Cloudflare error. Also fixed four broken Cancel buttons in the 2FA modal that were calling a renamed function (silent no-op before).
Per-stage median time-to-complete
Operator funnel widget now shows the median elapsed time from first "started" to first "completed" per stage, restricted to users who actually completed (excludes abandoners). Stages where the median exceeds 14 days get an amber callout — flags content that may need pacing review.
Stripe coupons / promo codes + hard auth gate
Promo codes flow through Checkout via ?promo=XYZ URL param — operator lists active coupons inline. App is now hard-gated behind sign-in (early <head> script redirects unauthenticated visitors), with explicit URL exemptions for landing-page signup / signin CTAs.
Per-user timeline + GDPR export v2
Operator can drill into any user and see a chronological feed of 9 event types across 7 tables: sign-up, verification, lessons, payments, top-ups, 2FA, email changes, security events, deletions. User data export now includes everything from new tables (2FA enrollment dates, email-change history, etc.) — schema bumped to v2.
TOTP 2FA — full flow with OAuth enforcement
RFC 6238 TOTP with backup recovery codes. Enrollment in account menu, login challenge on subsequent sign-ins, and — closing a quiet gap — enforced on OAuth callbacks too, not just password logins.
Email change with confirm + cancel + alert
Self-serve email change with separate confirm and cancel tokens. Confirmation goes to the new address; an alert ping goes to the old address so a compromised account can self-recover.
AI Teacher response cache (global + per-user)
SHA-256 canonical-input keying means identical lesson requests reuse the cached response — cuts Claude API spend on repeated content. Per-user daily aggregation lets the operator see who's hitting the cache most.
Funnel analytics + retention curves
Per-stage progress events with started/completed counts, plus a 5-stage retention funnel: signed_up → verified → first_lesson → subscribed → retained_7d. Operator dashboard now shows where users drop off.
Stripe integration end-to-end
Real Stripe Checkout for subscriptions + one-time top-up packs ($5 / $20 / $50). Webhook handler with retry queue. Customer Portal for cancellation and invoice download. Apple OAuth alongside Google. Production email delivery via Resend with verified custom domain.
Backend foundation — auth, sync, AI Teacher proxy
Cloudflare Workers + D1 backend stood up: signup/login with PBKDF2 hashes, opaque session cookies, per-user state sync to the D1 state_blobs table, server-side Anthropic proxy so users never see the API key, per-user usage tracking, password reset / change-password / account deletion flows, email verification, rate limits on auth endpoints, "log out everywhere" session list, and GDPR-aligned data export.
AI Teacher — 5-step lessons + Notebook
Proactive lesson runner: Objectives → Lecture → Worked example → Practice → Quiz. Adaptive curriculum picker based on mastery scores. Per-topic mastery tracking. Digital Notebook with markdown editor, tags, search, and Markdown export. Save-from-lesson buttons across all 5 step types.
Curriculum + content base
266 curated topics across 10 stages. 853 glossary entries. Skill graph, flashcards (50× coverage), study guide, omnisearch (Ctrl+K), career mode. Content expansion for backend (stage 4), DS/ML (stage 8), and AI engineering (stages 7-9).
Want all the gory detail? The full CHANGELOG.md is on the repo.
Want the next thing? Sign up — your feedback shapes what ships.