Identity, Auth & Access Control
Part of: Pickatale Master Build Wiki | Version: v1.7 | Last Updated: 2026-04-18
14. Lifecycle States
Source: (D) | Status: Confirmed as spec
14.1 Account State
| State | Definition | Entry Trigger | Exit Trigger |
|---|---|---|---|
invited |
Invite email sent; user has not registered | Admin/teacher sends invite | User completes registration β pending_verification |
pending_verification |
Registered but email not verified | Registration form submitted | Email link clicked β active; 48h no action β stays pending_verification |
active |
Email verified; can log in and use platform | Email verified | Subscription expires β expired; admin action β suspended |
suspended |
Access blocked by admin; data retained | Admin suspends account | Admin reinstates β active |
expired |
Subscription lapsed; login allowed but features locked | Subscription end date passed (no grace period renewal) | Renews β active; 30 days no renewal β archived |
archived |
Account deactivated; PII scheduled for deletion | Admin archives or GDPR request | Permanently deleted after retention window |
14.2 Subscription State
| State | Definition | Entry | Exit |
|---|---|---|---|
none |
No subscription on record | Account creation | Subscribes β trialing or active |
trialing |
Free trial; full access for N days | Teacher or school_admin completes registration | Trial expires β active (if card on file) or expired |
active |
Paid subscription, current | Payment confirmed | Renewal fails β past_due; cancelled β cancelled |
past_due |
Payment failed; grace period applies | Payment failure | Paid within grace period β active; grace expires β expired |
cancelled |
User cancelled; access until period end date | User cancels | Period end date reached β expired |
expired |
Access period ended with no renewal | Period end reached | Renews β active |
14.3 Child Account State
| State | Definition | Entry | Exit |
|---|---|---|---|
created |
Account exists; no login yet | Teacher imports | Child logs in β activated |
activated |
Child has completed first login + placement test | First login + placement test done | Assigned to class β in_class |
in_class |
Active in a class; receives assignments and bot cycles | Teacher assigns to class | Removed from class β activated; class archived β inactive |
inactive |
No sessions in 30+ days | Inactivity trigger | New session β in_class |
transferred |
Moving from one teacher/class to another | Teacher initiates transfer | Transfer confirmed β in_class under new class |
archived |
Account deactivated (school stop-pay, GDPR request) | Admin/parent request | Data deleted per retention schedule |
14.4 Reading Session State
| State | Definition |
|---|---|
not_started |
Book selected but no page_turned event yet |
active |
page_turned events received within last 5 minutes |
paused |
No events for 5β30 minutes; session not closed |
completed |
session_ended event received (child reached final page) |
abandoned |
book_abandoned event OR session idle >30 minutes without completion |
flagged |
Session completed but avg_time_per_page anomalous (bot flags for review) |
14.5 Intervention State
| State | Definition | Entry | Exit |
|---|---|---|---|
none |
No intervention on record | Default | Intervention Engine detects issue β suggested |
suggested |
Bot has flagged learner; awaiting teacher action | Bot nightly cycle | Teacher acknowledges β assigned; teacher dismisses β none |
assigned |
Teacher has assigned specific intervention content | Teacher action | Child starts session with assigned content β in_progress |
in_progress |
Child is actively engaging with intervention content | Reading session started | Quiz result received β completed or escalated |
completed |
Mastery achieved on intervention objective | Quiz result = raise |
Next objective cycle begins |
escalated |
3+ failed attempts; requires human action | Bot detects 3+ drop results |
Teacher manually resolves |
15. Identity and Access Flows
β See Section 28 for full 9-part contracts for all identity flows.
This section is superseded by Section 28 which contains the complete contract format (UI entry point, API endpoints, backend actions, DB writes, events emitted, permission check, entitlement check, audit log, success state, failure paths) for:
- Flow 28.1: Teacher Registration
- Flow 28.2: Email Verification
- Flow 28.3: Teacher / Parent Login
- Flow 28.4: Child Login (Username + PIN)
- Flow 28.5: Logout
- Flow 28.6: Forgot Password
- Flow 28.7: Reset Password
- Flow 28.8: Invite Acceptance
- Flow 28.9: Session Validation Middleware
- Flow 28.10: Session Expiry
- Flow 28.11: Suspended / Disabled Account
16. User Types and Account Ownership
Source: (D) | Status: Confirmed as spec
User Type Definitions
| Type | Auth Method | Can Create | Owned By | Has Email | DB Table |
|---|---|---|---|---|---|
platform_admin |
email + password (admin panel) | anyone | Pickatale | yes | users (role=admin) |
school_admin |
email + password | teachers, classes | school | yes | users (role=school_admin) |
teacher |
email + password | classes, child accounts | school or self | yes | users (role=teacher) |
parent |
email + password | read-only observer | via teacher/school invite only | yes (via invite link) | users (role=parent) |
child |
username + 4-digit PIN | nothing | teacher OR parent | no | students (separate table) |
Ownership Rules
- Teacher-created child:
students.created_by = teacher_id. The school/teacher owns the account initially. Parent can claim (flow 16.3). - Parent-created child:
OUT OF SCOPE v1β child accounts created by school/teacher only.students.parent_idis set only via the parent-link flow (see page 11-user-signup-flows). - Ownership transfer: Teacher can transfer child to parent. Parent cannot transfer child to teacher without teacher action.
- One child, multiple classes: NOT SUPPORTED. Child belongs to exactly one class at a time. Transfer changes class.
- One child, multiple teachers: NOT SUPPORTED. One active teacher per child.
- One parent, multiple children: Supported. Parent has
parent_idFK on each child row. - Teacher license covers all students: teacher's license covers all students in their classes (up to 33 per class).
Child Login: Direct or Parent-Switched?
Decision (spec): Child logs in directly using username + PIN at app.readingtester.com. Parent does NOT switch into child account. Parent has read-only view via parents.readingtester.com using their own login.
Who Can Deactivate a Child?
| Actor | Action | Condition |
|---|---|---|
| Teacher | Archive child in their class | Child must be in their class |
| Parent | Delete child account | Parent must be linked as students.parent_id |
| School admin | Archive any child in school | Always |
| Platform admin | Archive or delete any child | Always |
Parent Claiming a Teacher-Created Child [V1-PRODUCTION]
Flow: Parent registers β in onboarding, enters child's username β system sends verification email to parent β teacher approves claim (or auto-approves if school allows) β students.parent_id = parent.user_id β parent gains read access to child progress.
17. Registration and Onboarding Flows
Source: (D) | Status: Confirmed as spec
17.1 Teacher Onboarding Wizard
Entry: After email verification (flow 15.1) Steps (in order):
- School Details β school_name, country, year levels taught. Writes to
schoolsor links to existing. - Data Processing Agreement β checkbox: "I accept the DPA". Writes
users.dpa_accepted_at = now(). - Create First Class β class_name, year_level. Writes
classesrow. - Import or Add Students β CSV upload (bulk) OR manual add. Creates
studentsrows. - Print Login Cards β download PDF. PIN shown here only.
- First Book Assignment (optional β can skip) β pick from library, assign to class.
- Done β redirect to Teacher Portal dashboard.
Minimum to proceed: Steps 1β3 required. Steps 4β7 optional but prompted.
17.2 Parent Onboarding Wizard
Entry: After email verification (flow 15.2) Steps (in order):
- Create Child Profile β child_name, age/birthdate, language, grade_level.
- Interest Onboarding β select topics child enjoys (animals, space, sport, etc.). Writes
students.interest_tags. - Subscribe (optional) β show plan options. If skipped β subscription state =
none, limited access. - Done β redirect to Parent Portal dashboard. Child account is now in state
created.
17.3 Child First Login
Entry: Child visits app.readingtester.com, enters username + PIN Steps (in order):
- Login validated β session created
- Check
students.placement_test_completed:falseβ Placement Test screen
- Placement Test: 3 passages at different FK levels. Child reads + answers comprehension questions. System calculates FK level from responses. Writes to
students.reading_levelandlearner_profiles(FK + CEFR). Setsplacement_test_completed = true. - Interest Picker (if not done by parent): select favourite topics. Writes
students.interest_tags. - Library β first recommended book displayed in "Picked for You" hero card.
17.4 Class Creation
Entry: Teacher Portal β Classes β New Class
Required fields: class_name (VARCHAR), year_level (INT 1β13) Optional fields: subject, curriculum_territory
Backend actions:
- Insert
classesrow: class_name, year_level, teacher_id, school_id, state=active - Return class_id
Tables written: classes
Emits: no event (internal)
17.5 Student Import (Bulk)
Entry: Teacher Portal β Class β Import Students β Upload CSV
CSV format: name (required), year_level (optional β defaults to class year_level) Backend actions:
- Parse CSV β validate rows
- For each row: generate username (firstname + 3-digit number, unique), generate 4-digit PIN, bcrypt PIN, insert
studentsrow (state=created, class_id, teacher_id, school_id) - Return full list of students with plaintext PINs (shown once only, not stored)
Tables written: students
Emails sent: none
Output: login cards PDF download
17.6 Learner Profile Initialization
Trigger: Placement test completed (flow 17.3) OR teacher manually sets reading level
Backend actions:
- POST to Learner Profile Service: learner_id, reading_level (FK), cefr_level
- POST to Learner Bot: create initial memory records for this learner
- Set
students.placement_test_completed = true
Tables written: learner_profiles (in Learner Profile DB), memories (in Learner Bot DB)
17.7 First Reading Session
Entry: Child at Library β selects book OR clicks "Picked for You"
Steps:
- Book opened: fires
book_openedtelemetry event - First
page_turnedevent updatesreading_sessions.started_at - Reading proceeds (words counted, FK-levelled text served)
- Session ends: fires
session_endedβ telemetry processes β Learner Bot updated
First session note: No quiz expected after first session. Quiz introduced after 2nd completed book (to avoid friction on first contact).
17.8 First Report Generation
Trigger: Learner Bot nightly cycle (04:00 UTC), minimum 1 completed reading session exists
Output:
- Teacher report:
/api/v1/bot/:learner_id/reportreturns formatted text with curriculum language - Parent digest:
/api/v1/bot/:learner_id/digestreturns warm-tone summary
First report contents (minimal): reading level established, first book completed, N words read, vocabulary words tapped.
21. Permissions Matrix
Source: (D) | Status: Confirmed as spec
Entitlement tiers: full = active/trialing/gifted/cancelled-before-end | free = none/expired/past_due-after-grace
| Feature | Child (full) | Child (free) | Teacher (full) | Teacher (free) | Parent (full) | Parent (free) | School Admin | Platform Admin |
|---|---|---|---|---|---|---|---|---|
| Read books (first 50) | β | β | β | β | β | β | β | β |
| Read books (all 9,089) | β | β | β | β | β | β | β | β |
| Placement test | β | β | β | β | β | β | β | β |
| Comprehension quiz | β | β | β | β | β | β | β | β |
| Finger-follow TTS | β | β | β | β | β | β | β | β |
| Karaoke TTS | β | β | β | β | β | β | β | β |
| Speedread RSVP | β (8+, FKβ₯3) | β | β | β | β | β | β | β |
| Colour overlay / fonts | β | β | β | β | β | β | β | β |
| Miles + Tokens | β | β | β | β | β | β | β | β |
| View class list | β | β | β | β | β | β | β | β |
| Create class | β | β | β | β | β | β | β | β |
| Import students | β | β | β | β | β | β | β | β |
| Assign books | β | β | β | β | β | β | β | β |
| View Learner Bot reports | β | β | β | β | β | β | β | β |
| Intervention tools | β | β | β | β | β | β | β | β |
| Attention list | β | β | β | β | β | β | β | β |
| View parent digest | β | β | β | β | β | β | β | β |
| View child reading history | β | β | β | β | β | β (limited) | β | β |
| Create child account | β | β | β | β | β | β | β | β |
| Reset child PIN | β | β | β | β | β | β | β | β |
| Claim child account | β | β | β | β | β | β | β | β |
| Manage school teachers | β | β | β | β | β | β | β | β |
| View subscription status | β | β | β (own) | β (own) | β (own) | β (own) | β (school) | β (all) |
| Upgrade subscription | β | β | β | β | β | β | β | β |
| Impersonate user | β | β | β | β | β | β | β | β |
| View audit logs | β | β | β | β | β | β | β (own school) | β (all) |
| Grant entitlement | β | β | β | β | β | β | β | β |
28. Identity and Access Flows β Full Contracts
Source: (D) Sig direct instruction | Status: Confirmed as spec. Implementation status per flow noted.
Contract format for every flow:
- UI Entry Point
- API Endpoint(s)
- Backend Actions (in order)
- DB Writes
- Events Emitted
- Permission Check
- Entitlement Check
- Audit Log
- Success State
- Failure Paths
Auth conventions (all flows):
- Cookie:
uc_sessionβ httpOnly, SameSite=Lax, domain=.readingtester.com, 7-day rolling expiry - Child auth:
uc_sessionwith role=child, sub=learner_id β same cookie, scoped differently - JWT in cookie payload:
{sub: user_id, role, school_id?, class_id?} - Password hashing: bcrypt cost 12
- PIN hashing: bcrypt cost 10 (faster, 4-digit)
- Rate limit on all auth endpoints: 5 attempts per 15 min per IP (express-rate-limit)
- All auth events: write to
audit_log
Flow 28.1: Teacher Registration
UI Entry Point: account.readingtester.com/register β role selector β "I'm a Teacher"
API Endpoint: POST /api/auth/register
Input:
{
"name": "string",
"email": "string",
"password": "string (min 8, 1 upper, 1 number)",
"role": "teacher",
"school_name": "string (optional)"
}
Backend Actions (in order):
- Validate input (Zod schema). Return 422 if invalid.
- Query
usersby email. If exists β 409. - bcrypt hash password (cost 12).
- INSERT
users(name, email, password_hash, role='teacher', state='pending_verification', created_at=now()). - If school_name provided: INSERT
schools(name, country='', admin_user_id=users.id, state='pending'). UPDATEusers.school_id. - INSERT
subscriptions(user_id, state='trialing', tier='trial', trial_ends_at=now()+14d). - Generate verification token (UUID v4). INSERT
email_verification_tokens(user_id, token_hash=sha256(token), expires_at=now()+48h). - POST to SendGrid: template VERIFY_EMAIL (name, verify_url=
account.readingtester.com/verify?token=${token}). - INSERT
email_log(user_id, template='VERIFY_EMAIL', status='sent'|'failed').
DB Writes: users, schools (optional), subscriptions, email_verification_tokens, email_log
Events Emitted: none (no event bus yet; email is the notification)
Permission Check: none (public endpoint)
Entitlement Check: none (pre-auth)
Audit Log: INSERT audit_log (action='register', actor_id=new users.id, metadata={role, email_domain})
Success State: HTTP 201 {ok: true, state: 'pending_verification'}. User redirected to "Check your email" page.
Failure Paths:
| Condition | HTTP | Response | Action |
|---|---|---|---|
| Email already exists (state=active) | 409 | {error: 'email_taken'} |
Client shows "Account exists. Log in." |
| Email exists (state=pending_verification) | 409 | {error: 'pending_verification'} |
Client shows "Check your email" + resend link |
| Weak password | 422 | {error: 'password_too_weak', rules: [...]} |
Client shows rules |
| SendGrid failure | 500βhandled | log to email_log, status='failed' | Registration succeeds; show "Email may be delayed" |
| DB write fails | 500 | {error: 'server_error'} |
Rollback all inserts |
Flow 28.2: Email Verification
UI Entry Point: Link in VERIFY_EMAIL email β account.readingtester.com/verify?token=<UUID>
API Endpoint: POST /api/auth/verify-email
Input: { "token": "string" }
Backend Actions:
- sha256(token) β query
email_verification_tokensby token_hash. - Check expires_at > now(). If expired β 410.
- Check already used:
used_at IS NULL. If used β 410. - UPDATE
users.email_verified=true, state='active'WHERE id=token.user_id. - UPDATE
email_verification_tokens.used_at=now(). - INSERT
sessions(user_id, token_hash=sha256(session_uuid), expires_at=now()+7d, ip, user_agent). - Set uc_session cookie.
- POST to SendGrid: template WELCOME_TEACHER or WELCOME_PARENT (based on role).
DB Writes: users, email_verification_tokens, sessions, email_log
Events Emitted: none
Permission Check: none (public)
Entitlement Check: none
Audit Log: INSERT audit_log (action='email_verified', actor_id=user_id)
Success State: HTTP 200, uc_session set, redirect to /onboarding.
Failure Paths:
| Condition | HTTP | Response |
|---|---|---|
| Token not found | 404 | "Invalid link. Request a new one." |
| Token expired | 410 | "Link expired." + resend button |
| Token already used | 410 | "Already verified. Log in." |
Flow 28.3: Teacher / Parent Login
UI Entry Point: account.readingtester.com/login OR teacher.readingtester.com (redirects to account center)
API Endpoint: POST /api/auth/login
Input: { "email": "string", "password": "string" }
Backend Actions:
- Query
usersby email. If not found β 401 (identical response β no enumeration). - Check
users.locked_until. If > now() β 423 with{retry_after: locked_until}. - bcrypt.compare(password, password_hash). If false: increment
users.failed_attempts. If failed_attempts β₯ 5: setlocked_until=now()+15m, send ACCOUNT_LOCKED_ALERT email. Return 401. - If valid: reset
users.failed_attempts=0, locked_until=null. - Check
users.state:pending_verificationβ 403{error: 'email_not_verified'}suspendedβ 403{error: 'account_suspended'}archivedβ 403{error: 'account_archived'}
- INSERT
sessions(user_id, token_hash, expires_at=now()+7d, ip, user_agent). - Set uc_session cookie.
- Query
subscriptions.stateβ include in session payload for client-side feature gating hints (server always re-checks).
DB Writes: users (failed_attempts, locked_until), sessions, email_log (if lock email sent)
Events Emitted: none
Permission Check: none (public)
Entitlement Check: none (checked per-request downstream)
Audit Log: INSERT audit_log (action='login', actor_id=user_id, metadata={ip, user_agent, success: true/false})
Success State: HTTP 200, uc_session set, return {ok: true, role, redirect: '/dashboard'}.
Failure Paths:
| Condition | HTTP | Response |
|---|---|---|
| Wrong password | 401 | {error: 'invalid_credentials'} |
| Account locked | 423 | {error: 'account_locked', retry_after: ISO} |
| Email not verified | 403 | {error: 'email_not_verified'} + resend link |
| Account suspended | 403 | {error: 'account_suspended', message: 'Contact support'} |
| Account archived | 403 | {error: 'account_archived'} |
Flow 28.4: Child Login (Username + PIN)
UI Entry Point: app.readingtester.com β username field + 4-digit PIN
API Endpoint: POST /api/auth/child-login
Input: { "username": "string", "pin": "string (4 digits)" }
Backend Actions:
- Query
studentsby username (case-insensitive). If not found β 401. - Check
students.locked=true. If true β 423{error: 'account_locked', message: 'Ask your teacher to reset your PIN'}. - Check
students.state. Ifarchivedβ 403. - bcrypt.compare(pin, pin_hash). If false: increment
students.failed_attempts. If β₯ 5:students.locked=true. INSERTteacher_notifications(teacher_id, type='child_locked_pin', child_name, student_id). Return 401. - If valid: reset
students.failed_attempts=0. - Call
checkEntitlement(students.school_id OR students.parent_id)β determine tier. - INSERT
sessions(student_id, role='child', learner_id=students.uuid, class_id, expires_at=now()+24h). - Set uc_session cookie (role=child, sub=students.uuid, class_id, entitlement_tier).
DB Writes: students (failed_attempts, locked), sessions, teacher_notifications (on lock)
Events Emitted: none
Permission Check: none (public)
Entitlement Check: called to embed tier in session
Audit Log: INSERT audit_log (action='child_login', actor_id=students.uuid, metadata={ip, success})
Success State: HTTP 200, uc_session set. Client checks students.placement_test_completed:
- false β redirect to
/placement-test - true β redirect to
/library
Failure Paths:
| Condition | HTTP | Response |
|---|---|---|
| Username not found | 401 | {error: 'invalid_credentials'} |
| Wrong PIN | 401 | {error: 'invalid_credentials', attempts_remaining: N} |
| PIN locked (β₯5 fails) | 423 | {error: 'account_locked'} |
| Account archived | 403 | {error: 'account_inactive', message: 'Contact your teacher'} |
Flow 28.5: Logout
UI Entry Point: Any page β header "Sign Out" button
API Endpoint: POST /api/auth/logout
Input: none (reads uc_session cookie)
Backend Actions:
- Parse session_token from uc_session cookie.
- UPDATE
sessions SET invalidated_at=now()WHERE token_hash=sha256(token). - Set uc_session cookie with expired date (clear cookie).
DB Writes: sessions
Events Emitted: none
Permission Check: valid uc_session (if none β still clear cookie and redirect)
Entitlement Check: none
Audit Log: INSERT audit_log (action='logout', actor_id=user_id)
Success State: HTTP 200, cookie cleared, redirect to /login.
Failure Paths: Session already invalidated β same outcome (idempotent).
Flow 28.6: Forgot Password
UI Entry Point: /login β "Forgot password?" link β account.readingtester.com/forgot-password
Child exception: Children have no email. Must use teacher PIN reset (Flow 29.7).
API Endpoint: POST /api/auth/forgot-password
Input: { "email": "string" }
Backend Actions:
- Query
usersby email. If not found: respond identically (no enumeration). - If found: generate token (UUID v4). INSERT
password_reset_tokens(user_id, token_hash=sha256(token), expires_at=now()+1h). - POST to SendGrid: template PASSWORD_RESET (
name, reset_url=account.readingtester.com/reset-password?token=...). - INSERT
email_log.
DB Writes: password_reset_tokens, email_log
Events Emitted: none
Permission Check: none (public)
Entitlement Check: none
Audit Log: INSERT audit_log (action='forgot_password', metadata={email_domain}) β no user_id (not authenticated)
Success State: HTTP 200 {ok: true}. Client shows "If that email exists, you'll receive a link."
Failure Paths: None user-facing (all paths return 200).
Flow 28.7: Reset Password
UI Entry Point: Link in PASSWORD_RESET email β account.readingtester.com/reset-password?token=<UUID>
API Endpoint: POST /api/auth/reset-password
Input: { "token": "string", "password": "string" }
Backend Actions:
- sha256(token) β query
password_reset_tokens. If not found β 404. - Check expires_at > now(). If expired β 410.
- Check used_at IS NULL. If used β 410.
- Validate new password strength. If weak β 422.
- bcrypt hash new password (cost 12).
- UPDATE
users.password_hash, failed_attempts=0, locked_until=null. - UPDATE
password_reset_tokens.used_at=now(). - UPDATE
sessions SET invalidated_at=now()WHERE user_id=token.user_id (invalidate ALL sessions). - POST to SendGrid: template PASSWORD_CHANGED.
DB Writes: users, password_reset_tokens, sessions, email_log
Events Emitted: none
Permission Check: none (token-based)
Entitlement Check: none
Audit Log: INSERT audit_log (action='password_reset', actor_id=user_id)
Success State: HTTP 200. Redirect to /login?message=password_changed.
Failure Paths:
| Condition | HTTP | Response |
|---|---|---|
| Token not found | 404 | "Invalid link." |
| Token expired | 410 | "Link expired." + new request link |
| Token already used | 410 | "Link already used." |
| Weak password | 422 | Rules list |
Flow 28.8: Invite Acceptance
UI Entry Point: INVITE_EMAIL link β account.readingtester.com/accept-invite?token=<UUID>
API Endpoint: GET /api/auth/invite?token=<UUID> (prefill form), then POST /api/auth/accept-invite
GET Input: ?token=UUID
GET Output: {email: "prefilled@...", role: "teacher", school_name: "...", valid: true}
POST Input:
{
"token": "string",
"name": "string",
"password": "string"
}
Backend Actions (POST):
- sha256(token) β query
invites. If not found β 404. - Check expires_at > now(). If expired β 410.
- Check used_at IS NULL. If used β 410.
- Check email not already in
users. If exists β 409. - bcrypt hash password. INSERT
users(name, email=invite.target_email, password_hash, role=invite.target_role, school_id=invite.school_id, state='pending_verification'). - INSERT
subscriptions(state='trialing', trial_ends_at=now()+14d) OR inherit school subscription if school_id is set. - UPDATE
invites.used_at=now(). - Standard email verification flow (Flow 28.2) triggered automatically.
DB Writes: invites, users, subscriptions, email_verification_tokens, email_log
Events Emitted: none
Permission Check: none (token-based)
Entitlement Check: none
Audit Log: INSERT audit_log (action='invite_accepted', metadata={inviter_id, target_email, role})
Success State: User created, verification email sent, redirect to "Check your email".
Failure Paths:
| Condition | HTTP | Response |
|---|---|---|
| Token expired | 410 | "Invite expired. Ask sender to resend." |
| Email already registered | 409 | "Account exists. Log in." |
| Token already used | 410 | "Already accepted." |
Flow 28.9: Session Validation (Middleware β Every Protected Request)
Every API endpoint behind auth runs this middleware:
GET /api/auth/session
Steps:
- Read uc_session cookie.
- Decrypt/verify JWT. If invalid signature β 401.
- Query
sessionsby token_hash. If not found OR invalidated_at IS NOT NULL OR expires_at < now() β 401. - Return
{user_id, role, school_id, class_id, entitlement_tier}.
On 401: Client clears cookie, redirects to /login?next=<current_path> (child sessions β /login only, no next param).
Flow 28.10: Session Expiry (Automatic)
Trigger: uc_session cookie TTL expires (7 days inactivity) OR server-side invalidation
Client behaviour: Next API call returns 401 β client redirects to login.
Session extension: Every authenticated request: UPDATE sessions.expires_at=now()+7d (sliding window). Children: 24h sliding window.
DB Writes: sessions.expires_at
Flow 28.11: Suspended / Disabled Account
Trigger: Admin sets users.state='suspended'
Immediate effects:
- UPDATE all
sessions.invalidated_at=now()for user (all devices logged out instantly). - Send ACCOUNT_SUSPENDED email.
Login attempt while suspended: Flow 28.3 Step 5 β 403 {error: 'account_suspended'}.
Reactivation: Admin sets users.state='active' β INSERT audit_log.
32. Permissions Matrix β Complete [V1-PRODUCTION]
V1 Roles: child, teacher, parent, school_admin, platform_admin
| Feature | child | teacher | parent | school_admin | platform_admin |
|---|---|---|---|---|---|
| Read library (free tier, up to 50 books) | β (free) | β | β | β | β |
| Read library (all books) | β (teacher_paid/enterprise/gifted) | β | β | β | β |
| Take reading quizzes | β (teacher_paid/enterprise/gifted) | β | β | β | β |
| View own reading progress | β | β | β (own child only) | β | β |
| Manage classes | β | β (own classes) | β | β | β |
| Add/remove students | β | β (own classes) | β | β | β |
| View student progress | β | β (own students) | β (own child, limited) | β (school) | β |
| Invite parents | β | β | β | β | β |
| Reset student PIN | β | β (own students) | β (V1-DEFERRED) | β | β |
| Manage school settings | β | β | β | β | β |
| Manage billing / license | β | β (own license) | β | β | β |
| Impersonate users | β | β | β | β | β |
| View audit log | β | β | β | β (own school) | β |
| Manage all schools | β | β | β | β | β |
| Ingest external entitlements | β | β | β | β | β |
* = also requires ageβ₯8 AND FKβ₯3.0
β = v1: parent cannot create child accounts β child accounts created by school/teacher flow only
β‘ = within same school only