▶ shipping log

Changelog

What we shipped, when. Built in public. Most recent first.

Last updated: 2026-05-28 · 150+ pushes shipped since launch
150+
pushes shipped
705
backend tests
25
DB migrations
2026-05
latest deploy
PUSH 149

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.

PUSH 150

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.

PUSH 148

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.

PUSH 147

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.

PUSH 145

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.

PUSH 146

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.

PUSH 144

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.

PUSH 143

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.

PUSH 142

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.

PUSH 140

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.

PUSH 139

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.

PUSH 138

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.

PUSH 137

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.

PUSH 136

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.

PUSH 127–129

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.

PUSH 124 + 125

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.

PUSH 126

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.

PUSH 130

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.

PUSH 109 + 110

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.

PUSH 106 + 107

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.

PUSH 103 + 104

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.

PUSH 100 + 105

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.

PUSH 101 + 102

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).

PUSH 99

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.

PUSH 97 + 98

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.

PUSH 95 + 96

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.

PUSH 87 + 88 + 89

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.

PUSH 90 + 91

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.

PUSH 82 + 84

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.

PUSH 81 + 94

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.

━━━ Earlier 2026 ━━━
PUSH 59–62

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.

PUSH 42–53

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.

PUSH 32–37

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.

earlier

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.