Files
STCourses/docs/superpowers/plans/2026-10-30-learner.md
T
2026-10-30 03:15:48 +01:00

12 KiB
Raw Blame History

WP-A Learner App Implementation Plan

For agentic workers: executed inline by the WP-A agent (no subagents), task by task, TDD, one commit per task. Steps use checkbox (- [ ]) syntax.

Goal: Build packages/feature-learner: sign-in (Better Auth), roles/groups admin API, the library, the lesson reader for every lesson type, notes, progress, gating, and the in-browser JS/TS exercise runner. It must render sample, pure-snake and systemic-design.

Architecture:

  • Server side (src/server/*): learnerFeature(ctx): Feature & { auth: AuthService } returns the JSON API routes and the AuthService. It runs its own migrations (migrations/0001_learner_auth.sql) lazily on first use until Phase 2 wires migrationsDirs into migrate().
  • Auth: Better Auth with a thin custom adapter over Bun.SQL (src/server/auth/adapter.ts). One adapter serves both dialects because @stc/db already gives a Bun.SQL handle for SQLite and Postgres.
  • Web side (src/web/*): React routes /, /c/:courseId, /c/:courseId/:lessonId, /c/:courseId/notes, /account, /account/admin. Styles are plain CSS loaded as text and injected once, so the same module graph is safe to import on the server.
  • LessonView is a presentational lesson renderer that takes a LessonPayload. The studio uses it for preview.
  • The browser runner (src/web/runner/*) runs learner code and tests as ES modules from blob URLs inside a module Web Worker, with a hard timeout (the worker is terminated). TS is transpiled by the server's Bun.Transpiler (POST /api/learner/transpile).
  • Decks render client-side with pdfjs-dist. The worker script is served from /api/learner/pdf.worker.mjs.

Tech Stack: Bun 1.3, TypeScript, React 19, React Router 6, better-auth 1.7 (+ @better-auth/sso for SAML/OIDC SSO, generic-oauth for a configured OIDC provider), marked + DOMPurify, pdfjs-dist.

Spec: sections 4.1–4.4, 6, 7, 12, 13. Visual reference: docs/design/mockups/platform-v1.html minus its [ / ] / / / Ctrl+Enter shortcuts.

Global Constraints

Inherited from docs/superpowers/plans/2026-10-30-stcourses.md (Bun only, plain CSS, no course content in the DB, both dialects, AGPL, tokens, keyboard rules, commit trailer). Package-specific:

  • Owned paths: packages/feature-learner/**, docs/superpowers/plans/2026-10-30-learner.md, docs/contract-requests/learner.md. Shared touches: one line in apps/web/src/features.ts, the feature wiring in apps/server/src/main.ts.
  • Frozen: packages/contracts, packages/course-format, packages/db (src + 0001), packages/core, packages/ui.
  • No new colours: course accent (manifest) and the tokens only. Literata only for lesson titles and reading text.

Review Focus

  1. Auth adapter correctness on both dialects (where operators, AND/OR grouping, dates and booleans as text/int). Test: Task 2 runs the adapter suite under forEachDb.
  2. Owner bootstrap is race-safe: exactly one owner even with concurrent sign-ups (unique key claim). Test: Task 3.
  3. Visibility: lesson, outline, code and zip routes all apply canView and return 404 for hidden courses; gating returns locked without content. Test: Task 5/7.
  4. Zip download handles spaces and non-ASCII names (UTF-8 flag) and never follows symlinks out of the root. Test: Task 7.
  5. Runner isolation: learner code runs only inside the worker; an infinite loop is killed by the timeout. Test: Task 8.

Task 1: Package scaffold, migrations runner, test helper

Files: packages/feature-learner/{package.json,src/index.ts,src/server/migrate.ts,migrations/0001_learner_auth.sql,test/helpers.ts,test/migrate.test.ts}

Interfaces (Produces):

  • migrationsDirs: string[] (absolute path of packages/feature-learner/migrations).

  • runMigrations(db: Db, dirs?: string[]): Promise<string[]> — same bookkeeping table and semantics as @stc/db migrate().

  • Tables: auth_user, auth_session, auth_account, auth_verification, auth_sso_provider (Better Auth, snake_case columns, ISO text dates, integer booleans), learner_meta (key PK, value), learner_group_claims (user_id, group_id).

  • test helper forEachDb(name, fn) = @stc/db's helper plus the learner migrations.

  • Step 1: failing test: after runMigrations the tables exist on both dialects and a second run applies nothing.

  • Step 2: implement; Step 3: green on SQLite and Postgres; commit.

Task 2: Better Auth adapter over Bun.SQL

Files: src/server/auth/adapter.ts, test/adapter.test.ts

Interfaces: bunSqlAdapter(db: Db) → Better Auth DBAdapterInstance. Model x → table auth_<snake(x)>, field camelCase → snake_case. supportsDates/Booleans/JSON: false, no transactions (SQLite has one connection; a nested query in a transaction would deadlock).

  • Failing tests (both dialects): create/findOne/findMany (eq, ne, in, not_in, contains, starts_with, ends_with, lt/gt, insensitive mode, OR grouping, sort, limit, offset), update, updateMany, delete, deleteMany, count, consumeOne, incrementOne.
  • Implement, green, commit.

Task 3: AuthService, owner bootstrap, claim groups, feature wiring

Files: src/server/auth/index.ts, src/server/auth/groups.ts, src/server/feature.ts, test/auth.test.ts, apps/server/src/main.ts (wiring), docs/contract-requests/learner.md

Interfaces:

  • createAuth(ctx, opts?) → { handler(req), service: AuthService, providers(): ProviderInfo[] }.
  • Email/password always on; social providers (GitHub, Google, Microsoft, GitLab, Discord) from STC_AUTH_<NAME>_ID/SECRET; one generic OIDC provider from STC_AUTH_OIDC_*; SAML and per-domain OIDC through @better-auth/sso (owner-only registration).
  • First user to sign up becomes owner (claimed via INSERT into learner_meta key owner, PK conflict loses); others get learner.
  • syncClaimGroups(db, userId, names) creates groups by name and replaces the user's claim-sourced memberships; manual memberships are untouched.
  • learnerFeature(ctx) returns Feature & { auth }; startServer accepts feature factories (ctx) => Feature and installs a feature's auth (contract request filed; two-line change in main.ts).
  • Failing tests: sign-up → session cookie → userFromRequest returns roles [owner]; second user [learner]; concurrent first sign-ups give one owner; sign-out clears; claim sync keeps manual memberships.
  • Implement, green, commit.

Task 4: Admin API (roles, groups, course ACL, library curation)

Files: src/server/admin.ts, test/admin.test.ts

  • Routes (owner only, 403 otherwise): GET /api/admin/users, PUT /api/admin/users/:id/roles, GET|POST /api/admin/groups, DELETE /api/admin/groups/:id, PUT /api/admin/groups/:id/members, GET|PUT /api/admin/courses/:id/acl, GET|PUT /api/admin/library (featured + collections).
  • The last owner cannot drop their own owner role.
  • Tests first; implement; commit.

Task 5: Library, outline and lesson API

Files: src/server/catalog.ts, src/server/lesson.ts, src/server/gating.ts, test/catalog.test.ts, test/lesson.test.ts

  • GET /api/library → { featured, continue[], newest[], collections[], courses[] } filtered by canView. Each course: id, title, pitch, authors, coverUrl, accent, formats (text|video|slides|exercises), lessonCount, minutes (video duration, text estimated at 220 wpm, exercises 15 min), collections, progress, status.
  • GET /api/library/search?q= → courses and lessons matching title/pitch/lesson titles.
  • GET /api/courses/:id → outline, tracks, gating, locked per module, progress, diagnostics (errors only for owner/authors).
  • GET /api/courses/:id/lessons/:lessonId → LessonPayload (markdown, hints, decks, video, transcript, code, attachments, browser exercise, local command, prev/next) or { locked: true }.
  • Gating: module i locked when any lesson of an earlier module is not done; owners and course authors bypass.
  • Tests against the sample fixture plus temp courses; implement; commit.

Task 6: Progress, notes, exercise attempts API

Files: src/server/userdata.ts, test/userdata.test.ts

  • PUT /api/courses/:id/progress/:lessonId {state, positionSeconds?}; notes CRUD (GET/POST /api/courses/:id/notes, PATCH/DELETE /api/notes/:id); attempts GET/PUT /api/courses/:id/lessons/:lessonId/attempt?track=. A passing attempt marks the lesson done. Anonymous → 401. Hidden course → 404.
  • Tests on both dialects (acceptance: notes persist on SQLite and Postgres); implement; commit.

Task 7: Code files and zip download

Files: src/server/zip.ts, src/server/code.ts, test/zip.test.ts, test/code.test.ts

  • GET /api/courses/:id/code/:moduleId → { dir, command, files: {path,size}[] } (skips .git, node_modules, symlinks escaping root; caps 2000 files).
  • GET /api/courses/:id/code/:moduleId/zip → application/zip, Content-Disposition via contentDisposition("<dir name>.zip").
  • zip(entries): store/deflate, CRC32, UTF-8 name flag.
  • Tests: zip round-trip via unzip -l, non-ASCII names, symlink escape skipped; commit.

Task 8: Browser exercise runner

Files: src/web/runner/{assert.ts,worker-source.ts,runner.ts}, src/server/transpile.ts, test/runner.test.ts

  • createBrowserRunner(language, { transpile }): ExerciseRunner. Worker disables fetch, XMLHttpRequest, WebSocket, importScripts before importing code. Results carry expected/actual for failed asserts.
  • Tests (Bun supports module workers and blob URLs): starter fails, solution passes, a syntax error is reported, while(true) times out, TS is transpiled.
  • commit.

Task 9: Markdown pipeline

Files: src/web/markdown.ts, test/markdown.test.ts

  • renderLessonHtml(md, { courseId, baseFile, tracks, lessonFiles }) → sanitized HTML: heading ids, code blocks tagged with their track (data-t), bold track labels tagged, <details> turned into folds; consecutive hint folds become a tier group (data-tier, data-tiers), relative images/links rewritten to /api/media/... or lesson routes.
  • Tests with Pure Snake-style input; commit.

Task 10: Web shell and library

Files: src/web/{routes.tsx,api.ts,styles.css,shell/*,library/*}

  • Header extras (course switcher, search, Reading/Work, account) portalled into the Phase 0 header; course outline sidebar (mobile overlay, Escape closes); library: featured hero, continue row, New row, collection rows of covers, format/length/in-progress filters, search.
  • Component smoke tests with react-dom/server render; screenshot check later; commit.

Task 11: Lesson views and LessonView

Files: src/web/lesson/*

  • Text (track switcher, Reading/Work, folds, hint tiers), video (range-served <video>, resume, done near end, timestamp notes), deck viewer (pdfjs, thumbs, arrows, fullscreen, per-slide note), code files (tree, tabs, copy, zip), exercise page (editor, run, reset, per-test results, hints, local command; local-only: command + "Mark done" + optional pasted evidence), notes list, floating action bar.
  • Render tests for LessonView with each lesson type; commit.

Task 12: Account and admin pages

Files: src/web/account/*

  • Sign in / create account (email+password), configured provider buttons, SSO by email, sign out; owner admin page for users/roles, groups, featured course and collections.
  • Render tests; commit.

Task 13: Wiring, verification, report

  • Append learnerWeb to apps/web/src/features.ts; learnerFeature in main.ts.
  • bun test, bun run typecheck, Postgres run; dev server on 3461 with STC_COURSE_DIRS=./fixtures/courses:./overlays; headless Chrome screenshots of library, text, video, deck, code, exercise, account for all three courses; fix what looks wrong.
  • Write /home/user/STCourses/.superpowers/sdd/2026-10-30-stcourses/wp-learner-report.md.