12 KiB
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 theAuthService. It runs its own migrations (migrations/0001_learner_auth.sql) lazily on first use until Phase 2 wiresmigrationsDirsintomigrate(). - Auth: Better Auth with a thin custom adapter over
Bun.SQL(src/server/auth/adapter.ts). One adapter serves both dialects because@stc/dbalready gives aBun.SQLhandle 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. LessonViewis a presentational lesson renderer that takes aLessonPayload. 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'sBun.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 inapps/web/src/features.ts, the feature wiring inapps/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
- 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. - Owner bootstrap is race-safe: exactly one owner even with concurrent sign-ups (unique key claim). Test: Task 3.
- Visibility: lesson, outline, code and zip routes all apply
canViewand return 404 for hidden courses; gating returnslockedwithout content. Test: Task 5/7. - Zip download handles spaces and non-ASCII names (UTF-8 flag) and never follows symlinks out of the root. Test: Task 7.
- 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 ofpackages/feature-learner/migrations). -
runMigrations(db: Db, dirs?: string[]): Promise<string[]>— same bookkeeping table and semantics as@stc/dbmigrate(). -
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
runMigrationsthe 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 fromSTC_AUTH_OIDC_*; SAML and per-domain OIDC through@better-auth/sso(owner-only registration). - First user to sign up becomes
owner(claimed viaINSERTintolearner_metakeyowner, PK conflict loses); others getlearner. syncClaimGroups(db, userId, names)creates groups by name and replaces the user's claim-sourced memberships; manual memberships are untouched.learnerFeature(ctx)returnsFeature & { auth };startServeraccepts feature factories(ctx) => Featureand installs a feature'sauth(contract request filed; two-line change inmain.ts).- Failing tests: sign-up → session cookie →
userFromRequestreturns 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 bycanView. 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,lockedper 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); attemptsGET/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-DispositionviacontentDisposition("<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 disablesfetch,XMLHttpRequest,WebSocket,importScriptsbefore 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/serverrender; 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
LessonViewwith 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
learnerWebtoapps/web/src/features.ts;learnerFeatureinmain.ts. bun test,bun run typecheck, Postgres run; dev server on 3461 withSTC_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.