apps/learn and runs locally on port 7812. It is the learner-facing companion to the main Tuturuuu web platform: students and parents use Learn for lessons, practice, assignments, reports, marks, XP, streaks, and hearts, while teachers and admins manage education data in Teach.
Unmatched /api/workspaces/* requests, including workspace invitations and
workspace details, use the Web fallback with their paths and queries intact.
Local Learn API handlers retain precedence. Regression coverage lives in
apps/learn/src/next-config.test.ts.
Ownership model
Learn does not render its own login portal. Its/login route first checks for an existing Learn app-session JWT. If the app session is already present, it redirects inside Learn to the requested next path, usually /dashboard. Otherwise it redirects to the platform login at apps/web with a returnUrl pointing back to /verify-token. Learn stores a host-only tuturuuu_app_session cookie after token verification, not a Learn-local Supabase Auth session.
The /dashboard entry route must only send users to /login when the Learn app-session is missing. If the app-session exists but the Learn bootstrap API returns no eligible education workspace, render the empty workspace state instead of redirecting back to login.
Social auth providers still run on apps/web because OAuth provider callbacks are registered for tuturuuu.com. After the platform login confirms the current account, apps/web generates a cross-app token for the learn target app and redirects back to /verify-token. Learn’s local POST /api/auth/verify-app-token route only completes the host-only cookie handoff; token validation is delegated back to the central web app so Learn does not depend on a Learn-local Supabase Auth project. The handoff stores a Learn-local app-session cookie for satellite route guards and shared session material used when a retained platform service must be called. If older session material cannot be refreshed, Learn sends the user back through the platform handoff so the coordinated cookies are renewed without manual deletion.
Learn owns learner and parent-facing v1 API contracts, including bootstrap,
courses, modules, tests, practice, assignments, reports, marks, parent links,
and vocabulary support. These handlers live under apps/learn/src/app/api/v1.
Next.js fallback rewrites send all unmatched /api/* paths
to Web; they do not proxy a path that Learn implements locally.
Programming
The Learn Programming workspace is available inside eligible education workspaces. Its default layout fills the available dashboard viewport: a compact challenge and language picker sits above a resizable problem pane, code editor, and test console. Narrow screens use vertically resizable panes. Monaco runs in the browser with syntax highlighting for supported languages, and local JavaScript and TypeScript diagnostics and definition lookup. Local editor checks are guidance; only the platform Judge determines the result. Students can run public cases and one optional custom input/expected-output pair without consuming hidden cases. Submitting runs the public and private cases. Public inputs, expected outputs, actual outputs, diagnostics, and timing are visible to the learner. Private cases disclose only pass/fail and an aggregate count; their input, output, errors, and individual timing stay private. The default Judge feature is off on a newly connected Devbox until an administrator enables it and the machine reports a verified sandbox with cached pinned images. An unavailable language remains editable for learning but cannot run there. Parents may read the challenge and a linked learner’s execution history but cannot run or submit code. Each test run and submission records the source, language, timestamp, verdicts, and public case wall times in private platform tables. History is scoped to the learner, workspace, and challenge and can restore earlier source into the editor. The displayed median and range use the same monotonic wall-clock measurement for each public case. They include container startup and, where needed, compilation, so repeated runs may differ; the UI does not claim a hardware-normalized benchmark or rank users by these numbers. Hidden-case timings are omitted from learner responses.packages/education-core contains reusable server-only education domain logic;
it does not own HTTP traffic. Programming catalog clients are exposed through
@tuturuuu/internal-api/programming; keep new catalog consumers on that scoped
entry point instead of extending the legacy education client module. The Tulearn helpers in
packages/internal-api/src/tulearn.ts select the Learn origin for learner
contracts. Explicit exceptions such as platform profile updates keep the Web
origin.
Learn-local logout clears the host-only app-session cookies and stale Supabase Auth cookies on learn.tuturuuu.*, then redirects browser form submissions to the central apps/web /logout?from=Learn continuation. JSON callers can still POST /api/auth/logout and receive { success: true }.
Learn also exposes a learner-focused AI Chat surface at /[wsId]/ai-chat. The UI stays inside apps/learn, while its unmatched /api/ai/* requests use the Web fallback so model routing, credits, logging, and chat persistence remain centralized.
Workspace access
Every Learn workspace API requires both:- the authenticated user has student access or an explicit, parent-accepted (
active) parent-student link; and workspace_secrets.name = ENABLE_EDUCATIONis set to the canonical valuetruefor that workspace.
personal do not diverge between selection and final reads. Linked learners in workspace_user_linked_users count as eligible student access even when the platform user is not a direct workspace member, so course lists and learner dashboard bootstrap must agree on the same workspace set.
Learner test access
The Learn start, attempt, save-answer, and submit routes require the selected course in the student’s assigned, published courses for the resolved workspace. Parent and guest subjects keep their existing read-only restrictions. Course access is checked before test, answer, attempt, or grading operations.Parent links
Parent access is explicit, parent-consented and read-only in v1. The existing Tulearn schema adds:tulearn_parent_student_linksfor parent to student links (pending,active,revoked);tulearn_parent_invitesfor emailed invitations;- staff-managed parent-link APIs under
/api/v1/workspaces/[wsId]/tulearn/parent-links, and parent-only accept routes.
Consent rule
Workspace staff (manage_users) can only propose a parent link. Only the parent can make it active, and only active links grant a parent any learner access (resolveTulearnSubject and the Learn bootstrap read nothing else).
- Scope and default: every Tulearn parent-student link in an education-enabled workspace. New links default to
pendingwith noaccepted_at. - Direct link (
POST .../parent-linkswithparentUserId): creates apendinglink. An existingpendingoractivelink for the same parent and student is returned unchanged; itsstudent_platform_user_idandaccepted_atare never rewritten. The parent accepts withPOST .../parent-links/[linkId]/accept, which succeeds only when the signed-in Learn session user is the link’sparent_user_idand the link ispending, and is idempotent onceactive. Staff cannot accept on a parent’s behalf, and acceptance is refused if the student’s platform identity was re-linked since the proposal. - Invite link (
POST .../parent-linkswithparentEmail): the single-use token is emailed to that address and is never returned by the API. The email uses the workspace’s own email credentials when it has them and the root credentials otherwise; limits, blocklist and audit stay attributed to the workspace. If the email cannot be delivered, the invite is revoked and the request fails. A newer invite supersedes older pending invites for the same recipient and student. The parent opens the emailed Learn link (/parent-invite) and accepts withPOST .../parent-invites/accept; the token’s hash must match apending, unexpired invite (14 days) and the caller’s verified account email must equal the invited address (case-insensitive). The learner binding is checked before the invite is claimed, so an unbound learner does not burn it. The invite is claimed with a conditional update onpending, so concurrent or repeated use cannot create a second link; a repeat by the same parent returns their existing active link and never restores revoked access or activates a pending link (replays also respect expiry). The only repair is an interrupted acceptance, where no link of any status exists since the invite was accepted. Accepting an invite activates an existingpendinglink for that parent and student (re-pinned to the learner’s current binding) instead of duplicating it. Revoking a link also revokes pending invites for that parent’s email and learner. - Re-consent: reactivating a
revokedlink (PATCH .../parent-links/[linkId],activeorpending) returns it topending, never directly toactive, and is refused with409while anotherpendingoractivelink exists for the same parent and student. - Uniqueness: the database allows one
activelink per workspace, parent and student. Duplicatependinglinks are prevented in code (not by an index); a rare concurrent duplicate is harmless because it grants nothing and activating it resolves to the existingactivelink. - Surfaces: Learn API routes above, the Learn
/parent-invitepage (English and Vietnamese), and theacceptTulearnParentInvite/acceptTulearnParentLinkhelpers in@tuturuuu/internal-api/tulearn. Teach and Learn have no UI that lists pending links for a parent yet; direct-link acceptance is API-only until one is added.
parent-links/route.test.ts, parent-links/[linkId]/route.test.ts, parent-links/[linkId]/accept/route.test.ts, parent-invites/accept/route.test.ts, packages/education-core/src/tulearn/parent-consent-access.test.ts and internal API tulearn-parent-consent.test.ts.
Gamification
Learn-owned gamification behavior is backed by the existing Tulearn tables, separate from teacher-owned course data:tulearn_gamification_eventsstores XP events with an idempotency key per workspace and learner.tulearn_learner_statestores hearts, max hearts, total XP, streaks, freezes, selected workspace, and UI preferences. Direct authenticated writes are limited by RLS to the learner’s own row in workspaces where that learner is a member; non-nullselected_workspace_idvalues must also point to a workspace the learner can access.
award_tulearn_xp and lose_tulearn_heart. Those functions lock the learner state row and keep event insertion, streak updates, XP totals, heart decrements, and refill timer resets atomic under concurrent practice or assignment submissions. Streak dates currently use UTC day boundaries until Learn stores learner or workspace time zones.
Courses, modules, quiz sets, flashcards, assignments, monthly reports, and marks continue to reuse existing education and user-group tables.
Programming route and operations
Learn owns the learner-facing Programming workspace at/[wsId]/programming; /[wsId]/coding redirects there. It starts with
curated array, search, and stack exercises. Students in a workspace with
ENABLE_EDUCATION=true can submit solutions; parents with read-only learner
access cannot. The audited imported catalog is shared across eligible education workspaces; workspace managers can additionally author private workspace catalogs. Python, JavaScript, TypeScript, C, C++,
Java, Rust, Go, Ruby, and PHP use the same standard-input/standard-output
exercise contract. The selected language only becomes runnable when a platform
Judge runner reports a cached pinned image for it.
The fixed Judge command budget is 48,000 base64url characters for the combined
source, catalog cases, and optional custom case. Individual field limits are upper
bounds, not independently reserved capacity. Author saves reject catalogs that
cannot fit even minimal source; execution validates the complete combined payload
and rejects oversized commands before enqueue. Learners may reduce source or
custom input to fit the shared budget. This applies to both workspace and imported
catalogs and preserves the existing runner contract.
The Programming workspace uses a full-bleed shell at this route, with shared
Select, Button, Textarea, Tooltip, Tabs, and Accordion controls. Public test
cases start collapsed in both problem and console panels. The bottom console
can be resized or collapsed from the editor toolbar; collapsed content is inert,
and completing a newly queued execution expands the console to show its result.
Compact run/submit icons retain localized accessible labels and tooltips.
The Judge readiness warning uses foreground text on a muted surface in both
color modes and is informational; it does not enable a runner or change security.
Code uses the self-hosted regular JetBrains Mono v2.304 font, scoped to this
workspace. Its unmodified webfont, full SIL OFL 1.1 license, copyright and
source/hash notice are colocated in the Programming component’s fonts folder.
There is no operating-system font installation or additional font package.
Submissions are stored in private.learn_coding_submissions and queued
atomically with a private Devbox lease and typed Judge run. The default is at
most 20 submissions per learner per hour. Learn reads only that learner’s
submission, displays aggregate pass counts and visible case outcomes, and does
not award XP or affect hearts yet. Hidden cases remain server-owned. If no
eligible Judge machine is ready, the editor remains available and submission
is disabled. Workspace members consume platform capacity; they do not control
the machines. Root administrators manage runner feature toggles and Judge
resource limits in Infrastructure > Devboxes.
Code organization
Learn learner pages keep the publicapps/learn/src/components/learner-pages.tsx entrypoint as a thin barrel export. Route-level screens live under apps/learn/src/components/learner-pages/*, with shared page motion, loading, empty state, and section primitives in shared.tsx.
The home dashboard should be a real learner workspace, not a landing page. Keep daily plan, quest board, learner toolkit, course path, assignments, marks, reports, practice, and AI Chat entrypoints visible as responsive Neobrutalist panels. Secondary rails should stack below the main grid until there is enough width for readable cards, and color should use dynamic theme tokens rather than a single accent.
Keep Learn learner files under 400 LOC and individual components under 200 LOC. When a screen grows, split reusable cards, rows, panels, and route-specific helpers into adjacent modules before final verification instead of letting learner-pages.tsx or any route screen become a monolith again.
Language switching is handled inside Learn with next-intl, but locale prefixes are hidden from URLs. Legacy locale-prefixed paths such as /vi/dashboard are canonicalized back to /dashboard while preserving the selected locale in NEXT_LOCALE. Keep English and Vietnamese strings in apps/learn/messages/en.json and apps/learn/messages/vi.json, then run bun i18n:sort.
Learn metadata and auth return URLs must resolve to absolute HTTP(S) app URLs. Prefer LEARN_APP_URL or NEXT_PUBLIC_LEARN_APP_URL for the app origin. A valid absolute BASE_URL can be used as a fallback, but non-URL environment values such as development are ignored so local development falls back to https://learn.tuturuuu.localhost through Portless.
Do not configure Learn return URLs with the retired tulearn.tuturuuu.com origin. Production learner redirects should use https://learn.tuturuuu.com, and teacher-facing redirects should use https://teach.tuturuuu.com.
Programming problem catalog
Programming opens its authorized catalog at/[wsId]/programming and each problem
at /[wsId]/programming/problems/[problemId]. The legacy /coding entry redirects
only after learner access is checked. Catalog pages return at most 50 summaries
and expose a UUID continuation cursor; a selected problem remains accessible even
when it is outside the first catalog page.
Workspace authors require workspace membership, manage_users, and enabled
education. Author forms can create and revise workspace problems; imported global
problems remain read-only. Up to nine cases can be stored per problem, reserving
the existing runner’s tenth slot for a learner’s custom test. Revision conflicts
preserve the author’s form so another author’s changes are not overwritten.
Learners receive published problem data and visible cases; hidden cases remain
behind the authorized server boundary.
Editor code, language, custom input, and inspected result selection persist per
tab in session storage scoped to actor, workspace, learner, and problem. When
storage is unavailable, drafts use memory for the current tab. History and polling
bind to the opaque problem UUID; only the three audited imported global problems
may read never-bound legacy slug history. Deleting a problem preserves existing
submission history through its permanent identity binding.
These decisions apply to the Learn catalog, problem workspace, author forms, and
shared Programming lab. Backend access, case capacity, pagination, history, and
subject regressions live in packages/education-core/src/education/programming-*.test.ts
and apps/web/src/lib/programming-api.test.ts. The disposable actual Next/auth/API
regression is apps/web/e2e/programming-catalog.noauth.spec.ts; it exercises RPC
author writes, stale revisions, hidden-case filtering, and workspace isolation.
Learn actor/history regressions
live in apps/learn/src/lib/programming/server.test.ts and
apps/learn/src/lib/coding/programming-store.test.ts; scoped draft regressions
live in apps/learn/src/lib/programming/drafts.test.ts. The mounted canonical
harness in apps/learn/tests/programming-workspace/run-canonical.mjs uses synthetic
Next navigation and server-action adapters and does not replace authenticated
Next/API/database integration evidence.
Verification
After changing Learn routes, run focused non-build route or component tests locally. Run applicable typecheck, lint, tests, and the owning Learn build in CI for the exact candidate commit. Do not runbun validate or a product build locally;
their task graphs may compile workspace dependencies.
For the owning Learn build, dispatch .github/workflows/vercel-preview-learn.yaml
on trusted main with preview_ref set to the immutable candidate SHA. Verify
the checked-out source SHA and successful build step separately from preview
deployment. Schema changes require disposable full-schema replay and generated
types before integration; message changes require bun i18n:sort. Cover parent
read-only access, student-only writes, XP idempotency, assignments, marks, and
reports when those behaviors change.
CI and deployment
Learn has dedicated Vercel workflows:.github/workflows/vercel-preview-learn.yaml.github/workflows/vercel-production-learn.yaml
main, then checks out the supplied preview_ref. They require environment-scoped Vercel credentials plus VERCEL_LEARN_PROJECT_ID; production Supabase values should live in the Vercel project environment rather than GitHub Actions.
Deployment credentials must stay environment-scoped in GitHub Actions. The preview job is bound to the vercel-preview-learn GitHub Environment, and the production job is bound to vercel-production-learn. Store VERCEL_TOKEN, VERCEL_ORG_ID, and VERCEL_LEARN_PROJECT_ID in those environments instead of repository-wide or organization-wide secrets. Production Supabase values remain in the Vercel project environment pulled by vercel pull. The repository-level TURBO_TOKEN and TURBO_TEAM variable are passed only to the wrapped vercel build step; never place them at workflow or job scope, and never expose the token to pull-request or Dependabot code.
Preview dispatch is manual-only. Run vercel-preview-learn.yaml from main, set preview_ref to the reviewed branch, tag, or SHA, and keep TRUSTED_PREVIEW_DEPLOY_ACTORS limited to maintainers approved to run secret-backed preview builds. Manual production dispatch is only valid from refs/heads/production.
apps/learn/vercel.json disables Vercel Git deployments and GitHub integration so preview and production deploys only happen through the CI workflows.
The full-bleed shell measures its mobile navigation header, including safe-area padding and wrapped rows, and reserves bottom safe-area space inside the viewport. Disabled Run and Submit controls expose their tooltips to keyboard focus. Font loading failures retain fallback editor metrics without unhandled rejections. Source regression coverage is in apps/learn/tests/programming-workspace/review-regressions.node-test.mjs; real mobile and Next build checks remain separate verification gates.
Programming database acceptance
The enabledprogramming-database-contract.yaml pull request gate checks out the
exact source head and replays all repository migrations in an owned disposable
Supabase project. learn-programming-full-schema.sql requires strict pgTAP success
for grants, workspace boundaries, hidden-case redaction, stale revisions, invalid
case visibility, and complete transaction rollback. Actual public schema types
are uploaded under that source SHA after scoped cleanup succeeds; they are not
committed by the workflow. The job has read-only repository permissions and a
45-minute deadline. This gate validates the current catalog contract without
activating the separate inert hosted typegen proposals.
Run the finite parser and session regressions with
node --test apps/database/scripts/programming-database-tap.test.mjs apps/database/tests/programming-disposable/session.test.mjs.
Use the repository isolated lifecycle for local schema acceptance, never a shared
or production database. A failed migration, TAP assertion, type generation, or
owned-project cleanup blocks acceptance.
Hosted projects and meetings
Programming also supports hosted playgrounds and the shared Meet coding workbench. Project saves belong to personal Drive; normal access requires a paid personal account or an active Infrastructure benefit. See account benefits for the centralized grant model and the realtime runbook for implementation, egress controls and operations.Personal To do
The To do sidebar destination (/{wsId}/todo) is the signed-in actor’s own
work in this education-enabled workspace. It intentionally ignores parent learner
selection: the actor’s verified workspace-user link is resolved on the server,
and studentId/other subject query overrides are rejected. A parent without an
own learner link has an empty personal list; existing parent learner views remain
available elsewhere.
Tutoring lists explicitly assigned, pending, unresolved sessions. Assignments,
lessons and tests come only from the actor’s actual STUDENT course memberships
in active, non-guest, published courses. Only approved assignments and published
lessons/tests are visible. Completion comes from this actor’s assignment checks,
module completion records and submitted test attempts. Completed entries are
hidden on the current page by default; the count includes assigned completed
entries, and the toggle plus pagination avoids presenting an empty page as an
empty workspace. Sessions show their recorded local date/time and duration,
including explicit longer tutoring exceptions.
Reads use Learn-owned /api/v1/workspaces/{wsId}/tulearn/todo and the shared
internal API transport. They are private/no-store, paginated by category, and
cached by app, workspace and verified actor. Failures offer Retry and do not
turn unavailable data into an empty-success response. These existing app-owned
education routes are outside the platform Web-to-Rust/TanStack migration inventory.
Regression evidence: packages/education-core/src/todo/service.test.ts, both
app-owned todo/route.test.ts files, internal API education-todo.test.ts, and
shared UI education-todo.test.tsx. Exact app builds and authenticated runtime
verification remain required delivery evidence.
Education read and progress boundaries
Teach quiz-set APIs and each detail page segment require education access in the route workspace and a quiz set or module owned by that workspace. Attempt detail requiresview_user_groups_reports. Attendance changes accept only students
assigned to the course. Learn quiz resets and vocabulary reads use the same
published, assigned, unlocked module access as lesson detail. Teachers with
ai_lab retain workspace vocabulary access. Parent vocabulary requests carry
the selected student, and parent links require that student’s verified platform
identity in the workspace and the parent’s own acceptance (see Parent links).
Regression coverage is in Teach’s src/test/*access.test.* and
attendance-scope.test.ts, plus Learn’s parent-link, reset-access and vocabulary
route tests. These rules apply to the active Teach and Learn surfaces.